feat(settings): add doktainer database backup and restore
This commit is contained in:
1 parent
a6274eaa03
commit
62ca9a5bae
7 files changed
+311
-6
No files matched your search
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Database, Download, Loader2, Upload } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||||
|
|
||||||
|
export default function DatabaseSettingsPanel({
|
||||||
|
onError,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onSuccess: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [loading, setLoading] = useState<"backup" | "restore" | null>(null);
|
||||||
|
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
setLoading("backup");
|
||||||
|
try {
|
||||||
|
const result = await settingsApi.downloadDatabaseBackup();
|
||||||
|
const url = URL.createObjectURL(result.blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = result.fileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
onSuccess("Database backup downloaded");
|
||||||
|
} catch (error) {
|
||||||
|
onError(error instanceof Error ? error.message : "Database backup failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = async (file: File) => {
|
||||||
|
setLoading("restore");
|
||||||
|
try {
|
||||||
|
await settingsApi.restoreDatabaseBackup(file);
|
||||||
|
onSuccess("Database restore completed. Refresh the page to load restored data.");
|
||||||
|
} catch (error) {
|
||||||
|
onError(error instanceof Error ? error.message : "Database restore failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
if (fileInput.current) fileInput.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRestoreFile = () => {
|
||||||
|
setRestoreFile(null);
|
||||||
|
if (fileInput.current) fileInput.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={restoreFile !== null}
|
||||||
|
title="Restore database?"
|
||||||
|
description="The current database will be overwritten by the contents of the selected backup. This action cannot be undone."
|
||||||
|
confirmLabel="Restore database"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
tone="danger"
|
||||||
|
note="Ensure you have saved the latest backup before proceeding."
|
||||||
|
icon={<Upload size={14} />}
|
||||||
|
onClose={clearRestoreFile}
|
||||||
|
onConfirm={() => {
|
||||||
|
const file = restoreFile;
|
||||||
|
setRestoreFile(null);
|
||||||
|
if (file) void restore(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<section className="card" style={{ padding: 24 }}>
|
||||||
|
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||||
|
<Database size={20} style={{ color: "#2563eb", marginTop: 2 }} />
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: "var(--text-primary)" }}>Database backup & restore</h2>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--text-muted)", marginTop: 6, lineHeight: 1.6 }}>
|
||||||
|
Export the internal Doktainer database as a PostgreSQL dump or restore it from a previous dump file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 24 }}>
|
||||||
|
<button className="btn btn-primary" type="button" onClick={() => void download()} disabled={loading !== null}>
|
||||||
|
{loading === "backup" ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
|
||||||
|
Download backup
|
||||||
|
</button>
|
||||||
|
<input ref={fileInput} type="file" accept=".dump,.backup,application/octet-stream" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) setRestoreFile(file); }} />
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={() => fileInput.current?.click()} disabled={loading !== null}>
|
||||||
|
{loading === "restore" ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
|
||||||
|
Restore from dump
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: "#b45309", marginTop: 18 }}>Restoring will overwrite the current database data. Ensure you have downloaded the latest backup.</p>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Globe2, Lock, Settings, type LucideIcon } from "lucide-react";
|
import { Database, Globe2, Lock, Settings, type LucideIcon } from "lucide-react";
|
||||||
import type { SettingsTab } from "@/app/settings/components/settings-types";
|
import type { SettingsTab } from "@/app/settings/components/settings-types";
|
||||||
|
|
||||||
export const TIMEZONE_OPTIONS = [
|
export const TIMEZONE_OPTIONS = [
|
||||||
@@ -27,10 +27,12 @@ export const SETTINGS_TABS: Array<{
|
|||||||
{ id: "general", label: "General", icon: Settings },
|
{ id: "general", label: "General", icon: Settings },
|
||||||
{ id: "panel-access", label: "Panel Access", icon: Globe2 },
|
{ id: "panel-access", label: "Panel Access", icon: Globe2 },
|
||||||
{ id: "security", label: "Security", icon: Lock },
|
{ id: "security", label: "Security", icon: Lock },
|
||||||
|
{ id: "database", label: "Database", icon: Database },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ACTIVE_TAB_LABELS: Record<SettingsTab, string> = {
|
export const ACTIVE_TAB_LABELS: Record<SettingsTab, string> = {
|
||||||
general: "General",
|
general: "General",
|
||||||
"panel-access": "Panel Access",
|
"panel-access": "Panel Access",
|
||||||
security: "Security",
|
security: "Security",
|
||||||
|
database: "Database",
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import type { SettingsRecord } from "@/lib/api";
|
import type { SettingsRecord } from "@/lib/api";
|
||||||
|
|
||||||
export type SettingsTab = "general" | "panel-access" | "security";
|
export type SettingsTab = "general" | "panel-access" | "security" | "database";
|
||||||
|
|
||||||
export type IntegrationProvider = "cloudflare" | "awsS3" | "github" | "gitlab";
|
export type IntegrationProvider = "cloudflare" | "awsS3" | "github" | "gitlab";
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getStoredTheme, storeUiPreferences } from "@/lib/preferences";
|
|||||||
import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel";
|
import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel";
|
||||||
import PanelAccessSettingsPanel from "@/app/settings/components/PanelAccessSettingsPanel";
|
import PanelAccessSettingsPanel from "@/app/settings/components/PanelAccessSettingsPanel";
|
||||||
import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel";
|
import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel";
|
||||||
|
import DatabaseSettingsPanel from "@/app/settings/components/DatabaseSettingsPanel";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
SettingsLoadingPanel,
|
SettingsLoadingPanel,
|
||||||
@@ -366,6 +367,13 @@ export default function SettingsPage() {
|
|||||||
onSave={() => void saveSettings()}
|
onSave={() => void saveSettings()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "database" ? (
|
||||||
|
<DatabaseSettingsPanel
|
||||||
|
onError={setError}
|
||||||
|
onSuccess={setSuccess}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+29
-1
@@ -669,7 +669,9 @@ async function requestRaw(
|
|||||||
? setTimeout(() => controller.abort(), timeoutMs)
|
? setTimeout(() => controller.abort(), timeoutMs)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (options.body !== undefined && !("Content-Type" in headers)) {
|
const isMultipartBody =
|
||||||
|
typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||||
|
if (options.body !== undefined && !isMultipartBody && !("Content-Type" in headers)) {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,6 +1116,32 @@ export const apiKeys = {
|
|||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => get<{ success: boolean; data: SettingsRecord }>("/settings"),
|
get: () => get<{ success: boolean; data: SettingsRecord }>("/settings"),
|
||||||
|
|
||||||
|
downloadDatabaseBackup: async () => {
|
||||||
|
const res = await requestRaw("/settings/database-backup", { method: "GET" });
|
||||||
|
if (!res.ok) throw new Error(await readApiErrorMessage(res, "Database backup failed"));
|
||||||
|
const disposition = res.headers.get("content-disposition") || "";
|
||||||
|
const serverFileName = disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:]/g, "")
|
||||||
|
.replace(/\.\d{3}Z$/, "Z")
|
||||||
|
.replace("T", "-");
|
||||||
|
const fileName =
|
||||||
|
serverFileName && /^doktainer-database-\d{8}-\d{6}Z\.dump$/i.test(serverFileName)
|
||||||
|
? serverFileName
|
||||||
|
: `doktainer-database-${timestamp}.dump`;
|
||||||
|
return { blob: await res.blob(), fileName };
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreDatabaseBackup: async (file: File) => {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
const res = await requestRaw("/settings/database-restore", { method: "POST", body }, { timeoutMs: 120000 });
|
||||||
|
const json = await readJsonResponse<{ success?: boolean; message?: string; error?: string }>(res, "Database restore failed");
|
||||||
|
if (!res.ok) throw new Error(json.error || json.message || "Database restore failed");
|
||||||
|
return json;
|
||||||
|
},
|
||||||
|
|
||||||
getPanelAccessCapabilities: () =>
|
getPanelAccessCapabilities: () =>
|
||||||
get<{ success: boolean; data: PanelAccessCapabilities }>(
|
get<{ success: boolean; data: PanelAccessCapabilities }>(
|
||||||
"/settings/panel-access/capabilities",
|
"/settings/panel-access/capabilities",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Fastify, { FastifyError } from "fastify";
|
|||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import jwt from "@fastify/jwt";
|
import jwt from "@fastify/jwt";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import multipart from "@fastify/multipart";
|
||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
import prisma from "./lib/prisma";
|
import prisma from "./lib/prisma";
|
||||||
import { validateEncryptionConfiguration } from "./lib/crypto";
|
import { validateEncryptionConfiguration } from "./lib/crypto";
|
||||||
@@ -144,6 +145,7 @@ async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.register(websocket);
|
await app.register(websocket);
|
||||||
|
await app.register(multipart, { limits: { fileSize: 512 * 1024 * 1024, files: 1 } });
|
||||||
|
|
||||||
// ── Health check ──────────────────────────────────────
|
// ── Health check ──────────────────────────────────────
|
||||||
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { resolvePublicAppOrigin } from "../lib/public-url";
|
import { resolvePublicAppOrigin } from "../lib/public-url";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
import { decrypt, encrypt } from "../lib/crypto";
|
import { decrypt, encrypt } from "../lib/crypto";
|
||||||
import { authenticate } from "../middleware/auth";
|
import { authenticate, requireRole } from "../middleware/auth";
|
||||||
import { auditLog } from "../services/audit.service";
|
import { auditLog } from "../services/audit.service";
|
||||||
import {
|
import {
|
||||||
getPanelAccessCapabilities,
|
getPanelAccessCapabilities,
|
||||||
@@ -638,6 +644,167 @@ async function ensureModularSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
|
const databaseAccess = [authenticate, requireRole("DEVELOPER")];
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const getPostgresCliUrl = (databaseUrl: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(databaseUrl);
|
||||||
|
// `schema` is a Prisma connection parameter and is rejected by pg_dump/pg_restore.
|
||||||
|
parsed.searchParams.delete("schema");
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return databaseUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findLocalPostgresBinary = async (
|
||||||
|
command: "pg_dump" | "pg_restore" | "psql",
|
||||||
|
) => {
|
||||||
|
if (process.platform !== "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
"powershell.exe",
|
||||||
|
[
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-Command",
|
||||||
|
"Get-Process -Name postgres -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch { $null } } | Where-Object { $_ } | Sort-Object -Unique | ConvertTo-Json -Compress",
|
||||||
|
],
|
||||||
|
{ windowsHide: true, timeout: 5000, maxBuffer: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(stdout.trim() || "[]") as string | string[];
|
||||||
|
const postgresExecutables = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
|
||||||
|
for (const postgresExecutable of postgresExecutables) {
|
||||||
|
if (!postgresExecutable) continue;
|
||||||
|
const candidate = join(
|
||||||
|
postgresExecutable.replace(/[\\/]postgres(?:\.exe)?$/i, ""),
|
||||||
|
`${command}.exe`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fs.access(candidate);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// Try another active PostgreSQL process, if more than one exists.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// PowerShell/CIM may be unavailable or PostgreSQL may not be running locally.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runDatabaseCommand = async (
|
||||||
|
command: "pg_dump" | "pg_restore" | "psql",
|
||||||
|
args: string[],
|
||||||
|
options: { inputPath?: string; outputPath?: string } = {},
|
||||||
|
) => {
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
if (!databaseUrl) throw new Error("DATABASE_URL is not configured");
|
||||||
|
const databaseCliUrl = getPostgresCliUrl(databaseUrl);
|
||||||
|
|
||||||
|
const run = (executable: string, commandArgs: string[], usePipe = false) => new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(executable, commandArgs, {
|
||||||
|
env: { ...process.env, PGPASSFILE: undefined },
|
||||||
|
stdio: [options.inputPath ? "pipe" : "ignore", usePipe ? "pipe" : "ignore", "pipe"],
|
||||||
|
});
|
||||||
|
let stderr = "";
|
||||||
|
const output = options.outputPath ? createWriteStream(options.outputPath) : null;
|
||||||
|
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||||
|
const childExit = new Promise<number>((resolveExit, rejectExit) => {
|
||||||
|
child.once("error", rejectExit);
|
||||||
|
child.once("close", (code) => resolveExit(code ?? 1));
|
||||||
|
});
|
||||||
|
const streamTasks: Promise<unknown>[] = [];
|
||||||
|
if (options.inputPath) streamTasks.push(pipeline(createReadStream(options.inputPath), child.stdin!));
|
||||||
|
if (output && child.stdout) streamTasks.push(pipeline(child.stdout, output));
|
||||||
|
void Promise.all([childExit, ...streamTasks])
|
||||||
|
.then(([code]) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(stderr.trim() || `${command} failed`));
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const localBinary = await findLocalPostgresBinary(command);
|
||||||
|
if (localBinary) {
|
||||||
|
await run(localBinary, ["--dbname", databaseCliUrl, ...args], Boolean(options.outputPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseContainer =
|
||||||
|
process.env.DATABASE_BACKUP_CONTAINER?.trim() || "doktainer-postgres";
|
||||||
|
const dockerArgs = ["exec", "-i", databaseContainer, command, "--dbname", databaseCliUrl, ...args];
|
||||||
|
try {
|
||||||
|
await run("docker", dockerArgs, Boolean(options.outputPath));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||||
|
throw new Error(
|
||||||
|
process.platform === "win32"
|
||||||
|
? "PostgreSQL lokal yang aktif tidak ditemukan dan Docker CLI juga tidak tersedia."
|
||||||
|
: "Docker CLI tidak tersedia untuk menjalankan backup PostgreSQL.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get("/database-backup", { preHandler: databaseAccess }, async (_req, reply) => {
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:]/g, "")
|
||||||
|
.replace(/\.\d{3}Z$/, "Z")
|
||||||
|
.replace("T", "-");
|
||||||
|
const downloadName = `doktainer-database-${timestamp}.dump`;
|
||||||
|
const filePath = join(tmpdir(), `.${downloadName}`);
|
||||||
|
try {
|
||||||
|
await runDatabaseCommand("pg_dump", ["--format=custom"], { outputPath: filePath });
|
||||||
|
reply.header("Content-Type", "application/octet-stream");
|
||||||
|
reply.header("Content-Disposition", `attachment; filename="${downloadName}"`);
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
stream.once("close", () => void fs.rm(filePath, { force: true }));
|
||||||
|
return reply.send(stream);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.status(500).send({ success: false, error: error instanceof Error ? error.message : "Database backup failed" });
|
||||||
|
} finally {
|
||||||
|
// The successful response owns cleanup through the stream close handler.
|
||||||
|
if (!reply.sent) await fs.rm(filePath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/database-restore", { preHandler: databaseAccess }, async (req, reply) => {
|
||||||
|
const upload = await req.file();
|
||||||
|
if (!upload) return reply.status(400).send({ success: false, error: "Select a PostgreSQL dump file" });
|
||||||
|
const filePath = join(tmpdir(), `doktainer-restore-${Date.now()}.dump`);
|
||||||
|
try {
|
||||||
|
await pipeline(upload.file, createWriteStream(filePath));
|
||||||
|
const header = Buffer.alloc(5);
|
||||||
|
const handle = await fs.open(filePath, "r");
|
||||||
|
try {
|
||||||
|
await handle.read(header, 0, header.length, 0);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCustomArchive = header.toString("ascii") === "PGDMP";
|
||||||
|
await runDatabaseCommand(
|
||||||
|
isCustomArchive ? "pg_restore" : "psql",
|
||||||
|
isCustomArchive
|
||||||
|
? ["--clean", "--if-exists", "--no-owner", "--exit-on-error"]
|
||||||
|
: ["--set", "ON_ERROR_STOP=1"],
|
||||||
|
{ inputPath: filePath },
|
||||||
|
);
|
||||||
|
await auditLog({ userId: req.userId, organizationId: req.organizationId, action: "DATABASE_RESTORE", category: "SYSTEM", level: "WARNING", message: "Doktainer database restored from uploaded dump" });
|
||||||
|
return reply.send({ success: true, message: "Database restore completed" });
|
||||||
|
} catch (error) {
|
||||||
|
return reply.status(500).send({ success: false, error: error instanceof Error ? error.message : "Database restore failed" });
|
||||||
|
} finally {
|
||||||
|
await fs.rm(filePath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
|
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
|
||||||
const bundle = await ensureModularSettings(req.userId!, req.organizationId);
|
const bundle = await ensureModularSettings(req.userId!, req.organizationId);
|
||||||
return reply.send({ success: true, data: serializeSettings(bundle) });
|
return reply.send({ success: true, data: serializeSettings(bundle) });
|
||||||
@@ -646,7 +813,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
app.get(
|
app.get(
|
||||||
"/panel-access/capabilities",
|
"/panel-access/capabilities",
|
||||||
{ preHandler: [authenticate] },
|
{ preHandler: [authenticate] },
|
||||||
async (_req, reply) => {
|
async (req, reply) => {
|
||||||
const capabilities = await getPanelAccessCapabilities();
|
const capabilities = await getPanelAccessCapabilities();
|
||||||
return reply.send({ success: true, data: capabilities });
|
return reply.send({ success: true, data: capabilities });
|
||||||
},
|
},
|
||||||
@@ -702,7 +869,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
error instanceof Error ? error.message : "Panel domain provisioning failed";
|
error instanceof Error ? error.message : "Panel domain provisioning failed";
|
||||||
const statusCode =
|
const statusCode =
|
||||||
typeof (error as { statusCode?: unknown })?.statusCode === "number"
|
typeof (error as { statusCode?: unknown })?.statusCode === "number"
|
||||||
? (error as { statusCode: number }).statusCode
|
? ((error as { statusCode: number }).statusCode)
|
||||||
: 400;
|
: 400;
|
||||||
|
|
||||||
await auditLog({
|
await auditLog({
|
||||||
|
|||||||
Reference in new issue
Block a user