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.
This commit is contained in:
parent
1037dc68b8
commit
9811e39cfe
10 changed files with 1143 additions and 94 deletions
|
|
@ -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<MeOk>({
|
||||
|
|
@ -27,23 +42,18 @@ export default function Admin() {
|
|||
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const [active, setActive] = useState<TabId>('users');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<div className="text-sm text-muted-foreground">Checking permissions…</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="max-w-3xl mx-auto p-6 text-sm text-muted-foreground">Checking permissions…</div>;
|
||||
}
|
||||
|
||||
if (me?.user.role !== 'admin') {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<section className={card} data-testid="admin-access-denied">
|
||||
<h1 className="text-xl font-semibold">Admin only</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -51,28 +61,43 @@ export default function Admin() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-4" data-testid="admin-shell">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Admin Panel</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className={card} data-testid="admin-shell">
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
|
||||
<p className="text-amber-900 dark:text-amber-100">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/#admin" className={btnPrimary + ' inline-block'}>
|
||||
Open admin in legacy viewer
|
||||
</a>
|
||||
</section>
|
||||
<div className="flex flex-wrap gap-2" data-testid="admin-subnav">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActive(t.id)}
|
||||
className={
|
||||
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
|
||||
(active === t.id
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted hover:bg-muted/80 border-border')
|
||||
}
|
||||
data-testid={'admin-tab-' + t.id}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active === 'users' && <AdminUsersTab />}
|
||||
{active === 'settings' && <AdminSettingsTab />}
|
||||
{active === 'announcement' && <AdminAnnouncementTab />}
|
||||
{active === 'models' && <AdminModelsTab />}
|
||||
{active === 'tts' && <AdminTtsTab />}
|
||||
{active === 'stt' && <AdminSttTab />}
|
||||
{active === 'smtp' && <AdminSmtpTab />}
|
||||
{active === 'email' && <AdminEmailTab />}
|
||||
{active === 'prompts' && <AdminPromptsTab />}
|
||||
{active === 'logs' && <AdminLogsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
337
client/src/pages/AdminPanels.tsx
Normal file
337
client/src/pages/AdminPanels.tsx
Normal file
|
|
@ -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 <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>
|
||||
);
|
||||
}
|
||||
499
client/src/pages/AdminPanels2.tsx
Normal file
499
client/src/pages/AdminPanels2.tsx
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
// ============================================================
|
||||
// ADMIN PANELS (batch 2) — SMTP, Email Templates, AI Prompts,
|
||||
// AI Models, TTS provider, STT provider, Audit Logs.
|
||||
// All endpoints live at /api/admin/config/*.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import type {
|
||||
AdminConfigOk,
|
||||
AdminSmtpStatusOk,
|
||||
AdminPromptsOk,
|
||||
AdminModelsOk,
|
||||
AdminLogsOk,
|
||||
} 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>;
|
||||
}
|
||||
|
||||
// Shared putConfig — PUT /api/admin/config/:key with {value}.
|
||||
function useConfigPut() {
|
||||
return useMutation({
|
||||
mutationFn: (body: { key: string; value: string }) =>
|
||||
api.put<{ success: true }>(`/api/admin/config/${encodeURIComponent(body.key)}`, { value: body.value }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── SMTP ────────────────────────────────────────────────────
|
||||
interface SmtpStatusExt extends AdminSmtpStatusOk { source?: string }
|
||||
export function AdminSmtpTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [clearConfirm, setClearConfirm] = useState(false);
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('587');
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [secure, setSecure] = useState('false');
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [testTo, setTestTo] = useState('');
|
||||
const [testTemplate, setTestTemplate] = useState('verify');
|
||||
|
||||
const { data } = useQuery<SmtpStatusExt>({
|
||||
queryKey: ['admin-smtp-status'],
|
||||
queryFn: () => api.get<SmtpStatusExt>('/api/admin/config/smtp/status'),
|
||||
});
|
||||
if (!hydrated && data) {
|
||||
setHost(data.host || '');
|
||||
setPort(String(data.port ?? '587'));
|
||||
setUser(data.user || '');
|
||||
setFrom(data.from || '');
|
||||
setHydrated(true);
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: { host: string; port: string; user: string; pass: string; from: string; secure: boolean }) =>
|
||||
api.put<{ success: true }>('/api/admin/config/smtp', body),
|
||||
onSuccess: () => { setMsg({ text: 'SMTP settings saved', kind: 'ok' }); setPass(''); qc.invalidateQueries({ queryKey: ['admin-smtp-status'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.delete<{ message: string }>('/api/admin/config/smtp'),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-smtp-status'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const testEmail = useMutation({
|
||||
mutationFn: (body: { to: string; template: string }) =>
|
||||
api.post<{ success: true }>('/api/admin/config/test-email', body),
|
||||
onSuccess: () => setMsg({ text: `Test email sent to ${testTo}`, kind: 'ok' }),
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-smtp-tab">
|
||||
<h2 className="text-lg font-semibold">SMTP</h2>
|
||||
{data && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Status: {data.configured ? '✅ Configured' : '❌ Not configured'}
|
||||
{data.source && <> · source: <strong>{data.source}</strong></>}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={label}>Host</label><input className={input} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" data-testid="smtp-host" /></div>
|
||||
<div><label className={label}>Port</label><input className={input} value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" data-testid="smtp-port" /></div>
|
||||
<div><label className={label}>Username</label><input className={input} value={user} onChange={(e) => setUser(e.target.value)} data-testid="smtp-user" /></div>
|
||||
<div><label className={label}>Password</label><input type="password" className={input} value={pass} onChange={(e) => setPass(e.target.value)} placeholder="Leave blank to keep existing" data-testid="smtp-pass" /></div>
|
||||
<div><label className={label}>From</label><input className={input} value={from} onChange={(e) => setFrom(e.target.value)} placeholder="noreply@example.com" data-testid="smtp-from" /></div>
|
||||
<div><label className={label}>Secure (TLS)</label><select className={input} value={secure} onChange={(e) => setSecure(e.target.value)} data-testid="smtp-secure"><option value="false">STARTTLS (587)</option><option value="true">SSL/TLS (465)</option></select></div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button" className={btnPrimary} disabled={save.isPending || !host}
|
||||
onClick={() => save.mutate({ host, port, user, pass, from, secure: secure === 'true' })}
|
||||
data-testid="smtp-save">
|
||||
{save.isPending ? 'Saving…' : 'Save SMTP settings'}
|
||||
</button>
|
||||
<button type="button" className={btnDanger} onClick={() => setClearConfirm(true)} data-testid="smtp-clear">Clear DB override</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/40 p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">Send test email</div>
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="flex-1 min-w-[200px]"><label className={label}>Recipient</label><input type="email" className={input} value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="recipient@example.com" data-testid="smtp-test-to" /></div>
|
||||
<div><label className={label}>Template</label><select className={input} value={testTemplate} onChange={(e) => setTestTemplate(e.target.value)} data-testid="smtp-test-template"><option value="verify">verify</option><option value="reset">reset</option><option value="password-changed">password-changed</option></select></div>
|
||||
<button type="button" className={btnGhost} disabled={testEmail.isPending || !testTo} onClick={() => testEmail.mutate({ to: testTo, template: testTemplate })} data-testid="smtp-test-send">
|
||||
{testEmail.isPending ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
<ConfirmModal
|
||||
open={clearConfirm}
|
||||
title="Clear DB SMTP settings?"
|
||||
body="Removes smtp.* entries from the DB. Env vars will still apply if they're set (e.g. SMTP_HOST from OpenBao)."
|
||||
confirmText="Clear"
|
||||
danger
|
||||
busy={clear.isPending}
|
||||
onConfirm={() => { clear.mutate(); setClearConfirm(false); }}
|
||||
onCancel={() => setClearConfirm(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Email templates ────────────────────────────────────────
|
||||
const EMAIL_TEMPLATES = ['verify', 'reset', 'password-changed'];
|
||||
export function AdminEmailTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [template, setTemplate] = useState('verify');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
const { data } = useQuery<AdminConfigOk>({
|
||||
queryKey: ['admin-config'],
|
||||
queryFn: () => api.get<AdminConfigOk>('/api/admin/config'),
|
||||
});
|
||||
|
||||
function pick(tpl: string) {
|
||||
setTemplate(tpl);
|
||||
const map = new Map((data?.config || []).map((c) => [c.key, c.value || '']));
|
||||
setSubject(map.get('email.' + tpl + '.subject') || '');
|
||||
setBody(map.get('email.' + tpl + '.body') || '');
|
||||
}
|
||||
// Hydrate when data first arrives.
|
||||
if (data && !subject && !body) {
|
||||
pick(template);
|
||||
}
|
||||
|
||||
const putConfig = useConfigPut();
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await Promise.all([
|
||||
putConfig.mutateAsync({ key: 'email.' + template + '.subject', value: subject }),
|
||||
putConfig.mutateAsync({ key: 'email.' + template + '.body', value: body }),
|
||||
]);
|
||||
setMsg({ text: 'Email template saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-config'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-email-tab">
|
||||
<h2 className="text-lg font-semibold">Email templates</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Template</label>
|
||||
<select className={input} value={template} onChange={(e) => pick(e.target.value)} data-testid="email-template">
|
||||
{EMAIL_TEMPLATES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className={label}>Subject</label><input className={input} value={subject} onChange={(e) => setSubject(e.target.value)} data-testid="email-subject" /></div>
|
||||
<div><label className={label}>Body (HTML)</label><textarea rows={10} className={input + ' resize-y font-mono text-xs'} value={body} onChange={(e) => setBody(e.target.value)} data-testid="email-body" /></div>
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="email-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save template'}
|
||||
</button>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Prompts ─────────────────────────────────────────────
|
||||
export function AdminPromptsTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [selected, setSelected] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [resetConfirm, setResetConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery<AdminPromptsOk>({
|
||||
queryKey: ['admin-prompts'],
|
||||
queryFn: () => api.get<AdminPromptsOk>('/api/admin/config/prompts'),
|
||||
});
|
||||
if (data && !selected && data.prompts.length > 0) {
|
||||
setSelected(data.prompts[0].key);
|
||||
setValue(data.prompts[0].value);
|
||||
}
|
||||
function pick(key: string) {
|
||||
setSelected(key);
|
||||
const p = data?.prompts.find((x) => x.key === key);
|
||||
setValue(p?.value || '');
|
||||
}
|
||||
|
||||
const putConfig = useConfigPut();
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (key: string) =>
|
||||
api.post<{ value: string }>(`/api/admin/config/prompts/${encodeURIComponent(key)}/reset`, {}),
|
||||
onSuccess: (d) => { setValue(d.value); setMsg({ text: 'Prompt reset to default', kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-prompts'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await putConfig.mutateAsync({ key: 'prompt.' + selected, value });
|
||||
setMsg({ text: 'Prompt saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-prompts'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-prompts-tab">
|
||||
<h2 className="text-lg font-semibold">AI Prompts</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
System prompts injected before each generation. Reset restores the hardcoded default from src/utils/prompts.ts.
|
||||
</p>
|
||||
<div className="max-w-md">
|
||||
<label className={label}>Prompt</label>
|
||||
<select className={input} value={selected} onChange={(e) => pick(e.target.value)} data-testid="prompts-select">
|
||||
{(data?.prompts || []).map((p) => <option key={p.key} value={p.key}>{p.key}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<textarea rows={14} className={input + ' resize-y font-mono text-xs'} value={value} onChange={(e) => setValue(e.target.value)} data-testid="prompts-text" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending || !selected} onClick={save} data-testid="prompts-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save prompt'}
|
||||
</button>
|
||||
<button type="button" className={btnGhost} disabled={!selected} onClick={() => setResetConfirm(true)} data-testid="prompts-reset">
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
<StatusLine msg={msg} />
|
||||
<ConfirmModal
|
||||
open={resetConfirm}
|
||||
title={`Reset "${selected}"?`}
|
||||
body="Restores the hardcoded default. Cannot be undone."
|
||||
confirmText="Reset"
|
||||
danger
|
||||
busy={resetMutation.isPending}
|
||||
onConfirm={() => { resetMutation.mutate(selected); setResetConfirm(false); }}
|
||||
onCancel={() => setResetConfirm(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Models ──────────────────────────────────────────────
|
||||
interface AdminModelsExtra extends AdminModelsOk { litellmHint?: boolean; custom?: Array<{ id: string; label?: string }> }
|
||||
export function AdminModelsTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const { data } = useQuery<AdminModelsExtra>({
|
||||
queryKey: ['admin-models'],
|
||||
queryFn: () => api.get<AdminModelsExtra>('/api/admin/config/models'),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (body: { id: string; enabled: boolean }) =>
|
||||
api.put<{ success: true }>('/api/admin/config/models/toggle', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-models'] }),
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const setDefault = useMutation({
|
||||
mutationFn: (modelId: string) =>
|
||||
api.put<{ success: true }>('/api/admin/config/models/default', { modelId }),
|
||||
onSuccess: (_, id) => { setMsg({ text: `Default model set to ${id}`, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-models'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-models-tab">
|
||||
<h2 className="text-lg font-semibold">AI Models</h2>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active provider: <strong>{data?.provider || '—'}</strong>
|
||||
{data?.defaultModel && <> · Default: <strong>{data.defaultModel}</strong></>}
|
||||
</div>
|
||||
{data?.litellmHint && (
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
LiteLLM provider has no built-in model list — use the legacy "Discover" flow to populate.
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="admin-models-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={th}>Enabled</th>
|
||||
<th className={th}>Default</th>
|
||||
<th className={th}>Model ID</th>
|
||||
<th className={th}>Label</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.models || []).map((m) => (
|
||||
<tr key={m.id} data-testid={`admin-model-row-${m.id}`}>
|
||||
<td className={td}>
|
||||
<input type="checkbox" className="accent-primary size-4" checked={m.enabled} onChange={(e) => toggle.mutate({ id: m.id, enabled: e.target.checked })} />
|
||||
</td>
|
||||
<td className={td}>
|
||||
<input type="radio" name="default-model" checked={data?.defaultModel === m.id} onChange={() => setDefault.mutate(m.id)} disabled={!m.enabled} />
|
||||
</td>
|
||||
<td className={td + ' font-mono text-xs'}>{m.id}</td>
|
||||
<td className={td}>{m.label || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(data?.models || []).length === 0 && <tr><td className={td + ' italic text-muted-foreground'} colSpan={4}>No models available.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
Model discovery (search + add-custom) still lives in the legacy viewer — ports when the provider integration is revamped.
|
||||
</div>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TTS / STT Provider ─────────────────────────────────────
|
||||
interface VoiceProviderResp {
|
||||
provider: string;
|
||||
defaultVoice?: string | null;
|
||||
defaultModel?: string | null;
|
||||
voices?: Array<{ value: string; label?: string }>;
|
||||
models?: Array<{ value: string; label?: string }>;
|
||||
configured?: boolean;
|
||||
}
|
||||
function useVoiceProvider(path: '/api/admin/config/tts' | '/api/admin/config/stt') {
|
||||
return useQuery<VoiceProviderResp>({
|
||||
queryKey: ['voice-provider', path],
|
||||
queryFn: () => api.get<VoiceProviderResp>(path),
|
||||
});
|
||||
}
|
||||
export function AdminTtsTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const { data } = useVoiceProvider('/api/admin/config/tts');
|
||||
const putConfig = useConfigPut();
|
||||
const [voice, setVoice] = useState('');
|
||||
if (data && voice === '' && data.defaultVoice) setVoice(data.defaultVoice);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await putConfig.mutateAsync({ key: 'tts.default_voice', value: voice });
|
||||
setMsg({ text: 'Default TTS voice saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['voice-provider', '/api/admin/config/tts'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-tts-tab">
|
||||
<h2 className="text-lg font-semibold">TTS Provider</h2>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active provider: <strong>{data?.provider || '—'}</strong>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<label className={label}>Default voice</label>
|
||||
<select className={input} value={voice} onChange={(e) => setVoice(e.target.value)} data-testid="admin-tts-voice">
|
||||
<option value="">(none)</option>
|
||||
{(data?.voices || []).map((v) => <option key={v.value} value={v.value}>{v.label || v.value}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="admin-tts-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save default voice'}
|
||||
</button>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function AdminSttTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const { data } = useVoiceProvider('/api/admin/config/stt');
|
||||
const putConfig = useConfigPut();
|
||||
const [model, setModel] = useState('');
|
||||
if (data && model === '' && data.defaultModel) setModel(data.defaultModel);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await putConfig.mutateAsync({ key: 'stt.default_model', value: model });
|
||||
setMsg({ text: 'Default STT model saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['voice-provider', '/api/admin/config/stt'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-stt-tab">
|
||||
<h2 className="text-lg font-semibold">STT Provider</h2>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active provider: <strong>{data?.provider || '—'}</strong>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<label className={label}>Default STT model</label>
|
||||
<select className={input} value={model} onChange={(e) => setModel(e.target.value)} data-testid="admin-stt-model">
|
||||
<option value="">(none)</option>
|
||||
{(data?.models || []).map((m) => <option key={m.value} value={m.value}>{m.label || m.value}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="admin-stt-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save default model'}
|
||||
</button>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audit logs ─────────────────────────────────────────────
|
||||
const LOG_CATEGORIES = ['', 'auth', 'admin', 'clinical', 'export', 'integration', 'documents'];
|
||||
export function AdminLogsTab() {
|
||||
const [category, setCategory] = useState('');
|
||||
const [limit, setLimit] = useState(100);
|
||||
const { data, isLoading, error, refetch } = useQuery<AdminLogsOk>({
|
||||
queryKey: ['admin-logs', category, limit],
|
||||
queryFn: () => api.get<AdminLogsOk>(
|
||||
`/api/admin/logs/all?limit=${limit}${category ? '&category=' + encodeURIComponent(category) : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-logs-tab">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h2 className="text-lg font-semibold">Audit Logs</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className={label}>Category</label>
|
||||
<select className={input + ' w-36 text-xs'} value={category} onChange={(e) => setCategory(e.target.value)} data-testid="admin-logs-category">
|
||||
{LOG_CATEGORIES.map((c) => <option key={c} value={c}>{c || '(all)'}</option>)}
|
||||
</select>
|
||||
<label className={label}>Limit</label>
|
||||
<select className={input + ' w-24 text-xs'} value={limit} onChange={(e) => setLimit(Number(e.target.value))} data-testid="admin-logs-limit">
|
||||
{[50, 100, 200, 500].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<button type="button" className={btnGhost} onClick={() => refetch()}>Refresh</button>
|
||||
</div>
|
||||
</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 max-h-[70vh] overflow-y-auto">
|
||||
<table className="w-full text-sm" data-testid="admin-logs-table">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr>
|
||||
<th className={th}>Time</th>
|
||||
<th className={th}>User</th>
|
||||
<th className={th}>Category</th>
|
||||
<th className={th}>Action</th>
|
||||
<th className={th}>Detail</th>
|
||||
<th className={th}>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.logs || []).map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className={td + ' text-xs whitespace-nowrap'}>{new Date(l.timestamp).toLocaleString()}</td>
|
||||
<td className={td + ' text-xs'}>{l.user_email || '—'}{l.user_name ? ` (${l.user_name})` : ''}</td>
|
||||
<td className={td + ' text-xs'}>{l.category}</td>
|
||||
<td className={td + ' text-xs font-mono'}>{l.action}</td>
|
||||
<td className={td + ' text-xs'}>{l.detail}</td>
|
||||
<td className={td + ' text-xs text-muted-foreground'}>{l.ip_address || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -314,6 +314,100 @@ export interface MemoriesOk {
|
|||
memories: MemoryRow[];
|
||||
}
|
||||
|
||||
// ── Admin ───────────────────────────────────────────────────
|
||||
// /api/admin/users (admin-gated)
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
email_verified: boolean;
|
||||
totp_enabled: boolean;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
nextcloud_url?: string | null;
|
||||
api_calls?: number;
|
||||
last_login?: string | null;
|
||||
}
|
||||
export interface AdminUsersOk { users: AdminUser[] }
|
||||
export interface AdminUserOk { user: AdminUser }
|
||||
export interface AdminSettingsOk {
|
||||
settings: { registrationEnabled: boolean };
|
||||
stats: { totalUsers: number; totalApiCalls: number; todayApiCalls: number };
|
||||
}
|
||||
export interface AdminLogEntry {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
action: string;
|
||||
detail: string;
|
||||
category: string;
|
||||
ip_address?: string | null;
|
||||
timestamp: string;
|
||||
user_email?: string | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
export interface AdminLogsOk { logs: AdminLogEntry[] }
|
||||
|
||||
// /api/admin/config / /api/admin/config/:key
|
||||
export interface AdminConfigRow {
|
||||
key: string;
|
||||
value: string | null;
|
||||
description?: string | null;
|
||||
source?: 'env' | 'db' | 'openbao' | string;
|
||||
}
|
||||
export interface AdminConfigOk { config: AdminConfigRow[] }
|
||||
export interface AdminAnnouncementOk {
|
||||
enabled: boolean;
|
||||
message: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
// /api/admin/config/prompts
|
||||
export interface AdminPromptRow {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
default?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
export interface AdminPromptsOk { prompts: AdminPromptRow[] }
|
||||
|
||||
// /api/admin/config/smtp/status
|
||||
export interface AdminSmtpStatusOk {
|
||||
configured: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
// /api/admin/config/models
|
||||
export interface AdminModelRow {
|
||||
id: string;
|
||||
label?: string;
|
||||
provider?: string;
|
||||
enabled: boolean;
|
||||
isDefault?: boolean;
|
||||
isCustom?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
export interface AdminModelsOk {
|
||||
models: AdminModelRow[];
|
||||
provider?: string;
|
||||
defaultModel?: string | null;
|
||||
}
|
||||
|
||||
// /api/admin/config/tts and /stt
|
||||
export interface AdminVoiceProviderOk {
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
voice?: string | null;
|
||||
model?: string | null;
|
||||
endpoint?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Learning Hub (user-facing) ──────────────────────────────
|
||||
// Categories
|
||||
export interface LearningCategory {
|
||||
|
|
|
|||
52
public/app/assets/index-4IQgLQaQ.js
Normal file
52
public/app/assets/index-4IQgLQaQ.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/app/assets/index-DnJqszjp.css
Normal file
2
public/app/assets/index-DnJqszjp.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-OyPXrXo8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-CNgx4Nge.css">
|
||||
<script type="module" crossorigin src="/app/assets/index-4IQgLQaQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-DnJqszjp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -314,6 +314,100 @@ export interface MemoriesOk {
|
|||
memories: MemoryRow[];
|
||||
}
|
||||
|
||||
// ── Admin ───────────────────────────────────────────────────
|
||||
// /api/admin/users (admin-gated)
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
email_verified: boolean;
|
||||
totp_enabled: boolean;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
nextcloud_url?: string | null;
|
||||
api_calls?: number;
|
||||
last_login?: string | null;
|
||||
}
|
||||
export interface AdminUsersOk { users: AdminUser[] }
|
||||
export interface AdminUserOk { user: AdminUser }
|
||||
export interface AdminSettingsOk {
|
||||
settings: { registrationEnabled: boolean };
|
||||
stats: { totalUsers: number; totalApiCalls: number; todayApiCalls: number };
|
||||
}
|
||||
export interface AdminLogEntry {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
action: string;
|
||||
detail: string;
|
||||
category: string;
|
||||
ip_address?: string | null;
|
||||
timestamp: string;
|
||||
user_email?: string | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
export interface AdminLogsOk { logs: AdminLogEntry[] }
|
||||
|
||||
// /api/admin/config / /api/admin/config/:key
|
||||
export interface AdminConfigRow {
|
||||
key: string;
|
||||
value: string | null;
|
||||
description?: string | null;
|
||||
source?: 'env' | 'db' | 'openbao' | string;
|
||||
}
|
||||
export interface AdminConfigOk { config: AdminConfigRow[] }
|
||||
export interface AdminAnnouncementOk {
|
||||
enabled: boolean;
|
||||
message: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
// /api/admin/config/prompts
|
||||
export interface AdminPromptRow {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
default?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
export interface AdminPromptsOk { prompts: AdminPromptRow[] }
|
||||
|
||||
// /api/admin/config/smtp/status
|
||||
export interface AdminSmtpStatusOk {
|
||||
configured: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
// /api/admin/config/models
|
||||
export interface AdminModelRow {
|
||||
id: string;
|
||||
label?: string;
|
||||
provider?: string;
|
||||
enabled: boolean;
|
||||
isDefault?: boolean;
|
||||
isCustom?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
export interface AdminModelsOk {
|
||||
models: AdminModelRow[];
|
||||
provider?: string;
|
||||
defaultModel?: string | null;
|
||||
}
|
||||
|
||||
// /api/admin/config/tts and /stt
|
||||
export interface AdminVoiceProviderOk {
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
voice?: string | null;
|
||||
model?: string | null;
|
||||
endpoint?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Learning Hub (user-facing) ──────────────────────────────
|
||||
// Categories
|
||||
export interface LearningCategory {
|
||||
|
|
|
|||
Loading…
Reference in a new issue