diff --git a/README.md b/README.md index 30cb62e..f9682f7 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a31b79f..8b5b958 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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 (`` 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 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 182ac8a..1389064 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0b4fca..f271e2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 7c22bb7..1a4ff24 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -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(null); const [editTarget, setEditTarget] = useState(null); + const [resetTarget, setResetTarget] = useState(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: () )} + {resetSent && ( +
+

{t("admin.resetSent", { user: resetSent.username, email: resetSent.email })}

+
+ )} + {!showCreate ? (
+
)} @@ -410,6 +430,19 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () onConfirm={() => deleteTarget && remove(deleteTarget)} /> + 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); + }} + /> + setEditTarget(null)} onSaved={refresh} /> ); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 589b2cf..3e5b535 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -44,8 +44,16 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports )} - {!isUser && (onReport || onToggleSpeak || (isFailed && onRetry)) && ( + {!isUser && (msg.model || onReport || onToggleSpeak || (isFailed && onRetry)) && (
+ {msg.model ? ( + + {msg.model} + + ) : null} {onToggleSpeak && ( {speaking ? : } @@ -534,7 +542,12 @@ export function Chat({
-
KisAssistant
+
+ KisAssistant + {model ? ( + {model} + ) : null} +
diff --git a/frontend/src/components/SharePage.tsx b/frontend/src/components/SharePage.tsx index 173e7ba..29fa25f 100644 --- a/frontend/src/components/SharePage.tsx +++ b/frontend/src/components/SharePage.tsx @@ -79,6 +79,9 @@ export function SharePage({ publicId }: { publicId: string }) {
{m.role === "user" ? : } {m.role === "user" ? chat.authorName : "KisAssistant"} + {m.role !== "user" && m.model ? ( + {m.model} + ) : null}
{m.role === "user" ? (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5ebb7d9..759e521 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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", diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 44405d2..2771f60 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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…", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 782d7ce..6241e14 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 { diff --git a/src/db.ts b/src/db.ts index 7a3cabd..c51fd38 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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 { }); } -export async function addMessage(convId: string, role: string, content: string): Promise { +export async function addMessage(convId: string, role: string, content: string, model = ""): Promise { 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(), ); } diff --git a/src/db/client.ts b/src/db/client.ts index 7d15b20..2266130 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -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 { 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); } diff --git a/src/index.ts b/src/index.ts index 70f1c44..4766493 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 => { 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); }