import React, { useEffect, useState } from 'react'; import { Layout } from '../components/Layout'; import { useNavigate } from 'react-router-dom'; import * as api from '../api'; import type { Collection } from '../types'; import { Upload, Moon, Sun, Info, Archive } from 'lucide-react'; import { ConfirmModal } from '../components/ConfirmModal'; import { importLegacyFiles } from '../utils/importUtils'; import { useTheme } from '../context/ThemeContext'; import { useAuth } from '../context/AuthContext'; export const Settings: React.FC = () => { const [collections, setCollections] = useState([]); const navigate = useNavigate(); const { theme, toggleTheme } = useTheme(); const { authEnabled, user } = useAuth(); const [legacyDbImportConfirmation, setLegacyDbImportConfirmation] = useState<{ isOpen: boolean; file: File | null; info: null | { drawings: number; collections: number; legacyLatestMigration: string | null; currentLatestMigration: string | null; }; }>({ isOpen: false, file: null, info: null }); const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const [importSuccess, setImportSuccess] = useState<{ isOpen: boolean; message: React.ReactNode }>({ isOpen: false, message: '' }); const [legacyDbImportLoading, setLegacyDbImportLoading] = useState(false); const [authToggleLoading, setAuthToggleLoading] = useState(false); const [authToggleError, setAuthToggleError] = useState(null); const [authToggleConfirm, setAuthToggleConfirm] = useState<{ isOpen: boolean; nextEnabled: boolean | null }>({ isOpen: false, nextEnabled: null, }); const [authDisableFinalConfirmOpen, setAuthDisableFinalConfirmOpen] = useState(false); const [backupExportExt, setBackupExportExt] = useState<'excalidash' | 'excalidash.zip'>('excalidash'); const [backupImportConfirmation, setBackupImportConfirmation] = useState<{ isOpen: boolean; file: File | null; info: null | { formatVersion: number; exportedAt: string; excalidashBackendVersion: string | null; collections: number; drawings: number; }; }>({ isOpen: false, file: null, info: null }); const [backupImportLoading, setBackupImportLoading] = useState(false); const [backupImportSuccess, setBackupImportSuccess] = useState(false); const [backupImportError, setBackupImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const appVersion = import.meta.env.VITE_APP_VERSION || 'Unknown version'; const buildLabel = import.meta.env.VITE_APP_BUILD_LABEL; useEffect(() => { const fetchCollections = async () => { try { const data = await api.getCollections(); setCollections(data); } catch (err) { console.error('Failed to fetch collections:', err); } }; fetchCollections(); }, []); const setAuthEnabled = async (enabled: boolean) => { setAuthToggleLoading(true); setAuthToggleError(null); try { const response = await api.api.post<{ authEnabled: boolean; bootstrapRequired?: boolean }>( '/auth/auth-enabled', { enabled }, ); if (response.data.authEnabled) { // Auth enabled -> bootstrap registration only when required. window.location.href = response.data.bootstrapRequired ? '/register' : '/login'; return; } // Auth disabled -> reload to drop auth gating. window.location.reload(); } catch (err: unknown) { let message = 'Failed to update authentication setting'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setAuthToggleError(message); } finally { setAuthToggleLoading(false); } }; const confirmToggleAuthEnabled = () => { if (authEnabled === null) return; if (authToggleLoading) return; setAuthToggleConfirm({ isOpen: true, nextEnabled: !authEnabled }); }; const exportBackup = async () => { try { const extQuery = backupExportExt === 'excalidash.zip' ? '?ext=zip' : ''; const response = await api.api.get(`/export/excalidash${extQuery}`, { responseType: 'blob' }); const blob = new Blob([response.data], { type: 'application/zip' }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; const date = new Date().toISOString().split('T')[0]; link.download = backupExportExt === 'excalidash.zip' ? `excalidash-backup-${date}.excalidash.zip` : `excalidash-backup-${date}.excalidash`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (err: unknown) { console.error('Backup export failed:', err); setBackupImportError({ isOpen: true, message: 'Failed to export backup. Please try again.' }); } }; const verifyBackupFile = async (file: File) => { setBackupImportLoading(true); try { const formData = new FormData(); formData.append('archive', file); const response = await api.api.post<{ valid: boolean; formatVersion: number; exportedAt: string; excalidashBackendVersion: string | null; collections: number; drawings: number; }>('/import/excalidash/verify', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setBackupImportConfirmation({ isOpen: true, file, info: { formatVersion: response.data.formatVersion, exportedAt: response.data.exportedAt, excalidashBackendVersion: response.data.excalidashBackendVersion ?? null, collections: response.data.collections, drawings: response.data.drawings, }, }); } catch (err: unknown) { console.error('Backup verify failed:', err); let message = 'Failed to verify backup file.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setBackupImportError({ isOpen: true, message }); } finally { setBackupImportLoading(false); } }; const verifyLegacyDbFile = async (file: File) => { setLegacyDbImportLoading(true); try { const formData = new FormData(); formData.append('db', file); const response = await api.api.post<{ valid: boolean; drawings: number; collections: number; latestMigration: string | null; currentLatestMigration: string | null; }>('/import/sqlite/legacy/verify', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setLegacyDbImportConfirmation({ isOpen: true, file, info: { drawings: response.data.drawings, collections: response.data.collections, legacyLatestMigration: response.data.latestMigration ?? null, currentLatestMigration: response.data.currentLatestMigration ?? null, }, }); } catch (err: unknown) { console.error('Legacy DB verify failed:', err); let message = 'Failed to verify legacy database file.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setImportError({ isOpen: true, message }); } finally { setLegacyDbImportLoading(false); } }; const handleCreateCollection = async (name: string) => { await api.createCollection(name); const newCollections = await api.getCollections(); setCollections(newCollections); }; const handleEditCollection = async (id: string, name: string) => { setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c)); await api.updateCollection(id, name); }; const handleDeleteCollection = async (id: string) => { setCollections(prev => prev.filter(c => c.id !== id)); await api.deleteCollection(id); }; const handleSelectCollection = (id: string | null | undefined) => { if (id === undefined) navigate('/'); else if (id === null) navigate('/collections?id=unorganized'); else navigate(`/collections?id=${id}`); }; return (

Settings

{authToggleError && (

{authToggleError}

)}

Export Backup

Exports an `.excalidash` archive (zip) organized by collections

{ const file = (e.target.files || [])[0]; if (!file) return; await verifyBackupFile(file); e.target.value = ''; }} />

Version Info

{appVersion} {buildLabel && ( {buildLabel} )}
Advanced / Legacy
{ const files = Array.from(e.target.files || []); if (files.length === 0) return; const databaseFile = files.find(f => f.name.endsWith('.sqlite') || f.name.endsWith('.db')); if (databaseFile) { if (files.length > 1) { setImportError({ isOpen: true, message: 'Please import legacy database files separately from other files.' }); e.target.value = ''; return; } await verifyLegacyDbFile(databaseFile); e.target.value = ''; return; } const result = await importLegacyFiles(files, null, () => { }); if (result.failed > 0) { setImportError({ isOpen: true, message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}` }); } else { setImportSuccess({ isOpen: true, message: `Imported ${result.success} file(s).` }); } e.target.value = ''; }} />
This will merge legacy data into your account (it will not replace the server database).
{legacyDbImportConfirmation.info && (
Drawings: {legacyDbImportConfirmation.info.drawings}
Collections: {legacyDbImportConfirmation.info.collections}
Legacy migration: {legacyDbImportConfirmation.info.legacyLatestMigration || 'Unknown'}
Current migration: {legacyDbImportConfirmation.info.currentLatestMigration || 'Unknown'}
)} } confirmText="Merge Import" cancelText="Cancel" onConfirm={async () => { const file = legacyDbImportConfirmation.file; if (!file) return; setLegacyDbImportConfirmation({ isOpen: false, file: null, info: null }); const formData = new FormData(); formData.append('db', file); try { const response = await api.api.post<{ success: boolean; collections: { created: number; updated: number; idConflicts: number }; drawings: { created: number; updated: number; idConflicts: number }; }>('/import/sqlite/legacy', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setImportSuccess({ isOpen: true, message: `Legacy DB imported. Collections: +${response.data.collections.created} / ~${response.data.collections.updated}. Drawings: +${response.data.drawings.created} / ~${response.data.drawings.updated}.`, }); } catch (err: unknown) { console.error(err); let message = 'Failed to import legacy database.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setImportError({ isOpen: true, message }); } }} onCancel={() => setLegacyDbImportConfirmation({ isOpen: false, file: null, info: null })} /> setImportError({ isOpen: false, message: '' })} onCancel={() => setImportError({ isOpen: false, message: '' })} /> setImportSuccess({ isOpen: false, message: '' })} onCancel={() => setImportSuccess({ isOpen: false, message: '' })} />
This will turn off authentication for the entire instance.
Recommendation: keep authentication enabled unless this instance is fully private.
) } confirmText={authToggleConfirm.nextEnabled ? 'Enable' : 'Continue'} cancelText="Cancel" isDangerous={!authToggleConfirm.nextEnabled} onConfirm={async () => { const nextEnabled = authToggleConfirm.nextEnabled; setAuthToggleConfirm({ isOpen: false, nextEnabled: null }); if (typeof nextEnabled !== 'boolean') return; if (!nextEnabled) { setAuthDisableFinalConfirmOpen(true); return; } await setAuthEnabled(nextEnabled); }} onCancel={() => setAuthToggleConfirm({ isOpen: false, nextEnabled: null })} />
With authentication off, any user who can access this URL can view and modify all drawings and settings. They can also turn authentication back on and lock you out.
This is only safe on a trusted private network.
} confirmText="Disable Authentication" cancelText="Keep Enabled (Recommended)" isDangerous onConfirm={async () => { setAuthDisableFinalConfirmOpen(false); await setAuthEnabled(false); }} onCancel={() => setAuthDisableFinalConfirmOpen(false)} /> { const file = backupImportConfirmation.file; if (!file) return; setBackupImportConfirmation({ ...backupImportConfirmation, isOpen: false }); setBackupImportLoading(true); try { const formData = new FormData(); formData.append('archive', file); await api.api.post('/import/excalidash', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setBackupImportConfirmation({ isOpen: false, file: null, info: null }); setBackupImportSuccess(true); } catch (err: unknown) { console.error('Backup import failed:', err); let message = 'Failed to import backup.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setBackupImportError({ isOpen: true, message }); setBackupImportConfirmation({ isOpen: false, file: null, info: null }); } finally { setBackupImportLoading(false); } }} onCancel={() => setBackupImportConfirmation({ isOpen: false, file: null, info: null })} /> setBackupImportSuccess(false)} onCancel={() => setBackupImportSuccess(false)} /> setBackupImportError({ isOpen: false, message: '' })} onCancel={() => setBackupImportError({ isOpen: false, message: '' })} />
); };