refactor(ts): convert CleanupPanel + UsersTable admin cards to TSX (B17)
CleanupPanel (CleanupForm/CleanupStatus/CleanupResult; keyed toggle array typed [keyof CleanupForm,string][]). UsersTable (AdminUser type exported + reused by AdminPage's users state; ResetForm; typed row handlers). typecheck 0, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bb3e9f551c
commit
a9a58d8aa2
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Play, Wrench } from 'lucide-react';
|
import { Play, Wrench } 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 { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -9,9 +10,42 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { FieldRow, Toggle, formatDateTime } from './adminShared';
|
import { FieldRow, Toggle, formatDateTime } from './adminShared';
|
||||||
|
|
||||||
|
interface CleanupForm {
|
||||||
|
import_sessions_enabled: boolean;
|
||||||
|
temp_exports_enabled: boolean;
|
||||||
|
temp_export_max_age_hours: number;
|
||||||
|
backup_partials_enabled: boolean;
|
||||||
|
import_history_enabled: boolean;
|
||||||
|
import_history_max_age_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CleanupTask {
|
||||||
|
pruned?: number;
|
||||||
|
removed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CleanupResult {
|
||||||
|
import_sessions?: CleanupTask;
|
||||||
|
temp_export_files?: CleanupTask;
|
||||||
|
backup_partials?: CleanupTask;
|
||||||
|
import_history?: CleanupTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CleanupStatus {
|
||||||
|
last_run_at?: string | null;
|
||||||
|
last_result?: CleanupResult | null;
|
||||||
|
settings?: CleanupForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_TOGGLES: [keyof CleanupForm, string][] = [
|
||||||
|
['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'],
|
||||||
|
];
|
||||||
|
|
||||||
export default function CleanupPanel() {
|
export default function CleanupPanel() {
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState<CleanupStatus | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState<CleanupForm>({
|
||||||
import_sessions_enabled: true,
|
import_sessions_enabled: true,
|
||||||
temp_exports_enabled: true,
|
temp_exports_enabled: true,
|
||||||
temp_export_max_age_hours: 2,
|
temp_export_max_age_hours: 2,
|
||||||
|
|
@ -25,11 +59,11 @@ export default function CleanupPanel() {
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.adminCleanup();
|
const data = await api.adminCleanup() as CleanupStatus;
|
||||||
setStatus(data);
|
setStatus(data);
|
||||||
if (data.settings) setForm(data.settings);
|
if (data.settings) setForm(data.settings);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to load cleanup settings.');
|
toast.error(errMessage(err, 'Failed to load cleanup settings.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -37,16 +71,16 @@ export default function CleanupPanel() {
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
|
const set = (k: keyof CleanupForm, v: boolean | number) => setForm(prev => ({ ...prev, [k]: v }) as CleanupForm);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const next = await api.saveAdminCleanup(form);
|
const next = await api.saveAdminCleanup(form) as CleanupForm | null;
|
||||||
if (next) setForm(next);
|
if (next) setForm(next);
|
||||||
toast.success('Cleanup settings saved.');
|
toast.success('Cleanup settings saved.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save cleanup settings.');
|
toast.error(errMessage(err, 'Failed to save cleanup settings.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +89,7 @@ export default function CleanupPanel() {
|
||||||
async function handleRunNow() {
|
async function handleRunNow() {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.runAdminCleanup();
|
const result = await api.runAdminCleanup() as { ran_at?: string; tasks?: CleanupResult };
|
||||||
setStatus(prev => ({
|
setStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
last_run_at: result.ran_at,
|
last_run_at: result.ran_at,
|
||||||
|
|
@ -63,13 +97,13 @@ export default function CleanupPanel() {
|
||||||
}));
|
}));
|
||||||
toast.success('Cleanup tasks completed.');
|
toast.success('Cleanup tasks completed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Cleanup run failed.');
|
toast.error(errMessage(err, 'Cleanup run failed.'));
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultLine(label, task, countKey) {
|
function resultLine(label: string, task: CleanupTask | undefined, countKey: keyof CleanupTask): string | null {
|
||||||
if (!task || task[countKey] == null) return null;
|
if (!task || task[countKey] == null) return null;
|
||||||
return `${label}: ${task[countKey]}`;
|
return `${label}: ${task[countKey]}`;
|
||||||
}
|
}
|
||||||
|
|
@ -137,11 +171,7 @@ export default function CleanupPanel() {
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">Task Settings</p>
|
<p className="text-sm font-medium">Task Settings</p>
|
||||||
{[
|
{TASK_TOGGLES.map(([key, label]) => (
|
||||||
['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}>
|
<FieldRow key={key} label={label}>
|
||||||
<Toggle checked={!!form[key]} onChange={v => set(key, v)} label={label} />
|
<Toggle checked={!!form[key]} onChange={v => set(key, v)} label={label} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, errMessage } from '@/lib/utils';
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
@ -11,19 +11,41 @@ import {
|
||||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import type { User } from '@/hooks/useAuth';
|
||||||
|
|
||||||
export default function UsersTable({ users, onRefresh, currentUser }) {
|
export interface AdminUser {
|
||||||
const [resetForms, setResetForms] = useState({});
|
id: number;
|
||||||
const [deleting, setDeleting] = useState(null);
|
username: string;
|
||||||
const [resetting, setResetting] = useState(null);
|
role?: string;
|
||||||
const [roleUpdating, setRoleUpdating] = useState(null);
|
is_default_admin?: boolean;
|
||||||
const [activeUpdating, setActiveUpdating] = useState(null);
|
active?: boolean | number;
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
must_change_password?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
const setReset = (id, v) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } }));
|
interface ResetForm {
|
||||||
const getForm = (id) => resetForms[id] || { pw: '', open: false };
|
pw?: string;
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const handleReset = async (user) => {
|
interface UsersTableProps {
|
||||||
|
users?: AdminUser[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
currentUser?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersTable({ users, onRefresh, currentUser }: UsersTableProps) {
|
||||||
|
const [resetForms, setResetForms] = useState<Record<number, ResetForm>>({});
|
||||||
|
const [deleting, setDeleting] = useState<number | null>(null);
|
||||||
|
const [resetting, setResetting] = useState<number | null>(null);
|
||||||
|
const [roleUpdating, setRoleUpdating] = useState<number | null>(null);
|
||||||
|
const [activeUpdating, setActiveUpdating] = useState<number | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
||||||
|
|
||||||
|
const setReset = (id: number, v: ResetForm) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } }));
|
||||||
|
const getForm = (id: number): ResetForm => resetForms[id] || { pw: '', open: false };
|
||||||
|
|
||||||
|
const handleReset = async (user: AdminUser) => {
|
||||||
const form = getForm(user.id);
|
const form = getForm(user.id);
|
||||||
if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; }
|
if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; }
|
||||||
setResetting(user.id);
|
setResetting(user.id);
|
||||||
|
|
@ -32,7 +54,7 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
toast.success(`Password reset for ${user.username}.`);
|
toast.success(`Password reset for ${user.username}.`);
|
||||||
setReset(user.id, { pw: '', open: false });
|
setReset(user.id, { pw: '', open: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to reset password.');
|
toast.error(errMessage(err, 'Failed to reset password.'));
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(null);
|
setResetting(null);
|
||||||
}
|
}
|
||||||
|
|
@ -47,13 +69,13 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to delete user.');
|
toast.error(errMessage(err, 'Failed to delete user.'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRoleChange = async (user, role) => {
|
const handleRoleChange = async (user: AdminUser, role: string) => {
|
||||||
if (user.role === role) return;
|
if (user.role === role) return;
|
||||||
setRoleUpdating(user.id);
|
setRoleUpdating(user.id);
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,20 +83,20 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`);
|
toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`);
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update user role.');
|
toast.error(errMessage(err, 'Failed to update user role.'));
|
||||||
} finally {
|
} finally {
|
||||||
setRoleUpdating(null);
|
setRoleUpdating(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActiveChange = async (user, active) => {
|
const handleActiveChange = async (user: AdminUser, active: boolean) => {
|
||||||
setActiveUpdating(user.id);
|
setActiveUpdating(user.id);
|
||||||
try {
|
try {
|
||||||
await api.updateUserActive(user.id, { active });
|
await api.updateUserActive(user.id, { active });
|
||||||
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
|
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update user status.');
|
toast.error(errMessage(err, 'Failed to update user status.'));
|
||||||
} finally {
|
} finally {
|
||||||
setActiveUpdating(null);
|
setActiveUpdating(null);
|
||||||
}
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import UsersTable from '@/components/admin/UsersTable';
|
||||||
import AddUserCard from '@/components/admin/AddUserCard';
|
import AddUserCard from '@/components/admin/AddUserCard';
|
||||||
import BackupManagementCard from '@/components/admin/BackupManagementCard';
|
import BackupManagementCard from '@/components/admin/BackupManagementCard';
|
||||||
import CleanupPanel from '@/components/admin/CleanupPanel';
|
import CleanupPanel from '@/components/admin/CleanupPanel';
|
||||||
|
import type { AdminUser } from '@/components/admin/UsersTable';
|
||||||
import type { User } from '@/hooks/useAuth';
|
import type { User } from '@/hooks/useAuth';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
|
|
@ -20,7 +21,7 @@ export default function AdminPage() {
|
||||||
const [me, setMe] = useState<User | null>(null);
|
const [me, setMe] = useState<User | null>(null);
|
||||||
const [hasUsers, setHasUsers] = useState<boolean | null>(null);
|
const [hasUsers, setHasUsers] = useState<boolean | null>(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [users, setUsers] = useState<unknown[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [authMode, setAuthMode] = useState('multi');
|
const [authMode, setAuthMode] = useState('multi');
|
||||||
|
|
||||||
const loadMe = useCallback(async () => {
|
const loadMe = useCallback(async () => {
|
||||||
|
|
@ -34,7 +35,7 @@ export default function AdminPage() {
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.adminUsers() as { users?: unknown[] } | unknown[];
|
const d = await api.adminUsers() as { users?: AdminUser[] } | AdminUser[];
|
||||||
setUsers(Array.isArray(d) ? d : (d.users || []));
|
setUsers(Array.isArray(d) ? d : (d.users || []));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue