This commit is contained in:
k
2026-09-24 11:48:46 +02:00
parent ac04c674a6
commit 6ebd26b60e
13 changed files with 127 additions and 15 deletions
+1
View File
@@ -58,6 +58,7 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
| POST | `/api/admin/users/:id/reset-link` | admin | email a one-time password-reset link (needs user email + SMTP) |
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
+3
View File
@@ -81,6 +81,9 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
- Assistant bubbles show the producing model (`<model>` chip in the footer,
stored per message in `messages.model` — survives model switches; same
chip on the streaming bubble and shared pages).
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
`/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
+4 -1
View File
@@ -126,7 +126,10 @@ dedicated per-IP envelope (separate from login brute-force protection):
every hit counts, successes included (recovery answers are always-ok by
design, so enumeration is impossible). Over-limit answers 429 with
`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in
Admin Center → Mail → Password recovery limits, no restart.
Admin Center → Mail → Password recovery limits, no restart. Admins can also
email a one-time reset link to any user with an address from the Accounts
tab (`POST /api/admin/users/:id/reset-link` — 409 without email, 501
without SMTP).
## Model access policy (kill-switch + rate limits)
+3 -3
View File
@@ -432,7 +432,7 @@ export function App() {
} else {
setFlight(convId);
}
const userMsg: ChatMessage = { role: "user", content: text };
const userMsg: ChatMessage = { role: "user", content: text, model };
// Attachments travel as reviewed text blocks appended to the message.
const blocks = attachments.map(
(a) =>
@@ -460,7 +460,7 @@ export function App() {
// switched chats mid-stream, the reply is already persisted
// server-side and the other view reloads it on selection.
if (activeIdRef.current === convId) {
setMessages([...history, { role: "assistant", content: full }]);
setMessages([...history, { role: "assistant", content: full, model }]);
}
setStreaming("");
clearFailedEntry(convId!);
@@ -505,7 +505,7 @@ export function App() {
try {
const full = await streamReply(history, convId);
if (activeIdRef.current === convId) {
setMessages([...history, { role: "assistant", content: full }]);
setMessages([...history, { role: "assistant", content: full, model }]);
}
setRetryCount(0);
clearFailedEntry(convId);
+34 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type FC, type ReactNode } from "react";
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Mail, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts";
import type { DriverModel, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
@@ -190,6 +190,8 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [editTarget, setEditTarget] = useState<User | null>(null);
const [resetTarget, setResetTarget] = useState<User | null>(null);
const [resetSent, setResetSent] = useState<{ username: string; email: string } | null>(null);
// Listing: search + sort + filter + pagination (server-side).
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
@@ -270,6 +272,17 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
}
}
async function sendResetLink(u: User) {
setError("");
setResetSent(null);
try {
const res = await api.adminResetLink(u.id);
setResetSent({ username: u.username, email: res.email });
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.resetFailed"));
}
}
async function remove(u: User) {
try {
await api.adminDelete(u.id);
@@ -303,6 +316,12 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
</div>
)}
{resetSent && (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="flex items-center gap-2 text-sm font-medium"><Mail size={15} /> {t("admin.resetSent", { user: resetSent.username, email: resetSent.email })}</p>
</div>
)}
{!showCreate ? (
<div className="mb-5 flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
@@ -372,6 +391,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
<div className="flex shrink-0 gap-1">
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
<button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
<button title={t("admin.resetLink")} onClick={() => setResetTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Mail size={15} /></button>
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
</div>
)}
@@ -410,6 +430,19 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
onConfirm={() => deleteTarget && remove(deleteTarget)}
/>
<ConfirmDialog
open={resetTarget !== null}
onClose={() => setResetTarget(null)}
title={t("admin.resetTitle", { user: resetTarget?.username ?? "" })}
message={t("admin.resetMsg", { email: resetTarget?.email || t("admin.noEmail") })}
confirmLabel={t("admin.resetConfirm")}
onConfirm={() => {
const u = resetTarget;
setResetTarget(null);
if (u) void sendResetLink(u);
}}
/>
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
</>
);
+15 -2
View File
@@ -44,8 +44,16 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
<Markdown text={msg.content} />
</div>
)}
{!isUser && (onReport || onToggleSpeak || (isFailed && onRetry)) && (
{!isUser && (msg.model || onReport || onToggleSpeak || (isFailed && onRetry)) && (
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
{msg.model ? (
<span
title={msg.model}
className="mr-1 max-w-40 truncate rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-[11px] opacity-80 dark:bg-zinc-800"
>
{msg.model}
</span>
) : null}
{onToggleSpeak && (
<IconButton title={speaking ? t("voice.stopSpeak") : t("voice.speak")} onClick={onToggleSpeak}>
{speaking ? <VolumeX size={14} className="speaking-orb text-accent-500" /> : <Volume2 size={14} />}
@@ -534,7 +542,12 @@ export function Chat({
<div className="flex gap-2 sm:gap-3">
<AssistantAvatar size={32} dynamic />
<div className="min-w-0 max-w-[88%] sm:max-w-[85%]">
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
<span>KisAssistant</span>
{model ? (
<span title={model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{model}</span>
) : null}
</div>
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 dark:border-zinc-800 dark:bg-zinc-900">
<div className="typing-cursor"><Markdown text={streaming} /></div>
</div>
+3
View File
@@ -79,6 +79,9 @@ export function SharePage({ publicId }: { publicId: string }) {
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
{m.role === "user" ? chat.authorName : "KisAssistant"}
{m.role !== "user" && m.model ? (
<span title={m.model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{m.model}</span>
) : null}
</div>
{m.role === "user" ? (
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900"><UserMessageContent content={m.content} tone="plain" /></div>
+1
View File
@@ -267,6 +267,7 @@ export const api = {
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
adminResetLink: (id: string) => req<{ ok: boolean; email: string }>(`/api/admin/users/${id}/reset-link`, { method: "POST" }),
adminShadowban: (id: string, shadowbanned: boolean) =>
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
method: "POST",
+36
View File
@@ -244,6 +244,12 @@ const en = {
"admin.email": "Email (recovery)",
"admin.createFailed": "Create failed",
"admin.regenFailed": "Regenerate failed",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Delete failed",
"admin.loadFailed": "Load failed",
"admin.searchPh": "Search accounts…",
@@ -957,6 +963,12 @@ const es: Dict = {
"admin.email": "Email (recuperación)",
"admin.createFailed": "No se pudo crear",
"admin.regenFailed": "No se pudo regenerar",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "No se pudo eliminar",
"admin.loadFailed": "No se pudo cargar",
"admin.searchPh": "Buscar cuentas…",
@@ -1662,6 +1674,12 @@ const fr: Dict = {
"admin.email": "Email (récupération)",
"admin.createFailed": "Création impossible",
"admin.regenFailed": "Régénération impossible",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Suppression impossible",
"admin.loadFailed": "Chargement impossible",
"admin.searchPh": "Rechercher des comptes…",
@@ -2361,6 +2379,12 @@ const ru: Dict = {
"admin.email": "Email (восстановление)",
"admin.createFailed": "Не создалось",
"admin.regenFailed": "Не сменилось",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Не удалилось",
"admin.loadFailed": "Не загрузилось",
"admin.searchPh": "Поиск аккаунтов…",
@@ -3055,6 +3079,12 @@ const it: Dict = {
"admin.email": "Email (recupero)",
"admin.createFailed": "Creazione fallita",
"admin.regenFailed": "Rigenerazione fallita",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Eliminazione fallita",
"admin.loadFailed": "Caricamento fallito",
"admin.searchPh": "Cerca account…",
@@ -3750,6 +3780,12 @@ const pt: Dict = {
"admin.email": "Email (recuperação)",
"admin.createFailed": "Falha ao criar",
"admin.regenFailed": "Falha ao regenerar",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Falha ao eliminar",
"admin.loadFailed": "Falha ao carregar",
"admin.searchPh": "Pesquisar contas…",
+2
View File
@@ -23,6 +23,8 @@ export interface Conversation {
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
/** Fully-qualified model id that produced this message (assistant only, "" = unknown). */
model?: string;
}
export interface DriverModel {
+5 -3
View File
@@ -34,6 +34,8 @@ export interface MessageRow {
conv_id: string;
role: string;
content: string;
/** Fully-qualified model id that produced this message ("" = unknown/pre-migration). */
model: string;
created_at: number;
}
@@ -236,10 +238,10 @@ export async function deleteConversation(id: string): Promise<void> {
});
}
export async function addMessage(convId: string, role: string, content: string): Promise<void> {
export async function addMessage(convId: string, role: string, content: string, model = ""): Promise<void> {
await db().run(
"INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
convId, role, content, Date.now(),
"INSERT INTO messages (conv_id, role, content, model, created_at) VALUES (?, ?, ?, ?, ?)",
convId, role, content, model.slice(0, 160), Date.now(),
);
}
+5
View File
@@ -397,6 +397,7 @@ const LATER_COLUMNS = [
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''",
];
/** Split a multi-statement DDL string into single statements. */
@@ -430,6 +431,10 @@ async function initSqliteSchema(c: DbClient): Promise<void> {
if (!convCols.some((col) => col.name === "archived_at")) {
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
}
const msgCols = await c.all<{ name: string }>("PRAGMA table_info(messages)");
if (!msgCols.some((col) => col.name === "model")) {
await c.exec("ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''");
}
await ensureAdminRow(c);
}
+15 -5
View File
@@ -453,7 +453,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
authorAvatarUrl: s.authorAvatarUrl,
archived: s.archived,
sharedAt: s.sharedAt,
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
messages: s.messages.map((m) => ({ role: m.role, content: m.content, model: m.model || undefined })),
});
}
@@ -980,7 +980,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return json({ user: toPublicUser(created), key }, 201);
}
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/);
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban|\/reset-link)?$/);
if (adminUserMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = await getUserById(adminUserMatch[1]!);
@@ -1002,6 +1002,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
return json({ shadowbanned: (await setReportShadowbanned(target.id, body.shadowbanned)) === 1 });
}
// Admin-initiated key recovery: email a one-time reset link to the
// user (same flow as self-service /api/auth/recover, but targeted).
if (req.method === "POST" && adminUserMatch[2] === "/reset-link") {
if (!target.email) return json({ error: "user has no email address — key recovery needs one" }, 409);
if (!mailEnabled()) return json({ error: "email is not configured on this platform" }, 501);
const token = "rt_" + randomBytes(24).toString("base64url");
await createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {});
return json({ ok: true, email: target.email });
}
if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
@@ -1931,7 +1941,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const stored = await getMessages(conv.id);
const lastStored = stored[stored.length - 1];
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
await addMessage(conv.id, "user", lastUser.content);
await addMessage(conv.id, "user", lastUser.content, body.model as string);
if (conv.title === "New chat") {
await updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
}
@@ -1941,7 +1951,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const persistReply = async (text: string): Promise<void> => {
if (conv && text) {
await addMessage(conv.id, "assistant", text);
await addMessage(conv.id, "assistant", text, body.model as string);
await touchConversation(conv.id, body.model as string);
}
};
@@ -1950,7 +1960,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
try {
const text = await driver.chat(messages, { model, signal: req.signal });
await persistReply(text);
return json({ model: body.model, message: { role: "assistant", content: text } });
return json({ model: body.model, message: { role: "assistant", content: text, model: body.model } });
} catch (e) {
return json({ error: (e as Error).message }, 502);
}