pediatric-ai-scribe-v3/client/src/pages/AdminPanels.tsx
Daniel 2d781ed935 feat(client): port Admin panel — all 10 sub-tabs now run in React
Replaces the legacy-link shell at /app/admin with a proper
multi-sub-tab admin panel. Covers every module the vanilla admin.js
exposed: Users, Site settings, Announcement banner, AI models,
TTS / STT providers, SMTP, Email templates, AI prompts, Audit logs.

Priority 1 per Daniel's note: **Users**. Full CRUD flow ported —
list (with live filter), verify, disable/enable, set role
(user/moderator/admin), delete (with confirm modal), admin-side
password reset (inline modal). Self-protection rules preserved:
can't disable/delete yourself, can't demote your own admin role.

client/src/pages/Admin.tsx (rewritten)
  Sub-tab shell. Role check via useQuery(['auth-me']) reusing the
  Layout cache. 10 pills drive which panel renders. Access-denied
  card for non-admins (data-testid='admin-access-denied' unchanged).

client/src/pages/AdminPanels.tsx (new) — batch 1
  • AdminUsersTab — useQuery ['admin-users'] + 6 mutations
    (verify / disable / enable / set role / delete / reset password).
    Color-coded rows (disabled users opacity-60), inline role select,
    inline search filter over email+name.
  • AdminSettingsTab — GET /api/admin/settings → stats (totalUsers /
    totalApiCalls / todayApiCalls) + registration toggle.
  • AdminAnnouncementTab — reads announcement.{enabled,type,text} via
    /api/admin/config/announcement, saves via 3 parallel PUT
    /api/admin/config/<key> calls. Info/Warning/Critical severity
    select; text rendered in the top-of-page banner.

client/src/pages/AdminPanels2.tsx (new) — batch 2
  • AdminSmtpTab — host/port/user/pass/from/secure form + source
    badge (env / database / none). PUT /api/admin/config/smtp,
    DELETE /api/admin/config/smtp (with ConfirmModal). Inline test
    email sender (recipient + template) calling
    POST /api/admin/config/test-email.
  • AdminEmailTab — template selector (verify / reset /
    password-changed), subject + HTML body textarea. Pulls values
    from /api/admin/config, saves via 2 parallel PUT calls.
  • AdminPromptsTab — GET /api/admin/config/prompts populates the
    selector; textarea edits the active prompt; save via
    PUT /api/admin/config/prompt.<key>; reset-to-default via
    POST /api/admin/config/prompts/<key>/reset with ConfirmModal.
  • AdminModelsTab — GET /api/admin/config/models renders the
    provider-scoped model table with per-row Enabled checkbox +
    Default radio. Mutations hit /api/admin/config/models/toggle
    and /default. LiteLLM + model-discovery flows flagged as
    legacy-viewer follow-up.
  • AdminTtsTab / AdminSttTab — show active provider + voice/model
    selector, save default via PUT /api/admin/config/tts.default_voice
    and /stt.default_model respectively.
  • AdminLogsTab — GET /api/admin/logs/all?category=&limit= with
    sticky-header scrollable table. Category filter
    (auth / admin / clinical / export / integration / documents) and
    limit selector (50-500). Renders time / user / category /
    action / detail / IP per row.

shared/types.ts + client/src/shared/types.ts — additive only:
  AdminUser, AdminUsersOk, AdminUserOk, AdminSettingsOk,
  AdminLogEntry, AdminLogsOk, AdminConfigRow, AdminConfigOk,
  AdminAnnouncementOk, AdminPromptRow, AdminPromptsOk,
  AdminSmtpStatusOk, AdminModelRow, AdminModelsOk,
  AdminVoiceProviderOk.

With this commit the React sidebar covers the full legacy nav.
Access-control is preserved (Admin is still role-gated and the nav
link hides for non-admins). Every existing backend endpoint is
reused as-is — no server changes.

Backend tsc + client tsc + vite build + 136/136 vitest all green.
2026-04-24 02:27:50 +02:00

337 lines
16 KiB
TypeScript

