import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { ChevronLeft, ChevronRight, Database, Download, Eye, EyeOff, Play, RefreshCw, RotateCcw, Trash2, Upload, Wrench, } from 'lucide-react'; import { api } from '@/api'; import { cn, fmtBytes } from '@/lib/utils'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; import AppNavigation from '@/components/layout/Sidebar'; // ─── Helpers ────────────────────────────────────────────────────────────────── function SectionHeading({ children }) { return

{children}

; } function FieldRow({ label, children }) { return (
{children}
); } function Toggle({ checked, onChange, label, disabled = false }) { return ( ); } function defaultOidcRedirectUri() { if (typeof window === 'undefined') return ''; return `${window.location.origin}/api/auth/oidc/callback`; } function looksLikeOidcEndpoint(url) { const value = String(url || '').toLowerCase(); return /\/(?:authorize|token|userinfo|jwks|certs)\/?$/.test(value); } function formatDateTime(value) { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(); } function BackupTypeBadge({ type }) { const cls = { manual: 'bg-blue-500/15 text-blue-400 border-blue-500/20', scheduled: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20', imported: 'bg-violet-500/15 text-violet-400 border-violet-500/20', 'pre-restore': 'bg-amber-500/15 text-amber-400 border-amber-500/20', }[type] || 'bg-muted text-muted-foreground border-border'; return {type || 'backup'}; } // ─── Onboarding Wizard ──────────────────────────────────────────────────────── function OnboardingWizard({ onComplete }) { const [step, setStep] = useState(0); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [loading, setLoading] = useState(false); const handleCreate = async (e) => { e.preventDefault(); if (password !== confirm) { toast.error('Passwords do not match.'); return; } if (password.length < 6) { toast.error('Password must be at least 6 characters.'); return; } setLoading(true); try { await api.createUser({ username, password }); toast.success('User created successfully.'); onComplete(); } catch (err) { toast.error(err.message || 'Failed to create user.'); } finally { setLoading(false); } }; return (
{/* Step dots */}
{[0, 1].map(i => ( ))}
{step === 0 && ( Welcome, Administrator

Before creating your first user, please understand what your admin account can and cannot do.

{[ { can: true, text: 'Create and manage user accounts' }, { can: true, text: 'Reset user passwords' }, { can: true, text: 'Configure email notifications' }, { can: true, text: 'Toggle single-user / multi-user mode' }, { can: false, text: 'Cannot view bills or financial data' }, { can: false, text: 'Cannot access user settings or history' }, ].map(({ can, text }) => (
{can ? '✓' : '✗'} {text}
))}
)} {step === 1 && ( Create first user

This account will be used to access the bill tracker.

setUsername(e.target.value)} required />
setPassword(e.target.value)} required />
setConfirm(e.target.value)} required />
)}
); } // ─── Email Notifications Card ───────────────────────────────────────────────── function EmailNotifCard() { const DEFAULTS = { enabled: false, sender_name: '', sender_address: '', smtp_host: '', smtp_port: '587', smtp_encryption: 'starttls', smtp_self_signed: false, smtp_username: '', smtp_password: '', allow_user_config: false, global_recipient: '', }; const [cfg, setCfg] = useState(DEFAULTS); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [showPw, setShowPw] = useState(false); const [testEmail, setTestEmail] = useState(''); const [testing, setTesting] = useState(false); useEffect(() => { api.notifAdmin() .then(d => setCfg({ ...DEFAULTS, ...d })) .catch(() => {}) .finally(() => setLoading(false)); }, []); // eslint-disable-line react-hooks/exhaustive-deps const set = (k, v) => setCfg(p => ({ ...p, [k]: v })); const handleSave = async () => { setSaving(true); try { await api.saveNotifAdmin(cfg); toast.success('Email settings saved.'); } catch (err) { toast.error(err.message || 'Failed to save.'); } finally { setSaving(false); } }; const handleTest = async () => { if (!testEmail) { toast.error('Enter a recipient email.'); return; } setTesting(true); try { await api.testEmail({ to: testEmail }); toast.success('Test email sent.'); } catch (err) { toast.error(err.message || 'Failed to send test email.'); } finally { setTesting(false); } }; if (loading) return Loading…; return ( Email Notifications {/* Enable toggle */}

Enable email notifications

Configure SMTP to send bill reminders

