BillTracker/client/pages/AdminPage.jsx

1842 lines
72 KiB
React
Raw Normal View History

2026-05-03 19:51:57 -05:00
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 ──────────────────────────────────────────────────────────────────
2026-05-03 22:33:21 -05:00
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
2026-05-03 19:51:57 -05:00
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">
2026-05-03 22:33:21 -05:00
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="h-5 w-5 shrink-0 object-contain"
/>
2026-05-03 19:51:57 -05:00
<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">
2026-05-03 22:33:21 -05:00
<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>
2026-05-03 19:51:57 -05:00
<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'; }}
>
2026-05-03 22:33:21 -05:00
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="mr-2 h-4 w-4 shrink-0 object-contain"
/>
2026-05-03 19:51:57 -05:00
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>
);
}