1842 lines
72 KiB
JavaScript
1842 lines
72 KiB
JavaScript
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, 1–72)">
|
||
<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, 30–3650)">
|
||
<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>
|
||
);
|
||
}
|