set('enabled', v)} label="Enable email notifications" />
Sender set('sender_name', e.target.value)} placeholder="BillTracker" /> set('sender_address', e.target.value)} placeholder="no-reply@example.com" type="email" />
SMTP Server set('smtp_host', e.target.value)} placeholder="smtp.example.com" /> set('smtp_port', e.target.value)} placeholder="587" type="number" className="w-28" />
set('smtp_self_signed', e.target.checked)} className="h-4 w-4 rounded border-input bg-input accent-primary" />
set('smtp_username', e.target.value)} placeholder="user@example.com" />
set('smtp_password', e.target.value)} placeholder="••••••••" className="pr-9" />
User Access
set('allow_user_config', e.target.checked)} className="h-4 w-4 rounded border-input bg-input accent-primary" />
set('global_recipient', e.target.value)} placeholder="recipient@example.com" type="email" />
Test Email
setTestEmail(e.target.value)} placeholder="you@example.com" type="email" />
); } // ─── Login Mode Card ────────────────────────────────────────────────────────── function LoginModeCard({ users }) { const [modeData, setModeData] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [selectedUser, setSelectedUser] = useState(''); // Single-user mode confirmation dialog const [confirmSingle, setConfirmSingle] = useState(false); const [pendingUserId, setPendingUserId] = useState(null); useEffect(() => { api.authModeConfig() .then(d => { setModeData(d); setSelectedUser(d.default_user_id?.toString() || ''); }) .catch(() => {}) .finally(() => setLoading(false)); }, []); const doSetMode = async (mode, userId) => { setSaving(true); try { await api.setAuthMode({ auth_mode: mode, default_user_id: mode === 'single' ? parseInt(userId, 10) : null, }); const d = await api.authModeConfig(); setModeData(d); toast.success(mode === 'single' ? 'Single-user mode enabled.' : 'Login requirement restored.'); } catch (err) { toast.error(err.message || 'Failed to update auth mode.'); } finally { setSaving(false); } }; const handleRequestSingle = () => { if (!selectedUser) { toast.error('Select a user first.'); return; } setPendingUserId(selectedUser); setConfirmSingle(true); }; const handleConfirmSingle = () => { setConfirmSingle(false); doSetMode('single', pendingUserId); }; if (loading) return Loading…; const isMulti = !modeData || modeData.auth_mode === 'multi'; const activeUser = users?.find(u => u.id === modeData?.default_user_id); const selectedUsername = users?.find(u => u.id.toString() === selectedUser)?.username ?? selectedUser; return ( <>
Login Mode {isMulti ? 'Multi-user' : 'Single-user'}
{isMulti ? ( <>

Single-user mode bypasses the login screen and automatically signs in as the selected user.

) : ( <>

Currently auto-signing in as{' '} {activeUser?.username ?? '—'}. Restoring login requirement will require all users to sign in manually.

)}
{/* Single-user mode confirmation */} Enable Single-User Mode? Anyone who opens the app will be automatically signed in as{' '} {selectedUsername}. The admin login still requires a password. Cancel Enable Single-User Mode ); } // ─── AuthMethodsCard ───────────────────────────────────────────────────────── // Controls login methods and DB-backed authentik/OIDC provider settings. // Client secret is write-only; the API returns only a "set" marker. function AuthMethodsCard() { const [data, setData] = useState(null); const [form, setForm] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testingOidc, setTestingOidc] = useState(false); const [oidcTest, setOidcTest] = useState(null); const load = useCallback(async () => { try { const d = await api.authModeConfig(); setData(d); setForm({ local_login_enabled: d.local_login_enabled !== false, oidc_login_enabled: !!d.oidc_login_enabled, oidc_provider_name: d.oidc_provider_name || 'authentik', oidc_issuer_url: d.oidc_issuer_url || '', oidc_client_id: d.oidc_client_id || '', oidc_client_secret: '', oidc_client_secret_clear: false, oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic', oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(), oidc_scopes: d.oidc_scopes || 'openid email profile groups', oidc_auto_provision: d.oidc_auto_provision !== false, oidc_admin_group: d.oidc_admin_group || '', oidc_default_role: d.oidc_default_role || 'user', }); } catch (err) { toast.error(err.message || 'Failed to load auth settings.'); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); async function handleSave() { setSaving(true); try { const d = await api.setAuthMode(form); setData(d); setForm({ local_login_enabled: d.local_login_enabled !== false, oidc_login_enabled: !!d.oidc_login_enabled, oidc_provider_name: d.oidc_provider_name || 'authentik', oidc_issuer_url: d.oidc_issuer_url || '', oidc_client_id: d.oidc_client_id || '', oidc_client_secret: '', oidc_client_secret_clear: false, oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic', oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(), oidc_scopes: d.oidc_scopes || 'openid email profile groups', oidc_auto_provision: d.oidc_auto_provision !== false, oidc_admin_group: d.oidc_admin_group || '', oidc_default_role: d.oidc_default_role || 'user', }); toast.success('Auth method settings saved.'); } catch (err) { toast.error(err.message || 'Failed to save auth method settings.'); } finally { setSaving(false); } } async function handleTestOidc() { setTestingOidc(true); setOidcTest(null); try { const result = await api.testOidcConfig(form); setOidcTest(result); toast.success('authentik configuration test passed.'); } catch (err) { const result = err.data || { ok: false, error: err.message || 'OIDC configuration test failed.' }; setOidcTest(result); toast.error(result.error || 'OIDC configuration test failed.'); } finally { setTestingOidc(false); } } if (loading || !form) { return ( Loading auth settings… ); } const secretAvailable = form.oidc_client_secret.trim() ? true : form.oidc_client_secret_clear ? false : !!data?.oidc_client_secret_set; const oidcConfigured = !!( form.oidc_issuer_url.trim() && form.oidc_client_id.trim() && secretAvailable && form.oidc_redirect_uri.trim() ); const adminGroupConfigured = !!form.oidc_admin_group.trim(); const wouldLockOut = !form.local_login_enabled && !form.oidc_login_enabled; const cantDisableLocal = !form.local_login_enabled && (!oidcConfigured || !form.oidc_login_enabled || !adminGroupConfigured); const oidcEnabledButIncomplete = form.oidc_login_enabled && !oidcConfigured; const canSave = !wouldLockOut && !cantDisableLocal && !oidcEnabledButIncomplete && !saving; const canTestOidc = oidcConfigured && !testingOidc; const missingFields = [ !form.oidc_issuer_url.trim() && 'Issuer URL', !form.oidc_client_id.trim() && 'Client ID', !secretAvailable && 'Client Secret', !form.oidc_redirect_uri.trim() && 'Redirect URI', ].filter(Boolean); const issuerEndpointWarning = looksLikeOidcEndpoint(form.oidc_issuer_url); return (
Authentication Methods

