From 9811e39cfedecf1ec7964f5aef2f246fa8802fb1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 24 Apr 2026 02:27:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20port=20Admin=20panel=20?= =?UTF-8?q?=E2=80=94=20all=2010=20sub-tabs=20now=20run=20in=20React?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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.; reset-to-default via POST /api/admin/config/prompts//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. --- client/src/pages/Admin.tsx | 101 ++++-- client/src/pages/AdminPanels.tsx | 337 ++++++++++++++++++ client/src/pages/AdminPanels2.tsx | 499 +++++++++++++++++++++++++++ client/src/shared/types.ts | 94 +++++ public/app/assets/index-4IQgLQaQ.js | 52 +++ public/app/assets/index-CNgx4Nge.css | 2 - public/app/assets/index-DnJqszjp.css | 2 + public/app/assets/index-OyPXrXo8.js | 52 --- public/app/index.html | 4 +- shared/types.ts | 94 +++++ 10 files changed, 1143 insertions(+), 94 deletions(-) create mode 100644 client/src/pages/AdminPanels.tsx create mode 100644 client/src/pages/AdminPanels2.tsx create mode 100644 public/app/assets/index-4IQgLQaQ.js delete mode 100644 public/app/assets/index-CNgx4Nge.css create mode 100644 public/app/assets/index-DnJqszjp.css delete mode 100644 public/app/assets/index-OyPXrXo8.js diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index 36df74b..bf37803 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -1,25 +1,40 @@ // ============================================================ -// ADMIN PANEL — shell + access gate. -// -// The legacy admin page has 12+ sub-sections (users, feature flags, -// announcement banner, OIDC, SMTP, AI model management, TTS/STT, -// email templates, AI prompts, site-wide saved encounters, audit -// logs, learning admin). Each is tied to /api/admin/* or -// /api/admin/config endpoints and involves destructive operations -// that need careful UX consideration (role revoke, feature-flag -// toggles hit prod immediately, etc.). -// -// This first commit ships the access gate + a link to the legacy -// admin viewer. Per-section React ports arrive as discrete commits -// once the core React tree has settled. +// ADMIN — sub-tab shell for the admin panel. Tabs live in +// AdminPanels.tsx (batch 1: Users, Settings, Announcement) + +// AdminPanels2.tsx (batch 2: SMTP, Email, Prompts, Models, +// TTS, STT, Logs). Role check + query cache shared with Layout. // ============================================================ +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api'; import type { MeOk } from '@/shared/types'; +import { AdminUsersTab, AdminSettingsTab, AdminAnnouncementTab } from './AdminPanels'; +import { + AdminSmtpTab, AdminEmailTab, AdminPromptsTab, + AdminModelsTab, AdminTtsTab, AdminSttTab, AdminLogsTab, +} from './AdminPanels2'; const card = 'rounded-lg border border-border bg-card p-5 space-y-3'; -const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium'; + +type TabId = + | 'users' | 'settings' | 'announcement' + | 'models' | 'tts' | 'stt' + | 'smtp' | 'email' | 'prompts' + | 'logs'; + +const TABS: Array<{ id: TabId; label: string }> = [ + { id: 'users', label: 'Users' }, + { id: 'settings', label: 'Site settings' }, + { id: 'announcement', label: 'Announcement' }, + { id: 'models', label: 'AI models' }, + { id: 'tts', label: 'TTS provider' }, + { id: 'stt', label: 'STT provider' }, + { id: 'smtp', label: 'SMTP' }, + { id: 'email', label: 'Email templates' }, + { id: 'prompts', label: 'AI prompts' }, + { id: 'logs', label: 'Audit logs' }, +]; export default function Admin() { const { data: me, isLoading } = useQuery({ @@ -27,23 +42,18 @@ export default function Admin() { queryFn: () => api.get('/api/auth/me'), staleTime: 5 * 60_000, }); + const [active, setActive] = useState('users'); if (isLoading) { - return ( -
-
Checking permissions…
-
- ); + return
Checking permissions…
; } - if (me?.user.role !== 'admin') { return (

Admin only

- This page is restricted to users with the admin role. If you believe this is a mistake, - contact your site administrator. + This page is restricted to users with the admin role. If you believe this is a mistake, contact your site administrator.

@@ -51,28 +61,43 @@ export default function Admin() { } return ( -
+

Admin Panel

- Registration, users, feature flags, OIDC, email templates, AI prompts, SMTP, saved - encounters (site-wide), AI model management, TTS/STT provider selection. + Users, site settings, announcement banner, AI model management, TTS/STT provider, SMTP, email templates, and audit logs.

-
-
-

- Admin sub-sections still live in the legacy viewer. Each one (user management, feature - flags, announcements, OIDC config, SMTP, AI prompts, model management, TTS/STT) touches - production state immediately when saved — those controls port in discrete follow-up - commits with their own tests before they go live in the React tree. -

-
- - Open admin in legacy viewer - -
+
+ {TABS.map((t) => ( + + ))} +
+ + {active === 'users' && } + {active === 'settings' && } + {active === 'announcement' && } + {active === 'models' && } + {active === 'tts' && } + {active === 'stt' && } + {active === 'smtp' && } + {active === 'email' && } + {active === 'prompts' && } + {active === 'logs' && }
); } diff --git a/client/src/pages/AdminPanels.tsx b/client/src/pages/AdminPanels.tsx new file mode 100644 index 0000000..b55fa45 --- /dev/null +++ b/client/src/pages/AdminPanels.tsx @@ -0,0 +1,337 @@ +// ============================================================ +// 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
{msg.text}
; +} + +// ── Users ─────────────────────────────────────────────────── +export function AdminUsersTab() { + const qc = useQueryClient(); + const [msg, setMsg] = useState(null); + const [query, setQuery] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + const [resetTarget, setResetTarget] = useState(null); + const [resetPw, setResetPw] = useState(''); + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin-users'], + queryFn: () => api.get('/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 ( +
+
+

