@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.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
package/src/pages/Backups.tsx
CHANGED
|
@@ -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'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'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
|
+
}
|