Control local login and authentik/OIDC. Settings are saved in the database; environment variables only fill blank fields as bootstrap defaults.

{/* Warnings */} {(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && (
{wouldLockOut && (

Cannot disable all login methods; at least one must remain enabled.

)} {cantDisableLocal && !wouldLockOut && (

Cannot disable local login without authentik/OIDC configured, enabled, and mapped to an admin group.

)} {oidcEnabledButIncomplete && (

authentik/OIDC needs {missingFields.join(', ')} before it can be enabled.

)} {data?.warnings?.map((w, i) => (

{w}

))}
)} {/* Local login toggle */}
set('local_login_enabled', v)} label="Enable local login" /> {form.local_login_enabled ? 'Enabled' : 'Disabled'}
{/* OIDC / authentik login toggle */}
set('oidc_login_enabled', v)} label="Enable OIDC login" /> {!oidcConfigured ? 'Not fully configured' : form.oidc_login_enabled ? 'Enabled' : 'Disabled'}

authentik / OIDC configuration

set('oidc_provider_name', e.target.value)} placeholder="authentik" className="max-w-xs h-8 text-sm" />
set('oidc_issuer_url', e.target.value)} placeholder="https://auth.example.com/application/o/bill-tracker/" className="max-w-xl h-8 text-sm" />

Use the authentik provider issuer URL, not the authorize/token/userinfo endpoint.

{issuerEndpointWarning && (

This looks like an authorization endpoint. In authentik, copy the OpenID Configuration Issuer value.

)}
set('oidc_client_id', e.target.value)} placeholder="authentik client ID" className="max-w-xl h-8 text-sm" />
setForm(prev => ({ ...prev, oidc_client_secret: e.target.value, oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear, }))} placeholder="Leave blank to keep existing secret" className="h-8 text-sm" /> {data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'Secret is set' : 'No secret saved'}

Advanced. Keep client_secret_basic unless your authentik provider explicitly requires client_secret_post.

