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:
Daniel 2026-04-24 02:27:49 +02:00
parent 1037dc68b8
commit 9811e39cfe
10 changed files with 1143 additions and 94 deletions

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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 {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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 {