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 { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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';
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 '';
return `${window.location.origin}/api/auth/oidc/callback`;
}
function looksLikeOidcEndpoint(url) {
function looksLikeOidcEndpoint(url: string | null | undefined): boolean {
const value = String(url || '').toLowerCase();
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() {
const [data, setData] = useState(null);
const [form, setForm] = useState(null);
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(null);
const [oidcTest, setOidcTest] = useState<OidcTestResult | null>(null);
const load = useCallback(async () => {
try {
const d = await api.authModeConfig();
const d = await api.authModeConfig() as AuthConfigData;
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',
});
setForm(formFromData(d));
} catch (err) {
toast.error(err.message || 'Failed to load auth settings.');
toast.error(errMessage(err, 'Failed to load auth settings.'));
} finally {
setLoading(false);
}
@ -54,31 +99,17 @@ export default function AuthMethodsCard() {
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() {
setSaving(true);
try {
const d = await api.setAuthMode(form);
const d = await api.setAuthMode(form) as AuthConfigData;
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',
});
setForm(formFromData(d));
toast.success('Auth method settings saved.');
} catch (err) {
toast.error(err.message || 'Failed to save auth method settings.');
toast.error(errMessage(err, 'Failed to save auth method settings.'));
} finally {
setSaving(false);
}
@ -88,11 +119,12 @@ export default function AuthMethodsCard() {
setTestingOidc(true);
setOidcTest(null);
try {
const result = await api.testOidcConfig(form);
const result = await api.testOidcConfig(form) as OidcTestResult;
setOidcTest(result);
toast.success('authentik configuration test passed.');
} 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);
toast.error(result.error || 'OIDC configuration test failed.');
} finally {
@ -151,7 +183,7 @@ export default function AuthMethodsCard() {
<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">
{wouldLockOut && (
<p className="text-sm text-amber-600 dark:text-amber-400">
@ -229,11 +261,11 @@ export default function AuthMethodsCard() {
<Input
type="password"
value={form.oidc_client_secret}
onChange={e => setForm(prev => ({
onChange={e => setForm(prev => prev ? ({
...prev,
oidc_client_secret: e.target.value,
oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear,
}))}
}) : prev)}
placeholder="Leave blank to keep existing secret"
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 { toast } from 'sonner';
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 { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@ -18,7 +18,25 @@ import {
import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared';
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,
frequency: 'daily',
time: '02:00',
@ -29,24 +47,24 @@ const DEFAULT_SETTINGS = {
};
export default function BackupManagementCard() {
const [backups, setBackups] = useState([]);
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [backups, setBackups] = useState<Backup[]>([]);
const [settings, setSettings] = useState<BackupSettings>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState('');
const [restoreTarget, setRestoreTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [backupData, settingsData] = await Promise.all([
api.adminBackups(),
api.adminBackupSettings(),
api.adminBackups() as Promise<{ backups?: Backup[] }>,
api.adminBackupSettings() as Promise<Partial<BackupSettings>>,
]);
setBackups(backupData.backups || []);
setSettings({ ...DEFAULT_SETTINGS, ...settingsData });
} catch (err) {
toast.error(err.message || 'Failed to load backups.');
toast.error(errMessage(err, 'Failed to load backups.'));
} finally {
setLoading(false);
}
@ -55,7 +73,7 @@ export default function BackupManagementCard() {
useEffect(() => { load(); }, [load]);
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() {
setBusy('create');
@ -64,16 +82,16 @@ export default function BackupManagementCard() {
toast.success('Backup created.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to create backup.');
toast.error(errMessage(err, 'Failed to create backup.'));
} finally {
setBusy('');
}
}
async function handleDownload(backup) {
async function handleDownload(backup: Backup) {
setBusy(`download:${backup.id}`);
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 a = document.createElement('a');
a.href = url;
@ -83,13 +101,13 @@ export default function BackupManagementCard() {
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
toast.error(err.message || 'Failed to download backup.');
toast.error(errMessage(err, 'Failed to download backup.'));
} finally {
setBusy('');
}
}
async function handleImport(e) {
async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
@ -100,7 +118,7 @@ export default function BackupManagementCard() {
toast.success('Backup imported.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to import backup.');
toast.error(errMessage(err, 'Failed to import backup.'));
} finally {
setBusy('');
}
@ -110,12 +128,12 @@ export default function BackupManagementCard() {
if (!restoreTarget) return;
setBusy(`restore:${restoreTarget.id}`);
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}`);
setRestoreTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to restore backup.');
toast.error(errMessage(err, 'Failed to restore backup.'));
} finally {
setBusy('');
}
@ -130,7 +148,7 @@ export default function BackupManagementCard() {
setDeleteTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to delete backup.');
toast.error(errMessage(err, 'Failed to delete backup.'));
} finally {
setBusy('');
}
@ -143,12 +161,12 @@ export default function BackupManagementCard() {
enabled: !!settings.enabled,
frequency: settings.frequency,
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 });
toast.success('Backup schedule saved.');
} catch (err) {
toast.error(err.message || 'Failed to save backup schedule.');
toast.error(errMessage(err, 'Failed to save backup schedule.'));
} finally {
setBusy('');
}
@ -161,7 +179,7 @@ export default function BackupManagementCard() {
toast.success('Scheduled backup created.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to run scheduled backup.');
toast.error(errMessage(err, 'Failed to run scheduled backup.'));
} finally {
setBusy('');
}
@ -196,12 +214,12 @@ export default function BackupManagementCard() {
</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]) => (
] as [string, React.ReactNode][]).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>
@ -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 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}>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[120px] truncate" title={backup.checksum ?? undefined}>
{backup.checksum || '—'}
</td>
<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 { toast } from 'sonner';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
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;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function timeAgo(iso) {
if (!iso) return null;
const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000);
function timeAgo(iso: string | null | undefined): string | null {
const d = parseUtc(iso);
if (!d) return null;
const secs = Math.floor((Date.now() - d.getTime()) / 1000);
if (secs < 60) return 'just now';
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`;
}
function timeUntil(iso) {
if (!iso) return null;
const secs = Math.floor((parseUtc(iso).getTime() - Date.now()) / 1000);
function timeUntil(iso: string | null | undefined): string | null {
const d = parseUtc(iso);
if (!d) return null;
const secs = Math.floor((d.getTime() - Date.now()) / 1000);
if (secs <= 0) return 'soon';
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
@ -32,31 +52,32 @@ function timeUntil(iso) {
}
export default function BankSyncAdminCard() {
const [config, setConfig] = useState(null);
const [config, setConfig] = useState<BankSyncConfig | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [saving, setSaving] = useState(false);
const [enabled, setEnabled] = useState(false);
const [syncInterval, setSyncInterval] = useState(4);
const [syncDays, setSyncDays] = useState(30);
const [syncInterval, setSyncInterval] = useState<number | string>(4);
const [syncDays, setSyncDays] = useState<number | string>(30);
const [debugLogging, setDebugLogging] = useState(false);
useEffect(() => {
api.bankSyncConfig()
.then(d => {
.then(raw => {
const d = raw as BankSyncConfig;
setConfig(d);
setEnabled(!!d.enabled);
setSyncInterval(d.sync_interval_hours ?? 4);
setSyncDays(d.sync_days ?? 30);
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));
}, []);
const handleSave = async () => {
const hours = parseFloat(syncInterval);
const days = parseInt(syncDays, 10);
const hours = parseFloat(String(syncInterval));
const days = parseInt(String(syncDays), 10);
const maxDays = config?.sync_days_max ?? 45;
if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) {
toast.error('Sync interval must be between 0.5 and 168 hours.');
@ -68,7 +89,7 @@ export default function BankSyncAdminCard() {
}
setSaving(true);
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);
setEnabled(!!result.enabled);
setSyncInterval(result.sync_interval_hours ?? 4);
@ -76,7 +97,7 @@ export default function BankSyncAdminCard() {
setDebugLogging(!!result.debug_logging);
toast.success('Bank sync settings saved.');
} catch (err) {
toast.error(err.message || 'Failed to update bank sync setting.');
toast.error(errMessage(err, 'Failed to update bank sync setting.'));
} finally {
setSaving(false);
}
@ -103,8 +124,8 @@ export default function BankSyncAdminCard() {
}
const changed = enabled !== !!config?.enabled
|| parseFloat(syncInterval) !== config?.sync_interval_hours
|| parseInt(syncDays, 10) !== config?.sync_days
|| parseFloat(String(syncInterval)) !== config?.sync_interval_hours
|| parseInt(String(syncDays), 10) !== config?.sync_days
|| debugLogging !== !!config?.debug_logging;
const worker = config?.worker;
const seedDays = config?.seed_days ?? 44;
@ -209,7 +230,7 @@ export default function BankSyncAdminCard() {
</div>
{/* 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">
<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">