set('oidc_redirect_uri', e.target.value)} placeholder={defaultOidcRedirectUri()} className="h-8 text-sm" />

Add this exact URL to the Redirect URIs allowed by authentik.

set('oidc_scopes', e.target.value)} placeholder="openid email profile groups" className="max-w-xl h-8 text-sm" />
set('oidc_admin_group', e.target.value)} placeholder="e.g. bill-tracker-admins" className="max-w-sm h-8 text-sm" />

Only users in this authentik group become app admins. Admin is never granted by default.

set('oidc_auto_provision', v)} label="Auto-provision users" /> {form.oidc_auto_provision ? 'Enabled' : 'Disabled'}

When enabled, valid authentik users are created in this app on first login.

Admin role only via admin group.
{data?.oidc_env_fallback_used && (
One or more blank database fields are currently using environment fallback values. Saving values here takes precedence.
)} {oidcTest && (
{oidcTest.ok ? `Configuration test passed for ${oidcTest.issuer || form.oidc_issuer_url}.` : oidcTest.error || 'Configuration test failed.'}
)}
); } // ─── Users Table ────────────────────────────────────────────────────────────── function UsersTable({ users, onRefresh, currentUser }) { const [resetForms, setResetForms] = useState({}); const [deleting, setDeleting] = useState(null); const [resetting, setResetting] = useState(null); const [roleUpdating, setRoleUpdating] = useState(null); // Delete confirmation dialog const [deleteTarget, setDeleteTarget] = useState(null); // user object const setReset = (id, v) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } })); const getForm = (id) => resetForms[id] || { pw: '', open: false }; const handleReset = async (user) => { const form = getForm(user.id); if (!form.pw || form.pw.length < 6) { toast.error('Password must be at least 6 characters.'); return; } setResetting(user.id); try { await api.resetPassword(user.id, { password: form.pw }); toast.success(`Password reset for ${user.username}.`); setReset(user.id, { pw: '', open: false }); } catch (err) { toast.error(err.message || 'Failed to reset password.'); } finally { setResetting(null); } }; const handleDelete = async () => { if (!deleteTarget) return; setDeleting(deleteTarget.id); try { await api.deleteUser(deleteTarget.id); toast.success(`User ${deleteTarget.username} deleted.`); setDeleteTarget(null); onRefresh(); } catch (err) { toast.error(err.message || 'Failed to delete user.'); } finally { setDeleting(null); } }; const handleRoleChange = async (user, role) => { if (user.role === role) return; setRoleUpdating(user.id); try { await api.updateUserRole(user.id, { role }); toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`); onRefresh(); } catch (err) { toast.error(err.message || 'Failed to update user role.'); } finally { setRoleUpdating(null); } }; return ( <> Users
{(users || []).map(user => { const form = getForm(user.id); const isSelf = currentUser?.id === user.id; return ( ); })} {!users?.length && ( )}
Username Role Password Reset Password
{user.username}
{user.role}
{user.must_change_password ? Temporary : Set } {user.role !== 'admin' && ( form.open ? (
setReset(user.id, { pw: e.target.value })} className="h-8 text-sm w-36" />
) : ( ) )}
{user.role !== 'admin' && ( )}
No users found.
{/* Delete user confirmation */} { if (!open) setDeleteTarget(null); }}> Delete {deleteTarget?.username}? This user will be permanently removed. All their sessions will be invalidated. Cancel {deleting ? 'Deleting…' : 'Delete User'} ); } // ─── Add User Card ──────────────────────────────────────────────────────────── function AddUserCard({ onCreated }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const handleCreate = async (e) => { e.preventDefault(); if (password.length < 6) { toast.error('Password must be at least 6 characters.'); return; } setLoading(true); try { await api.createUser({ username, password }); toast.success(`User "${username}" created.`); setUsername(''); setPassword(''); onCreated(); } catch (err) { toast.error(err.message || 'Failed to create user.'); } finally { setLoading(false); } }; return ( Add User
setUsername(e.target.value)} placeholder="username" required />
setPassword(e.target.value)} placeholder="Password" required />
); } // ─── Backup Management Card ────────────────────────────────────────────────── function BackupManagementCard() { const DEFAULT_SETTINGS = { enabled: false, frequency: 'daily', time: '02:00', retention_count: 14, last_run_at: null, next_run_at: null, last_error: null, }; const [backups, setBackups] = useState([]); const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(''); const [restoreTarget, setRestoreTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const load = useCallback(async () => { setLoading(true); try { const [backupData, settingsData] = await Promise.all([ api.adminBackups(), api.adminBackupSettings(), ]); setBackups(backupData.backups || []); setSettings({ ...DEFAULT_SETTINGS, ...settingsData }); } catch (err) { toast.error(err.message || 'Failed to load backups.'); } finally { setLoading(false); } }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { load(); }, [load]); const latest = backups[0]; const setSchedule = (key, value) => setSettings(prev => ({ ...prev, [key]: value })); async function handleCreate() { setBusy('create'); try { await api.createAdminBackup(); toast.success('Backup created.'); await load(); } catch (err) { toast.error(err.message || 'Failed to create backup.'); } finally { setBusy(''); } } async function handleDownload(backup) { setBusy(`download:${backup.id}`); try { const { blob, filename } = await api.downloadAdminBackup(backup.id); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || backup.id; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (err) { toast.error(err.message || 'Failed to download backup.'); } finally { setBusy(''); } } async function handleImport(e) { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; setBusy('import'); try { await api.importAdminBackup(file); toast.success('Backup imported.'); await load(); } catch (err) { toast.error(err.message || 'Failed to import backup.'); } finally { setBusy(''); } } async function handleRestore() { if (!restoreTarget) return; setBusy(`restore:${restoreTarget.id}`); try { const result = await api.restoreAdminBackup(restoreTarget.id); toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`); setRestoreTarget(null); await load(); } catch (err) { toast.error(err.message || 'Failed to restore backup.'); } finally { setBusy(''); } } async function handleDelete() { if (!deleteTarget) return; setBusy(`delete:${deleteTarget.id}`); try { await api.deleteAdminBackup(deleteTarget.id); toast.success('Backup deleted.'); setDeleteTarget(null); await load(); } catch (err) { toast.error(err.message || 'Failed to delete backup.'); } finally { setBusy(''); } } async function handleSaveSettings() { setBusy('settings'); try { const saved = await api.saveAdminBackupSettings({ enabled: !!settings.enabled, frequency: settings.frequency, time: settings.time, retention_count: parseInt(settings.retention_count, 10) || 14, }); setSettings({ ...DEFAULT_SETTINGS, ...saved }); toast.success('Backup schedule saved.'); } catch (err) { toast.error(err.message || 'Failed to save backup schedule.'); } finally { setBusy(''); } } async function handleRunScheduledNow() { setBusy('run-scheduled'); try { await api.runScheduledBackupNow(); toast.success('Scheduled backup created.'); await load(); } catch (err) { toast.error(err.message || 'Failed to run scheduled backup.'); } finally { setBusy(''); } } if (loading) { return Loading backups…; } return ( <>
Database Backups

