diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java index 6cb5d2bce..59a35b32f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java @@ -12,8 +12,9 @@ public class H2SQLCondition implements Condition { boolean enableCustomDatabase = env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false); - if (enableCustomDatabase) { - return false; + // If custom database is not enabled, H2 is used by default + if (!enableCustomDatabase) { + return true; } String dataSourceType = env.getProperty("system.datasource.type", String.class, ""); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java index dead1b44f..ba4a2b410 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java @@ -36,12 +36,12 @@ class H2SQLConditionTest { @Test void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() { - // Flag=true, Typ=h2 -> false + // Flag=true, Typ=h2 -> true MockEnvironment envTrueH2 = new MockEnvironment() .withProperty("system.datasource.enableCustomDatabase", "true") .withProperty("system.datasource.type", "h2"); - assertFalse(eval(envTrueH2)); + assertTrue(eval(envTrueH2)); // Flag=true, Typ=postgres -> false MockEnvironment envTrueOther = @@ -57,20 +57,6 @@ class H2SQLConditionTest { assertFalse(eval(envTrueMissingType)); } - @Test - void returnsFalse_whenTypeNotH2_orMissing_andFlagNotEnabled() { - // Flag fehlt, Typ=postgres -> false - MockEnvironment envNotH2 = - new MockEnvironment().withProperty("system.datasource.type", "postgresql"); - assertFalse(eval(envNotH2)); - - // Flag=false, Typ fehlt -> false (Default: "") - MockEnvironment envMissingType = - new MockEnvironment() - .withProperty("system.datasource.enableCustomDatabase", "false"); - assertFalse(eval(envMissingType)); - } - @Test void returnsFalse_whenEnabled_but_type_not_h2_or_missing() { MockEnvironment envNotH2 = @@ -84,4 +70,18 @@ class H2SQLConditionTest { .withProperty("system.datasource.enableCustomDatabase", "true"); assertFalse(eval(envMissingType)); } + + @Test + void returnsTrue_whenTypeNotH2_andCustomDatabaseDisabled() { + MockEnvironment envNotH2 = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "false") + .withProperty("system.datasource.type", "postgresql"); + assertTrue(eval(envNotH2)); + + MockEnvironment envMissingType = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "false"); + assertTrue(eval(envMissingType)); + } } diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index b7571bdfb..ed02b6284 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4759,6 +4759,48 @@ mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disa title = "Database" description = "Configure custom database connection settings for enterprise deployments." configuration = "Database Configuration" +backupTitle = "Backups & Restore" +backupDescription = "Manage H2 backups directly from the admin console." +loadError = "Failed to load database backups" +backupCreated = "Backup created successfully" +backupFailed = "Failed to create backup" +deleteTitle = "Delete backup" +deleteConfirm = "Delete this backup? This cannot be undone." +deleteSuccess = "Backup deleted" +deleteFailed = "Failed to delete backup" +deleteConfirmAction = "Delete backup" +downloadFailed = "Failed to download backup" +version = "H2 Version" +embedded = "Embedded H2" +external = "External DB" +h2Only = "Backups are available only for the embedded H2 database." +h2Hint = "Set the database type to H2 and disable custom database to enable backup and restore." +manageBackups = "Manage backups" +refresh = "Refresh" +createBackup = "Create backup" +uploadTitle = "Upload & import" +chooseFile = "Choose a .sql backup file" +importFromUpload = "Import upload" +confirmImportTitle = "Confirm database import" +overwriteWarning = "Warning: This will overwrite the current database." +overwriteWarningBody = "All existing data will be replaced by the uploaded backup. This action cannot be undone." +confirmCodeLabel = "Enter the confirmation code to proceed" +enterCode = "Enter the code shown above" +confirmImport = "Confirm import" +codeMismatch = "Confirmation code does not match" +codeMismatchBody = "Please enter the code exactly as shown to proceed." +selectFile = "Please select a .sql file to import" +importSuccess = "Backup imported successfully" +importFailed = "Failed to import backup" +noBackups = "No backups found yet." +unavailable = "Backup list unavailable for the current database configuration." +fileName = "File" +created = "Created" +size = "Size" +actions = "Actions" +download = "Download" +import = "Import" +delete = "Delete" [admin.settings.database.enableCustom] label = "Enable Custom Database" diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx index fdc4768bb..d4655c914 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx @@ -1,14 +1,38 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, TextInput, PasswordInput, Select, Badge } from '@mantine/core'; -import { alert } from '@app/components/toast'; -import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; -import { useRestartServer } from '@app/components/shared/config/useRestartServer'; -import { useAdminSettings } from '@app/hooks/useAdminSettings'; -import PendingBadge from '@app/components/shared/config/PendingBadge'; -import { useLoginRequired } from '@app/hooks/useLoginRequired'; -import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; -import apiClient from '@app/services/apiClient'; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + NumberInput, + Switch, + Button, + Stack, + Paper, + Text, + Loader, + Group, + TextInput, + PasswordInput, + Select, + Badge, + Table, + ActionIcon, + Tooltip, + FileInput, + Alert, + Divider, + Box, + Modal, +} from "@mantine/core"; +import { alert } from "@app/components/toast"; +import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal"; +import { useRestartServer } from "@app/components/shared/config/useRestartServer"; +import { useAdminSettings } from "@app/hooks/useAdminSettings"; +import PendingBadge from "@app/components/shared/config/PendingBadge"; +import { useLoginRequired } from "@app/hooks/useLoginRequired"; +import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; +import apiClient from "@app/services/apiClient"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import databaseManagementService, { DatabaseBackupFile } from "@app/services/databaseManagementService"; +import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; interface DatabaseSettingsData { enableCustomDatabase?: boolean; @@ -25,60 +49,66 @@ export default function AdminDatabaseSection() { const { t } = useTranslation(); const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const [backupFiles, setBackupFiles] = useState([]); + const [databaseVersion, setDatabaseVersion] = useState(null); + const [backupsLoading, setBackupsLoading] = useState(false); + const [creatingBackup, setCreatingBackup] = useState(false); + const [importingUpload, setImportingUpload] = useState(false); + const [importingBackupFile, setImportingBackupFile] = useState(null); + const [deletingFile, setDeletingFile] = useState(null); + const [downloadingFile, setDownloadingFile] = useState(null); + const [uploadFile, setUploadFile] = useState(null); + const [confirmImportOpen, setConfirmImportOpen] = useState(false); + const [deleteConfirmFile, setDeleteConfirmFile] = useState(null); + const [confirmCode, setConfirmCode] = useState(""); + const [confirmInput, setConfirmInput] = useState(""); - const { - settings, - setSettings, - loading, - saving, - fetchSettings, - saveSettings, - isFieldPending, - } = useAdminSettings({ - sectionName: 'database', - fetchTransformer: async () => { - const response = await apiClient.get('/api/v1/admin/settings/section/system'); - const systemData = response.data || {}; + const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } = + useAdminSettings({ + sectionName: "database", + fetchTransformer: async () => { + const response = await apiClient.get("/api/v1/admin/settings/section/system"); + const systemData = response.data || {}; - // Extract datasource from system response and handle pending - const datasource = systemData.datasource || { - enableCustomDatabase: false, - customDatabaseUrl: '', - username: '', - password: '', - type: 'postgresql', - hostName: 'localhost', - port: 5432, - name: 'postgres' - }; + // Extract datasource from system response and handle pending + const datasource = systemData.datasource || { + enableCustomDatabase: false, + customDatabaseUrl: "", + username: "", + password: "", + type: "postgresql", + hostName: "localhost", + port: 5432, + name: "postgres", + }; - // Map pending changes from system._pending.datasource to root level - const result: any = { ...datasource }; - if (systemData._pending?.datasource) { - result._pending = systemData._pending.datasource; - } + // Map pending changes from system._pending.datasource to root level + const result: any = { ...datasource }; + if (systemData._pending?.datasource) { + result._pending = systemData._pending.datasource; + } - return result; - }, - saveTransformer: (settings) => { - // Convert flat settings to dot-notation for delta endpoint - const deltaSettings: Record = { - 'system.datasource.enableCustomDatabase': settings.enableCustomDatabase, - 'system.datasource.customDatabaseUrl': settings.customDatabaseUrl, - 'system.datasource.username': settings.username, - 'system.datasource.password': settings.password, - 'system.datasource.type': settings.type, - 'system.datasource.hostName': settings.hostName, - 'system.datasource.port': settings.port, - 'system.datasource.name': settings.name - }; + return result; + }, + saveTransformer: (settings) => { + // Convert flat settings to dot-notation for delta endpoint + const deltaSettings: Record = { + "system.datasource.enableCustomDatabase": settings.enableCustomDatabase, + "system.datasource.customDatabaseUrl": settings.customDatabaseUrl, + "system.datasource.username": settings.username, + "system.datasource.password": settings.password, + "system.datasource.type": settings.type, + "system.datasource.hostName": settings.hostName, + "system.datasource.port": settings.port, + "system.datasource.name": settings.name, + }; - return { - sectionData: {}, - deltaSettings - }; - } - }); + return { + sectionData: {}, + deltaSettings, + }; + }, + }); useEffect(() => { if (loginEnabled) { @@ -86,6 +116,45 @@ export default function AdminDatabaseSection() { } }, [loginEnabled, fetchSettings]); + const datasourceType = (settings?.type || "").toLowerCase(); + const isCustomDatabase = settings?.enableCustomDatabase === true; + const isEmbeddedH2 = useMemo(() => { + if (isCustomDatabase === false) { + return true; + } + if (datasourceType === "h2") { + return true; + } + return false; + }, [isCustomDatabase, datasourceType]); + + const loadBackupData = async () => { + if (!loginEnabled || !isEmbeddedH2) { + setBackupFiles([]); + setDatabaseVersion(null); + return; + } + setBackupsLoading(true); + try { + const data = await databaseManagementService.getDatabaseData(); + setBackupFiles(data.backupFiles || []); + setDatabaseVersion(data.databaseVersion || null); + } catch (error: any) { + const message = error?.response?.data?.message || error?.message; + alert({ + alertType: "error", + title: t("admin.settings.database.loadError", "Failed to load database backups"), + body: message, + }); + } finally { + setBackupsLoading(false); + } + }; + + useEffect(() => { + loadBackupData(); + }, [loginEnabled, isEmbeddedH2, isCustomDatabase, datasourceType]); + const handleSave = async () => { if (!validateLoginEnabled()) { return; @@ -96,13 +165,169 @@ export default function AdminDatabaseSection() { showRestartModal(); } catch (_error) { alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.saveError', 'Failed to save settings'), + alertType: "error", + title: t("admin.error", "Error"), + body: t("admin.settings.saveError", "Failed to save settings"), }); } }; + const handleCreateBackup = async () => { + if (!validateLoginEnabled()) return; + setCreatingBackup(true); + try { + await databaseManagementService.createBackup(); + alert({ alertType: "success", title: t("admin.settings.database.backupCreated", "Backup created successfully") }); + await loadBackupData(); + } catch (error: any) { + const message = error?.response?.data?.message || error?.message; + alert({ + alertType: "error", + title: t("admin.settings.database.backupFailed", "Failed to create backup"), + body: message, + }); + } finally { + setCreatingBackup(false); + } + }; + + const performUploadImport = async () => { + if (!uploadFile) return; + setImportingUpload(true); + try { + await databaseManagementService.uploadAndImport(uploadFile); + alert({ alertType: "success", title: t("admin.settings.database.importSuccess", "Backup imported successfully") }); + setUploadFile(null); + await loadBackupData(); + } catch (error: any) { + const message = error?.response?.data?.message || error?.message; + alert({ + alertType: "error", + title: t("admin.settings.database.importFailed", "Failed to import backup"), + body: message, + }); + } finally { + setImportingUpload(false); + } + }; + + const generateConfirmationCode = () => { + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const array = new Uint32Array(1); + crypto.getRandomValues(array); + const randomNumber = array[0] % 10000; // 0-9999 + return randomNumber.toString().padStart(4, "0"); + } + // Fallback: non-cryptographic but avoids Math.random(); this is only a UX safeguard. + const fallbackNumber = Date.now() % 10000; + return fallbackNumber.toString().padStart(4, "0"); + }; + + const handleUploadImport = () => { + if (!validateLoginEnabled()) return; + if (!uploadFile) { + alert({ alertType: "warning", title: t("admin.settings.database.selectFile", "Please select a .sql file to import") }); + return; + } + const code = generateConfirmationCode(); + setConfirmCode(code); + setConfirmInput(""); + setConfirmImportOpen(true); + }; + + const closeConfirmImportModal = () => { + setConfirmImportOpen(false); + setConfirmInput(""); + }; + + const handleConfirmImport = async () => { + if (confirmInput !== confirmCode) { + alert({ + alertType: "warning", + title: t("admin.settings.database.codeMismatch", "Confirmation code does not match"), + body: t("admin.settings.database.codeMismatchBody", "Please enter the code exactly as shown to proceed."), + }); + return; + } + closeConfirmImportModal(); + await performUploadImport(); + }; + + const handleImportExisting = async (fileName: string) => { + if (!validateLoginEnabled()) return; + setImportingBackupFile(fileName); + try { + await databaseManagementService.importFromFileName(fileName); + alert({ alertType: "success", title: t("admin.settings.database.importSuccess", "Backup imported successfully") }); + await loadBackupData(); + } catch (error: any) { + const message = error?.response?.data?.message || error?.message; + alert({ + alertType: "error", + title: t("admin.settings.database.importFailed", "Failed to import backup"), + body: message, + }); + } finally { + setImportingBackupFile(null); + } + }; + + const handleDelete = async (fileName: string) => { + if (!validateLoginEnabled()) return; + setDeletingFile(fileName); + try { + await databaseManagementService.deleteBackup(fileName); + alert({ alertType: "success", title: t("admin.settings.database.deleteSuccess", "Backup deleted") }); + await loadBackupData(); + } catch (error: any) { + const message = error?.response?.data?.message || error?.message; + alert({ + alertType: "error", + title: t("admin.settings.database.deleteFailed", "Failed to delete backup"), + body: message, + }); + } finally { + setDeletingFile(null); + setDeleteConfirmFile(null); + } + }; + + const handleDeleteClick = (fileName: string) => { + if (!validateLoginEnabled()) return; + setDeleteConfirmFile(fileName); + }; + + const handleDownload = async (fileName: string) => { + if (!validateLoginEnabled()) return; + setDownloadingFile(fileName); + let url: string | null = null; + + const link = document.createElement("a"); + try { + const blob = await databaseManagementService.downloadBackup(fileName); + url = window.URL.createObjectURL(blob); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + } catch (error: any) { + const message = error?.response?.data?.message || error?.message; + alert({ + alertType: "error", + title: t("admin.settings.database.downloadFailed", "Failed to download backup"), + body: message, + }); + } finally { + if (link.isConnected) { + link.remove(); + } + if (url) { + window.URL.revokeObjectURL(url); + } + setDownloadingFile(null); + } + }; + // Override loading state when login is disabled const actualLoading = loginEnabled ? loading : false; @@ -121,25 +346,39 @@ export default function AdminDatabaseSection() {
- {t('admin.settings.database.title', 'Database')} + + {t("admin.settings.database.title", "Database")} + - {t('admin.settings.database.description', 'Configure custom database connection settings for enterprise deployments.')} + {t( + "admin.settings.database.description", + "Configure custom database connection settings for enterprise deployments.", + )}
- ENTERPRISE + + ENTERPRISE +
{/* Database Configuration */} - {t('admin.settings.database.configuration', 'Database Configuration')} + + {t("admin.settings.database.configuration", "Database Configuration")} + -
+
- {t('admin.settings.database.enableCustom.label', 'Enable Custom Database')} + + {t("admin.settings.database.enableCustom.label", "Enable Custom Database")} + - {t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')} + {t( + "admin.settings.database.enableCustom.description", + "Use your own custom database configuration instead of the default embedded database", + )}
@@ -152,7 +391,7 @@ export default function AdminDatabaseSection() { disabled={!loginEnabled} styles={getDisabledStyles()} /> - +
@@ -162,12 +401,15 @@ export default function AdminDatabaseSection() { - {t('admin.settings.database.customUrl.label', 'Custom Database URL')} - + {t("admin.settings.database.customUrl.label", "Custom Database URL")} + } - description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')} - value={settings?.customDatabaseUrl || ''} + description={t( + "admin.settings.database.customUrl.description", + "Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.", + )} + value={settings?.customDatabaseUrl || ""} onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })} placeholder="jdbc:postgresql://localhost:5432/postgres" disabled={!loginEnabled} @@ -178,18 +420,21 @@ export default function AdminDatabaseSection() {