// ============================================================
// ADMIN PANELS — real React components for each admin sub-tab.
// Batch 1: Users / Settings / Announcement. Remaining tabs
// (SMTP, Email Templates, AI Prompts, AI Models, TTS/STT, Logs)
// ship in follow-up commits.
// ============================================================
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type {
AdminUser,
AdminUsersOk,
AdminSettingsOk,
AdminAnnouncementOk,
} from '@/shared/types';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-medium text-muted-foreground';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnDanger = 'rounded-md bg-destructive text-white px-3 py-2 text-xs font-medium disabled:opacity-50';
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
type Msg = { text: string; kind: 'ok' | 'err' | 'info' } | null;
function StatusLine({ msg }: { msg: Msg }) {
if (!msg) return null;
const c = msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground';
return <div className={'text-sm ' + c}>{msg.text}</div>;
}
// ── Users ───────────────────────────────────────────────────
export function AdminUsersTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [query, setQuery] = useState('');
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
const [resetTarget, setResetTarget] = useState<AdminUser | null>(null);
const [resetPw, setResetPw] = useState('');
const { data, isLoading, error } = useQuery<AdminUsersOk>({
queryKey: ['admin-users'],
queryFn: () => api.get<AdminUsersOk>('/api/admin/users'),
});
const verify = useMutation({
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/verify`, {}),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const disable = useMutation({
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/disable`, {}),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const enable = useMutation({
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/enable`, {}),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const setRole = useMutation({
mutationFn: (body: { id: number; role: string }) =>
api.post<{ message: string }>(`/api/admin/users/${body.id}/role`, { role: body.role }),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ message: string }>(`/api/admin/users/${id}`),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const resetPwMutation = useMutation({
mutationFn: (body: { id: number; newPassword: string }) =>
api.post<{ message: string }>(`/api/admin/users/${body.id}/reset-password`, { newPassword: body.newPassword }),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); setResetTarget(null); setResetPw(''); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const users = (data?.users || []).filter((u) =>
!query || u.email.toLowerCase().includes(query.toLowerCase()) || u.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<section className={card} data-testid="admin-users-tab">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h2 className="text-lg font-semibold">Users</h2>
<input
type="search"
className={input + ' max-w-xs'}
placeholder="Search by name or email…"
value={query}
onChange={(e) => setQuery(e.target.value)}
data-testid="admin-users-search"
/>
</div>
{isLoading && <div className="text-sm text-muted-foreground">Loading</div>}
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="admin-users-table">
<thead>
<tr>
<th className={th}>Email</th>
<th className={th}>Name</th>
<th className={th}>Role</th>
<th className={th}>Verified</th>
<th className={th}>2FA</th>
<th className={th}>Status</th>
<th className={th}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} data-testid={`admin-user-row-${u.id}`} className={u.disabled ? 'opacity-60' : ''}>
<td className={td}>{u.email}</td>
<td className={td}>{u.name}</td>
<td className={td}>
<select
className={input + ' text-xs w-28'}
value={u.role || 'user'}
onChange={(e) => setRole.mutate({ id: u.id, role: e.target.value })}
data-testid={`admin-user-role-${u.id}`}
>
<option value="user">user</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
</td>
<td className={td + ' text-xs'}>
{u.email_verified ? '✅' : (
<button type="button" className={btnGhost} onClick={() => verify.mutate(u.id)} data-testid={`admin-user-verify-${u.id}`}>Verify</button>
)}
</td>
<td className={td + ' text-xs'}>{u.totp_enabled ? '✅' : '—'}</td>
<td className={td + ' text-xs'}>
{u.disabled ? (
<button type="button" className={btnGhost} onClick={() => enable.mutate(u.id)} data-testid={`admin-user-enable-${u.id}`}>Enable</button>
) : (
<button type="button" className={btnGhost} onClick={() => disable.mutate(u.id)} data-testid={`admin-user-disable-${u.id}`}>Disable</button>
)}
</td>
<td className={td + ' text-xs'}>
<div className="flex gap-1">
<button type="button" className={btnGhost} onClick={() => setResetTarget(u)} data-testid={`admin-user-reset-${u.id}`}>Reset pw</button>
<button type="button" className={btnDanger} onClick={() => setDeleteTarget(u)} data-testid={`admin-user-delete-${u.id}`}>Delete</button>
</div>
</td>
</tr>
))}
{users.length === 0 && data && (
<tr><td className={td + ' text-muted-foreground italic'} colSpan={7}>No users match "{query}".</td></tr>
)}
</tbody>
</table>
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title={`Delete ${deleteTarget?.email}?`}
body="This deletes the user account. Audit log entries are preserved (user_id set to NULL)."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => { if (deleteTarget) del.mutate(deleteTarget.id); setDeleteTarget(null); }}
onCancel={() => setDeleteTarget(null)}
/>
{resetTarget && (
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setResetTarget(null)}>
<div className="w-full max-w-sm rounded-lg border border-border bg-background p-5 shadow-lg space-y-3" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold">Reset password for {resetTarget.email}</h3>
<input
type="text"
className={input}
placeholder="New password (8+ chars)"
value={resetPw}
onChange={(e) => setResetPw(e.target.value)}
autoFocus
minLength={8}
data-testid="admin-user-reset-input"
/>
<div className="flex justify-end gap-2">
<button type="button" className={btnGhost} onClick={() => { setResetTarget(null); setResetPw(''); }}>Cancel</button>
<button
type="button"
className={btnPrimary}
disabled={resetPw.length < 8 || resetPwMutation.isPending}
onClick={() => resetPwMutation.mutate({ id: resetTarget.id, newPassword: resetPw })}
data-testid="admin-user-reset-submit"
>
{resetPwMutation.isPending ? 'Saving…' : 'Reset'}
</button>
</div>
</div>
</div>
)}
</section>
);
}
// ── Settings (registration + stats) ────────────────────────
export function AdminSettingsTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const { data } = useQuery<AdminSettingsOk>({
queryKey: ['admin-settings'],
queryFn: () => api.get<AdminSettingsOk>('/api/admin/settings'),
});
const toggle = useMutation({
mutationFn: (enabled: boolean) =>
api.post<{ message: string; registrationEnabled: boolean }>('/api/admin/settings/registration', { enabled }),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-settings'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
return (
<section className={card} data-testid="admin-settings-tab">
<h2 className="text-lg font-semibold">Site settings</h2>
{data && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Total users</div><div className="text-xl font-bold">{data.stats.totalUsers}</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">API calls (all time)</div><div className="text-xl font-bold">{data.stats.totalApiCalls}</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">API calls (today)</div><div className="text-xl font-bold">{data.stats.todayApiCalls}</div></div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-primary size-4"
checked={data.settings.registrationEnabled}
onChange={(e) => toggle.mutate(e.target.checked)}
data-testid="admin-registration-toggle"
/>
<span className="text-sm font-medium">Allow new user registration</span>
</label>
</div>
</>
)}
<StatusLine msg={msg} />
</section>
);
}
// ── Announcement banner ────────────────────────────────────
export function AdminAnnouncementTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [enabled, setEnabled] = useState(false);
const [type, setType] = useState<'info' | 'warning' | 'critical'>('info');
const [text, setText] = useState('');
const [hydrated, setHydrated] = useState(false);
const { data } = useQuery<AdminAnnouncementOk>({
queryKey: ['admin-announcement'],
queryFn: () => api.get<AdminAnnouncementOk>('/api/admin/config/announcement'),
});
// one-time hydrate from server
if (!hydrated && data) {
setEnabled(data.enabled);
setType(((data as unknown as { type?: string }).type as typeof type) || 'info');
setText((data as unknown as { text?: string }).text || '');
setHydrated(true);
}
const putConfig = useMutation({
mutationFn: async (body: { key: string; value: string }) =>
api.put<{ success: true }>(`/api/admin/config/${encodeURIComponent(body.key)}`, { value: body.value }),
});
async function save() {
setMsg(null);
try {
await Promise.all([
putConfig.mutateAsync({ key: 'announcement.enabled', value: enabled ? 'true' : 'false' }),
putConfig.mutateAsync({ key: 'announcement.type', value: type }),
putConfig.mutateAsync({ key: 'announcement.text', value: text }),
]);
setMsg({ text: 'Announcement saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['admin-announcement'] });
} catch (e) {
setMsg({ text: (e as Error).message, kind: 'err' });
}
}
return (
<section className={card} data-testid="admin-announcement-tab">
<h2 className="text-lg font-semibold">Announcement banner</h2>
<p className="text-sm text-muted-foreground">
Shown at the top of every page when enabled. Use for scheduled maintenance, outage notices, or release notes.
</p>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-primary size-4"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
data-testid="admin-announcement-enabled"
/>
<span className="text-sm">Show banner</span>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="sm:col-span-1">
<label className={label}>Severity</label>
<select className={input} value={type} onChange={(e) => setType(e.target.value as typeof type)} data-testid="admin-announcement-type">
<option value="info">Info (blue)</option>
<option value="warning">Warning (amber)</option>
<option value="critical">Critical (red)</option>
</select>
</div>
<div className="sm:col-span-2">
<label className={label}>Message</label>
<textarea
rows={3}
className={input + ' resize-y'}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="e.g. Scheduled maintenance Thursday 02:00 UTC — expect 10 minutes of downtime."
data-testid="admin-announcement-text"
/>
</div>
</div>
<button type="button" onClick={save} disabled={putConfig.isPending} className={btnPrimary} data-testid="admin-announcement-save">
{putConfig.isPending ? 'Saving…' : 'Save announcement'}
</button>
<StatusLine msg={msg} />
</section>
);
}