@better-forge/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,170 +1,170 @@
1
- import { useCallback, useEffect, useId, useRef, useState } from 'react'
2
- import { api } from '../api/client'
3
- import { ConfirmModal } from '../components/ConfirmModal'
4
- import { Alert, EmptyState, EmptyStateLink, ErrorState, LoadingState, PageContainer, PageHeader, Switch } from '../components/ui'
5
- import { formatDateTime } from '../lib/dateFormat'
6
-
7
- interface BackupInfo { filename: string; size: number; createdAt: number }
8
- interface BackupConfig { enabled: boolean; frequency: 'daily' | 'weekly'; hour: number; minute: number; weekday: number; retention: number }
9
- interface BackupStatus { state: string; lastCompletedAt: number | null; lastFilename: string | null; lastError: string | null }
10
- interface State { backups: BackupInfo[]; config: BackupConfig; status: BackupStatus }
11
- type BusyAction = 'create' | 'validate' | 'save' | 'delete'
12
- interface Message { tone: 'success' | 'error' | 'warning'; text: string }
13
- const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
14
-
15
- const errorText = (e: unknown) => e instanceof Error ? e.message : String(e)
16
-
17
- export default function BackupsPage() {
18
- const [state, setState] = useState<State | null>(null)
19
- const [loadError, setLoadError] = useState('')
20
- const [busy, setBusy] = useState<BusyAction | null>(null)
21
- const [message, setMessage] = useState<Message | null>(null)
22
- const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
23
- const [savedConfig, setSavedConfig] = useState<BackupConfig | null>(null)
24
- const file = useRef<HTMLInputElement>(null)
25
- const scheduleSwitchId = useId()
26
- const load = useCallback(() => api.get<State>('/admin/backups')
27
- .then((data) => { setState(data); setSavedConfig(data.config); setLoadError('') })
28
- .catch((e: unknown) => setLoadError(errorText(e))), [])
29
- useEffect(() => { void load() }, [load])
30
-
31
- async function create() {
32
- setBusy('create'); setMessage(null)
33
- try { await api.post('/admin/backups', {}); await load(); setMessage({ tone: 'success', text: 'Backup created successfully.' }) }
34
- catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
35
- }
36
- async function saveConfig(config: BackupConfig) {
37
- setBusy('save'); setMessage(null)
38
- try { const saved = await api.put<BackupConfig>('/admin/backups/config', config); setState((s) => s && ({ ...s, config: saved })); setSavedConfig(saved); setMessage({ tone: 'success', text: 'Schedule saved.' }) }
39
- catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
40
- }
41
- async function validate(selected: File) {
42
- setBusy('validate'); setMessage(null)
43
- const form = new FormData(); form.append('file', selected)
44
- try {
45
- const result = await api.upload<{ manifest: { createdAt: number }; vaultKeyMatches: boolean | null }>('/admin/backups/validate', form)
46
- const keyMismatch = result.vaultKeyMatches === false
47
- const key = keyMismatch ? ' Warning: VAULT_ENCRYPTION_KEY does not match.' : ''
48
- setMessage({ tone: keyMismatch ? 'warning' : 'success', text: `Backup is valid (${formatDateTime(result.manifest.createdAt)}).${key} Stop the app and run: npm run restore -- --input <file>` })
49
- } catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null); if (file.current) file.current.value = '' }
50
- }
51
- async function removeBackup(filename: string) {
52
- setBusy('delete'); setMessage(null)
53
- try {
54
- await api.delete(`/admin/backups/${encodeURIComponent(filename)}?confirm=${encodeURIComponent(filename)}`)
55
- setDeleteTarget(null)
56
- await load()
57
- setMessage({ tone: 'success', text: 'Backup deleted.' })
58
- } catch (e) { setDeleteTarget(null); setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
59
- }
60
-
61
- const header = <PageHeader title="Backups" subtitle="Create, download, validate and schedule complete application backups." />
62
- if (!state && loadError) return <PageContainer>{header}<ErrorState message={loadError} onRetry={() => void load()} /></PageContainer>
63
- if (!state) return <PageContainer>{header}<LoadingState label="Loading backups…" /></PageContainer>
64
- const config = state.config
65
- const scheduleChanged = JSON.stringify(config) !== JSON.stringify(savedConfig)
66
- const scheduleSummary = config.enabled
67
- ? `${config.frequency === 'daily' ? 'Every day' : `Every ${WEEKDAYS[config.weekday]}`} at ${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')} · keep ${config.retention} ${config.retention === 1 ? 'backup' : 'backups'}`
68
- : 'Automatic backups are disabled'
69
- const updateConfig = (updates: Partial<BackupConfig>) => setState({ ...state, config: { ...config, ...updates } })
70
- // Turns the switch on and moves focus to it; the schedule is still saved with "Save schedule".
71
- const enableAutomaticBackups = () => {
72
- updateConfig({ enabled: true })
73
- document.getElementById(scheduleSwitchId)?.focus()
74
- }
75
- return <PageContainer>
76
- {header}
77
- {message && <Alert tone={message.tone} onDismiss={() => setMessage(null)}>{message.text}</Alert>}
78
- {state.status.lastCompletedAt && <div className="text-sm" style={{ color: state.status.state === 'error' ? 'var(--m3-down)' : 'var(--m3-secondary)' }}>Last run: {formatDateTime(state.status.lastCompletedAt)} · {state.status.state}{state.status.lastError ? ` · ${state.status.lastError}` : ''}</div>}
79
- <section className="rounded-2xl p-6 space-y-4" style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)' }}>
80
- <div className="flex flex-wrap gap-3">
81
- <button type="button" disabled={busy !== null} onClick={create} className="btn btn-primary">{busy === 'create' ? 'Creating backup…' : 'Create backup'}</button>
82
- <button type="button" disabled={busy !== null} onClick={() => file.current?.click()} className="btn btn-secondary">{busy === 'validate' ? 'Validating…' : 'Validate restore file'}</button>
83
- <input ref={file} className="hidden" type="file" accept=".backup" aria-label="Restore file" onChange={(e) => { const selected = e.target.files?.[0]; if (selected) void validate(selected) }} />
84
- </div>
85
- <p className="text-sm" style={{ color: 'var(--m3-secondary)' }}>Restore is intentionally offline. The encryption key is never included; keep VAULT_ENCRYPTION_KEY separately.</p>
86
- </section>
87
- <section className="rounded-2xl overflow-hidden" style={{ background: 'var(--m3-surface-container-lowest)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }}>
88
- <div className="p-5 md:p-6 flex items-start justify-between gap-5">
89
- <div className="flex gap-4 min-w-0">
90
- <span className="material-symbols-outlined rounded-xl p-2.5 h-fit" aria-hidden="true" style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}>schedule</span>
91
- <div>
92
- <h2 className="font-headline text-xl font-semibold">Automatic backups</h2>
93
- <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>Create backups on a recurring schedule using the server&apos;s local time.</p>
94
- </div>
95
- </div>
96
- <Switch id={scheduleSwitchId} checked={config.enabled} onChange={(enabled) => updateConfig({ enabled })} aria-label="Automatic backups" />
97
- </div>
98
-
99
- <div className="px-5 pb-5 md:px-6 md:pb-6">
100
- {config.enabled ? (
101
- <div className="rounded-2xl p-4 md:p-5 grid gap-5 md:grid-cols-3" style={{ background: 'var(--m3-surface-container-low)' }}>
102
- <fieldset>
103
- <legend className="font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Frequency</legend>
104
- <div className="grid grid-cols-2 rounded-xl overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)' }}>
105
- {(['daily', 'weekly'] as const).map((frequency) => {
106
- const active = config.frequency === frequency
107
- return (
108
- <button
109
- key={frequency}
110
- type="button"
111
- aria-pressed={active}
112
- onClick={() => updateConfig({ frequency })}
113
- className={`px-3 py-2 text-sm font-semibold transition-colors focus-ring ${active ? 'selection-active' : ''}`}
114
- style={{ background: 'transparent', color: 'var(--m3-secondary)' }}
115
- >
116
- {frequency === 'daily' ? 'Daily' : 'Weekly'}
117
- </button>
118
- )
119
- })}
120
- </div>
121
- </fieldset>
122
-
123
- {config.frequency === 'weekly' && <label>
124
- <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Day of week</span>
125
- <select className="input-sig w-full" value={config.weekday} onChange={(e) => updateConfig({ weekday: Number(e.target.value) })}>{WEEKDAYS.map((day, index) => <option key={day} value={index}>{day}</option>)}</select>
126
- </label>}
127
-
128
- <label>
129
- <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Start time</span>
130
- <input className="input-sig w-full" type="time" step="60" value={`${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')}`} onChange={(e) => { const parts = e.target.value.split(':'); const hour = Number(parts[0]); const minute = Number(parts[1]); if (parts.length === 2 && Number.isInteger(hour) && Number.isInteger(minute)) updateConfig({ hour, minute }) }} />
131
- <span className="block text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>Server local time</span>
132
- </label>
133
-
134
- <label>
135
- <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Retention</span>
136
- <div className="relative"><input className="input-sig w-full pr-24" type="number" min="1" max="365" value={config.retention} onChange={(e) => updateConfig({ retention: Number(e.target.value) })} /><span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm" aria-hidden="true" style={{ color: 'var(--m3-secondary)' }}>backups</span></div>
137
- <span className="block text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>Older automatic backups are removed after a successful run.</span>
138
- </label>
139
- </div>
140
- ) : <div className="rounded-xl px-4 py-3 text-sm flex items-center gap-3" style={{ background: 'var(--m3-surface-container-low)', color: 'var(--m3-secondary)' }}><span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '20px' }}>info</span>Enable scheduling to configure recurring backups.</div>}
141
-
142
- <div className="mt-5 pt-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ borderTop: '1px solid var(--m3-outline-variant)' }}>
143
- <div><p className="text-xs font-mono uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Current schedule</p><p className="text-sm font-medium mt-1">{scheduleSummary}</p></div>
144
- <button type="button" disabled={busy !== null || !scheduleChanged} onClick={() => saveConfig(config)} className="btn btn-primary">{busy === 'save' ? 'Saving…' : scheduleChanged ? 'Save schedule' : 'Saved'}</button>
145
- </div>
146
- </div>
147
- </section>
148
- <section className="rounded-2xl overflow-hidden" style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)' }}>
149
- <h2 className="font-headline text-xl font-semibold p-6 pb-3">Available backups</h2>
150
- {state.backups.length === 0
151
- ? <EmptyState
152
- variant="inset"
153
- icon="cloud_off"
154
- title="No backups yet"
155
- description={savedConfig?.enabled
156
- ? 'Automatic backups are on; the first one will appear here after it runs.'
157
- : 'Automatic backups are off. The database is not protected until the first backup exists.'}
158
- action={config.enabled ? undefined : <EmptyStateLink onClick={enableAutomaticBackups}>Enable automatic backups</EmptyStateLink>}
159
- />
160
- : state.backups.map((item) => <div key={item.filename} className="p-4 px-6 flex flex-wrap items-center justify-between gap-3" style={{ borderTop: '1px solid var(--m3-outline-variant)' }}>
161
- <div className="min-w-0"><div className="font-mono text-sm break-all">{item.filename}</div><div className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{formatDateTime(item.createdAt)} · {(item.size / 1024 / 1024).toFixed(2)} MB</div></div>
162
- <div className="flex gap-2">
163
- <button type="button" onClick={() => api.download(`/admin/backups/${encodeURIComponent(item.filename)}/download`, item.filename)} aria-label={`Download ${item.filename}`} className="btn btn-secondary btn-sm">Download</button>
164
- <button type="button" onClick={() => setDeleteTarget(item.filename)} aria-label={`Delete ${item.filename}`} className="btn btn-danger-outline btn-sm">Delete</button>
165
- </div>
166
- </div>)}
167
- </section>
168
- {deleteTarget && <ConfirmModal title="Delete backup" message="This permanently deletes the backup archive. This action cannot be undone." confirmLabel="Delete backup" confirmationText={deleteTarget} pending={busy === 'delete'} pendingLabel="Deleting…" onConfirm={() => { if (!busy) void removeBackup(deleteTarget) }} onCancel={() => setDeleteTarget(null)} />}
169
- </PageContainer>
170
- }
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
2
+ import { api } from '../api/client'
3
+ import { ConfirmModal } from '../components/ConfirmModal'
4
+ import { Alert, EmptyState, EmptyStateLink, ErrorState, LoadingState, PageContainer, PageHeader, Switch } from '../components/ui'
5
+ import { formatDateTime } from '../lib/dateFormat'
6
+
7
+ interface BackupInfo { filename: string; size: number; createdAt: number }
8
+ interface BackupConfig { enabled: boolean; frequency: 'daily' | 'weekly'; hour: number; minute: number; weekday: number; retention: number }
9
+ interface BackupStatus { state: string; lastCompletedAt: number | null; lastFilename: string | null; lastError: string | null }
10
+ interface State { backups: BackupInfo[]; config: BackupConfig; status: BackupStatus }
11
+ type BusyAction = 'create' | 'validate' | 'save' | 'delete'
12
+ interface Message { tone: 'success' | 'error' | 'warning'; text: string }
13
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
14
+
15
+ const errorText = (e: unknown) => e instanceof Error ? e.message : String(e)
16
+
17
+ export default function BackupsPage() {
18
+ const [state, setState] = useState<State | null>(null)
19
+ const [loadError, setLoadError] = useState('')
20
+ const [busy, setBusy] = useState<BusyAction | null>(null)
21
+ const [message, setMessage] = useState<Message | null>(null)
22
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
23
+ const [savedConfig, setSavedConfig] = useState<BackupConfig | null>(null)
24
+ const file = useRef<HTMLInputElement>(null)
25
+ const scheduleSwitchId = useId()
26
+ const load = useCallback(() => api.get<State>('/admin/backups')
27
+ .then((data) => { setState(data); setSavedConfig(data.config); setLoadError('') })
28
+ .catch((e: unknown) => setLoadError(errorText(e))), [])
29
+ useEffect(() => { void load() }, [load])
30
+
31
+ async function create() {
32
+ setBusy('create'); setMessage(null)
33
+ try { await api.post('/admin/backups', {}); await load(); setMessage({ tone: 'success', text: 'Backup created successfully.' }) }
34
+ catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
35
+ }
36
+ async function saveConfig(config: BackupConfig) {
37
+ setBusy('save'); setMessage(null)
38
+ try { const saved = await api.put<BackupConfig>('/admin/backups/config', config); setState((s) => s && ({ ...s, config: saved })); setSavedConfig(saved); setMessage({ tone: 'success', text: 'Schedule saved.' }) }
39
+ catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
40
+ }
41
+ async function validate(selected: File) {
42
+ setBusy('validate'); setMessage(null)
43
+ const form = new FormData(); form.append('file', selected)
44
+ try {
45
+ const result = await api.upload<{ manifest: { createdAt: number }; vaultKeyMatches: boolean | null }>('/admin/backups/validate', form)
46
+ const keyMismatch = result.vaultKeyMatches === false
47
+ const key = keyMismatch ? ' Warning: VAULT_ENCRYPTION_KEY does not match.' : ''
48
+ setMessage({ tone: keyMismatch ? 'warning' : 'success', text: `Backup is valid (${formatDateTime(result.manifest.createdAt)}).${key} Stop the app and run: npm run restore -- --input <file>` })
49
+ } catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null); if (file.current) file.current.value = '' }
50
+ }
51
+ async function removeBackup(filename: string) {
52
+ setBusy('delete'); setMessage(null)
53
+ try {
54
+ await api.delete(`/admin/backups/${encodeURIComponent(filename)}?confirm=${encodeURIComponent(filename)}`)
55
+ setDeleteTarget(null)
56
+ await load()
57
+ setMessage({ tone: 'success', text: 'Backup deleted.' })
58
+ } catch (e) { setDeleteTarget(null); setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
59
+ }
60
+
61
+ const header = <PageHeader title="Backups" subtitle="Create, download, validate and schedule complete application backups." />
62
+ if (!state && loadError) return <PageContainer>{header}<ErrorState message={loadError} onRetry={() => void load()} /></PageContainer>
63
+ if (!state) return <PageContainer>{header}<LoadingState label="Loading backups…" /></PageContainer>
64
+ const config = state.config
65
+ const scheduleChanged = JSON.stringify(config) !== JSON.stringify(savedConfig)
66
+ const scheduleSummary = config.enabled
67
+ ? `${config.frequency === 'daily' ? 'Every day' : `Every ${WEEKDAYS[config.weekday]}`} at ${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')} · keep ${config.retention} ${config.retention === 1 ? 'backup' : 'backups'}`
68
+ : 'Automatic backups are disabled'
69
+ const updateConfig = (updates: Partial<BackupConfig>) => setState({ ...state, config: { ...config, ...updates } })
70
+ // Turns the switch on and moves focus to it; the schedule is still saved with "Save schedule".
71
+ const enableAutomaticBackups = () => {
72
+ updateConfig({ enabled: true })
73
+ document.getElementById(scheduleSwitchId)?.focus()
74
+ }
75
+ return <PageContainer>
76
+ {header}
77
+ {message && <Alert tone={message.tone} onDismiss={() => setMessage(null)}>{message.text}</Alert>}
78
+ {state.status.lastCompletedAt && <div className="text-sm" style={{ color: state.status.state === 'error' ? 'var(--m3-down)' : 'var(--m3-secondary)' }}>Last run: {formatDateTime(state.status.lastCompletedAt)} · {state.status.state}{state.status.lastError ? ` · ${state.status.lastError}` : ''}</div>}
79
+ <section className="rounded-2xl p-6 space-y-4" style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)' }}>
80
+ <div className="flex flex-wrap gap-3">
81
+ <button type="button" disabled={busy !== null} onClick={create} className="btn btn-primary">{busy === 'create' ? 'Creating backup…' : 'Create backup'}</button>
82
+ <button type="button" disabled={busy !== null} onClick={() => file.current?.click()} className="btn btn-secondary">{busy === 'validate' ? 'Validating…' : 'Validate restore file'}</button>
83
+ <input ref={file} className="hidden" type="file" accept=".backup" aria-label="Restore file" onChange={(e) => { const selected = e.target.files?.[0]; if (selected) void validate(selected) }} />
84
+ </div>
85
+ <p className="text-sm" style={{ color: 'var(--m3-secondary)' }}>Restore is intentionally offline. The encryption key is never included; keep VAULT_ENCRYPTION_KEY separately.</p>
86
+ </section>
87
+ <section className="rounded-2xl overflow-hidden" style={{ background: 'var(--m3-surface-container-lowest)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }}>
88
+ <div className="p-5 md:p-6 flex items-start justify-between gap-5">
89
+ <div className="flex gap-4 min-w-0">
90
+ <span className="material-symbols-outlined rounded-xl p-2.5 h-fit" aria-hidden="true" style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}>schedule</span>
91
+ <div>
92
+ <h2 className="font-headline text-xl font-semibold">Automatic backups</h2>
93
+ <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>Create backups on a recurring schedule using the server&apos;s local time.</p>
94
+ </div>
95
+ </div>
96
+ <Switch id={scheduleSwitchId} checked={config.enabled} onChange={(enabled) => updateConfig({ enabled })} aria-label="Automatic backups" />
97
+ </div>
98
+
99
+ <div className="px-5 pb-5 md:px-6 md:pb-6">
100
+ {config.enabled ? (
101
+ <div className="rounded-2xl p-4 md:p-5 grid gap-5 md:grid-cols-3" style={{ background: 'var(--m3-surface-container-low)' }}>
102
+ <fieldset>
103
+ <legend className="font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Frequency</legend>
104
+ <div className="grid grid-cols-2 rounded-xl overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)' }}>
105
+ {(['daily', 'weekly'] as const).map((frequency) => {
106
+ const active = config.frequency === frequency
107
+ return (
108
+ <button
109
+ key={frequency}
110
+ type="button"
111
+ aria-pressed={active}
112
+ onClick={() => updateConfig({ frequency })}
113
+ className={`px-3 py-2 text-sm font-semibold transition-colors focus-ring ${active ? 'selection-active' : ''}`}
114
+ style={{ background: 'transparent', color: 'var(--m3-secondary)' }}
115
+ >
116
+ {frequency === 'daily' ? 'Daily' : 'Weekly'}
117
+ </button>
118
+ )
119
+ })}
120
+ </div>
121
+ </fieldset>
122
+
123
+ {config.frequency === 'weekly' && <label>
124
+ <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Day of week</span>
125
+ <select className="input-sig w-full" value={config.weekday} onChange={(e) => updateConfig({ weekday: Number(e.target.value) })}>{WEEKDAYS.map((day, index) => <option key={day} value={index}>{day}</option>)}</select>
126
+ </label>}
127
+
128
+ <label>
129
+ <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Start time</span>
130
+ <input className="input-sig w-full" type="time" step="60" value={`${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')}`} onChange={(e) => { const parts = e.target.value.split(':'); const hour = Number(parts[0]); const minute = Number(parts[1]); if (parts.length === 2 && Number.isInteger(hour) && Number.isInteger(minute)) updateConfig({ hour, minute }) }} />
131
+ <span className="block text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>Server local time</span>
132
+ </label>
133
+
134
+ <label>
135
+ <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Retention</span>
136
+ <div className="relative"><input className="input-sig w-full pr-24" type="number" min="1" max="365" value={config.retention} onChange={(e) => updateConfig({ retention: Number(e.target.value) })} /><span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm" aria-hidden="true" style={{ color: 'var(--m3-secondary)' }}>backups</span></div>
137
+ <span className="block text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>Older automatic backups are removed after a successful run.</span>
138
+ </label>
139
+ </div>
140
+ ) : <div className="rounded-xl px-4 py-3 text-sm flex items-center gap-3" style={{ background: 'var(--m3-surface-container-low)', color: 'var(--m3-secondary)' }}><span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '20px' }}>info</span>Enable scheduling to configure recurring backups.</div>}
141
+
142
+ <div className="mt-5 pt-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ borderTop: '1px solid var(--m3-outline-variant)' }}>
143
+ <div><p className="text-xs font-mono uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Current schedule</p><p className="text-sm font-medium mt-1">{scheduleSummary}</p></div>
144
+ <button type="button" disabled={busy !== null || !scheduleChanged} onClick={() => saveConfig(config)} className="btn btn-primary">{busy === 'save' ? 'Saving…' : scheduleChanged ? 'Save schedule' : 'Saved'}</button>
145
+ </div>
146
+ </div>
147
+ </section>
148
+ <section className="rounded-2xl overflow-hidden" style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)' }}>
149
+ <h2 className="font-headline text-xl font-semibold p-6 pb-3">Available backups</h2>
150
+ {state.backups.length === 0
151
+ ? <EmptyState
152
+ variant="inset"
153
+ icon="cloud_off"
154
+ title="No backups yet"
155
+ description={savedConfig?.enabled
156
+ ? 'Automatic backups are on; the first one will appear here after it runs.'
157
+ : 'Automatic backups are off. The database is not protected until the first backup exists.'}
158
+ action={config.enabled ? undefined : <EmptyStateLink onClick={enableAutomaticBackups}>Enable automatic backups</EmptyStateLink>}
159
+ />
160
+ : state.backups.map((item) => <div key={item.filename} className="p-4 px-6 flex flex-wrap items-center justify-between gap-3" style={{ borderTop: '1px solid var(--m3-outline-variant)' }}>
161
+ <div className="min-w-0"><div className="font-mono text-sm break-all">{item.filename}</div><div className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{formatDateTime(item.createdAt)} · {(item.size / 1024 / 1024).toFixed(2)} MB</div></div>
162
+ <div className="flex gap-2">
163
+ <button type="button" onClick={() => api.download(`/admin/backups/${encodeURIComponent(item.filename)}/download`, item.filename)} aria-label={`Download ${item.filename}`} className="btn btn-secondary btn-sm">Download</button>
164
+ <button type="button" onClick={() => setDeleteTarget(item.filename)} aria-label={`Delete ${item.filename}`} className="btn btn-danger-outline btn-sm">Delete</button>
165
+ </div>
166
+ </div>)}
167
+ </section>
168
+ {deleteTarget && <ConfirmModal title="Delete backup" message="This permanently deletes the backup archive. This action cannot be undone." confirmLabel="Delete backup" confirmationText={deleteTarget} pending={busy === 'delete'} pendingLabel="Deleting…" onConfirm={() => { if (!busy) void removeBackup(deleteTarget) }} onCancel={() => setDeleteTarget(null)} />}
169
+ </PageContainer>
170
+ }
@@ -1,84 +1,84 @@
1
- import { useState } from 'react'
2
- import { useNavigate } from 'react-router-dom'
3
- import { api, enterAfterSignIn, getCurrentUser, setSession, type AuthUser } from '../api/client'
4
- import { Alert, Field } from '../components/ui'
5
-
6
- export default function ChangePasswordPage() {
7
- const navigate = useNavigate()
8
- const [password, setPassword] = useState('')
9
- const [confirm, setConfirm] = useState('')
10
- const [loading, setLoading] = useState(false)
11
- const [error, setError] = useState('')
12
-
13
- async function handleSubmit(e: React.FormEvent) {
14
- e.preventDefault()
15
- if (password !== confirm) { setError('Passwords do not match'); return }
16
- if (password.length < 8) { setError('Password must be at least 8 characters'); return }
17
- setError('')
18
- setLoading(true)
19
- try {
20
- const res = await api.post<AuthUser>('/auth/change-password', { newPassword: password })
21
- setSession(res)
22
- enterAfterSignIn(res, navigate)
23
- } catch (err) {
24
- setError(err instanceof Error ? err.message : 'Failed to change password')
25
- } finally {
26
- setLoading(false)
27
- }
28
- }
29
-
30
- return (
31
- <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--m3-surface)' }}>
32
- <div className="w-full max-w-sm">
33
- <div className="rounded-2xl p-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
34
- <div className="mb-6">
35
- <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-4" style={{ background: 'var(--m3-on-surface)' }}>
36
- <span className="material-symbols-outlined" aria-hidden="true" style={{ color: 'var(--m3-surface)', fontSize: '22px' }}>lock_reset</span>
37
- </div>
38
- <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>Set your password</h1>
39
- <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>
40
- {getCurrentUser()?.authMethod === 'oidc'
41
- ? 'Password sign-in is enabled, so set your own password to replace the temporary one before continuing.'
42
- : 'You must change your temporary password before continuing.'}
43
- </p>
44
- </div>
45
-
46
- <form onSubmit={handleSubmit} className="space-y-4">
47
- {error && <Alert tone="error">{error}</Alert>}
48
- <Field label="New Password" hint="Minimum 8 characters">
49
- <input
50
- type="password"
51
- autoComplete="new-password"
52
- value={password}
53
- onChange={(e) => setPassword(e.target.value)}
54
- required
55
- minLength={8}
56
- className="input-sig w-full"
57
- placeholder="Minimum 8 characters"
58
- autoFocus
59
- />
60
- </Field>
61
- <Field label="Confirm Password">
62
- <input
63
- type="password"
64
- autoComplete="new-password"
65
- value={confirm}
66
- onChange={(e) => setConfirm(e.target.value)}
67
- required
68
- className="input-sig w-full"
69
- placeholder="Repeat the password"
70
- />
71
- </Field>
72
- <button
73
- type="submit"
74
- disabled={loading}
75
- className="btn btn-primary w-full py-3 font-headline font-bold"
76
- >
77
- {loading ? 'Saving…' : 'Set Password & Continue'}
78
- </button>
79
- </form>
80
- </div>
81
- </div>
82
- </div>
83
- )
84
- }
1
+ import { useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { api, enterAfterSignIn, getCurrentUser, setSession, type AuthUser } from '../api/client'
4
+ import { Alert, Field } from '../components/ui'
5
+
6
+ export default function ChangePasswordPage() {
7
+ const navigate = useNavigate()
8
+ const [password, setPassword] = useState('')
9
+ const [confirm, setConfirm] = useState('')
10
+ const [loading, setLoading] = useState(false)
11
+ const [error, setError] = useState('')
12
+
13
+ async function handleSubmit(e: React.FormEvent) {
14
+ e.preventDefault()
15
+ if (password !== confirm) { setError('Passwords do not match'); return }
16
+ if (password.length < 8) { setError('Password must be at least 8 characters'); return }
17
+ setError('')
18
+ setLoading(true)
19
+ try {
20
+ const res = await api.post<AuthUser>('/auth/change-password', { newPassword: password })
21
+ setSession(res)
22
+ enterAfterSignIn(res, navigate)
23
+ } catch (err) {
24
+ setError(err instanceof Error ? err.message : 'Failed to change password')
25
+ } finally {
26
+ setLoading(false)
27
+ }
28
+ }
29
+
30
+ return (
31
+ <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--m3-surface)' }}>
32
+ <div className="w-full max-w-sm">
33
+ <div className="rounded-2xl p-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
34
+ <div className="mb-6">
35
+ <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-4" style={{ background: 'var(--m3-on-surface)' }}>
36
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ color: 'var(--m3-surface)', fontSize: '22px' }}>lock_reset</span>
37
+ </div>
38
+ <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>Set your password</h1>
39
+ <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>
40
+ {getCurrentUser()?.authMethod === 'oidc'
41
+ ? 'Password sign-in is enabled, so set your own password to replace the temporary one before continuing.'
42
+ : 'You must change your temporary password before continuing.'}
43
+ </p>
44
+ </div>
45
+
46
+ <form onSubmit={handleSubmit} className="space-y-4">
47
+ {error && <Alert tone="error">{error}</Alert>}
48
+ <Field label="New Password" hint="Minimum 8 characters">
49
+ <input
50
+ type="password"
51
+ autoComplete="new-password"
52
+ value={password}
53
+ onChange={(e) => setPassword(e.target.value)}
54
+ required
55
+ minLength={8}
56
+ className="input-sig w-full"
57
+ placeholder="Minimum 8 characters"
58
+ autoFocus
59
+ />
60
+ </Field>
61
+ <Field label="Confirm Password">
62
+ <input
63
+ type="password"
64
+ autoComplete="new-password"
65
+ value={confirm}
66
+ onChange={(e) => setConfirm(e.target.value)}
67
+ required
68
+ className="input-sig w-full"
69
+ placeholder="Repeat the password"
70
+ />
71
+ </Field>
72
+ <button
73
+ type="submit"
74
+ disabled={loading}
75
+ className="btn btn-primary w-full py-3 font-headline font-bold"
76
+ >
77
+ {loading ? 'Saving…' : 'Set Password & Continue'}
78
+ </button>
79
+ </form>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ )
84
+ }