refactor(ts): convert admin/ cards to TypeScript (AuthMethods, BankSync, Backup, ...)
Completes the entire client/components/admin/ directory conversion to .tsx: OidcForm/AuthConfigData/OidcTest types for the OIDC config form; Backup/ BackupSettings types for the backup manager. errMessage() on all strict catch(unknown) sites. typecheck 0, lint 0 errors, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c9b5f93569
commit
a8496a9c64
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
|
@ -8,29 +9,58 @@ import { FieldRow, Toggle } from './adminShared';
|
||||||
|
|
||||||
const AUTHENTIK_ICON_URL = '/img/auth.png';
|
const AUTHENTIK_ICON_URL = '/img/auth.png';
|
||||||
|
|
||||||
function defaultOidcRedirectUri() {
|
interface OidcForm {
|
||||||
|
local_login_enabled: boolean;
|
||||||
|
oidc_login_enabled: boolean;
|
||||||
|
oidc_provider_name: string;
|
||||||
|
oidc_issuer_url: string;
|
||||||
|
oidc_client_id: string;
|
||||||
|
oidc_client_secret: string;
|
||||||
|
oidc_client_secret_clear: boolean;
|
||||||
|
oidc_token_auth_method: string;
|
||||||
|
oidc_redirect_uri: string;
|
||||||
|
oidc_scopes: string;
|
||||||
|
oidc_auto_provision: boolean;
|
||||||
|
oidc_admin_group: string;
|
||||||
|
oidc_default_role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthConfigData {
|
||||||
|
local_login_enabled?: boolean;
|
||||||
|
oidc_login_enabled?: boolean;
|
||||||
|
oidc_provider_name?: string;
|
||||||
|
oidc_issuer_url?: string;
|
||||||
|
oidc_client_id?: string;
|
||||||
|
oidc_token_auth_method?: string;
|
||||||
|
oidc_redirect_uri?: string;
|
||||||
|
oidc_scopes?: string;
|
||||||
|
oidc_auto_provision?: boolean;
|
||||||
|
oidc_admin_group?: string;
|
||||||
|
oidc_default_role?: string;
|
||||||
|
oidc_client_secret_set?: boolean;
|
||||||
|
oidc_configured?: boolean;
|
||||||
|
oidc_env_fallback_used?: boolean;
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OidcTestResult {
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string;
|
||||||
|
issuer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOidcRedirectUri(): string {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
return `${window.location.origin}/api/auth/oidc/callback`;
|
return `${window.location.origin}/api/auth/oidc/callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeOidcEndpoint(url) {
|
function looksLikeOidcEndpoint(url: string | null | undefined): boolean {
|
||||||
const value = String(url || '').toLowerCase();
|
const value = String(url || '').toLowerCase();
|
||||||
return /\/(?:authorize|token|userinfo|jwks|certs)\/?$/.test(value);
|
return /\/(?:authorize|token|userinfo|jwks|certs)\/?$/.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthMethodsCard() {
|
function formFromData(d: AuthConfigData): OidcForm {
|
||||||
const [data, setData] = useState(null);
|
return {
|
||||||
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,
|
local_login_enabled: d.local_login_enabled !== false,
|
||||||
oidc_login_enabled: !!d.oidc_login_enabled,
|
oidc_login_enabled: !!d.oidc_login_enabled,
|
||||||
oidc_provider_name: d.oidc_provider_name || 'authentik',
|
oidc_provider_name: d.oidc_provider_name || 'authentik',
|
||||||
|
|
@ -44,9 +74,24 @@ export default function AuthMethodsCard() {
|
||||||
oidc_auto_provision: d.oidc_auto_provision !== false,
|
oidc_auto_provision: d.oidc_auto_provision !== false,
|
||||||
oidc_admin_group: d.oidc_admin_group || '',
|
oidc_admin_group: d.oidc_admin_group || '',
|
||||||
oidc_default_role: d.oidc_default_role || 'user',
|
oidc_default_role: d.oidc_default_role || 'user',
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthMethodsCard() {
|
||||||
|
const [data, setData] = useState<AuthConfigData | null>(null);
|
||||||
|
const [form, setForm] = useState<OidcForm | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testingOidc, setTestingOidc] = useState(false);
|
||||||
|
const [oidcTest, setOidcTest] = useState<OidcTestResult | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const d = await api.authModeConfig() as AuthConfigData;
|
||||||
|
setData(d);
|
||||||
|
setForm(formFromData(d));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to load auth settings.');
|
toast.error(errMessage(err, 'Failed to load auth settings.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -54,31 +99,17 @@ export default function AuthMethodsCard() {
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
|
const set = (k: keyof OidcForm, v: string | boolean) => setForm(prev => prev ? ({ ...prev, [k]: v }) as OidcForm : prev);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const d = await api.setAuthMode(form);
|
const d = await api.setAuthMode(form) as AuthConfigData;
|
||||||
setData(d);
|
setData(d);
|
||||||
setForm({
|
setForm(formFromData(d));
|
||||||
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.');
|
toast.success('Auth method settings saved.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save auth method settings.');
|
toast.error(errMessage(err, 'Failed to save auth method settings.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -88,11 +119,12 @@ export default function AuthMethodsCard() {
|
||||||
setTestingOidc(true);
|
setTestingOidc(true);
|
||||||
setOidcTest(null);
|
setOidcTest(null);
|
||||||
try {
|
try {
|
||||||
const result = await api.testOidcConfig(form);
|
const result = await api.testOidcConfig(form) as OidcTestResult;
|
||||||
setOidcTest(result);
|
setOidcTest(result);
|
||||||
toast.success('authentik configuration test passed.');
|
toast.success('authentik configuration test passed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const result = err.data || { ok: false, error: err.message || 'OIDC configuration test failed.' };
|
const e = err as { data?: OidcTestResult; message?: string };
|
||||||
|
const result = e.data || { ok: false, error: e.message || 'OIDC configuration test failed.' };
|
||||||
setOidcTest(result);
|
setOidcTest(result);
|
||||||
toast.error(result.error || 'OIDC configuration test failed.');
|
toast.error(result.error || 'OIDC configuration test failed.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -151,7 +183,7 @@ export default function AuthMethodsCard() {
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
|
|
||||||
{(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && (
|
{((data?.warnings?.length ?? 0) > 0 || wouldLockOut || cantDisableLocal) && (
|
||||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-1">
|
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-1">
|
||||||
{wouldLockOut && (
|
{wouldLockOut && (
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
|
@ -229,11 +261,11 @@ export default function AuthMethodsCard() {
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.oidc_client_secret}
|
value={form.oidc_client_secret}
|
||||||
onChange={e => setForm(prev => ({
|
onChange={e => setForm(prev => prev ? ({
|
||||||
...prev,
|
...prev,
|
||||||
oidc_client_secret: e.target.value,
|
oidc_client_secret: e.target.value,
|
||||||
oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear,
|
oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear,
|
||||||
}))}
|
}) : prev)}
|
||||||
placeholder="Leave blank to keep existing secret"
|
placeholder="Leave blank to keep existing secret"
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Database, Download, Play, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react';
|
import { Database, Download, Play, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmtBytes } from '@/lib/utils';
|
import { cn, fmtBytes, errMessage } from '@/lib/utils';
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -18,7 +18,25 @@ import {
|
||||||
import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared';
|
import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
interface Backup {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
modified_at?: string | null;
|
||||||
|
size_bytes?: number;
|
||||||
|
checksum?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
frequency: string;
|
||||||
|
time: string;
|
||||||
|
retention_count: number | string;
|
||||||
|
last_run_at: string | null;
|
||||||
|
next_run_at: string | null;
|
||||||
|
last_error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: BackupSettings = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
frequency: 'daily',
|
frequency: 'daily',
|
||||||
time: '02:00',
|
time: '02:00',
|
||||||
|
|
@ -29,24 +47,24 @@ const DEFAULT_SETTINGS = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BackupManagementCard() {
|
export default function BackupManagementCard() {
|
||||||
const [backups, setBackups] = useState([]);
|
const [backups, setBackups] = useState<Backup[]>([]);
|
||||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
const [settings, setSettings] = useState<BackupSettings>(DEFAULT_SETTINGS);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState('');
|
const [busy, setBusy] = useState('');
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [backupData, settingsData] = await Promise.all([
|
const [backupData, settingsData] = await Promise.all([
|
||||||
api.adminBackups(),
|
api.adminBackups() as Promise<{ backups?: Backup[] }>,
|
||||||
api.adminBackupSettings(),
|
api.adminBackupSettings() as Promise<Partial<BackupSettings>>,
|
||||||
]);
|
]);
|
||||||
setBackups(backupData.backups || []);
|
setBackups(backupData.backups || []);
|
||||||
setSettings({ ...DEFAULT_SETTINGS, ...settingsData });
|
setSettings({ ...DEFAULT_SETTINGS, ...settingsData });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to load backups.');
|
toast.error(errMessage(err, 'Failed to load backups.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +73,7 @@ export default function BackupManagementCard() {
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const latest = backups[0];
|
const latest = backups[0];
|
||||||
const setSchedule = (key, value) => setSettings(prev => ({ ...prev, [key]: value }));
|
const setSchedule = (key: keyof BackupSettings, value: string | boolean) => setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
setBusy('create');
|
setBusy('create');
|
||||||
|
|
@ -64,16 +82,16 @@ export default function BackupManagementCard() {
|
||||||
toast.success('Backup created.');
|
toast.success('Backup created.');
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to create backup.');
|
toast.error(errMessage(err, 'Failed to create backup.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('');
|
setBusy('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload(backup) {
|
async function handleDownload(backup: Backup) {
|
||||||
setBusy(`download:${backup.id}`);
|
setBusy(`download:${backup.id}`);
|
||||||
try {
|
try {
|
||||||
const { blob, filename } = await api.downloadAdminBackup(backup.id);
|
const { blob, filename } = await api.downloadAdminBackup(backup.id) as { blob: Blob; filename?: string };
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
@ -83,13 +101,13 @@ export default function BackupManagementCard() {
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to download backup.');
|
toast.error(errMessage(err, 'Failed to download backup.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('');
|
setBusy('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImport(e) {
|
async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -100,7 +118,7 @@ export default function BackupManagementCard() {
|
||||||
toast.success('Backup imported.');
|
toast.success('Backup imported.');
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to import backup.');
|
toast.error(errMessage(err, 'Failed to import backup.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('');
|
setBusy('');
|
||||||
}
|
}
|
||||||
|
|
@ -110,12 +128,12 @@ export default function BackupManagementCard() {
|
||||||
if (!restoreTarget) return;
|
if (!restoreTarget) return;
|
||||||
setBusy(`restore:${restoreTarget.id}`);
|
setBusy(`restore:${restoreTarget.id}`);
|
||||||
try {
|
try {
|
||||||
const result = await api.restoreAdminBackup(restoreTarget.id);
|
const result = await api.restoreAdminBackup(restoreTarget.id) as { pre_restore_backup?: string };
|
||||||
toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`);
|
toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`);
|
||||||
setRestoreTarget(null);
|
setRestoreTarget(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to restore backup.');
|
toast.error(errMessage(err, 'Failed to restore backup.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('');
|
setBusy('');
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +148,7 @@ export default function BackupManagementCard() {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to delete backup.');
|
toast.error(errMessage(err, 'Failed to delete backup.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('');
|
setBusy('');
|
||||||
}
|
}
|
||||||
|
|
@ -143,12 +161,12 @@ export default function BackupManagementCard() {
|
||||||
enabled: !!settings.enabled,
|
enabled: !!settings.enabled,
|
||||||
frequency: settings.frequency,
|
frequency: settings.frequency,
|
||||||
time: settings.time,
|
time: settings.time,
|
||||||
retention_count: parseInt(settings.retention_count, 10) || 2,
|
retention_count: parseInt(String(settings.retention_count), 10) || 2,
|
||||||
});
|
}) as Partial<BackupSettings>;
|
||||||
setSettings({ ...DEFAULT_SETTINGS, ...saved });
|
setSettings({ ...DEFAULT_SETTINGS, ...saved });
|
||||||
toast.success('Backup schedule saved.');
|
toast.success('Backup schedule saved.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save backup schedule.');
|
toast.error(errMessage(err, 'Failed to save backup schedule.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('');
|
setBusy('');
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +179,7 @@ export default function BackupManagementCard() {
|
||||||
toast.success('Scheduled backup created.');
|
toast.success('Scheduled backup created.');
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to run scheduled backup.');
|
toast.error(errMessage(err, 'Failed to run scheduled backup.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('');
|
setBusy('');
|
||||||
}
|
}
|
||||||
|
|
@ -196,12 +214,12 @@ export default function BackupManagementCard() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
{[
|
{([
|
||||||
['Managed backups', backups.length],
|
['Managed backups', backups.length],
|
||||||
['Latest backup', latest ? formatDateTime(latest.modified_at) : '—'],
|
['Latest backup', latest ? formatDateTime(latest.modified_at) : '—'],
|
||||||
['Latest size', latest ? fmtBytes(latest.size_bytes) : '—'],
|
['Latest size', latest ? fmtBytes(latest.size_bytes) : '—'],
|
||||||
['Scheduled', settings.enabled ? `${settings.frequency} ${settings.time}` : 'Disabled'],
|
['Scheduled', settings.enabled ? `${settings.frequency} ${settings.time}` : 'Disabled'],
|
||||||
].map(([label, value]) => (
|
] as [string, React.ReactNode][]).map(([label, value]) => (
|
||||||
<div key={label} className="rounded-lg border border-border bg-muted/20 p-3">
|
<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-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||||
<p className="text-sm font-medium mt-1 truncate">{value}</p>
|
<p className="text-sm font-medium mt-1 truncate">{value}</p>
|
||||||
|
|
@ -258,7 +276,7 @@ export default function BackupManagementCard() {
|
||||||
<td className="px-4 py-3"><BackupTypeBadge type={backup.type} /></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 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">{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}>
|
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[120px] truncate" title={backup.checksum ?? undefined}>
|
||||||
{backup.checksum || '—'}
|
{backup.checksum || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
|
@ -1,30 +1,50 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { AlertTriangle, Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Toggle } from './adminShared';
|
import { Toggle } from './adminShared';
|
||||||
|
|
||||||
function parseUtc(str) {
|
interface BankSyncWorker {
|
||||||
|
running?: boolean;
|
||||||
|
last_run_at?: string | null;
|
||||||
|
next_run_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BankSyncConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
sync_interval_hours?: number;
|
||||||
|
sync_days?: number;
|
||||||
|
debug_logging?: boolean;
|
||||||
|
sync_days_max?: number;
|
||||||
|
seed_days?: number;
|
||||||
|
worker?: BankSyncWorker;
|
||||||
|
encryption_key_source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUtc(str: string | null | undefined): Date | null {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
||||||
return new Date(normalized);
|
return new Date(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(iso) {
|
function timeAgo(iso: string | null | undefined): string | null {
|
||||||
if (!iso) return null;
|
const d = parseUtc(iso);
|
||||||
const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000);
|
if (!d) return null;
|
||||||
|
const secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||||
if (secs < 60) return 'just now';
|
if (secs < 60) return 'just now';
|
||||||
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
||||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
||||||
return `${Math.floor(secs / 86400)}d ago`;
|
return `${Math.floor(secs / 86400)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeUntil(iso) {
|
function timeUntil(iso: string | null | undefined): string | null {
|
||||||
if (!iso) return null;
|
const d = parseUtc(iso);
|
||||||
const secs = Math.floor((parseUtc(iso).getTime() - Date.now()) / 1000);
|
if (!d) return null;
|
||||||
|
const secs = Math.floor((d.getTime() - Date.now()) / 1000);
|
||||||
if (secs <= 0) return 'soon';
|
if (secs <= 0) return 'soon';
|
||||||
if (secs < 60) return `${secs}s`;
|
if (secs < 60) return `${secs}s`;
|
||||||
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
||||||
|
|
@ -32,31 +52,32 @@ function timeUntil(iso) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BankSyncAdminCard() {
|
export default function BankSyncAdminCard() {
|
||||||
const [config, setConfig] = useState(null);
|
const [config, setConfig] = useState<BankSyncConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
const [syncInterval, setSyncInterval] = useState(4);
|
const [syncInterval, setSyncInterval] = useState<number | string>(4);
|
||||||
const [syncDays, setSyncDays] = useState(30);
|
const [syncDays, setSyncDays] = useState<number | string>(30);
|
||||||
const [debugLogging, setDebugLogging] = useState(false);
|
const [debugLogging, setDebugLogging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.bankSyncConfig()
|
api.bankSyncConfig()
|
||||||
.then(d => {
|
.then(raw => {
|
||||||
|
const d = raw as BankSyncConfig;
|
||||||
setConfig(d);
|
setConfig(d);
|
||||||
setEnabled(!!d.enabled);
|
setEnabled(!!d.enabled);
|
||||||
setSyncInterval(d.sync_interval_hours ?? 4);
|
setSyncInterval(d.sync_interval_hours ?? 4);
|
||||||
setSyncDays(d.sync_days ?? 30);
|
setSyncDays(d.sync_days ?? 30);
|
||||||
setDebugLogging(!!d.debug_logging);
|
setDebugLogging(!!d.debug_logging);
|
||||||
})
|
})
|
||||||
.catch(err => setLoadError(err.message || 'Failed to load bank sync config'))
|
.catch(err => setLoadError(errMessage(err, 'Failed to load bank sync config')))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const hours = parseFloat(syncInterval);
|
const hours = parseFloat(String(syncInterval));
|
||||||
const days = parseInt(syncDays, 10);
|
const days = parseInt(String(syncDays), 10);
|
||||||
const maxDays = config?.sync_days_max ?? 45;
|
const maxDays = config?.sync_days_max ?? 45;
|
||||||
if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) {
|
if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) {
|
||||||
toast.error('Sync interval must be between 0.5 and 168 hours.');
|
toast.error('Sync interval must be between 0.5 and 168 hours.');
|
||||||
|
|
@ -68,7 +89,7 @@ export default function BankSyncAdminCard() {
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days, debug_logging: debugLogging });
|
const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days, debug_logging: debugLogging }) as BankSyncConfig;
|
||||||
setConfig(result);
|
setConfig(result);
|
||||||
setEnabled(!!result.enabled);
|
setEnabled(!!result.enabled);
|
||||||
setSyncInterval(result.sync_interval_hours ?? 4);
|
setSyncInterval(result.sync_interval_hours ?? 4);
|
||||||
|
|
@ -76,7 +97,7 @@ export default function BankSyncAdminCard() {
|
||||||
setDebugLogging(!!result.debug_logging);
|
setDebugLogging(!!result.debug_logging);
|
||||||
toast.success('Bank sync settings saved.');
|
toast.success('Bank sync settings saved.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update bank sync setting.');
|
toast.error(errMessage(err, 'Failed to update bank sync setting.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +124,8 @@ export default function BankSyncAdminCard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const changed = enabled !== !!config?.enabled
|
const changed = enabled !== !!config?.enabled
|
||||||
|| parseFloat(syncInterval) !== config?.sync_interval_hours
|
|| parseFloat(String(syncInterval)) !== config?.sync_interval_hours
|
||||||
|| parseInt(syncDays, 10) !== config?.sync_days
|
|| parseInt(String(syncDays), 10) !== config?.sync_days
|
||||||
|| debugLogging !== !!config?.debug_logging;
|
|| debugLogging !== !!config?.debug_logging;
|
||||||
const worker = config?.worker;
|
const worker = config?.worker;
|
||||||
const seedDays = config?.seed_days ?? 44;
|
const seedDays = config?.seed_days ?? 44;
|
||||||
|
|
@ -209,7 +230,7 @@ export default function BankSyncAdminCard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amber warning at the SimpleFIN limit */}
|
{/* Amber warning at the SimpleFIN limit */}
|
||||||
{parseInt(syncDays, 10) >= maxDays && (
|
{parseInt(String(syncDays), 10) >= maxDays && (
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/8 px-3 py-2.5">
|
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/8 px-3 py-2.5">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
Loading…
Reference in New Issue