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:
null 2026-07-04 21:26:22 -05:00
parent c9b5f93569
commit a8496a9c64
3 changed files with 164 additions and 93 deletions

View File

@ -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,45 +9,89 @@ 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);
} }
function formFromData(d: AuthConfigData): OidcForm {
return {
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',
};
}
export default function AuthMethodsCard() { export default function AuthMethodsCard() {
const [data, setData] = useState(null); const [data, setData] = useState<AuthConfigData | null>(null);
const [form, setForm] = useState(null); const [form, setForm] = useState<OidcForm | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testingOidc, setTestingOidc] = useState(false); const [testingOidc, setTestingOidc] = useState(false);
const [oidcTest, setOidcTest] = useState(null); const [oidcTest, setOidcTest] = useState<OidcTestResult | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const d = await api.authModeConfig(); const d = await api.authModeConfig() 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',
});
} 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"
/> />

View File

@ -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">

View File

@ -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">