BillTracker/client/pages/AdminPage.jsx

1842 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ──────────────────────────────────────────────────────────────────
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
function SectionHeading({ children }) {
return <h2 className="text-base font-semibold text-foreground">{children}</h2>;
}
function FieldRow({ label, children }) {
return (
<div className="grid grid-cols-[200px_1fr] items-center gap-4">
<Label className="text-right text-muted-foreground">{label}</Label>
{children}
</div>
);
}
function Toggle({ checked, onChange, label, disabled = false }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${checked ? 'bg-primary' : 'bg-input'}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform ${checked ? 'translate-x-4' : 'translate-x-0'}`} />
{label && <span className="sr-only">{label}</span>}
</button>
);
}
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 <Badge className={cls}>{type || 'backup'}</Badge>;
}
// ─── 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 (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] px-4 py-12">
<div className="w-full max-w-md">
{/* Step dots */}
<div className="flex justify-center gap-2 mb-8">
{[0, 1].map(i => (
<span
key={i}
className={`h-2 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-2 bg-border'}`}
/>
))}
</div>
{step === 0 && (
<Card>
<CardHeader>
<CardTitle className="text-xl">Welcome, Administrator</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Before creating your first user, please understand what your admin account can and cannot do.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2.5">
{[
{ 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 }) => (
<div key={text} className="flex items-center gap-3 text-sm">
<span className={`shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${can ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
{can ? '✓' : '✗'}
</span>
<span className={can ? 'text-foreground' : 'text-muted-foreground'}>{text}</span>
</div>
))}
</div>
<div className="pt-4">
<Button className="w-full" onClick={() => setStep(1)}>
Got it, create my user account
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-xl">Create first user</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
This account will be used to access the bill tracker.
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ob-username">Username</Label>
<Input
id="ob-username"
placeholder="username"
value={username}
onChange={e => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-password">Password</Label>
<Input
id="ob-password"
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-confirm">Confirm password</Label>
<Input
id="ob-confirm"
type="password"
placeholder="Confirm password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
/>
</div>
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setStep(0)}>
<ChevronLeft className="h-4 w-4" />
Back
</Button>
<Button type="submit" className="flex-1" disabled={loading}>
{loading ? 'Creating…' : 'Create User'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
</div>
);
}
// ─── 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 <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading</CardContent></Card>;
return (
<Card>
<CardHeader className="pb-4">
<CardTitle>Email Notifications</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Enable email notifications</p>
<p className="text-xs text-muted-foreground">Configure SMTP to send bill reminders</p>
</div>
<Toggle checked={cfg.enabled} onChange={v => set('enabled', v)} label="Enable email notifications" />
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>Sender</SectionHeading>
<FieldRow label="Sender name">
<Input value={cfg.sender_name} onChange={e => set('sender_name', e.target.value)} placeholder="BillTracker" />
</FieldRow>
<FieldRow label="Sender address">
<Input value={cfg.sender_address} onChange={e => set('sender_address', e.target.value)} placeholder="no-reply@example.com" type="email" />
</FieldRow>
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>SMTP Server</SectionHeading>
<FieldRow label="SMTP host">
<Input value={cfg.smtp_host} onChange={e => set('smtp_host', e.target.value)} placeholder="smtp.example.com" />
</FieldRow>
<FieldRow label="Port">
<Input value={cfg.smtp_port} onChange={e => set('smtp_port', e.target.value)} placeholder="587" type="number" className="w-28" />
</FieldRow>
<FieldRow label="Encryption">
<Select value={cfg.smtp_encryption} onValueChange={v => set('smtp_encryption', v)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</FieldRow>
<FieldRow label="Allow self-signed cert">
<div className="flex items-center h-9">
<input
type="checkbox"
id="self-signed"
checked={cfg.smtp_self_signed}
onChange={e => set('smtp_self_signed', e.target.checked)}
className="h-4 w-4 rounded border-input bg-input accent-primary"
/>
<label htmlFor="self-signed" className="ml-2 text-sm text-muted-foreground">
Accept self-signed certificates
</label>
</div>
</FieldRow>
<FieldRow label="SMTP username">
<Input value={cfg.smtp_username} onChange={e => set('smtp_username', e.target.value)} placeholder="user@example.com" />
</FieldRow>
<FieldRow label="SMTP password">
<div className="relative">
<Input
type={showPw ? 'text' : 'password'}
value={cfg.smtp_password}
onChange={e => set('smtp_password', e.target.value)}
placeholder="••••••••"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowPw(p => !p)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FieldRow>
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>User Access</SectionHeading>
<FieldRow label="Allow user config">
<div className="flex items-center h-9">
<input
type="checkbox"
id="allow-user"
checked={cfg.allow_user_config}
onChange={e => set('allow_user_config', e.target.checked)}
className="h-4 w-4 rounded border-input bg-input accent-primary"
/>
<label htmlFor="allow-user" className="ml-2 text-sm text-muted-foreground">
Let users configure their own notification preferences
</label>
</div>
</FieldRow>
<FieldRow label="Global recipient">
<Input
value={cfg.global_recipient}
onChange={e => set('global_recipient', e.target.value)}
placeholder="recipient@example.com"
type="email"
/>
</FieldRow>
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>Test Email</SectionHeading>
<FieldRow label="Send test to">
<div className="flex gap-2">
<Input
value={testEmail}
onChange={e => setTestEmail(e.target.value)}
placeholder="you@example.com"
type="email"
/>
<Button variant="outline" onClick={handleTest} disabled={testing} className="shrink-0">
{testing ? 'Sending…' : 'Send Test Email'}
</Button>
</div>
</FieldRow>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</Button>
</div>
</CardContent>
</Card>
);
}
// ─── 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 <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading</CardContent></Card>;
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 (
<>
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle>Login Mode</CardTitle>
<Badge className={isMulti ? 'bg-sky-500/15 text-sky-400 border-sky-500/20' : 'bg-amber-500/15 text-amber-400 border-amber-500/20'}>
{isMulti ? 'Multi-user' : 'Single-user'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isMulti ? (
<>
<p className="text-sm text-muted-foreground">
Single-user mode bypasses the login screen and automatically signs in as the selected user.
</p>
<div className="space-y-1.5">
<Label>Default user</Label>
<Select value={selectedUser} onValueChange={setSelectedUser}>
<SelectTrigger>
<SelectValue placeholder="Select a user…" />
</SelectTrigger>
<SelectContent>
{(users || []).filter(u => u.role === 'user').map(u => (
<SelectItem key={u.id} value={u.id.toString()}>{u.username}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleRequestSingle} disabled={saving} className="w-full">
{saving ? 'Enabling…' : 'Enable Single-User Mode'}
</Button>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
Currently auto-signing in as{' '}
<span className="font-medium text-foreground">{activeUser?.username ?? '—'}</span>.
Restoring login requirement will require all users to sign in manually.
</p>
<Button variant="outline" onClick={() => doSetMode('multi', null)} disabled={saving} className="w-full">
{saving ? 'Restoring…' : 'Restore Login Requirement'}
</Button>
</>
)}
</CardContent>
</Card>
{/* Single-user mode confirmation */}
<AlertDialog open={confirmSingle} onOpenChange={setConfirmSingle}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Enable Single-User Mode?</AlertDialogTitle>
<AlertDialogDescription>
Anyone who opens the app will be automatically signed in as{' '}
<span className="font-medium text-foreground">{selectedUsername}</span>.
The admin login still requires a password.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSingle}>
Enable Single-User Mode
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ─── 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 (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Loading auth settings
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Authentication Methods</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Control local login and authentik/OIDC. Settings are saved in the database;
environment variables only fill blank fields as bootstrap defaults.
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Warnings */}
{(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-1">
{wouldLockOut && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Cannot disable all login methods; at least one must remain enabled.
</p>
)}
{cantDisableLocal && !wouldLockOut && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Cannot disable local login without authentik/OIDC configured, enabled, and mapped to an admin group.
</p>
)}
{oidcEnabledButIncomplete && (
<p className="text-sm text-amber-600 dark:text-amber-400">
authentik/OIDC needs {missingFields.join(', ')} before it can be enabled.
</p>
)}
{data?.warnings?.map((w, i) => (
<p key={i} className="text-sm text-amber-600 dark:text-amber-400">{w}</p>
))}
</div>
)}
{/* Local login toggle */}
<FieldRow label="Local username/password login">
<div className="flex items-center gap-3">
<Toggle
checked={form.local_login_enabled}
onChange={v => set('local_login_enabled', v)}
label="Enable local login"
/>
<span className="text-xs text-muted-foreground">
{form.local_login_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</FieldRow>
{/* OIDC / authentik login toggle */}
<FieldRow label="authentik / OIDC login">
<div className="flex items-center gap-3">
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="h-5 w-5 shrink-0 object-contain"
/>
<Toggle
checked={form.oidc_login_enabled}
onChange={v => set('oidc_login_enabled', v)}
label="Enable OIDC login"
/>
<span className={`text-xs ${oidcConfigured ? 'text-muted-foreground' : 'text-amber-500'}`}>
{!oidcConfigured
? 'Not fully configured'
: form.oidc_login_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</FieldRow>
<div className="space-y-4 pt-2 border-t border-border">
<div className="flex items-center gap-2 pt-1 text-sm font-medium text-muted-foreground">
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="h-5 w-5 shrink-0 object-contain"
/>
<span>authentik / OIDC configuration</span>
</div>
<FieldRow label="Provider name">
<Input
value={form.oidc_provider_name}
onChange={e => set('oidc_provider_name', e.target.value)}
placeholder="authentik"
className="max-w-xs h-8 text-sm"
/>
</FieldRow>
<FieldRow label="Issuer URL">
<div className="space-y-1">
<Input
value={form.oidc_issuer_url}
onChange={e => set('oidc_issuer_url', e.target.value)}
placeholder="https://auth.example.com/application/o/bill-tracker/"
className="max-w-xl h-8 text-sm"
/>
<p className={issuerEndpointWarning ? 'text-xs text-amber-500' : 'text-xs text-muted-foreground'}>
Use the authentik provider issuer URL, not the authorize/token/userinfo endpoint.
</p>
{issuerEndpointWarning && (
<p className="text-xs text-amber-500">
This looks like an authorization endpoint. In authentik, copy the OpenID Configuration Issuer value.
</p>
)}
</div>
</FieldRow>
<FieldRow label="Client ID">
<Input
value={form.oidc_client_id}
onChange={e => set('oidc_client_id', e.target.value)}
placeholder="authentik client ID"
className="max-w-xl h-8 text-sm"
/>
</FieldRow>
<FieldRow label="Client Secret">
<div className="space-y-2">
<div className="flex max-w-xl items-center gap-3">
<Input
type="password"
value={form.oidc_client_secret}
onChange={e => 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"
/>
<span className={`shrink-0 text-xs ${data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'text-emerald-500' : 'text-muted-foreground'}`}>
{data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'Secret is set' : 'No secret saved'}
</span>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={form.oidc_client_secret_clear}
onChange={e => set('oidc_client_secret_clear', e.target.checked)}
/>
Clear saved secret on save
</label>
</div>
</FieldRow>
<FieldRow label="Client auth method">
<div className="space-y-1">
<select
value={form.oidc_token_auth_method}
onChange={e => set('oidc_token_auth_method', e.target.value)}
className="h-8 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="client_secret_basic">client_secret_basic</option>
<option value="client_secret_post">client_secret_post</option>
</select>
<p className="text-xs text-muted-foreground">
Advanced. Keep client_secret_basic unless your authentik provider explicitly requires client_secret_post.
</p>
</div>
</FieldRow>
<FieldRow label="Redirect URI">
<div className="space-y-1">
<div className="flex max-w-xl gap-2">
<Input
value={form.oidc_redirect_uri}
onChange={e => set('oidc_redirect_uri', e.target.value)}
placeholder={defaultOidcRedirectUri()}
className="h-8 text-sm"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => set('oidc_redirect_uri', defaultOidcRedirectUri())}
>
Use Current
</Button>
</div>
<p className="text-xs text-muted-foreground">
Add this exact URL to the Redirect URIs allowed by authentik.
</p>
</div>
</FieldRow>
<FieldRow label="Scopes">
<Input
value={form.oidc_scopes}
onChange={e => set('oidc_scopes', e.target.value)}
placeholder="openid email profile groups"
className="max-w-xl h-8 text-sm"
/>
</FieldRow>
<FieldRow label="Admin group">
<div className="space-y-1">
<Input
value={form.oidc_admin_group}
onChange={e => set('oidc_admin_group', e.target.value)}
placeholder="e.g. bill-tracker-admins"
className="max-w-sm h-8 text-sm"
/>
<p className="text-xs text-muted-foreground">
Only users in this authentik group become app admins. Admin is never granted by default.
</p>
</div>
</FieldRow>
<FieldRow label="Auto-provision users">
<div className="space-y-1">
<div className="flex items-center gap-3">
<Toggle
checked={form.oidc_auto_provision}
onChange={v => set('oidc_auto_provision', v)}
label="Auto-provision users"
/>
<span className="text-xs text-muted-foreground">
{form.oidc_auto_provision ? 'Enabled' : 'Disabled'}
</span>
</div>
<p className="text-xs text-muted-foreground">
When enabled, valid authentik users are created in this app on first login.
</p>
</div>
</FieldRow>
<FieldRow label="Default role">
<div className="flex items-center gap-2">
<Input
value="user"
readOnly
className="max-w-[120px] h-8 text-sm"
/>
<span className="text-xs text-muted-foreground">
Admin role only via admin group.
</span>
</div>
</FieldRow>
{data?.oidc_env_fallback_used && (
<div className="rounded-lg border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs text-sky-700 dark:text-sky-400">
One or more blank database fields are currently using environment fallback values. Saving values here takes precedence.
</div>
)}
{oidcTest && (
<div className={`rounded-lg border px-4 py-3 text-xs ${
oidcTest.ok
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
: 'border-destructive/25 bg-destructive/10 text-destructive'
}`}>
{oidcTest.ok
? `Configuration test passed for ${oidcTest.issuer || form.oidc_issuer_url}.`
: oidcTest.error || 'Configuration test failed.'}
</div>
)}
</div>
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={handleTestOidc} disabled={!canTestOidc}>
{testingOidc ? 'Testing…' : 'Test Configuration'}
</Button>
<Button
variant="outline"
disabled={!data?.oidc_login_enabled || !data?.oidc_configured}
onClick={() => { window.location.href = '/api/auth/oidc/login?redirect_to=/admin'; }}
>
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="mr-2 h-4 w-4 shrink-0 object-contain"
/>
Test authentik Login
</Button>
<Button onClick={handleSave} disabled={!canSave}>
{saving ? 'Saving…' : 'Save Auth Settings'}
</Button>
</div>
</CardContent>
</Card>
);
}
// ─── 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 (
<>
<Card>
<CardHeader className="pb-4">
<CardTitle>Users</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Username</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Role</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Password</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody>
{(users || []).map(user => {
const form = getForm(user.id);
const isSelf = currentUser?.id === user.id;
return (
<tr key={user.id} className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-6 py-3 font-medium">{user.username}</td>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>
{user.role}
</Badge>
<select
value={user.role}
disabled={isSelf || roleUpdating === user.id}
onChange={e => handleRoleChange(user, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
title={isSelf ? 'You cannot change your own role' : 'Change user role'}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
</td>
<td className="px-6 py-3">
{user.must_change_password
? <Badge variant="due_soon">Temporary</Badge>
: <span className="text-muted-foreground">Set</span>
}
</td>
<td className="px-6 py-3">
{user.role !== 'admin' && (
form.open ? (
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="New password"
value={form.pw || ''}
onChange={e => setReset(user.id, { pw: e.target.value })}
className="h-8 text-sm w-36"
/>
<Button
size="sm"
onClick={() => handleReset(user)}
disabled={resetting === user.id}
>
{resetting === user.id ? '…' : 'Save'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setReset(user.id, { open: false, pw: '' })}
>
Cancel
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setReset(user.id, { open: true })}>
Reset
</Button>
)
)}
</td>
<td className="px-6 py-3 text-right">
{user.role !== 'admin' && (
<Button
size="sm"
variant="destructive"
onClick={() => setDeleteTarget(user)}
disabled={deleting === user.id}
>
{deleting === user.id ? '…' : 'Delete'}
</Button>
)}
</td>
</tr>
);
})}
{!users?.length && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Delete user confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. All their sessions will be invalidated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={!!deleting}
>
{deleting ? 'Deleting…' : 'Delete User'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ─── 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 (
<Card>
<CardHeader className="pb-4">
<CardTitle>Add User</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="flex items-end gap-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-uname">Username</Label>
<Input
id="new-uname"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="username"
required
/>
</div>
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-upw">Password</Label>
<Input
id="new-upw"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
required
/>
</div>
<Button type="submit" disabled={loading} className="shrink-0">
{loading ? 'Creating…' : 'Create User'}
</Button>
</form>
</CardContent>
</Card>
);
}
// ─── 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 <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading backups</CardContent></Card>;
}
return (
<>
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Database Backups</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Admin-only SQLite backup, import, download, restore, and schedule controls.
</p>
</div>
<Badge className={settings.last_error ? 'bg-red-500/15 text-red-400 border-red-500/20' : 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'}>
{settings.last_error ? 'Attention' : 'Ready'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 md:grid-cols-4">
{[
['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]) => (
<div key={label} className="rounded-lg border border-border bg-muted/20 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-sm font-medium mt-1 truncate">{value}</p>
</div>
))}
</div>
{settings.last_error && (
<div className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{settings.last_error}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button onClick={handleCreate} disabled={!!busy}>
<Database className="h-4 w-4" />
{busy === 'create' ? 'Creating…' : 'Create Backup'}
</Button>
<Button asChild variant="outline" disabled={!!busy}>
<label className={cn('cursor-pointer', busy && 'pointer-events-none opacity-50')}>
<Upload className="h-4 w-4" />
{busy === 'import' ? 'Importing…' : 'Import Backup'}
<input
type="file"
accept=".sqlite,.db,application/octet-stream,application/x-sqlite3,application/vnd.sqlite3"
onChange={handleImport}
className="sr-only"
disabled={!!busy}
/>
</label>
</Button>
<Button variant="outline" onClick={load} disabled={!!busy}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40">
<tr className="border-b border-border">
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Type</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Modified</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Size</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Checksum</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{backups.map(backup => (
<tr key={backup.id} className="border-b border-border last:border-0 hover:bg-muted/25">
<td className="px-4 py-3 font-mono text-xs max-w-[260px] truncate" title={backup.id}>{backup.id}</td>
<td className="px-4 py-3"><BackupTypeBadge type={backup.type} /></td>
<td className="px-4 py-3 text-muted-foreground">{formatDateTime(backup.modified_at)}</td>
<td className="px-4 py-3 font-mono">{fmtBytes(backup.size_bytes)}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[120px] truncate" title={backup.checksum}>
{backup.checksum || '—'}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button size="icon" variant="ghost" title="Download backup" onClick={() => handleDownload(backup)} disabled={!!busy}>
<Download className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="Restore backup" onClick={() => setRestoreTarget(backup)} disabled={!!busy}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="Delete backup" onClick={() => setDeleteTarget(backup)} disabled={!!busy}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
{!backups.length && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No managed backups yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="border-t border-border pt-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<SectionHeading>Scheduled Backups</SectionHeading>
<p className="text-xs text-muted-foreground mt-1">
Scheduled runs create managed backups and only apply retention to scheduled backups.
</p>
</div>
<Toggle checked={!!settings.enabled} onChange={v => setSchedule('enabled', v)} label="Enable scheduled backups" />
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-1.5">
<Label>Frequency</Label>
<Select value={settings.frequency} onValueChange={v => setSchedule('frequency', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Time</Label>
<Input type="time" value={settings.time} onChange={e => setSchedule('time', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Keep scheduled</Label>
<Input
type="number"
min="1"
max="365"
value={settings.retention_count}
onChange={e => setSchedule('retention_count', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Next run</Label>
<div className="h-9 rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground truncate">
{formatDateTime(settings.next_run_at)}
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="text-sm text-muted-foreground">
Last run: <span className="text-foreground">{formatDateTime(settings.last_run_at)}</span>
</div>
<div className="text-sm text-muted-foreground">
Last error: <span className="text-foreground">{settings.last_error || '—'}</span>
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleRunScheduledNow} disabled={!!busy}>
<Play className="h-4 w-4" />
{busy === 'run-scheduled' ? 'Running…' : 'Run Scheduled Now'}
</Button>
<Button onClick={handleSaveSettings} disabled={!!busy}>
{busy === 'settings' ? 'Saving…' : 'Save Schedule'}
</Button>
</div>
</div>
</CardContent>
</Card>
<AlertDialog open={!!restoreTarget} onOpenChange={(open) => { if (!open) setRestoreTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore this database backup?</AlertDialogTitle>
<AlertDialogDescription>
This replaces the live database with <span className="font-mono">{restoreTarget?.id}</span>.
A pre-restore backup will be created first. Run this during a quiet maintenance window.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy.startsWith('restore:')}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleRestore}
disabled={busy.startsWith('restore:')}
>
{busy.startsWith('restore:') ? 'Restoring…' : 'Restore Database'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this backup?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes <span className="font-mono">{deleteTarget?.id}</span>.
The live database is not affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy.startsWith('delete:')}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={busy.startsWith('delete:')}
>
{busy.startsWith('delete:') ? 'Deleting…' : 'Delete Backup'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ─── 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 (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Loading cleanup settings
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Wrench className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle>Cleanup / Maintenance</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Automatic daily cleanup: expired import sessions, stale export temp files, orphaned backup partials. Runs at 6:00 AM.
</p>
</div>
</div>
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 shrink-0">Auto</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Last run summary */}
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border border-border bg-muted/20 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Last Run</p>
<p className="text-sm font-medium mt-1">{status?.last_run_at ? formatDateTime(status.last_run_at) : '—'}</p>
</div>
<div className="rounded-lg border border-border bg-muted/20 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Last Result</p>
{resultLines.length > 0 ? (
<ul className="mt-1 space-y-0.5">
{resultLines.map(line => (
<li key={line} className="text-xs text-muted-foreground">{line}</li>
))}
</ul>
) : (
<p className="text-sm font-medium mt-1 text-muted-foreground/60">No runs recorded yet</p>
)}
</div>
</div>
{/* Settings */}
<div className="space-y-3">
<p className="text-sm font-medium">Task Settings</p>
{[
['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]) => (
<FieldRow key={key} label={label}>
<Toggle checked={!!form[key]} onChange={v => set(key, v)} label={label} />
</FieldRow>
))}
<FieldRow label="Temp file max age (hours, 172)">
<Input
type="number" min="1" max="72"
value={form.temp_export_max_age_hours}
onChange={e => 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"
/>
</FieldRow>
<FieldRow label="Trim import history rows (off by default)">
<Toggle checked={!!form.import_history_enabled} onChange={v => set('import_history_enabled', v)} label="Trim import history rows" />
</FieldRow>
{form.import_history_enabled && (
<>
<FieldRow label="Import history max age (days, 303650)">
<Input
type="number" min="30" max="3650"
value={form.import_history_max_age_days}
onChange={e => set('import_history_max_age_days', parseInt(e.target.value, 10) || 365)}
className="max-w-[120px] h-8 text-sm"
/>
</FieldRow>
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-600 dark:text-amber-400">
<strong>Warning:</strong> Import history trimming permanently deletes audit records older than {form.import_history_max_age_days} days. This cannot be undone.
</div>
</>
)}
</div>
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<Button onClick={handleSave} disabled={saving || running}>
{saving ? 'Saving…' : 'Save Settings'}
</Button>
<Button variant="outline" onClick={handleRunNow} disabled={saving || running}>
<Play className="h-4 w-4" />
{running ? 'Running…' : 'Run Cleanup Now'}
</Button>
</div>
</CardContent>
</Card>
);
}
// ─── 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 (
<div className="flex items-center justify-center min-h-screen text-muted-foreground text-sm">
Loading
</div>
);
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
<AppNavigation adminMode />
{/* Content */}
{!hasUsers ? (
<OnboardingWizard onComplete={handleOnboardingComplete} />
) : (
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8 space-y-6">
<EmailNotifCard />
<BackupManagementCard />
<CleanupPanel />
<LoginModeCard users={users} />
<AuthMethodsCard />
<AddUserCard onCreated={loadUsers} />
<UsersTable users={users} onRefresh={loadUsers} currentUser={me} />
</main>
)}
</div>
);
}