Admin-only SQLite backup, import, download, restore, and schedule controls.

{settings.last_error ? 'Attention' : 'Ready'}
{[ ['Managed backups', backups.length], ['Latest backup', latest ? formatDateTime(latest.modified_at) : '—'], ['Latest size', latest ? fmtBytes(latest.size_bytes) : '—'], ['Scheduled', settings.enabled ? `${settings.frequency} ${settings.time}` : 'Disabled'], ].map(([label, value]) => (

{label}

{value}

))}
{settings.last_error && (
{settings.last_error}
)}
{backups.map(backup => ( ))} {!backups.length && ( )}
Backup Type Modified Size Checksum
{backup.id} {formatDateTime(backup.modified_at)} {fmtBytes(backup.size_bytes)} {backup.checksum || '—'}
No managed backups yet.
Scheduled Backups

Scheduled runs create managed backups and only apply retention to scheduled backups.

setSchedule('enabled', v)} label="Enable scheduled backups" />
setSchedule('time', e.target.value)} />
setSchedule('retention_count', e.target.value)} />
{formatDateTime(settings.next_run_at)}
Last run: {formatDateTime(settings.last_run_at)}
Last error: {settings.last_error || '—'}
{ if (!open) setRestoreTarget(null); }}> Restore this database backup? This replaces the live database with {restoreTarget?.id}. A pre-restore backup will be created first. Run this during a quiet maintenance window. Cancel {busy.startsWith('restore:') ? 'Restoring…' : 'Restore Database'} { if (!open) setDeleteTarget(null); }}> Delete this backup? This permanently deletes {deleteTarget?.id}. The live database is not affected. Cancel {busy.startsWith('delete:') ? 'Deleting…' : 'Delete Backup'} ); } // ─── CleanupPanel ───────────────────────────────────────────────────────────── function CleanupPanel() { const [status, setStatus] = useState(null); const [form, setForm] = useState({ import_sessions_enabled: true, temp_exports_enabled: true, temp_export_max_age_hours: 2, backup_partials_enabled: true, import_history_enabled: false, import_history_max_age_days: 365, }); const [saving, setSaving] = useState(false); const [running, setRunning] = useState(false); const [loading, setLoading] = useState(true); const load = useCallback(async () => { try { const data = await api.adminCleanup(); setStatus(data); if (data.settings) setForm(data.settings); } catch (err) { toast.error(err.message || 'Failed to load cleanup settings.'); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); async function handleSave() { setSaving(true); try { const next = await api.saveAdminCleanup(form); if (next) setForm(next); toast.success('Cleanup settings saved.'); } catch (err) { toast.error(err.message || 'Failed to save cleanup settings.'); } finally { setSaving(false); } } async function handleRunNow() { setRunning(true); try { const result = await api.runAdminCleanup(); setStatus(prev => ({ ...prev, last_run_at: result.ran_at, last_result: result.tasks, })); toast.success('Cleanup tasks completed.'); } catch (err) { toast.error(err.message || 'Cleanup run failed.'); } finally { setRunning(false); } } function resultLine(label, task, countKey) { if (!task || task[countKey] == null) return null; return `${label}: ${task[countKey]}`; } const resultLines = status?.last_result ? [ resultLine('Import sessions pruned', status.last_result.import_sessions, 'pruned'), resultLine('Temp export files removed', status.last_result.temp_export_files, 'removed'), resultLine('Backup partials removed', status.last_result.backup_partials, 'removed'), resultLine('Import history rows pruned', status.last_result.import_history, 'pruned'), ].filter(Boolean) : []; if (loading) { return ( Loading cleanup settings… ); } return (
Cleanup / Maintenance

