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.
337 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|