diff --git a/Dockerfile b/Dockerfile
index feb58ad3..7cb1d3d2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -81,7 +81,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM debian:bookworm-slim AS runtime-deps
RUN apt-get update \
- && apt-get install -y --no-install-recommends ca-certificates tzdata \
+ && apt-get install -y --no-install-recommends ca-certificates tzdata postgresql-client \
&& rm -rf /var/lib/apt/lists/*
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index f47228d1..d344aef4 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -293,6 +293,7 @@ func NewApp() (*App, error) {
adminService.SetPermissionGroupBillingPlanReferenceChecker(billingService)
adminHandler := adminhttp.NewHandler(adminService)
adminHandler.SetConversationExporter(conversationService)
+ adminHandler.SetDatabaseBackupManager(adminhttp.NewDatabaseBackupManager(db, cfg))
adminModule := adminhttp.NewModule(adminHandler)
userSettingsRepo := usersettingsrepo.NewRepo(db)
userSettingsService := usersettings.NewService(userSettingsRepo)
diff --git a/backend/internal/transport/http/admin/database_backup.go b/backend/internal/transport/http/admin/database_backup.go
new file mode 100644
index 00000000..134d170c
--- /dev/null
+++ b/backend/internal/transport/http/admin/database_backup.go
@@ -0,0 +1,126 @@
+package admin
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
+ "gorm.io/gorm"
+)
+
+const maxDatabaseRestoreBytes int64 = 1024 * 1024 * 1024
+
+var errDatabaseOperationBusy = errors.New("database backup or restore is already running")
+
+type databaseBackupManager struct {
+ db *gorm.DB
+ cfg config.Config
+ mu sync.Mutex
+}
+
+type databaseBackupFile struct {
+ Path string
+ Name string
+}
+
+func NewDatabaseBackupManager(db *gorm.DB, cfg config.Config) *databaseBackupManager {
+ return &databaseBackupManager{db: db, cfg: cfg}
+}
+
+func (m *databaseBackupManager) driver() string {
+ driver := strings.ToLower(strings.TrimSpace(m.cfg.DatabaseDriver))
+ if driver == "" {
+ return "postgres"
+ }
+ return driver
+}
+
+func (m *databaseBackupManager) withLock(fn func() error) error {
+ if !m.mu.TryLock() {
+ return errDatabaseOperationBusy
+ }
+ defer m.mu.Unlock()
+ return fn()
+}
+
+func (m *databaseBackupManager) Backup(ctx context.Context) (databaseBackupFile, error) {
+ var result databaseBackupFile
+ err := m.withLock(func() error {
+ timestamp := time.Now().UTC().Format("20060102T150405Z")
+ switch m.driver() {
+ case "sqlite":
+ path, err := tempBackupPath("deeix-chat-"+timestamp+"-", ".sqlite3")
+ if err != nil {
+ return err
+ }
+ if err := m.backupSQLite(ctx, path); err != nil {
+ _ = os.Remove(path)
+ return err
+ }
+ result = databaseBackupFile{Path: path, Name: "deeix-chat-" + timestamp + ".sqlite3"}
+ return nil
+ case "postgres":
+ path, err := tempBackupPath("deeix-chat-"+timestamp+"-", ".dump")
+ if err != nil {
+ return err
+ }
+ cmd := exec.CommandContext(ctx, "pg_dump", "--format=custom", "--no-owner", "--no-privileges", "--file", path, m.cfg.PostgresDSN)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ _ = os.Remove(path)
+ return fmt.Errorf("pg_dump failed: %w: %s", err, strings.TrimSpace(string(output)))
+ }
+ result = databaseBackupFile{Path: path, Name: "deeix-chat-" + timestamp + ".dump"}
+ return nil
+ default:
+ return fmt.Errorf("unsupported database driver %q", m.driver())
+ }
+ })
+ return result, err
+}
+
+func (m *databaseBackupManager) Restore(ctx context.Context, sourcePath string) error {
+ return m.withLock(func() error {
+ switch m.driver() {
+ case "sqlite":
+ return m.restoreSQLite(ctx, sourcePath)
+ case "postgres":
+ cmd := exec.CommandContext(ctx, "pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges", "--single-transaction", "--dbname", m.cfg.PostgresDSN, sourcePath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("pg_restore failed: %w: %s", err, strings.TrimSpace(string(output)))
+ }
+ return nil
+ default:
+ return fmt.Errorf("unsupported database driver %q", m.driver())
+ }
+ })
+}
+
+func tempBackupPath(pattern, suffix string) (string, error) {
+ file, err := os.CreateTemp("", pattern+"*"+suffix)
+ if err != nil {
+ return "", err
+ }
+ path := file.Name()
+ if err := file.Close(); err != nil {
+ _ = os.Remove(path)
+ return "", err
+ }
+ return path, nil
+}
+
+func (m *databaseBackupManager) backupSQLite(ctx context.Context, path string) error {
+ escaped := strings.ReplaceAll(filepath.ToSlash(path), "'", "''")
+ return m.db.WithContext(ctx).Exec("VACUUM INTO '" + escaped + "'").Error
+}
+
+func copyRestoreUpload(dst *os.File, src io.Reader) (int64, error) {
+ return io.Copy(dst, io.LimitReader(src, maxDatabaseRestoreBytes+1))
+}
diff --git a/backend/internal/transport/http/admin/database_backup_sqlite_restore_cgo.go b/backend/internal/transport/http/admin/database_backup_sqlite_restore_cgo.go
new file mode 100644
index 00000000..8930a48b
--- /dev/null
+++ b/backend/internal/transport/http/admin/database_backup_sqlite_restore_cgo.go
@@ -0,0 +1,77 @@
+//go:build cgo
+
+package admin
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/mattn/go-sqlite3"
+)
+
+func (m *databaseBackupManager) restoreSQLite(ctx context.Context, sourcePath string) error {
+ source, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(sourcePath)+"?mode=ro")
+ if err != nil {
+ return err
+ }
+ defer source.Close()
+
+ var integrity string
+ if err := source.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil {
+ return fmt.Errorf("validate sqlite backup: %w", err)
+ }
+ if integrity != "ok" {
+ return fmt.Errorf("invalid sqlite backup: integrity check returned %q", integrity)
+ }
+
+ destination, err := m.db.DB()
+ if err != nil {
+ return err
+ }
+ sourceConn, err := source.Conn(ctx)
+ if err != nil {
+ return err
+ }
+ defer sourceConn.Close()
+ destinationConn, err := destination.Conn(ctx)
+ if err != nil {
+ return err
+ }
+ defer destinationConn.Close()
+
+ return destinationConn.Raw(func(destinationDriver any) error {
+ dst, ok := destinationDriver.(*sqlite3.SQLiteConn)
+ if !ok {
+ return errors.New("unexpected sqlite destination driver")
+ }
+ return sourceConn.Raw(func(sourceDriver any) error {
+ src, ok := sourceDriver.(*sqlite3.SQLiteConn)
+ if !ok {
+ return errors.New("unexpected sqlite source driver")
+ }
+ backup, err := dst.Backup("main", src, "main")
+ if err != nil {
+ return err
+ }
+ defer backup.Close()
+ for {
+ done, err := backup.Step(256)
+ if err != nil {
+ return err
+ }
+ if done {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+ })
+ })
+}
diff --git a/backend/internal/transport/http/admin/database_backup_sqlite_restore_nocgo.go b/backend/internal/transport/http/admin/database_backup_sqlite_restore_nocgo.go
new file mode 100644
index 00000000..48f657c0
--- /dev/null
+++ b/backend/internal/transport/http/admin/database_backup_sqlite_restore_nocgo.go
@@ -0,0 +1,14 @@
+//go:build !cgo
+
+package admin
+
+import (
+ "context"
+ "errors"
+)
+
+func (m *databaseBackupManager) restoreSQLite(ctx context.Context, sourcePath string) error {
+ _ = ctx
+ _ = sourcePath
+ return errors.New("sqlite restore requires a CGO-enabled build with a C compiler")
+}
diff --git a/backend/internal/transport/http/admin/handler.go b/backend/internal/transport/http/admin/handler.go
index 416d5689..d7e333c9 100644
--- a/backend/internal/transport/http/admin/handler.go
+++ b/backend/internal/transport/http/admin/handler.go
@@ -41,6 +41,7 @@ type conversationExportManifest struct {
type Handler struct {
service *appadmin.Service
conversationExport conversationExporter
+ databaseBackup *databaseBackupManager
}
// NewHandler 创建处理器。
@@ -53,6 +54,10 @@ func (h *Handler) SetConversationExporter(exporter conversationExporter) {
h.conversationExport = exporter
}
+func (h *Handler) SetDatabaseBackupManager(manager *databaseBackupManager) {
+ h.databaseBackup = manager
+}
+
// ListUsers godoc
// @Summary 管理员查询用户
// @Description 管理员分页查看所有用户,实现账户隔离管理
diff --git a/backend/internal/transport/http/admin/handler_database_backup.go b/backend/internal/transport/http/admin/handler_database_backup.go
new file mode 100644
index 00000000..59a39009
--- /dev/null
+++ b/backend/internal/transport/http/admin/handler_database_backup.go
@@ -0,0 +1,99 @@
+package admin
+
+import (
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware"
+ "github.com/gin-gonic/gin"
+)
+
+func (h *Handler) DatabaseBackupInfo(c *gin.Context) {
+ if !h.requireSuperAdmin(c) || h.databaseBackup == nil {
+ return
+ }
+ response.Success(c, gin.H{"driver": h.databaseBackup.driver(), "maxRestoreBytes": maxDatabaseRestoreBytes})
+}
+
+func (h *Handler) DownloadDatabaseBackup(c *gin.Context) {
+ if !h.requireSuperAdmin(c) || h.databaseBackup == nil {
+ return
+ }
+ backup, err := h.databaseBackup.Backup(c.Request.Context())
+ if err != nil {
+ h.handleDatabaseOperationError(c, err, "database backup failed")
+ return
+ }
+ defer os.Remove(backup.Path)
+ c.Header("Cache-Control", "no-store")
+ c.FileAttachment(backup.Path, backup.Name)
+}
+
+func (h *Handler) RestoreDatabaseBackup(c *gin.Context) {
+ if !h.requireSuperAdmin(c) || h.databaseBackup == nil {
+ return
+ }
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxDatabaseRestoreBytes+1024*1024)
+ file, err := c.FormFile("file")
+ if err != nil {
+ response.Error(c, http.StatusBadRequest, "database backup file is required")
+ return
+ }
+ if file.Size <= 0 || file.Size > maxDatabaseRestoreBytes {
+ response.Error(c, http.StatusBadRequest, "database backup file size is invalid")
+ return
+ }
+ expected := ".dump"
+ if h.databaseBackup.driver() == "sqlite" {
+ expected = ".sqlite3"
+ }
+ if strings.ToLower(filepath.Ext(file.Filename)) != expected {
+ response.Error(c, http.StatusBadRequest, "database backup file type does not match current driver")
+ return
+ }
+
+ upload, err := os.CreateTemp("", "deeix-chat-restore-*"+expected)
+ if err != nil {
+ response.Error(c, http.StatusInternalServerError, "prepare database restore failed")
+ return
+ }
+ path := upload.Name()
+ defer os.Remove(path)
+ source, err := file.Open()
+ var copied int64
+ if err == nil {
+ copied, err = copyRestoreUpload(upload, source)
+ _ = source.Close()
+ }
+ closeErr := upload.Close()
+ if err != nil || closeErr != nil || copied > maxDatabaseRestoreBytes {
+ response.Error(c, http.StatusBadRequest, "read database backup failed")
+ return
+ }
+ if err := h.databaseBackup.Restore(c.Request.Context(), path); err != nil {
+ h.handleDatabaseOperationError(c, err, "database restore failed")
+ return
+ }
+ response.Success(c, gin.H{"restored": true})
+}
+
+func (h *Handler) requireSuperAdmin(c *gin.Context) bool {
+ if middleware.MustUserRole(c) != string(domainuser.RoleSuperAdmin) {
+ response.Error(c, http.StatusForbidden, "superadmin permission required")
+ return false
+ }
+ return true
+}
+
+func (h *Handler) handleDatabaseOperationError(c *gin.Context, err error, fallback string) {
+ if errors.Is(err, errDatabaseOperationBusy) {
+ response.Error(c, http.StatusConflict, errDatabaseOperationBusy.Error())
+ return
+ }
+ response.Error(c, http.StatusInternalServerError, fallback)
+}
diff --git a/backend/internal/transport/http/admin/router.go b/backend/internal/transport/http/admin/router.go
index 4a94e016..f315c11d 100644
--- a/backend/internal/transport/http/admin/router.go
+++ b/backend/internal/transport/http/admin/router.go
@@ -4,6 +4,9 @@ import "github.com/gin-gonic/gin"
// RegisterRoutes 注册后台管理路由(由管理员中间件保护)。
func (m *Module) RegisterRoutes(adminGroup *gin.RouterGroup) {
+ adminGroup.GET("/database/backup-info", m.Handler.DatabaseBackupInfo)
+ adminGroup.GET("/database/backup", m.Handler.DownloadDatabaseBackup)
+ adminGroup.POST("/database/restore", m.Handler.RestoreDatabaseBackup)
adminGroup.POST("/users", m.Handler.CreateUser)
adminGroup.GET("/users", m.Handler.ListUsers)
adminGroup.POST("/users/import/openwebui", m.Handler.ImportOpenWebUIUsers)
diff --git a/backend/internal/transport/http/middleware/security_headers.go b/backend/internal/transport/http/middleware/security_headers.go
index 39850f1f..bec04620 100644
--- a/backend/internal/transport/http/middleware/security_headers.go
+++ b/backend/internal/transport/http/middleware/security_headers.go
@@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin"
)
-const defaultContentSecurityPolicy = "default-src 'self'; base-uri 'self'; object-src 'self' blob:; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; connect-src 'self' http: https: ws: wss: blob:; worker-src 'self' blob:; frame-src 'self' blob: https://challenges.cloudflare.com"
+const defaultContentSecurityPolicy = "default-src 'self'; base-uri 'self'; object-src 'self' blob:; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob: https://worldloom.top https://cdn.ldstatic.com; media-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; connect-src 'self' http: https: ws: wss: blob:; worker-src 'self' blob:; frame-src 'self' blob: https://challenges.cloudflare.com"
// SecurityHeaders 为所有 HTTP 响应补充安全响应头;文件内容响应可在 handler 中覆盖为更严格策略。
func SecurityHeaders(env string) gin.HandlerFunc {
diff --git a/frontend/app/(admin)/admin/database/page.tsx b/frontend/app/(admin)/admin/database/page.tsx
new file mode 100644
index 00000000..7e6a2972
--- /dev/null
+++ b/frontend/app/(admin)/admin/database/page.tsx
@@ -0,0 +1,5 @@
+import { AdminDatabasePage } from "@/features/admin/components/sections/database/admin-database";
+
+export default function DatabasePage() {
+ return ;
+}
diff --git a/frontend/features/admin/api/database.ts b/frontend/features/admin/api/database.ts
new file mode 100644
index 00000000..201855c7
--- /dev/null
+++ b/frontend/features/admin/api/database.ts
@@ -0,0 +1,28 @@
+import { authedFetch, authedRequest } from "@/shared/api/authed-client";
+
+export type AdminDatabaseBackupInfo = { driver: "postgres" | "sqlite"; maxRestoreBytes: number };
+
+export function getAdminDatabaseBackupInfo(accessToken: string): Promise {
+ return authedRequest("/api/v1/admin/database/backup-info", { accessToken });
+}
+
+export async function downloadAdminDatabaseBackup(accessToken: string): Promise {
+ const response = await authedFetch("/api/v1/admin/database/backup", { method: "GET", accessToken });
+ const blob = await response.blob();
+ const disposition = response.headers.get("content-disposition") || "";
+ const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] || "deeix-chat-backup";
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+}
+
+export async function restoreAdminDatabaseBackup(accessToken: string, file: File): Promise {
+ const body = new FormData();
+ body.append("file", file);
+ await authedFetch("/api/v1/admin/database/restore", { method: "POST", accessToken, body });
+}
diff --git a/frontend/features/admin/api/index.ts b/frontend/features/admin/api/index.ts
index c6bb0a9a..8e0343cb 100644
--- a/frontend/features/admin/api/index.ts
+++ b/frontend/features/admin/api/index.ts
@@ -3,6 +3,7 @@ export * from "./announcements";
export * from "./auth";
export * from "./audit";
export * from "./billing";
+export * from "./database";
export * from "./llm";
export * from "./mcp";
export * from "./permission-groups";
diff --git a/frontend/features/admin/components/admin-sidebar.tsx b/frontend/features/admin/components/admin-sidebar.tsx
index 00e40d31..13442882 100644
--- a/frontend/features/admin/components/admin-sidebar.tsx
+++ b/frontend/features/admin/components/admin-sidebar.tsx
@@ -30,6 +30,7 @@ const ADMIN_SECTION_LABEL_KEYS: Record = {
"login-settings": "sections.loginSettings",
"conversation-settings": "sections.conversationSettings",
"chat-files": "sections.chatFiles",
+ database: "sections.database",
about: "sections.about",
};
diff --git a/frontend/features/admin/components/sections/database/admin-database.tsx b/frontend/features/admin/components/sections/database/admin-database.tsx
new file mode 100644
index 00000000..89bc6c6c
--- /dev/null
+++ b/frontend/features/admin/components/sections/database/admin-database.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import * as React from "react";
+import { DatabaseBackup, Download, RotateCcw, Upload } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
+import { downloadAdminDatabaseBackup, getAdminDatabaseBackupInfo, restoreAdminDatabaseBackup, type AdminDatabaseBackupInfo } from "@/features/admin/api";
+import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error";
+import { useAuthSession } from "@/shared/auth/auth-session-context";
+
+export function AdminDatabasePage() {
+ const t = useTranslations("adminDatabase");
+ const { accessToken, user } = useAuthSession();
+ const inputRef = React.useRef(null);
+ const [info, setInfo] = React.useState(null);
+ const [selectedFile, setSelectedFile] = React.useState(null);
+ const [confirmOpen, setConfirmOpen] = React.useState(false);
+ const [downloading, setDownloading] = React.useState(false);
+ const [restoring, setRestoring] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!accessToken || user?.role !== "superadmin") return;
+ void getAdminDatabaseBackupInfo(accessToken).then(setInfo).catch(() => undefined);
+ }, [accessToken, user?.role]);
+
+ if (user?.role !== "superadmin") return {t("superadminOnly")}
;
+
+ async function handleDownload() {
+ if (!accessToken || downloading) return;
+ setDownloading(true);
+ try { await downloadAdminDatabaseBackup(accessToken); toast.success(t("backup.success")); }
+ catch (error) { toast.error(resolveAdminErrorMessage(error, t("backup.failed"))); }
+ finally { setDownloading(false); }
+ }
+
+ async function handleRestore() {
+ if (!accessToken || !selectedFile || restoring) return;
+ setRestoring(true);
+ try {
+ await restoreAdminDatabaseBackup(accessToken, selectedFile);
+ toast.success(t("restore.success"));
+ setSelectedFile(null); setConfirmOpen(false);
+ if (inputRef.current) inputRef.current.value = "";
+ } catch (error) { toast.error(resolveAdminErrorMessage(error, t("restore.failed"))); }
+ finally { setRestoring(false); }
+ }
+
+ const extension = info?.driver === "sqlite" ? ".sqlite3" : ".dump";
+ return (
+
+
{t("title")}
{t("description")}
+
+
{t("backup.title")}{t("backup.description", { extension })}
+
{t("restore.title")}{t("restore.description", { extension })} setSelectedFile(event.target.files?.[0] ?? null)} />
+
+
{t("restore.confirmTitle")}{t("restore.confirmDescription", { filename: selectedFile?.name ?? "" })}{t("cancel")} { event.preventDefault(); void handleRestore(); }}>{restoring ? t("restore.restoring") : t("restore.confirmAction")}
+
+ );
+}
diff --git a/frontend/features/admin/model/admin-sections.ts b/frontend/features/admin/model/admin-sections.ts
index f1d80afb..a503192a 100644
--- a/frontend/features/admin/model/admin-sections.ts
+++ b/frontend/features/admin/model/admin-sections.ts
@@ -10,6 +10,7 @@ export const ADMIN_SECTIONS = [
{ id: "login-settings", label: "Login & auth", href: "/login" },
{ id: "conversation-settings", label: "Conversation", href: "/conversation" },
{ id: "chat-files", label: "Files & retrieval", href: "/chat-files" },
+ { id: "database", label: "Database", href: "/database" },
{ id: "about", label: "About", href: "/about" },
] as const;
diff --git a/frontend/i18n/messages.ts b/frontend/i18n/messages.ts
index 4aefbaec..97a92167 100644
--- a/frontend/i18n/messages.ts
+++ b/frontend/i18n/messages.ts
@@ -1,6 +1,7 @@
import enAdminAnnouncements from "@/i18n/messages/en-US/admin-announcements.json";
import enAdminBilling from "@/i18n/messages/en-US/admin-billing.json";
import enAdminConversation from "@/i18n/messages/en-US/admin-conversation.json";
+import enAdminDatabase from "@/i18n/messages/en-US/admin-database.json";
import enAdminFiles from "@/i18n/messages/en-US/admin-files.json";
import enAdminGroups from "@/i18n/messages/en-US/admin-groups.json";
import enAdminLogin from "@/i18n/messages/en-US/admin-login.json";
@@ -41,6 +42,7 @@ const ENGLISH_MESSAGES = {
adminAnnouncements: enAdminAnnouncements,
adminBilling: enAdminBilling,
adminConversation: enAdminConversation,
+ adminDatabase: enAdminDatabase,
adminFiles: enAdminFiles,
adminGroups: enAdminGroups,
adminLogin: enAdminLogin,
@@ -121,6 +123,7 @@ export async function loadLocaleMessages(locale: AppLocale): Promise