Users

+ setQuery(e.target.value)} + data-testid="admin-users-search" + /> +
+ {isLoading &&
Loading…
} + {error &&
{(error as Error).message}
} +
+ + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + ))} + {users.length === 0 && data && ( + + )} + +
EmailNameRoleVerified2FAStatusActions
{u.email}{u.name} + + + {u.email_verified ? '✅' : ( + + )} + {u.totp_enabled ? '✅' : '—'} + {u.disabled ? ( + + ) : ( + + )} + +
+ + +
+
No users match "{query}".
+
+ + + { if (deleteTarget) del.mutate(deleteTarget.id); setDeleteTarget(null); }} + onCancel={() => setDeleteTarget(null)} + /> + + {resetTarget && ( +
setResetTarget(null)}> +
e.stopPropagation()}> +

Reset password for {resetTarget.email}

+ setResetPw(e.target.value)} + autoFocus + minLength={8} + data-testid="admin-user-reset-input" + /> +
+ + +
+
+
+ )} +
+ ); +} + +// ── Settings (registration + stats) ──────────────────────── +export function AdminSettingsTab() { + const qc = useQueryClient(); + const [msg, setMsg] = useState(null); + + const { data } = useQuery({ + queryKey: ['admin-settings'], + queryFn: () => api.get('/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 ( +
+

Site settings

+ {data && ( + <> +
+
Total users
{data.stats.totalUsers}
+
API calls (all time)
{data.stats.totalApiCalls}
+
API calls (today)
{data.stats.todayApiCalls}
+
+
+ +
+ + )} + +
+ ); +} + +// ── Announcement banner ──────────────────────────────────── +export function AdminAnnouncementTab() { + const qc = useQueryClient(); + const [msg, setMsg] = useState(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({ + queryKey: ['admin-announcement'], + queryFn: () => api.get('/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 ( +
+

Announcement banner

+

+ Shown at the top of every page when enabled. Use for scheduled maintenance, outage notices, or release notes. +

+
+ +
+
+
+ + +
+
+ +