Automatic daily cleanup: expired import sessions, stale export temp files, orphaned backup partials. Runs at 6:00 AM.

Auto
{/* Last run summary */}

Last Run

{status?.last_run_at ? formatDateTime(status.last_run_at) : '—'}

Last Result

{resultLines.length > 0 ? (
    {resultLines.map(line => (
  • {line}
  • ))}
) : (

No runs recorded yet

)}
{/* Settings */}

Task Settings

{[ ['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'], ['temp_exports_enabled', 'Remove stale SQLite export temp files'], ['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'], ].map(([key, label]) => ( set(key, v)} label={label} /> ))} set('temp_export_max_age_hours', parseInt(e.target.value, 10) || 2)} disabled={!form.temp_exports_enabled} className="max-w-[120px] h-8 text-sm" /> set('import_history_enabled', v)} label="Trim import history rows" /> {form.import_history_enabled && ( <> set('import_history_max_age_days', parseInt(e.target.value, 10) || 365)} className="max-w-[120px] h-8 text-sm" />
Warning: Import history trimming permanently deletes audit records older than {form.import_history_max_age_days} days. This cannot be undone.
)}
{/* Action buttons */}
); } // ─── AdminPage ──────────────────────────────────────────────────────────────── export default function AdminPage() { const navigate = useNavigate(); const [me, setMe] = useState(null); const [hasUsers, setHasUsers] = useState(null); // null = loading const [users, setUsers] = useState([]); const loadMe = useCallback(async () => { try { const d = await api.me(); setMe(d.user); } catch { navigate('/login', { replace: true }); } }, [navigate]); const loadHasUsers = useCallback(async () => { try { const d = await api.hasUsers(); setHasUsers(d.has_users); if (d.has_users) loadUsers(); } catch {} }, []); // eslint-disable-line react-hooks/exhaustive-deps const loadUsers = useCallback(async () => { try { const d = await api.adminUsers(); setUsers(d.users || d); } catch {} }, []); useEffect(() => { loadMe(); loadHasUsers(); }, [loadMe, loadHasUsers]); const handleOnboardingComplete = () => { setHasUsers(true); loadUsers(); }; // Loading state if (hasUsers === null) { return (
Loading…
); } return (
{/* Content */} {!hasUsers ? ( ) : (
)}
); }