diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 97c151c..b99299f 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -234,7 +234,7 @@ export function Chat({ )} -
+ {features.attachments && ( + +
+ ) : (
@@ -96,6 +157,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) { {busy ? : (<>Unlock )} + )}
New here? diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index 503f4dd..da59695 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Globe, FileText, ImagePlus, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, Sparkles, Tag, Unplug, User as UserIcon, UserPlus, X } from "lucide-react"; +import { Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Unplug, User as UserIcon, UserPlus, X } from "lucide-react"; import { api } from "../lib/api.ts"; import { cn } from "../lib/cn.ts"; import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts"; @@ -340,10 +340,12 @@ export function SettingsModal({ user, onClose, onSaved, + onKeyRotated, }: { user: User | null; onClose: () => void; onSaved: (u: User) => void; + onKeyRotated?: () => void; }) { const [displayName, setDisplayName] = useState(""); const [avatarUrl, setAvatarUrl] = useState(""); @@ -363,6 +365,7 @@ export function SettingsModal({ { id: "profile", title: "Profile", icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] }, { id: "appearance", title: "Appearance", icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette"] }, { id: "features", title: "Features", icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] }, + { id: "security", title: "Security", icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] }, ] as const; type CatId = (typeof CATS)[number]["id"]; const [cat, setCat] = useState("profile"); @@ -560,6 +563,12 @@ export function SettingsModal({ )} + + {activeCat === "security" && ( + + + + )}
@@ -684,3 +693,159 @@ function FeaturesSection() { ); } + +/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */ + +function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) { + const [totpOn, setTotpOn] = useState(null); + const [secret, setSecret] = useState(null); + const [otpauthUrl, setOtpauthUrl] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [rotatedKey, setRotatedKey] = useState(null); + const [armingRotate, setArmingRotate] = useState(false); + const [armingDelete, setArmingDelete] = useState(false); + + useEffect(() => { + api.totpStatus().then((r) => setTotpOn(r.enabled)).catch(() => setTotpOn(false)); + }, []); + + async function rotate() { + if (!armingRotate) { + setArmingRotate(true); + return; + } + setBusy(true); + setError(""); + try { + const res = await api.rotateKey(); + setRotatedKey(res.key); + setArmingRotate(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Rotation failed"); + } finally { + setBusy(false); + } + } + + async function startTotp() { + setBusy(true); + setError(""); + try { + const res = await api.totpSetup(); + setSecret(res.secret); + setOtpauthUrl(res.otpauthUrl); + setCode(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Setup failed"); + } finally { + setBusy(false); + } + } + + async function confirmTotp() { + if (!secret || code.replace(/\D/g, "").length !== 6) return; + setBusy(true); + setError(""); + try { + await api.totpVerify(secret, code.replace(/\D/g, "")); + setTotpOn(true); + setSecret(null); + setCode(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid code"); + } finally { + setBusy(false); + } + } + + async function disableTotp() { + + return ( +
+
+

Access key

+ {rotatedKey ? ( +
+

New key — copy it now, it won't be shown again:

+
+ {rotatedKey} + +
+

Your old sessions are revoked. Log back in with the new key.

+ +
+ ) : ( + <> +

Rotate your access key. Old sessions are revoked immediately — you'll log back in with the new key.

+ + + )} +
+ +
+

Two-factor (TOTP)

+ {totpOn === null &&

Checking…

} + {totpOn === false && !secret && ( + <> +

Add a 6-digit code from your authenticator app on top of your access key.

+ + + )} + {secret && ( +
+

Add this secret to your authenticator app (or open the otpauth link), then enter a code to confirm:

+
+ {secret} + +
+ {otpauthUrl} +
+ setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + placeholder="000000" + aria-label="Authenticator code" + inputMode="numeric" + className="text-center tracking-[0.4em]" + /> + +
+
+ )} + {totpOn === true && ( +
+
+ + setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + placeholder="000000" + aria-label="Current authenticator code" + inputMode="numeric" + className="text-center tracking-[0.4em]" + /> + +
+ +
+ )} +
+ +
+ + + Passkeys (soon) + WebAuthn / platform authenticators — needs a server RP setup. + +
+ + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 2164b58..b792eec 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from import { createPortal } from "react-dom"; import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react"; import { cn } from "../lib/cn.ts"; +import { useT } from "../lib/i18n.ts"; /* ---------- Button ---------- */ @@ -82,7 +83,7 @@ export function Picker({ onChange, options, groups, - placeholder = "Select…", + placeholder, ariaLabel, disabled, icon: Icon, @@ -100,12 +101,14 @@ export function Picker({ className?: string; align?: "left" | "right"; }) { + const { t } = useT(); const [open, setOpen] = useState(false); const [pos, setPos] = useState({ top: 0, left: 0, width: 208 }); const btnRef = useRef(null); const popRef = useRef(null); const all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }]; const selected = all.flatMap((g) => g.options).find((o) => o.value === value); + const shownPlaceholder = placeholder ?? t("common.select"); // Position the popover in viewport coords (rendered in a portal so no // ancestor overflow — e.g. Modal's overflow-y-auto — can clip it). @@ -176,7 +179,7 @@ export function Picker({ )} > {Icon && } - {selected?.label ?? placeholder} + {selected?.label ?? shownPlaceholder} {open && createPortal( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a3bdf03..7d418ad 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -60,10 +60,15 @@ async function req(path: string, opts: RequestInit = {}, auth = true): Promis export const api = { login: (username: string, key: string) => - req<{ token: string; expiresAt: number; user: User }>("/api/auth/login", { + req<{ token: string; expiresAt: number; user: User } | { totpRequired: true; totpToken: string; username: string }>("/api/auth/login", { method: "POST", body: JSON.stringify({ username, key }), }, false), + totpLogin: (totpToken: string, code: string) => + req<{ token: string; expiresAt: number; user: User }>("/api/auth/totp", { + method: "POST", + body: JSON.stringify({ totpToken, code }), + }, false), verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"), logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })), @@ -79,6 +84,20 @@ export const api = { updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) => req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }), + /** Rotate your own access key (old sessions revoked, key shown once). */ + rotateKey: () => req<{ key: string }>("/api/me/key/rotate", { method: "POST" }), + + /** Permanently delete your own account (chats, shares, sessions). */ + deleteMe: () => req<{ ok: boolean }>("/api/me", { method: "DELETE" }), + + /** TOTP two-factor self-service. */ + totpStatus: () => req<{ enabled: boolean }>("/api/me/totp"), + totpSetup: () => req<{ secret: string; otpauthUrl: string }>("/api/me/totp/setup", { method: "POST" }), + totpVerify: (secret: string, code: string) => + req<{ enabled: boolean }>("/api/me/totp/verify", { method: "POST", body: JSON.stringify({ secret, code }) }), + totpDisable: (code: string) => + req<{ enabled: boolean }>("/api/me/totp", { method: "DELETE", body: JSON.stringify({ code }) }), + adminList: (p: { q?: string; sort?: string; filter?: string; page?: number; per?: number } = {}) => { const qs = new URLSearchParams(); if (p.q) qs.set("q", p.q); diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 0000000..b4ed7f4 --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,1696 @@ +import { useSyncExternalStore } from "react"; + +import { useSyncExternalStore } from "react"; + +/** + * Platform i18n — TypeScript dictionaries (NOT JSON). + * English is the source of truth: `StringKey = keyof typeof en`, and every + * other language is typed `Record`, so a missing + * translation is a COMPILE error. Fallback chain: current lang -> English. + */ + +export const LANGS = [ + { id: "en", label: "English" }, + { id: "es", label: "Español" }, + { id: "fr", label: "Français" }, + { id: "ru", label: "Русский" }, + { id: "it", label: "Italiano" }, + { id: "pt", label: "Português" }, +] as const; + +export type LangId = (typeof LANGS)[number]["id"]; + +const en = { + "common.close": "Close", + "common.cancel": "Cancel", + "common.save": "Save", + "common.done": "Done", + "common.copy": "Copy", + "common.copied": "Copied", + "common.create": "Create", + "common.delete": "Delete", + "common.loading": "Loading…", + "common.saving": "Saving…", + "common.username": "Username", + "common.displayName": "Display name", + "common.select": "Select…", + "common.theme": "Theme", + "common.themeAuto": "Auto", + "common.themeAutoFull": "Auto (follows system)", + "common.light": "Light", + "common.dark": "Dark", + "common.saveFailed": "Save failed", + "common.backToLogin": "Back to login", + + "loading.tagline": "Preparing your chats", + + "login.welcome": "Welcome back", + "login.subtitle": "Sign in to KisAssistant to continue", + "login.usernamePh": "Username", + "login.keyPh": "Access key", + "login.failed": "Login failed", + "login.unlock": "Unlock", + "login.newHere": "New here?", + "login.register": "Register", + "login.regTipOn": "Create an account", + "login.regTipOff": "Registration currently disabled on this platform", + "login.requestAccess": "Request access", + "login.forgot": "Forgot your access key?", + "login.perk1": "Private by design — no cookies, no tracking", + "login.perk2": "Your own models, streamed in real time", + "login.adminNote": "Admin key lives in the server .env; user keys are issued by the admin.", + + "sidebar.newChat": "New chat", + "sidebar.collapse": "Collapse sidebar", + "sidebar.closeDrawer": "Close chats", + "sidebar.searchPh": "Search chats…", + "sidebar.searchAria": "Search conversations", + "sidebar.clearSearch": "Clear search", + "sidebar.searching": "Searching…", + "sidebar.noMatch": "No match for “{q}”.", + "sidebar.youPrefix": "you: ", + "sidebar.emptyA": "No conversations yet.", + "sidebar.emptyB": "Start a new chat above.", + "sidebar.rename": "Rename", + "sidebar.topic": "Set topic", + "sidebar.share": "Share publicly", + "sidebar.delete": "Delete", + "sidebar.convOptions": "Conversation options", + "sidebar.admin": "admin", + "sidebar.adminCenter": "Admin Center", + "sidebar.adminCenterTip": "Open the Admin Center", + "sidebar.settingsTip": "Profile & settings", + "sidebar.logout": "Log out", + + "chat.expand": "Expand sidebar", + "chat.openNav": "Open chats", + "chat.modelAria": "Model", + "chat.noModels": "No models", + "chat.selectModel": "Select model…", + "chat.you": "You", + "chat.greet": "How can I help, {name}?", + "chat.greetSub": "Pick a model, then type below. Markdown is supported.", + "chat.thinking": "Thinking…", + "chat.placeholder": "Message KisAssistant…", + "chat.send": "Send", + "chat.attachTip": "Attach a file", + "chat.removeAttach": "Remove {name}", + "chat.ocrOpt": "Transcribe image", + "chat.unavailable": "(unavailable)", + "chat.ocrHint": "JPG, PNG, WEBP — OCR on the server, you review the text first", + "chat.textOpt": "Attach text file", + "chat.textHint": "TXT or Markdown, max 500KB", + "chat.attachNote": "Uploads go through platform tools only. Images are sent to the model as reviewed text — never as raw images.", + "chat.wiki": "Wiki & docs", + + "attach.tooBigImg": "Image too large (max 5MB).", + "attach.tooBigText": "Text file too large (max 500KB).", + "attach.uploadFailed": "Upload failed", + "attach.kindOcr": "image transcription", + "attach.kindText": "text file", + "attach.tpl": "[attached {kind}: {name}]", + "attach.chatError": "**Error:** the model did not respond. Try again.", + + "dlg.editConv": "Edit conversation", + "dlg.fTitle": "Title", + "dlg.fTopic": "Topic", + "dlg.topicHint": "A short theme tag, e.g. cooking, rust, travel.", + "dlg.noTopic": "No topic", + "dlg.recoverTitle": "Recover access key", + "dlg.recoverIntro": "Enter your username. You'll receive a link that issues a fresh key (the old one stops working).", + "dlg.sendLink": "Send reset link", + "dlg.reqFailed": "Request failed", + "dlg.recoverSent": "If @{user} exists and has a recovery email, a reset link is on its way{fromPart} (valid 60 minutes, single use).", + "dlg.recoverSentFrom": " from {from}", + "dlg.spamNote": "Check your inbox — and your spam folder, the message may have landed there.", + "dlg.noMailNote": "Nothing arrives? Then no recovery email is set on your account and the reset is not possible — please contact the site administrator.", + "dlg.registerTitle": "Create an account", + "dlg.regWelcome": "Welcome, @{user}! Your access key — copy it now, it won't be shown again.", + "dlg.regContinue": "Copy & continue to login", + "dlg.regFailed": "Registration failed", + "dlg.emailOpt": "Email (optional, for key recovery)", + "dlg.shareTitle": "Share publicly", + "dlg.shareCreating": "Creating public link…", + "dlg.shareIntro": "Anyone with this link can read {title}. No login required.", + "dlg.unshare": "Unshare", + "dlg.reviewOcr": "Review transcription", + "dlg.reviewText": "Review text attachment", + "dlg.transcribing": "Transcribing the image on the server (OCR)…", + "dlg.uploadingTools": "Uploading through platform tools…", + "dlg.ocrExpl": "The image was transcribed to text on the server — the model receives this text, never the image. Fix any reading mistakes below, then attach.", + "dlg.textExpl": "The file was uploaded through platform tools. Its text content is attached to your message. Edit below if needed, then attach.", + "dlg.truncWarn": "Transcription truncated to the server limit — the end is missing.", + "dlg.attachBtn": "Attach to message", + "dlg.chars": "{n} chars", + + "settings.title": "Settings", + "settings.searchPh": "Search settings…", + "settings.searchAria": "Search settings", + "settings.clearSearch": "Clear settings search", + "settings.noMatch": "No settings match “{q}”.", + "settings.profile": "Profile", + "settings.appearance": "Appearance", + "settings.features": "Features", + "settings.avatarUpload": "Avatar upload", + "settings.dropIdle": "Drop an image or click to upload", + "settings.dropActive": "Drop it!", + "settings.uploading": "Uploading…", + "settings.avatarHint": "jpg · png · webp — max 5MB — 5 changes per 2h", + "settings.email": "Recovery email", + "settings.emailHint": "Optional. Used only to send you a fresh access key if you lose it.", + "settings.accent": "Accent color", + "settings.accentHint": "Applied instantly, saved on this device.", + "settings.customColor": "Custom color", + "settings.customAria": "Custom accent color", + "settings.accentAria": "Accent: {label}", + "settings.featHint": "Turn composer capabilities on or off. Applied instantly, saved on this device.", + + "accent.preset.emerald": "Emerald", + "accent.preset.rose": "Rose", + "accent.preset.blue": "Blue", + "accent.preset.violet": "Violet", + "accent.preset.amber": "Yellow", + "accent.preset.orange": "Orange", + "accent.preset.teal": "Teal", + "accent.preset.pink": "Pink", + + "feat.thinking.label": "Thinking", + "feat.thinking.hint": "Ask the model to reason step-by-step before answering.", + "feat.attachments.label": "Attachments", + "feat.attachments.hint": "Attach images (OCR transcription) and text files to messages.", + "feat.deepSearch.label": "Deep search", + "feat.deepSearch.hint": "Let the assistant search the web for fresh sources. Coming soon.", + "feat.soon": "(soon)", + + "admin.title": "Accounts", + "admin.intro": "No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate.", + "admin.keyOnce": "Key for @{user} — shown once, copy it now:", + "admin.newAccount": "New account", + "admin.email": "Email (recovery)", + "admin.createFailed": "Create failed", + "admin.regenFailed": "Regenerate failed", + "admin.deleteFailed": "Delete failed", + "admin.loadFailed": "Load failed", + "admin.searchPh": "Search accounts…", + "admin.searchAria": "Search accounts", + "admin.sortAria": "Sort accounts", + "admin.filterAria": "Filter accounts", + "admin.sortNewest": "Newest first", + "admin.sortOldest": "Oldest first", + "admin.sortAz": "A → Z", + "admin.sortZa": "Z → A", + "admin.filterAll": "All accounts", + "admin.filterWith": "With recovery email", + "admin.filterNo": "No recovery email", + "admin.countMany": "{total} accounts · page {page} of {pages}", + "admin.countOne": "{total} account · page {page} of {pages}", + "admin.noEmail": "no recovery email", + "admin.editProfile": "Edit profile", + "admin.regenKey": "Regenerate key (revokes old sessions)", + "admin.deleteAccount": "Delete account", + "admin.deleteTitle": "Delete @{user}?", + "admin.deleteMsg": "This permanently removes the account, all its conversations and its shares.", + "admin.prevPage": "Previous page", + "admin.nextPage": "Next page", + + "ollama.models": "Ollama models", + "ollama.desc": "Download models from the Ollama library to your Ollama host, or remove local ones. Admin only.", + "ollama.pulling": "Pulling {name}", + "ollama.customTag": "Custom model tag", + "ollama.customHint": "e.g. llama3.2:3b, or a full library name", + "ollama.pull": "Pull", + "ollama.installed": "Installed ({n})", + "ollama.noneYet": "No local Ollama models yet.", + "ollama.catalog": "Catalog", + "ollama.delTitle": "Delete {name}?", + "ollama.delMsg": "The model is removed from the Ollama host. Pull it again anytime.", + "ollama.contacting": "Contacting Ollama…", + "ollama.pullingSt": "Pulling…", + "ollama.doneSt": "Done", + "ollama.pullFailed": "Pull failed", + "ollama.delTip": "Delete {name}", + "ollama.sizeAria": "{name} size", + "ollama.cat.llama3_2": "Meta's compact everyday model", + "ollama.cat.llama3_1": "Meta's general-purpose workhorse", + "ollama.cat.qwen2_5": "Alibaba's multilingual all-rounder", + "ollama.cat.gemma2": "Google's efficient open model", + "ollama.cat.mistral": "Mistral 7B — fast and capable", + "ollama.cat.phi3": "Microsoft's small research model", + "ollama.cat.deepseek_r1": "Reasoning model (chain-of-thought)", + "ollama.cat.codellama": "Code completion and chat", + "ollama.cat.llava": "Vision model (image understanding)", + "ollama.cat.nomic_embed_text": "Text embeddings", + + "center.title": "Admin Center", + "center.back": "Back to chat", + "center.tabAccounts": "Accounts", + "center.tabFeatures": "Features", + "center.tabMail": "Mail", + "center.locked": "Admins only", + "center.lockedSub": "Log in with the admin account to open the Admin Center.", + "center.regTitle": "Public registration", + "center.regDesc": "Show the Register button on the login page. Off = admin-created accounts only.", + "center.accTitle": "Access requests (wishlist)", + "center.accDescOn": "Unavailable while public registration is on — it replaces the wishlist.", + "center.accDescOff": "Show Request access on the login page. Visitors reserve a username and plead their case; you triage them in the Access tab.", + "center.ocrTitle": "OCR tool", + "center.ocrDesc": "Server-side image transcription for attachments. Requires the tesseract binary.", + "center.mailTitle": "Key recovery emails", + "center.mailChecking": "Checking…", + "center.mailFrom": "Enabled — sent from {from}", + "center.mailDefault": "Enabled — sent from the configured sender", + "center.mailOff": "Disabled — set SMTP_HOST on the server to enable", + "center.mailNote": "SMTP credentials live in the server environment (see docs/OPERATIONS.md), never in this UI. Users set their recovery address in profile settings or at account creation.", + "center.on": "On", + "center.off": "Off", + + "share.fallback": "Shared chat", + "share.open": "Open KisAssistant", + "share.gone": "This shared chat is gone", + "share.goneSub": "The link is invalid, expired, or the author unshared it.", + "share.back": "Go back", + "share.loading": "Loading shared chat…", + "share.by": "by {name}", + + "reset.checking": "Checking link…", + "reset.invalid": "Link invalid", + "reset.invalidSub": "This reset link is unknown, already used, or expired. Request a new one from the login page.", + "reset.readyTitle": "Reset key for @{user}?", + "reset.readySub": "This issues a fresh access key immediately. Your old key stops working.", + "reset.issue": "Issue new key", + "reset.issuing": "Issuing…", + "reset.doneTitle": "New key ready", + "reset.doneSub": "Copy it now — it won't be shown again. Then log in with it.", + "reset.goLogin": "Go to login", + "reset.failed": "Reset failed", + + "app.deleteConvTitle": "Delete conversation?", + "app.deleteConvMsg": "“{title}” and all its messages will be permanently removed.", +} as const; + +export type StringKey = keyof typeof en; +type Dict = Record; + +const es: Dict = { + "common.close": "Cerrar", + "common.cancel": "Cancelar", + "common.save": "Guardar", + "common.done": "Listo", + "common.copy": "Copiar", + "common.copied": "Copiado", + "common.create": "Crear", + "common.delete": "Eliminar", + "common.loading": "Cargando…", + "common.saving": "Guardando…", + "common.username": "Nombre de usuario", + "common.displayName": "Nombre visible", + "common.select": "Elegir…", + "common.theme": "Tema", + "common.themeAuto": "Automático", + "common.themeAutoFull": "Automático (según el sistema)", + "common.light": "Claro", + "common.dark": "Oscuro", + "common.saveFailed": "No se pudo guardar", + "common.backToLogin": "Volver al inicio de sesión", + + "loading.tagline": "Preparando tus chats", + + "login.welcome": "Bienvenido de nuevo", + "login.subtitle": "Inicia sesión en KisAssistant para continuar", + "login.usernamePh": "Nombre de usuario", + "login.keyPh": "Clave de acceso", + "login.failed": "Error de inicio de sesión", + "login.unlock": "Desbloquear", + "login.newHere": "¿Nuevo por aquí?", + "login.register": "Registrarse", + "login.regTipOn": "Crear una cuenta", + "login.regTipOff": "El registro está desactivado en esta plataforma", + "login.requestAccess": "Solicitar acceso", + "login.forgot": "¿Olvidaste tu clave de acceso?", + "login.perk1": "Privado por diseño — sin cookies ni rastreo", + "login.perk2": "Tus propios modelos, en tiempo real", + "login.adminNote": "La clave de admin vive en el .env del servidor; las claves de usuario las emite el admin.", + + "sidebar.newChat": "Nuevo chat", + "sidebar.collapse": "Ocultar barra lateral", + "sidebar.closeDrawer": "Cerrar chats", + "sidebar.searchPh": "Buscar chats…", + "sidebar.searchAria": "Buscar conversaciones", + "sidebar.clearSearch": "Borrar búsqueda", + "sidebar.searching": "Buscando…", + "sidebar.noMatch": "Sin resultados para «{q}».", + "sidebar.youPrefix": "tú: ", + "sidebar.emptyA": "Aún no hay conversaciones.", + "sidebar.emptyB": "Empieza un nuevo chat arriba.", + "sidebar.rename": "Renombrar", + "sidebar.topic": "Definir tema", + "sidebar.share": "Compartir públicamente", + "sidebar.delete": "Eliminar", + "sidebar.convOptions": "Opciones de conversación", + "sidebar.admin": "admin", + "sidebar.adminCenter": "Centro de admin", + "sidebar.adminCenterTip": "Abrir el Centro de admin", + "sidebar.settingsTip": "Perfil y ajustes", + "sidebar.logout": "Cerrar sesión", + + "chat.expand": "Mostrar barra lateral", + "chat.openNav": "Abrir chats", + "chat.modelAria": "Modelo", + "chat.noModels": "Sin modelos", + "chat.selectModel": "Elegir modelo…", + "chat.you": "Tú", + "chat.greet": "¿En qué te ayudo, {name}?", + "chat.greetSub": "Elige un modelo y escribe abajo. Markdown soportado.", + "chat.thinking": "Pensando…", + "chat.placeholder": "Escribe a KisAssistant…", + "chat.send": "Enviar", + "chat.attachTip": "Adjuntar un archivo", + "chat.removeAttach": "Quitar {name}", + "chat.ocrOpt": "Transcribir imagen", + "chat.unavailable": "(no disponible)", + "chat.ocrHint": "JPG, PNG, WEBP — OCR en el servidor, revisas el texto antes", + "chat.textOpt": "Adjuntar archivo de texto", + "chat.textHint": "TXT o Markdown, máx. 500KB", + "chat.attachNote": "Las subidas usan solo herramientas de la plataforma. Las imágenes llegan al modelo como texto revisado — nunca como imágenes.", + "chat.wiki": "Wiki y docs", + + "attach.tooBigImg": "Imagen muy grande (máx. 5MB).", + "attach.tooBigText": "Archivo muy grande (máx. 500KB).", + "attach.uploadFailed": "Error al subir", + "attach.kindOcr": "transcripción de imagen", + "attach.kindText": "archivo de texto", + "attach.tpl": "[adjunto {kind}: {name}]", + "attach.chatError": "**Error:** el modelo no respondió. Inténtalo de nuevo.", + + "dlg.editConv": "Editar conversación", + "dlg.fTitle": "Título", + "dlg.fTopic": "Tema", + "dlg.topicHint": "Etiqueta corta, p. ej. cocina, rust, viajes.", + "dlg.noTopic": "Sin tema", + "dlg.recoverTitle": "Recuperar clave de acceso", + "dlg.recoverIntro": "Escribe tu usuario. Recibirás un enlace que emite una clave nueva (la anterior deja de funcionar).", + "dlg.sendLink": "Enviar enlace", + "dlg.reqFailed": "Solicitud fallida", + "dlg.recoverSent": "Si @{user} existe y tiene email de recuperación, un enlace va en camino{fromPart} (válido 60 minutos, un solo uso).", + "dlg.recoverSentFrom": " desde {from}", + "dlg.spamNote": "Revisa tu bandeja — y el spam, puede haber caído allí.", + "dlg.noMailNote": "¿No llega nada? Entonces no hay email de recuperación en tu cuenta y no es posible — contacta al administrador del sitio.", + "dlg.registerTitle": "Crear una cuenta", + "dlg.regWelcome": "¡Bienvenido, @{user}! Tu clave de acceso — cópiala ahora, no se mostrará de nuevo.", + "dlg.regContinue": "Copiar e ir al login", + "dlg.regFailed": "Registro fallido", + "dlg.emailOpt": "Email (opcional, para recuperar la clave)", + "dlg.shareTitle": "Compartir públicamente", + "dlg.shareCreating": "Creando enlace público…", + "dlg.shareIntro": "Cualquiera con este enlace puede leer {title}. Sin login.", + "dlg.unshare": "Dejar de compartir", + "dlg.reviewOcr": "Revisar transcripción", + "dlg.reviewText": "Revisar adjunto de texto", + "dlg.transcribing": "Transcribiendo la imagen en el servidor (OCR)…", + "dlg.uploadingTools": "Subiendo con herramientas de la plataforma…", + "dlg.ocrExpl": "La imagen se transcribió a texto en el servidor — el modelo recibe este texto, nunca la imagen. Corrige errores abajo y adjunta.", + "dlg.textExpl": "El archivo se subió con herramientas de la plataforma. Su texto se adjunta a tu mensaje. Edita si quieres y adjunta.", + "dlg.truncWarn": "Transcripción recortada al límite del servidor — falta el final.", + "dlg.attachBtn": "Adjuntar al mensaje", + "dlg.chars": "{n} caracteres", + + "settings.title": "Ajustes", + "settings.searchPh": "Buscar ajustes…", + "settings.searchAria": "Buscar ajustes", + "settings.clearSearch": "Borrar búsqueda", + "settings.noMatch": "Sin ajustes para «{q}».", + "settings.profile": "Perfil", + "settings.appearance": "Apariencia", + "settings.features": "Funciones", + "settings.avatarUpload": "Subir avatar", + "settings.dropIdle": "Suelta una imagen o haz clic para subir", + "settings.dropActive": "¡Suéltala!", + "settings.uploading": "Subiendo…", + "settings.avatarHint": "jpg · png · webp — máx. 5MB — 5 cambios por 2h", + "settings.email": "Email de recuperación", + "settings.emailHint": "Opcional. Solo para enviarte una clave nueva si la pierdes.", + "settings.accent": "Color de acento", + "settings.accentHint": "Se aplica al instante, se guarda en este dispositivo.", + "settings.customColor": "Color personalizado", + "settings.customAria": "Color de acento personalizado", + "settings.accentAria": "Acento: {label}", + "settings.featHint": "Activa funciones del editor. Al instante, en este dispositivo.", + + "accent.preset.emerald": "Esmeralda", + "accent.preset.rose": "Rosa", + "accent.preset.blue": "Azul", + "accent.preset.violet": "Violeta", + "accent.preset.amber": "Amarillo", + "accent.preset.orange": "Naranja", + "accent.preset.teal": "Turquesa", + "accent.preset.pink": "Rosa fuerte", + + "feat.thinking.label": "Razonamiento", + "feat.thinking.hint": "Pide al modelo razonar paso a paso antes de responder.", + "feat.attachments.label": "Adjuntos", + "feat.attachments.hint": "Adjunta imágenes (transcripción OCR) y archivos de texto.", + "feat.deepSearch.label": "Búsqueda profunda", + "feat.deepSearch.hint": "Deja al asistente buscar fuentes frescas. Próximamente.", + "feat.soon": "(pronto)", + + "admin.title": "Cuentas", + "admin.intro": "Cuentas sin KYC: creas un usuario, entregas la clave una vez. Se guardan con hash — regenera para rotar.", + "admin.keyOnce": "Clave para @{user} — se muestra una vez, cópiala ahora:", + "admin.newAccount": "Nueva cuenta", + "admin.email": "Email (recuperación)", + "admin.createFailed": "No se pudo crear", + "admin.regenFailed": "No se pudo regenerar", + "admin.deleteFailed": "No se pudo eliminar", + "admin.loadFailed": "No se pudo cargar", + "admin.searchPh": "Buscar cuentas…", + "admin.searchAria": "Buscar cuentas", + "admin.sortAria": "Ordenar cuentas", + "admin.filterAria": "Filtrar cuentas", + "admin.sortNewest": "Más nuevas", + "admin.sortOldest": "Más viejas", + "admin.sortAz": "A → Z", + "admin.sortZa": "Z → A", + "admin.filterAll": "Todas las cuentas", + "admin.filterWith": "Con email de recuperación", + "admin.filterNo": "Sin email de recuperación", + "admin.countMany": "{total} cuentas · página {page} de {pages}", + "admin.countOne": "{total} cuenta · página {page} de {pages}", + "admin.noEmail": "sin email de recuperación", + "admin.editProfile": "Editar perfil", + "admin.regenKey": "Regenerar clave (revoca sesiones)", + "admin.deleteAccount": "Eliminar cuenta", + "admin.deleteTitle": "¿Eliminar @{user}?", + "admin.deleteMsg": "Esto elimina la cuenta, sus conversaciones y sus shares.", + "admin.prevPage": "Página anterior", + "admin.nextPage": "Página siguiente", + + "ollama.models": "Modelos Ollama", + "ollama.desc": "Descarga modelos de la librería Ollama a tu host, o elimina los locales. Solo admin.", + "ollama.pulling": "Descargando {name}", + "ollama.customTag": "Etiqueta personalizada", + "ollama.customHint": "p. ej. llama3.2:3b, o un nombre de librería", + "ollama.pull": "Descargar", + "ollama.installed": "Instalados ({n})", + "ollama.noneYet": "Aún no hay modelos Ollama locales.", + "ollama.catalog": "Catálogo", + "ollama.delTitle": "¿Eliminar {name}?", + "ollama.delMsg": "El modelo se elimina del host Ollama. Descárgalo de nuevo cuando quieras.", + "ollama.contacting": "Contactando Ollama…", + "ollama.pullingSt": "Descargando…", + "ollama.doneSt": "Listo", + "ollama.pullFailed": "Descarga fallida", + "ollama.delTip": "Eliminar {name}", + "ollama.sizeAria": "tamaño de {name}", + "ollama.cat.llama3_2": "Modelo compacto de Meta para el día a día", + "ollama.cat.llama3_1": "El caballo de batalla de Meta", + "ollama.cat.qwen2_5": "Multilingüe versátil de Alibaba", + "ollama.cat.gemma2": "Modelo abierto y eficiente de Google", + "ollama.cat.mistral": "Mistral 7B — rápido y capaz", + "ollama.cat.phi3": "Modelo pequeño de investigación de Microsoft", + "ollama.cat.deepseek_r1": "Modelo de razonamiento (cadena de pensamiento)", + "ollama.cat.codellama": "Autocompletado y chat de código", + "ollama.cat.llava": "Modelo de visión (entiende imágenes)", + "ollama.cat.nomic_embed_text": "Embeddings de texto", + + "center.title": "Centro de admin", + "center.back": "Volver al chat", + "center.tabAccounts": "Cuentas", + "center.tabFeatures": "Funciones", + "center.tabMail": "Correo", + "center.locked": "Solo admins", + "center.lockedSub": "Inicia sesión como admin para abrir el Centro de admin.", + "center.regTitle": "Registro público", + "center.regDesc": "Muestra el botón Registrarse en el login. Off = solo cuentas del admin.", + "center.accTitle": "Solicitudes de acceso (lista)", + "center.accDescOn": "No disponible con registro público — lo reemplaza.", + "center.accDescOff": "Muestra Solicitar acceso en el login. Triaje en la pestaña Acceso.", + "center.ocrTitle": "Herramienta OCR", + "center.ocrDesc": "Transcripción de imágenes en el servidor. Requiere tesseract.", + "center.mailTitle": "Emails de recuperación", + "center.mailChecking": "Verificando…", + "center.mailFrom": "Activado — enviados desde {from}", + "center.mailDefault": "Activado — enviados desde el remitente configurado", + "center.mailOff": "Desactivado — configura SMTP_HOST en el servidor", + "center.mailNote": "Las credenciales SMTP viven en el servidor (ver docs/OPERATIONS.md), nunca aquí. El email se define en el perfil o al crear la cuenta.", + "center.on": "Sí", + "center.off": "No", + + "share.fallback": "Chat compartido", + "share.open": "Abrir KisAssistant", + "share.gone": "Este chat compartido ya no existe", + "share.goneSub": "El enlace no vale, expiró o el autor lo retiró.", + "share.back": "Volver", + "share.loading": "Cargando chat compartido…", + "share.by": "por {name}", + + "reset.checking": "Verificando enlace…", + "reset.invalid": "Enlace inválido", + "reset.invalidSub": "Este enlace no existe, ya se usó o expiró. Pide uno nuevo en el login.", + "reset.readyTitle": "¿Nueva clave para @{user}?", + "reset.readySub": "Emite una clave nueva al instante. La anterior deja de funcionar.", + "reset.issue": "Emitir nueva clave", + "reset.issuing": "Emitiendo…", + "reset.doneTitle": "Nueva clave lista", + "reset.doneSub": "Cópiala ahora — no se mostrará de nuevo. Inicia sesión con ella.", + "reset.goLogin": "Ir al login", + "reset.failed": "Recuperación fallida", + + "app.deleteConvTitle": "¿Eliminar conversación?", + "app.deleteConvMsg": "«{title}» y sus mensajes se eliminarán.", +}; + +const fr: Dict = { + "common.close": "Fermer", + "common.cancel": "Annuler", + "common.save": "Enregistrer", + "common.done": "Terminé", + "common.copy": "Copier", + "common.copied": "Copié", + "common.create": "Créer", + "common.delete": "Supprimer", + "common.loading": "Chargement…", + "common.saving": "Enregistrement…", + "common.username": "Nom d'utilisateur", + "common.displayName": "Nom affiché", + "common.select": "Choisir…", + "common.theme": "Thème", + "common.themeAuto": "Auto", + "common.themeAutoFull": "Auto (suit le système)", + "common.light": "Clair", + "common.dark": "Sombre", + "common.saveFailed": "Échec de l'enregistrement", + "common.backToLogin": "Retour à la connexion", + + "loading.tagline": "Préparation de vos chats", + + "login.welcome": "Bon retour", + "login.subtitle": "Connectez-vous à KisAssistant pour continuer", + "login.usernamePh": "Nom d'utilisateur", + "login.keyPh": "Clé d'accès", + "login.failed": "Échec de connexion", + "login.unlock": "Déverrouiller", + "login.newHere": "Nouveau ici ?", + "login.register": "S'inscrire", + "login.regTipOn": "Créer un compte", + "login.regTipOff": "Inscription désactivée sur cette plateforme", + "login.requestAccess": "Demander l'accès", + "login.forgot": "Clé d'accès oubliée ?", + "login.perk1": "Privé par conception — sans cookies ni suivi", + "login.perk2": "Vos propres modèles, en temps réel", + "login.adminNote": "La clé admin vit dans le .env du serveur ; les clés utilisateur sont émises par l'admin.", + + "sidebar.newChat": "Nouveau chat", + "sidebar.collapse": "Masquer la barre latérale", + "sidebar.closeDrawer": "Fermer les chats", + "sidebar.searchPh": "Rechercher…", + "sidebar.searchAria": "Rechercher des conversations", + "sidebar.clearSearch": "Effacer la recherche", + "sidebar.searching": "Recherche…", + "sidebar.noMatch": "Aucun résultat pour « {q} ».", + "sidebar.youPrefix": "vous : ", + "sidebar.emptyA": "Aucune conversation.", + "sidebar.emptyB": "Démarrez un nouveau chat ci-dessus.", + "sidebar.rename": "Renommer", + "sidebar.topic": "Définir le sujet", + "sidebar.share": "Partager publiquement", + "sidebar.delete": "Supprimer", + "sidebar.convOptions": "Options de conversation", + "sidebar.admin": "admin", + "sidebar.adminCenter": "Centre admin", + "sidebar.adminCenterTip": "Ouvrir le Centre admin", + "sidebar.settingsTip": "Profil et réglages", + "sidebar.logout": "Déconnexion", + + "chat.expand": "Afficher la barre latérale", + "chat.openNav": "Ouvrir les chats", + "chat.modelAria": "Modèle", + "chat.noModels": "Aucun modèle", + "chat.selectModel": "Choisir un modèle…", + "chat.you": "Vous", + "chat.greet": "Comment puis-je aider, {name} ?", + "chat.greetSub": "Choisissez un modèle, puis écrivez ci-dessous. Markdown pris en charge.", + "chat.thinking": "Réflexion…", + "chat.placeholder": "Écrivez à KisAssistant…", + "chat.send": "Envoyer", + "chat.attachTip": "Joindre un fichier", + "chat.removeAttach": "Retirer {name}", + "chat.ocrOpt": "Transcrire une image", + "chat.unavailable": "(indisponible)", + "chat.ocrHint": "JPG, PNG, WEBP — OCR sur le serveur, vous relisez le texte avant", + "chat.textOpt": "Joindre un fichier texte", + "chat.textHint": "TXT ou Markdown, 500 Ko max", + "chat.attachNote": "Les envois passent uniquement par les outils de la plateforme. Les images arrivent au modèle comme texte relu — jamais brutes.", + "chat.wiki": "Wiki et docs", + + "attach.tooBigImg": "Image trop lourde (5 Mo max).", + "attach.tooBigText": "Fichier trop lourd (500 Ko max).", + "attach.uploadFailed": "Échec de l'envoi", + "attach.kindOcr": "transcription d'image", + "attach.kindText": "fichier texte", + "attach.tpl": "[pièce jointe {kind} : {name}]", + "attach.chatError": "**Erreur :** le modèle n'a pas répondu. Réessayez.", + + "dlg.editConv": "Modifier la conversation", + "dlg.fTitle": "Titre", + "dlg.fTopic": "Sujet", + "dlg.topicHint": "Courte étiquette, p. ex. cuisine, rust, voyages.", + "dlg.noTopic": "Sans sujet", + "dlg.recoverTitle": "Récupérer la clé d'accès", + "dlg.recoverIntro": "Entrez votre nom d'utilisateur. Vous recevrez un lien qui émet une clé neuve (l'ancienne cesse de fonctionner).", + "dlg.sendLink": "Envoyer le lien", + "dlg.reqFailed": "Requête échouée", + "dlg.recoverSent": "Si @{user} existe avec un email de récupération, un lien est en route{fromPart} (valide 60 minutes, usage unique).", + "dlg.recoverSentFrom": " depuis {from}", + "dlg.spamNote": "Vérifiez votre boîte — et les spams, le message a pu y atterrir.", + "dlg.noMailNote": "Rien ne vient ? Alors aucun email de récupération n'est défini et c'est impossible — contactez l'administrateur du site.", + "dlg.registerTitle": "Créer un compte", + "dlg.regWelcome": "Bienvenue, @{user} ! Votre clé d'accès — copiez-la vite, elle ne sera plus affichée.", + "dlg.regContinue": "Copier et aller au login", + "dlg.regFailed": "Inscription échouée", + "dlg.emailOpt": "Email (optionnel, pour récupérer la clé)", + "dlg.shareTitle": "Partager publiquement", + "dlg.shareCreating": "Création du lien public…", + "dlg.shareIntro": "Quiconque a ce lien peut lire {title}. Sans connexion.", + "dlg.unshare": "Ne plus partager", + "dlg.reviewOcr": "Relire la transcription", + "dlg.reviewText": "Relire la pièce jointe", + "dlg.transcribing": "Transcription de l'image sur le serveur (OCR)…", + "dlg.uploadingTools": "Envoi via les outils de la plateforme…", + "dlg.ocrExpl": "L'image a été transcrite en texte sur le serveur — le modèle reçoit ce texte, jamais l'image. Corrigez ci-dessous puis joignez.", + "dlg.textExpl": "Le fichier a été envoyé via les outils de la plateforme. Son texte est joint à votre message. Modifiez si besoin puis joignez.", + "dlg.truncWarn": "Transcription tronquée à la limite serveur — la fin manque.", + "dlg.attachBtn": "Joindre au message", + "dlg.chars": "{n} caractères", + + "settings.title": "Réglages", + "settings.searchPh": "Rechercher…", + "settings.searchAria": "Rechercher dans les réglages", + "settings.clearSearch": "Effacer la recherche", + "settings.noMatch": "Aucun réglage pour « {q} ».", + "settings.profile": "Profil", + "settings.appearance": "Apparence", + "settings.features": "Fonctions", + "settings.avatarUpload": "Envoyer un avatar", + "settings.dropIdle": "Déposez une image ou cliquez pour envoyer", + "settings.dropActive": "Lâchez !", + "settings.uploading": "Envoi…", + "settings.avatarHint": "jpg · png · webp — 5 Mo max — 5 changements par 2 h", + "settings.email": "Email de récupération", + "settings.emailHint": "Optionnel. Sert uniquement à vous renvoyer une clé.", + "settings.accent": "Couleur d'accent", + "settings.accentHint": "Appliquée aussitôt, gardée sur cet appareil.", + "settings.customColor": "Couleur perso", + "settings.customAria": "Couleur d'accent personnalisée", + "settings.accentAria": "Accent : {label}", + "settings.featHint": "Activez les fonctions du composer. Aussitôt, sur cet appareil.", + + "accent.preset.emerald": "Émeraude", + "accent.preset.rose": "Rose", + "accent.preset.blue": "Bleu", + "accent.preset.violet": "Violet", + "accent.preset.amber": "Jaune", + "accent.preset.orange": "Orange", + "accent.preset.teal": "Sarcelle", + "accent.preset.pink": "Rose vif", + + "feat.thinking.label": "Réflexion", + "feat.thinking.hint": "Demande au modèle de raisonner étape par étape.", + "feat.attachments.label": "Pièces jointes", + "feat.attachments.hint": "Joindre images (transcription OCR) et fichiers texte.", + "feat.deepSearch.label": "Recherche profonde", + "feat.deepSearch.hint": "Laisse l'assistant chercher des sources fraîches. Bientôt.", + "feat.soon": "(bientôt)", + + "admin.title": "Comptes", + "admin.intro": "Comptes sans KYC : vous créez un pseudo, remettez la clé une fois. Stockées hachées — régénérez pour tourner.", + "admin.keyOnce": "Clé pour @{user} — affichée une fois, copiez-la :", + "admin.newAccount": "Nouveau compte", + "admin.email": "Email (récupération)", + "admin.createFailed": "Création impossible", + "admin.regenFailed": "Régénération impossible", + "admin.deleteFailed": "Suppression impossible", + "admin.loadFailed": "Chargement impossible", + "admin.searchPh": "Rechercher des comptes…", + "admin.searchAria": "Rechercher des comptes", + "admin.sortAria": "Trier les comptes", + "admin.filterAria": "Filtrer les comptes", + "admin.sortNewest": "Plus récents", + "admin.sortOldest": "Plus anciens", + "admin.sortAz": "A → Z", + "admin.sortZa": "Z → A", + "admin.filterAll": "Tous les comptes", + "admin.filterWith": "Avec email de récupération", + "admin.filterNo": "Sans email de récupération", + "admin.countMany": "{total} comptes · page {page} sur {pages}", + "admin.countOne": "{total} compte · page {page} sur {pages}", + "admin.noEmail": "sans email de récupération", + "admin.editProfile": "Modifier le profil", + "admin.regenKey": "Régénérer la clé (révoque les sessions)", + "admin.deleteAccount": "Supprimer le compte", + "admin.deleteTitle": "Supprimer @{user} ?", + "admin.deleteMsg": "Supprime définitivement le compte, ses conversations et ses partages.", + "admin.prevPage": "Page précédente", + "admin.nextPage": "Page suivante", + + "ollama.models": "Modèles Ollama", + "ollama.desc": "Téléchargez des modèles Ollama sur votre hôte, ou retirez les locaux. Admin uniquement.", + "ollama.pulling": "Téléchargement de {name}", + "ollama.customTag": "Tag personnalisé", + "ollama.customHint": "p. ex. llama3.2:3b, ou un nom de bibliothèque", + "ollama.pull": "Télécharger", + "ollama.installed": "Installés ({n})", + "ollama.noneYet": "Aucun modèle Ollama local.", + "ollama.catalog": "Catalogue", + "ollama.delTitle": "Supprimer {name} ?", + "ollama.delMsg": "Le modèle est retiré de l'hôte Ollama. Retéléchargez-le à tout moment.", + "ollama.contacting": "Contact d'Ollama…", + "ollama.pullingSt": "Téléchargement…", + "ollama.doneSt": "Terminé", + "ollama.pullFailed": "Échec du téléchargement", + "ollama.delTip": "Supprimer {name}", + "ollama.sizeAria": "taille de {name}", + "ollama.cat.llama3_2": "Le compact de Meta pour tous les jours", + "ollama.cat.llama3_1": "Le bourreau de travail de Meta", + "ollama.cat.qwen2_5": "Polyglotte polyvalent d'Alibaba", + "ollama.cat.gemma2": "Le modèle ouvert et efficace de Google", + "ollama.cat.mistral": "Mistral 7B — rapide et capable", + "ollama.cat.phi3": "Le petit modèle de recherche Microsoft", + "ollama.cat.deepseek_r1": "Modèle de raisonnement (chaîne de pensée)", + "ollama.cat.codellama": "Complétion et chat de code", + "ollama.cat.llava": "Modèle de vision (comprend les images)", + "ollama.cat.nomic_embed_text": "Embeddings de texte", + + "center.title": "Centre admin", + "center.back": "Retour au chat", + "center.tabAccounts": "Comptes", + "center.tabFeatures": "Fonctions", + "center.tabMail": "Mail", + "center.locked": "Admins uniquement", + "center.lockedSub": "Connectez-vous en admin pour ouvrir le Centre admin.", + "center.regTitle": "Inscription publique", + "center.regDesc": "Affiche le bouton S'inscrire au login. Off = comptes admin uniquement.", + "center.accTitle": "Demandes d'accès (liste)", + "center.accDescOn": "Indispo avec l'inscription publique — elle la remplace.", + "center.accDescOff": "Affiche Demander l'accès au login. Réservez un pseudo, plaidez votre cas ; tri dans l'onglet Accès.", + "center.ocrTitle": "Outil OCR", + "center.ocrDesc": "Transcription d'images côté serveur. Requiert tesseract.", + "center.mailTitle": "Emails de récupération", + "center.mailChecking": "Vérification…", + "center.mailFrom": "Activé — envoyés depuis {from}", + "center.mailDefault": "Activé — envoyés depuis l'expéditeur configuré", + "center.mailOff": "Désactivé — définissez SMTP_HOST sur le serveur", + "center.mailNote": "Les identifiants SMTP vivent sur le serveur (voir docs/OPERATIONS.md), jamais ici. L'email se règle dans le profil ou à la création.", + "center.on": "Oui", + "center.off": "Non", + + "share.fallback": "Chat partagé", + "share.open": "Ouvrir KisAssistant", + "share.gone": "Ce chat partagé n'existe plus", + "share.goneSub": "Le lien est invalide, expiré, ou l'auteur l'a retiré.", + "share.back": "Retour", + "share.loading": "Chargement du chat partagé…", + "share.by": "par {name}", + + "reset.checking": "Vérification du lien…", + "reset.invalid": "Lien invalide", + "reset.invalidSub": "Ce lien est inconnu, déjà utilisé ou expiré. Redemandez-en un au login.", + "reset.readyTitle": "Nouvelle clé pour @{user} ?", + "reset.readySub": "Émet aussitôt une clé neuve. L'ancienne cesse de fonctionner.", + "reset.issue": "Émettre une clé", + "reset.issuing": "Émission…", + "reset.doneTitle": "Nouvelle clé prête", + "reset.doneSub": "Copiez-la vite — plus affichée ensuite. Connectez-vous avec.", + "reset.goLogin": "Aller au login", + "reset.failed": "Récupération échouée", + + "app.deleteConvTitle": "Supprimer la conversation ?", + "app.deleteConvMsg": "« {title} » et ses messages seront supprimés.", +}; + +const ru: Dict = { + "common.close": "Закрыть", + "common.cancel": "Отмена", + "common.save": "Сохранить", + "common.done": "Готово", + "common.copy": "Копировать", + "common.copied": "Скопировано", + "common.create": "Создать", + "common.delete": "Удалить", + "common.loading": "Загрузка…", + "common.saving": "Сохранение…", + "common.username": "Имя пользователя", + "common.displayName": "Отображаемое имя", + "common.select": "Выбрать…", + "common.theme": "Тема", + "common.themeAuto": "Авто", + "common.themeAutoFull": "Авто (как в системе)", + "common.light": "Светлая", + "common.dark": "Тёмная", + "common.saveFailed": "Не удалось сохранить", + "common.backToLogin": "Назад ко входу", + + "loading.tagline": "Готовим ваши чаты", + + "login.welcome": "С возвращением", + "login.subtitle": "Войдите в KisAssistant, чтобы продолжить", + "login.usernamePh": "Имя пользователя", + "login.keyPh": "Ключ доступа", + "login.failed": "Ошибка входа", + "login.unlock": "Разблокировать", + "login.newHere": "Впервые здесь?", + "login.register": "Регистрация", + "login.regTipOn": "Создать аккаунт", + "login.regTipOff": "Регистрация на этой платформе отключена", + "login.requestAccess": "Запросить доступ", + "login.forgot": "Забыли ключ доступа?", + "login.perk1": "Приватность по умолчанию — без cookies и трекинга", + "login.perk2": "Ваши модели, в реальном времени", + "login.adminNote": "Ключ админа живёт в .env сервера; ключи пользователей выдаёт админ.", + + "sidebar.newChat": "Новый чат", + "sidebar.collapse": "Скрыть панель", + "sidebar.closeDrawer": "Закрыть чаты", + "sidebar.searchPh": "Поиск чатов…", + "sidebar.searchAria": "Поиск разговоров", + "sidebar.clearSearch": "Очистить поиск", + "sidebar.searching": "Поиск…", + "sidebar.noMatch": "По запросу «{q}» ничего нет.", + "sidebar.youPrefix": "вы: ", + "sidebar.emptyA": "Пока нет разговоров.", + "sidebar.emptyB": "Начните новый чат выше.", + "sidebar.rename": "Переименовать", + "sidebar.topic": "Задать тему", + "sidebar.share": "Опубликовать ссылку", + "sidebar.delete": "Удалить", + "sidebar.convOptions": "Действия с разговором", + "sidebar.admin": "админ", + "sidebar.adminCenter": "Админ-центр", + "sidebar.adminCenterTip": "Открыть Админ-центр", + "sidebar.settingsTip": "Профиль и настройки", + "sidebar.logout": "Выйти", + + "chat.expand": "Показать панель", + "chat.openNav": "Открыть чаты", + "chat.modelAria": "Модель", + "chat.noModels": "Нет моделей", + "chat.selectModel": "Выбрать модель…", + "chat.you": "Вы", + "chat.greet": "Чем помочь, {name}?", + "chat.greetSub": "Выберите модель и пишите ниже. Поддерживается Markdown.", + "chat.thinking": "Думаю…", + "chat.placeholder": "Сообщение KisAssistant…", + "chat.send": "Отправить", + "chat.attachTip": "Прикрепить файл", + "chat.removeAttach": "Убрать {name}", + "chat.ocrOpt": "Распознать изображение", + "chat.unavailable": "(недоступно)", + "chat.ocrHint": "JPG, PNG, WEBP — OCR на сервере, текст вы проверите", + "chat.textOpt": "Прикрепить текстовый файл", + "chat.textHint": "TXT или Markdown, до 500 КБ", + "chat.attachNote": "Загрузка только через инструменты платформы. Изображения уходят модели проверенным текстом — не картинками.", + "chat.wiki": "Вики и доки", + + "attach.tooBigImg": "Изображение слишком большое (макс. 5 МБ).", + "attach.tooBigText": "Файл слишком большой (макс. 500 КБ).", + "attach.uploadFailed": "Ошибка загрузки", + "attach.kindOcr": "распознанное изображение", + "attach.kindText": "текстовый файл", + "attach.tpl": "[приложено {kind}: {name}]", + "attach.chatError": "**Ошибка:** модель не ответила. Попробуйте ещё.", + + "dlg.editConv": "Изменить разговор", + "dlg.fTitle": "Название", + "dlg.fTopic": "Тема", + "dlg.topicHint": "Короткий тег, напр. кухня, rust, поездки.", + "dlg.noTopic": "Без темы", + "dlg.recoverTitle": "Восстановить ключ", + "dlg.recoverIntro": "Введите имя пользователя. Вы получите ссылку на новый ключ (старый перестанет работать).", + "dlg.sendLink": "Отправить ссылку", + "dlg.reqFailed": "Запрос не удался", + "dlg.recoverSent": "Если @{user} существует и у него есть email, ссылка уже летит{fromPart} (действует 60 минут, одноразовая).", + "dlg.recoverSentFrom": " от {from}", + "dlg.spamNote": "Проверьте входящие — и спам, письмо могло попасть туда.", + "dlg.noMailNote": "Ничего нет? Значит, email не указан и сброс невозможен — обратитесь к администратору сайта.", + "dlg.registerTitle": "Создать аккаунт", + "dlg.regWelcome": "Добро пожаловать, @{user}! Ваш ключ — скопируйте сейчас, больше не покажем.", + "dlg.regContinue": "Скопировать и ко входу", + "dlg.regFailed": "Ошибка регистрации", + "dlg.emailOpt": "Email (необязательно, для восстановления)", + "dlg.shareTitle": "Опубликовать ссылку", + "dlg.shareCreating": "Создаём публичную ссылку…", + "dlg.shareIntro": "Каждый с этой ссылкой прочитает {title}. Вход не нужен.", + "dlg.unshare": "Закрыть доступ", + "dlg.reviewOcr": "Проверить распознавание", + "dlg.reviewText": "Проверить вложение", + "dlg.transcribing": "Распознаём изображение на сервере (OCR)…", + "dlg.uploadingTools": "Загружаем через инструменты платформы…", + "dlg.ocrExpl": "Изображение распознано в текст на сервере — модель получит текст, не картинку. Исправьте ошибки ниже и прикрепите.", + "dlg.textExpl": "Файл загружен через инструменты платформы. Его текст прикрепится к сообщению. Правьте и прикрепляйте.", + "dlg.truncWarn": "Распознавание обрезано лимитом сервера — конца нет.", + "dlg.attachBtn": "Прикрепить к сообщению", + "dlg.chars": "{n} символов", + + "settings.title": "Настройки", + "settings.searchPh": "Поиск настроек…", + "settings.searchAria": "Поиск настроек", + "settings.clearSearch": "Очистить поиск", + "settings.noMatch": "Настроек по «{q}» нет.", + "settings.profile": "Профиль", + "settings.appearance": "Внешний вид", + "settings.features": "Функции", + "settings.avatarUpload": "Загрузить аватар", + "settings.dropIdle": "Перетащите картинку или нажмите для загрузки", + "settings.dropActive": "Отпускайте!", + "settings.uploading": "Загрузка…", + "settings.avatarHint": "jpg · png · webp — до 5 МБ — 5 смен за 2 ч", + "settings.email": "Email для восстановления", + "settings.emailHint": "Необязательно. Только для отправки нового ключа.", + "settings.accent": "Акцентный цвет", + "settings.accentHint": "Применяется сразу, хранится на устройстве.", + "settings.customColor": "Свой цвет", + "settings.customAria": "Свой акцентный цвет", + "settings.accentAria": "Акцент: {label}", + "settings.featHint": "Включайте возможности редактора. Сразу, на этом устройстве.", + + "accent.preset.emerald": "Изумруд", + "accent.preset.rose": "Роза", + "accent.preset.blue": "Синий", + "accent.preset.violet": "Фиолет", + "accent.preset.amber": "Жёлтый", + "accent.preset.orange": "Оранжевый", + "accent.preset.teal": "Бирюза", + "accent.preset.pink": "Розовый", + + "feat.thinking.label": "Размышление", + "feat.thinking.hint": "Просить модель рассуждать шаг за шагом.", + "feat.attachments.label": "Вложения", + "feat.attachments.hint": "Картинки (OCR) и текстовые файлы к сообщениям.", + "feat.deepSearch.label": "Глубокий поиск", + "feat.deepSearch.hint": "Поиск свежих источников. Скоро.", + "feat.soon": "(скоро)", + + "admin.title": "Аккаунты", + "admin.intro": "Аккаунты без KYC: создаёте имя, ключ отдаёте один раз. Хранится хэш — смена ротирует.", + "admin.keyOnce": "Ключ @{user} — показан раз, копируйте:", + "admin.newAccount": "Новый аккаунт", + "admin.email": "Email (восстановление)", + "admin.createFailed": "Не создалось", + "admin.regenFailed": "Не сменилось", + "admin.deleteFailed": "Не удалилось", + "admin.loadFailed": "Не загрузилось", + "admin.searchPh": "Поиск аккаунтов…", + "admin.searchAria": "Поиск аккаунтов", + "admin.sortAria": "Сортировка", + "admin.filterAria": "Фильтр", + "admin.sortNewest": "Сначала новые", + "admin.sortOldest": "Сначала старые", + "admin.sortAz": "А → Я", + "admin.sortZa": "Я → А", + "admin.filterAll": "Все аккаунты", + "admin.filterWith": "С email", + "admin.filterNo": "Без email", + "admin.countMany": "{total} аккаунтов · стр. {page} из {pages}", + "admin.countOne": "{total} аккаунт · стр. {page} из {pages}", + "admin.noEmail": "без email", + "admin.editProfile": "Изменить профиль", + "admin.regenKey": "Сменить ключ (убьёт сессии)", + "admin.deleteAccount": "Удалить аккаунт", + "admin.deleteTitle": "Удалить @{user}?", + "admin.deleteMsg": "Аккаунт, разговоры и ссылки удалятся навсегда.", + "admin.prevPage": "Назад", + "admin.nextPage": "Вперёд", + + "ollama.models": "Модели Ollama", + "ollama.desc": "Качайте модели на Ollama-хост или удаляйте локальные. Только админ.", + "ollama.pulling": "Качаем {name}", + "ollama.customTag": "Свой тег", + "ollama.customHint": "напр. llama3.2:3b или имя из библиотеки", + "ollama.pull": "Скачать", + "ollama.installed": "Стоит ({n})", + "ollama.noneYet": "Локальных моделей пока нет.", + "ollama.catalog": "Каталог", + "ollama.delTitle": "Удалить {name}?", + "ollama.delMsg": "Модель уйдёт с хоста. Скачайте заново когда угодно.", + "ollama.contacting": "Связь с Ollama…", + "ollama.pullingSt": "Качаем…", + "ollama.doneSt": "Готово", + "ollama.pullFailed": "Не скачалось", + "ollama.delTip": "Удалить {name}", + "ollama.sizeAria": "размер {name}", + "ollama.cat.llama3_2": "Компактная повседневная модель Meta", + "ollama.cat.llama3_1": "Рабочая лошадка Meta", + "ollama.cat.qwen2_5": "Многоязычный универсал Alibaba", + "ollama.cat.gemma2": "Эффективная открытая модель Google", + "ollama.cat.mistral": "Mistral 7B — быстрый и способный", + "ollama.cat.phi3": "Маленькая исследовательская модель Microsoft", + "ollama.cat.deepseek_r1": "Рассуждающая модель (цепочка мыслей)", + "ollama.cat.codellama": "Код и чат про код", + "ollama.cat.llava": "Зрение (понимает картинки)", + "ollama.cat.nomic_embed_text": "Текстовые эмбеддинги", + + "center.title": "Админ-центр", + "center.back": "Назад в чат", + "center.tabAccounts": "Аккаунты", + "center.tabFeatures": "Функции", + "center.tabMail": "Почта", + "center.locked": "Только админы", + "center.lockedSub": "Войдите как админ, чтобы открыть центр.", + "center.regTitle": "Открытая регистрация", + "center.regDesc": "Кнопка регистрации на входе. Выкл = только админ создаёт.", + "center.accTitle": "Заявки (лист ожидания)", + "center.accDescOn": "Недоступно при открытой регистрации — она заменяет.", + "center.accDescOff": "Кнопка доступа на входе. Гости просят имя; разбор во вкладке.", + "center.ocrTitle": "Инструмент OCR", + "center.ocrDesc": "Распознавание картинок на сервере. Нужен tesseract.", + "center.mailTitle": "Письма восстановления", + "center.mailChecking": "Проверяем…", + "center.mailFrom": "Включено — шлём от {from}", + "center.mailDefault": "Включено — шлём с настроенного адреса", + "center.mailOff": "Выключено — задайте SMTP_HOST", + "center.mailNote": "SMTP-доступ живёт в окружении сервера (см. docs/OPERATIONS.md), не здесь. Email задаётся в профиле или при создании.", + "center.on": "Вкл", + "center.off": "Выкл", + + "share.fallback": "Общий чат", + "share.open": "Открыть KisAssistant", + "share.gone": "Этот общий чат исчез", + "share.goneSub": "Ссылка неверна, истекла или автор закрыл её.", + "share.back": "Назад", + "share.loading": "Загружаем общий чат…", + "share.by": "от {name}", + + "reset.checking": "Проверяем ссылку…", + "reset.invalid": "Ссылка неверна", + "reset.invalidSub": "Ссылка неизвестна, использована или истекла. Запросите новую на входе.", + "reset.readyTitle": "Новый ключ для @{user}?", + "reset.readySub": "Сразу выдадим свежий ключ. Старый перестанет работать.", + "reset.issue": "Выдать ключ", + "reset.issuing": "Выдаём…", + "reset.doneTitle": "Ключ готов", + "reset.doneSub": "Скопируйте сейчас — больше не покажем. Войдите с ним.", + "reset.goLogin": "Ко входу", + "reset.failed": "Не вышло", + + "app.deleteConvTitle": "Удалить разговор?", + "app.deleteConvMsg": "«{title}» и сообщения удалятся.", +}; + +const it: Dict = { + "common.close": "Chiudi", + "common.cancel": "Annulla", + "common.save": "Salva", + "common.done": "Fatto", + "common.copy": "Copia", + "common.copied": "Copiato", + "common.create": "Crea", + "common.delete": "Elimina", + "common.loading": "Caricamento…", + "common.saving": "Salvataggio…", + "common.username": "Nome utente", + "common.displayName": "Nome visualizzato", + "common.select": "Scegli…", + "common.theme": "Tema", + "common.themeAuto": "Auto", + "common.themeAutoFull": "Auto (segue il sistema)", + "common.light": "Chiaro", + "common.dark": "Scuro", + "common.saveFailed": "Salvataggio non riuscito", + "common.backToLogin": "Torna al login", + + "loading.tagline": "Preparo le tue chat", + + "login.welcome": "Bentornato", + "login.subtitle": "Accedi a KisAssistant per continuare", + "login.usernamePh": "Nome utente", + "login.keyPh": "Chiave di accesso", + "login.failed": "Accesso non riuscito", + "login.unlock": "Sblocca", + "login.newHere": "Nuovo qui?", + "login.register": "Registrati", + "login.regTipOn": "Crea un account", + "login.regTipOff": "Registrazione disattivata su questa piattaforma", + "login.requestAccess": "Richiedi accesso", + "login.forgot": "Chiave dimenticata?", + "login.perk1": "Privato per progettazione — niente cookie né traccianti", + "login.perk2": "I tuoi modelli, in tempo reale", + "login.adminNote": "La chiave admin vive nel .env del server; le chiavi utente le emette l'admin.", + + "sidebar.newChat": "Nuova chat", + "sidebar.collapse": "Nascondi barra laterale", + "sidebar.closeDrawer": "Chiudi chat", + "sidebar.searchPh": "Cerca chat…", + "sidebar.searchAria": "Cerca conversazioni", + "sidebar.clearSearch": "Cancella ricerca", + "sidebar.searching": "Ricerca…", + "sidebar.noMatch": "Nessun risultato per «{q}».", + "sidebar.youPrefix": "tu: ", + "sidebar.emptyA": "Nessuna conversazione.", + "sidebar.emptyB": "Inizia una nuova chat qui sopra.", + "sidebar.rename": "Rinomina", + "sidebar.topic": "Imposta argomento", + "sidebar.share": "Condividi pubblicamente", + "sidebar.delete": "Elimina", + "sidebar.convOptions": "Opzioni conversazione", + "sidebar.admin": "admin", + "sidebar.adminCenter": "Centro admin", + "sidebar.adminCenterTip": "Apri il Centro admin", + "sidebar.settingsTip": "Profilo e impostazioni", + "sidebar.logout": "Esci", + + "chat.expand": "Mostra barra laterale", + "chat.openNav": "Apri chat", + "chat.modelAria": "Modello", + "chat.noModels": "Nessun modello", + "chat.selectModel": "Scegli modello…", + "chat.you": "Tu", + "chat.greet": "Come posso aiutare, {name}?", + "chat.greetSub": "Scegli un modello e scrivi qui sotto. Markdown supportato.", + "chat.thinking": "Penso…", + "chat.placeholder": "Scrivi a KisAssistant…", + "chat.send": "Invia", + "chat.attachTip": "Allega un file", + "chat.removeAttach": "Rimuovi {name}", + "chat.ocrOpt": "Trascrivi immagine", + "chat.unavailable": "(non disponibile)", + "chat.ocrHint": "JPG, PNG, WEBP — OCR sul server, rivedi il testo prima", + "chat.textOpt": "Allega file di testo", + "chat.textHint": "TXT o Markdown, max 500KB", + "chat.attachNote": "Upload solo con gli strumenti della piattaforma. Le immagini arrivano al modello come testo rivisto — mai grezze.", + "chat.wiki": "Wiki e docs", + + "attach.tooBigImg": "Immagine troppo grande (max 5MB).", + "attach.tooBigText": "File troppo grande (max 500KB).", + "attach.uploadFailed": "Upload non riuscito", + "attach.kindOcr": "trascrizione immagine", + "attach.kindText": "file di testo", + "attach.tpl": "[allegato {kind}: {name}]", + "attach.chatError": "**Errore:** il modello non ha risposto. Riprova.", + + "dlg.editConv": "Modifica conversazione", + "dlg.fTitle": "Titolo", + "dlg.fTopic": "Argomento", + "dlg.topicHint": "Breve etichetta, es. cucina, rust, viaggi.", + "dlg.noTopic": "Nessun argomento", + "dlg.recoverTitle": "Recupera chiave di accesso", + "dlg.recoverIntro": "Inserisci il tuo nome utente. Riceverai un link che emette una chiave nuova (la vecchia smette di funzionare).", + "dlg.sendLink": "Invia link", + "dlg.reqFailed": "Richiesta fallita", + "dlg.recoverSent": "Se @{user} esiste con email di recupero, un link è in viaggio{fromPart} (valido 60 minuti, monouso).", + "dlg.recoverSentFrom": " da {from}", + "dlg.spamNote": "Controlla la posta — e lo spam, potrebbe essere finito lì.", + "dlg.noMailNote": "Nulla in arrivo? Allora nessuna email è impostata e non si può — contatta l'amministratore del sito.", + "dlg.registerTitle": "Crea un account", + "dlg.regWelcome": "Benvenuto, @{user}! La tua chiave — copiala ora, non sarà più mostrata.", + "dlg.regContinue": "Copia e vai al login", + "dlg.regFailed": "Registrazione fallita", + "dlg.emailOpt": "Email (opzionale, per recupero chiave)", + "dlg.shareTitle": "Condividi pubblicamente", + "dlg.shareCreating": "Creazione link pubblico…", + "dlg.shareIntro": "Chiunque abbia questo link può leggere {title}. Senza login.", + "dlg.unshare": "Rimuovi condivisione", + "dlg.reviewOcr": "Rivedi trascrizione", + "dlg.reviewText": "Rivedi allegato", + "dlg.transcribing": "Trascrizione immagine sul server (OCR)…", + "dlg.uploadingTools": "Upload con gli strumenti…", + "dlg.ocrExpl": "L'immagine è stata trascritta in testo sul server — il modello riceve questo testo, mai l'immagine. Correggi sotto e allega.", + "dlg.textExpl": "Il file è stato caricato con gli strumenti. Il suo testo sarà allegato al messaggio. Modifica e allega.", + "dlg.truncWarn": "Trascrizione troncata al limite server — manca la fine.", + "dlg.attachBtn": "Allega al messaggio", + "dlg.chars": "{n} caratteri", + + "settings.title": "Impostazioni", + "settings.searchPh": "Cerca impostazioni…", + "settings.searchAria": "Cerca impostazioni", + "settings.clearSearch": "Cancella ricerca", + "settings.noMatch": "Nessuna impostazione per «{q}».", + "settings.profile": "Profilo", + "settings.appearance": "Aspetto", + "settings.features": "Funzioni", + "settings.avatarUpload": "Carica avatar", + "settings.dropIdle": "Trascina un'immagine o clicca per caricare", + "settings.dropActive": "Lascia!", + "settings.uploading": "Caricamento…", + "settings.avatarHint": "jpg · png · webp — max 5MB — 5 cambi per 2h", + "settings.email": "Email di recupero", + "settings.emailHint": "Opzionale. Solo per inviarti una chiave nuova.", + "settings.accent": "Colore accento", + "settings.accentHint": "Subito attivo, salvato su questo dispositivo.", + "settings.customColor": "Colore custom", + "settings.customAria": "Colore accento personalizzato", + "settings.accentAria": "Accento: {label}", + "settings.featHint": "Attiva funzioni del composer. Subito, su questo dispositivo.", + + "accent.preset.emerald": "Smeraldo", + "accent.preset.rose": "Rosa", + "accent.preset.blue": "Blu", + "accent.preset.violet": "Viola", + "accent.preset.amber": "Giallo", + "accent.preset.orange": "Arancione", + "accent.preset.teal": "Teal", + "accent.preset.pink": "Rosa shocking", + + "feat.thinking.label": "Ragionamento", + "feat.thinking.hint": "Chiedi al modello di ragionare passo passo.", + "feat.attachments.label": "Allegati", + "feat.attachments.hint": "Allega immagini (trascrizione OCR) e file di testo.", + "feat.deepSearch.label": "Ricerca profonda", + "feat.deepSearch.hint": "Cerca fonti fresche sul web. Prossimamente.", + "feat.soon": "(presto)", + + "admin.title": "Account", + "admin.intro": "Account senza KYC: crei un nome, consegni la chiave una volta. Hash salvati — rigenera per ruotare.", + "admin.keyOnce": "Chiave per @{user} — mostrata una volta, copiala ora:", + "admin.newAccount": "Nuovo account", + "admin.email": "Email (recupero)", + "admin.createFailed": "Creazione fallita", + "admin.regenFailed": "Rigenerazione fallita", + "admin.deleteFailed": "Eliminazione fallita", + "admin.loadFailed": "Caricamento fallito", + "admin.searchPh": "Cerca account…", + "admin.searchAria": "Cerca account", + "admin.sortAria": "Ordina account", + "admin.filterAria": "Filtra account", + "admin.sortNewest": "Più recenti", + "admin.sortOldest": "Meno recenti", + "admin.sortAz": "A → Z", + "admin.sortZa": "Z → A", + "admin.filterAll": "Tutti gli account", + "admin.filterWith": "Con email di recupero", + "admin.filterNo": "Senza email di recupero", + "admin.countMany": "{total} account · pagina {page} di {pages}", + "admin.countOne": "{total} account · pagina {page} di {pages}", + "admin.noEmail": "senza email di recupero", + "admin.editProfile": "Modifica profilo", + "admin.regenKey": "Rigenera chiave (revoca sessioni)", + "admin.deleteAccount": "Elimina account", + "admin.deleteTitle": "Eliminare @{user}?", + "admin.deleteMsg": "Rimuove per sempre account, conversazioni e condivisioni.", + "admin.prevPage": "Pagina precedente", + "admin.nextPage": "Pagina successiva", + + "ollama.models": "Modelli Ollama", + "ollama.desc": "Scarica modelli sull'host Ollama o rimuovi i locali. Solo admin.", + "ollama.pulling": "Scarico {name}", + "ollama.customTag": "Tag personalizzato", + "ollama.customHint": "es. llama3.2:3b, o un nome di libreria", + "ollama.pull": "Scarica", + "ollama.installed": "Installati ({n})", + "ollama.noneYet": "Ancora nessun modello locale.", + "ollama.catalog": "Catalogo", + "ollama.delTitle": "Eliminare {name}?", + "ollama.delMsg": "Il modello lascia l'host Ollama. Riscaricalo quando vuoi.", + "ollama.contacting": "Contatto Ollama…", + "ollama.pullingSt": "Scarico…", + "ollama.doneSt": "Fatto", + "ollama.pullFailed": "Download fallito", + "ollama.delTip": "Elimina {name}", + "ollama.sizeAria": "taglia di {name}", + "ollama.cat.llama3_2": "Il compatto Meta per tutti i giorni", + "ollama.cat.llama3_1": "Il cavallo di battaglia Meta", + "ollama.cat.qwen2_5": "Multilingua Alibaba tuttofare", + "ollama.cat.gemma2": "L'open efficiente di Google", + "ollama.cat.mistral": "Mistral 7B — veloce e capace", + "ollama.cat.phi3": "Il piccolo modello Microsoft", + "ollama.cat.deepseek_r1": "Modello di ragionamento (catena di pensiero)", + "ollama.cat.codellama": "Completamento e chat di codice", + "ollama.cat.llava": "Visione (capisce le immagini)", + "ollama.cat.nomic_embed_text": "Embedding di testo", + + "center.title": "Centro admin", + "center.back": "Torna alla chat", + "center.tabAccounts": "Account", + "center.tabFeatures": "Funzioni", + "center.tabMail": "Mail", + "center.locked": "Solo admin", + "center.lockedSub": "Accedi come admin per aprire il Centro.", + "center.regTitle": "Registrazione pubblica", + "center.regDesc": "Mostra Registrati al login. Off = solo account admin.", + "center.accTitle": "Richieste di accesso (lista)", + "center.accDescOn": "Non disponibile con registrazione pubblica — la sostituisce.", + "center.accDescOff": "Mostra Richiedi accesso al login. Valuta nella scheda Accesso.", + "center.ocrTitle": "Strumento OCR", + "center.ocrDesc": "Trascrizione immagini sul server. Serve tesseract.", + "center.mailTitle": "Email di recupero", + "center.mailChecking": "Verifica…", + "center.mailFrom": "Attivo — inviate da {from}", + "center.mailDefault": "Attivo — inviate dal mittente configurato", + "center.mailOff": "Disattivo — imposta SMTP_HOST sul server", + "center.mailNote": "Le credenziali SMTP vivono sul server (vedi docs/OPERATIONS.md), mai qui. L'email si imposta nel profilo o alla creazione.", + "center.on": "Sì", + "center.off": "No", + + "share.fallback": "Chat condivisa", + "share.open": "Apri KisAssistant", + "share.gone": "Questa chat condivisa non c'è più", + "share.goneSub": "Il link non vale, è scaduto o l'autore l'ha tolto.", + "share.back": "Indietro", + "share.loading": "Carico chat condivisa…", + "share.by": "di {name}", + + "reset.checking": "Verifica link…", + "reset.invalid": "Link non valido", + "reset.invalidSub": "Link sconosciuto, usato o scaduto. Richiedine uno al login.", + "reset.readyTitle": "Nuova chiave per @{user}?", + "reset.readySub": "Emette subito una chiave nuova. La vecchia smette di funzionare.", + "reset.issue": "Emetti nuova chiave", + "reset.issuing": "Emissione…", + "reset.doneTitle": "Nuova chiave pronta", + "reset.doneSub": "Copiala ora — non sarà più mostrata. Accedi con essa.", + "reset.goLogin": "Vai al login", + "reset.failed": "Recupero fallito", + + "app.deleteConvTitle": "Eliminare conversazione?", + "app.deleteConvMsg": "«{title}» e i suoi messaggi saranno eliminati.", +}; + +const pt: Dict = { + "common.close": "Fechar", + "common.cancel": "Cancelar", + "common.save": "Guardar", + "common.done": "Pronto", + "common.copy": "Copiar", + "common.copied": "Copiado", + "common.create": "Criar", + "common.delete": "Eliminar", + "common.loading": "A carregar…", + "common.saving": "A guardar…", + "common.username": "Nome de utilizador", + "common.displayName": "Nome visível", + "common.select": "Escolher…", + "common.theme": "Tema", + "common.themeAuto": "Auto", + "common.themeAutoFull": "Auto (segue o sistema)", + "common.light": "Claro", + "common.dark": "Escuro", + "common.saveFailed": "Falha ao guardar", + "common.backToLogin": "Voltar ao login", + + "loading.tagline": "A preparar os teus chats", + + "login.welcome": "Bem-vindo de volta", + "login.subtitle": "Inicia sessão no KisAssistant para continuar", + "login.usernamePh": "Nome de utilizador", + "login.keyPh": "Chave de acesso", + "login.failed": "Falha no login", + "login.unlock": "Desbloquear", + "login.newHere": "Novo por aqui?", + "login.register": "Registar", + "login.regTipOn": "Criar uma conta", + "login.regTipOff": "Registo desativado nesta plataforma", + "login.requestAccess": "Pedir acesso", + "login.forgot": "Esqueceste a chave?", + "login.perk1": "Privado por design — sem cookies nem rastreio", + "login.perk2": "Os teus modelos, em tempo real", + "login.adminNote": "A chave admin vive no .env do servidor; as chaves são emitidas pelo admin.", + + "sidebar.newChat": "Novo chat", + "sidebar.collapse": "Ocultar barra lateral", + "sidebar.closeDrawer": "Fechar chats", + "sidebar.searchPh": "Pesquisar chats…", + "sidebar.searchAria": "Pesquisar conversas", + "sidebar.clearSearch": "Limpar pesquisa", + "sidebar.searching": "A pesquisar…", + "sidebar.noMatch": "Sem resultados para «{q}».", + "sidebar.youPrefix": "tu: ", + "sidebar.emptyA": "Ainda sem conversas.", + "sidebar.emptyB": "Começa um novo chat acima.", + "sidebar.rename": "Renomear", + "sidebar.topic": "Definir tópico", + "sidebar.share": "Partilhar publicamente", + "sidebar.delete": "Eliminar", + "sidebar.convOptions": "Opções da conversa", + "sidebar.admin": "admin", + "sidebar.adminCenter": "Centro admin", + "sidebar.adminCenterTip": "Abrir o Centro admin", + "sidebar.settingsTip": "Perfil e definições", + "sidebar.logout": "Sair", + + "chat.expand": "Mostrar barra lateral", + "chat.openNav": "Abrir chats", + "chat.modelAria": "Modelo", + "chat.noModels": "Sem modelos", + "chat.selectModel": "Escolher modelo…", + "chat.you": "Tu", + "chat.greet": "Como posso ajudar, {name}?", + "chat.greetSub": "Escolhe um modelo e escreve abaixo. Markdown suportado.", + "chat.thinking": "A pensar…", + "chat.placeholder": "Escreve ao KisAssistant…", + "chat.send": "Enviar", + "chat.attachTip": "Anexar ficheiro", + "chat.removeAttach": "Remover {name}", + "chat.ocrOpt": "Transcrever imagem", + "chat.unavailable": "(indisponível)", + "chat.ocrHint": "JPG, PNG, WEBP — OCR no servidor, revês o texto antes", + "chat.textOpt": "Anexar ficheiro de texto", + "chat.textHint": "TXT ou Markdown, máx. 500KB", + "chat.attachNote": "Uploads só via ferramentas da plataforma. Imagens chegam ao modelo como texto revisto — nunca brutas.", + "chat.wiki": "Wiki e docs", + + "attach.tooBigImg": "Imagem muito grande (máx. 5MB).", + "attach.tooBigText": "Ficheiro muito grande (máx. 500KB).", + "attach.uploadFailed": "Falha no upload", + "attach.kindOcr": "transcrição de imagem", + "attach.kindText": "ficheiro de texto", + "attach.tpl": "[anexo {kind}: {name}]", + "attach.chatError": "**Erro:** o modelo não respondeu. Tenta de novo.", + + "dlg.editConv": "Editar conversa", + "dlg.fTitle": "Título", + "dlg.fTopic": "Tópico", + "dlg.topicHint": "Etiqueta curta, ex. cozinha, rust, viagens.", + "dlg.noTopic": "Sem tópico", + "dlg.recoverTitle": "Recuperar chave", + "dlg.recoverIntro": "Escreve o teu username. Receberás um link que emite uma chave nova (a antiga deixa de funcionar).", + "dlg.sendLink": "Enviar link", + "dlg.reqFailed": "Pedido falhou", + "dlg.recoverSent": "Se @{user} existe com email de recuperação, um link vai a caminho{fromPart} (válido 60 minutos, uso único).", + "dlg.recoverSentFrom": " de {from}", + "dlg.spamNote": "Vê o inbox — e o spam, pode ter caído lá.", + "dlg.noMailNote": "Nada chega? Então não há email definido e não é possível — contacta o admin do site.", + "dlg.registerTitle": "Criar uma conta", + "dlg.regWelcome": "Bem-vindo, @{user}! A tua chave — copia agora, não será mostrada de novo.", + "dlg.regContinue": "Copiar e ir ao login", + "dlg.regFailed": "Registo falhou", + "dlg.emailOpt": "Email (opcional, para recuperar a chave)", + "dlg.shareTitle": "Partilhar publicamente", + "dlg.shareCreating": "A criar link público…", + "dlg.shareIntro": "Quem tiver este link pode ler {title}. Sem login.", + "dlg.unshare": "Remover partilha", + "dlg.reviewOcr": "Rever transcrição", + "dlg.reviewText": "Rever anexo", + "dlg.transcribing": "A transcrever imagem no servidor (OCR)…", + "dlg.uploadingTools": "Upload via ferramentas…", + "dlg.ocrExpl": "A imagem foi transcrita em texto no servidor — o modelo recebe este texto, nunca a imagem. Corrige abaixo e anexa.", + "dlg.textExpl": "O ficheiro foi enviado via ferramentas. O texto será anexado à mensagem. Edita e anexa.", + "dlg.truncWarn": "Transcrição cortada no limite — falta o fim.", + "dlg.attachBtn": "Anexar à mensagem", + "dlg.chars": "{n} caracteres", + + "settings.title": "Definições", + "settings.searchPh": "Pesquisar definições…", + "settings.searchAria": "Pesquisar definições", + "settings.clearSearch": "Limpar pesquisa", + "settings.noMatch": "Sem definições para «{q}».", + "settings.profile": "Perfil", + "settings.appearance": "Aparência", + "settings.features": "Funções", + "settings.avatarUpload": "Enviar avatar", + "settings.dropIdle": "Larga uma imagem ou clica para enviar", + "settings.dropActive": "Larga!", + "settings.uploading": "A enviar…", + "settings.avatarHint": "jpg · png · webp — máx. 5MB — 5 mudanças por 2h", + "settings.email": "Email de recuperação", + "settings.emailHint": "Opcional. Só para te enviar uma chave nova.", + "settings.accent": "Cor de destaque", + "settings.accentHint": "Aplicada na hora, guardada neste dispositivo.", + "settings.customColor": "Cor custom", + "settings.customAria": "Cor de destaque personalizada", + "settings.accentAria": "Destaque: {label}", + "settings.featHint": "Liga funções do editor. Na hora, neste dispositivo.", + + "accent.preset.emerald": "Esmeralda", + "accent.preset.rose": "Rosa", + "accent.preset.blue": "Azul", + "accent.preset.violet": "Violeta", + "accent.preset.amber": "Amarelo", + "accent.preset.orange": "Laranja", + "accent.preset.teal": "Turquesa", + "accent.preset.pink": "Rosa choque", + + "feat.thinking.label": "Raciocínio", + "feat.thinking.hint": "Pede ao modelo para raciocinar passo a passo.", + "feat.attachments.label": "Anexos", + "feat.attachments.hint": "Anexa imagens (transcrição OCR) e textos.", + "feat.deepSearch.label": "Pesquisa profunda", + "feat.deepSearch.hint": "Pesquisa fontes frescas na web. Em breve.", + "feat.soon": "(breve)", + + "admin.title": "Contas", + "admin.intro": "Contas sem KYC: crias um nome, entregas a chave uma vez. Hash guardado — regenera para rodar.", + "admin.keyOnce": "Chave para @{user} — mostrada uma vez, copia agora:", + "admin.newAccount": "Nova conta", + "admin.email": "Email (recuperação)", + "admin.createFailed": "Falha ao criar", + "admin.regenFailed": "Falha ao regenerar", + "admin.deleteFailed": "Falha ao eliminar", + "admin.loadFailed": "Falha ao carregar", + "admin.searchPh": "Pesquisar contas…", + "admin.searchAria": "Pesquisar contas", + "admin.sortAria": "Ordenar contas", + "admin.filterAria": "Filtrar contas", + "admin.sortNewest": "Mais novas", + "admin.sortOldest": "Mais antigas", + "admin.sortAz": "A → Z", + "admin.sortZa": "Z → A", + "admin.filterAll": "Todas as contas", + "admin.filterWith": "Com email", + "admin.filterNo": "Sem email", + "admin.countMany": "{total} contas · página {page} de {pages}", + "admin.countOne": "{total} conta · página {page} de {pages}", + "admin.noEmail": "sem email", + "admin.editProfile": "Editar perfil", + "admin.regenKey": "Regenerar chave (revoga sessões)", + "admin.deleteAccount": "Eliminar conta", + "admin.deleteTitle": "Eliminar @{user}?", + "admin.deleteMsg": "Remove para sempre conta, conversas e partilhas.", + "admin.prevPage": "Página anterior", + "admin.nextPage": "Página seguinte", + + "ollama.models": "Modelos Ollama", + "ollama.desc": "Saca modelos para o host Ollama ou remove locais. Só admin.", + "ollama.pulling": "A sacar {name}", + "ollama.customTag": "Tag custom", + "ollama.customHint": "ex. llama3.2:3b, ou nome da biblioteca", + "ollama.pull": "Sacar", + "ollama.installed": "Instalados ({n})", + "ollama.noneYet": "Sem modelos locais ainda.", + "ollama.catalog": "Catálogo", + "ollama.delTitle": "Eliminar {name}?", + "ollama.delMsg": "O modelo sai do host. Saca de novo quando quiseres.", + "ollama.contacting": "A contactar Ollama…", + "ollama.pullingSt": "A sacar…", + "ollama.doneSt": "Pronto", + "ollama.pullFailed": "Falha no download", + "ollama.delTip": "Eliminar {name}", + "ollama.sizeAria": "tamanho de {name}", + "ollama.cat.llama3_2": "Compacto Meta para o dia a dia", + "ollama.cat.llama3_1": "O workhorse da Meta", + "ollama.cat.qwen2_5": "Multilingue Alibaba versátil", + "ollama.cat.gemma2": "Aberto e eficiente do Google", + "ollama.cat.mistral": "Mistral 7B — rápido e capaz", + "ollama.cat.phi3": "Pequeno modelo Microsoft", + "ollama.cat.deepseek_r1": "Modelo de raciocínio (cadeia de pensamento)", + "ollama.cat.codellama": "Código e chat de código", + "ollama.cat.llava": "Visão (percebe imagens)", + "ollama.cat.nomic_embed_text": "Embeddings de texto", + + "center.title": "Centro admin", + "center.back": "Voltar ao chat", + "center.tabAccounts": "Contas", + "center.tabFeatures": "Funções", + "center.tabMail": "Mail", + "center.locked": "Só admins", + "center.lockedSub": "Entra como admin para abrir o Centro.", + "center.regTitle": "Registo público", + "center.regDesc": "Mostra Registar no login. Off = só contas do admin.", + "center.accTitle": "Pedidos de acesso (lista)", + "center.accDescOn": "Indisponível com registo público — substitui.", + "center.accDescOff": "Mostra Pedir acesso no login. Triagem na aba Acesso.", + "center.ocrTitle": "Ferramenta OCR", + "center.ocrDesc": "Transcrição no servidor. Precisa tesseract.", + "center.mailTitle": "Emails de recuperação", + "center.mailChecking": "A verificar…", + "center.mailFrom": "Ativo — enviados de {from}", + "center.mailDefault": "Ativo — enviados do remetente", + "center.mailOff": "Desligado — define SMTP_HOST", + "center.mailNote": "Credenciais SMTP vivem no servidor (ver docs/OPERATIONS.md), nunca aqui. Email define-se no perfil ou criação.", + "center.on": "Sim", + "center.off": "Não", + + "share.fallback": "Chat partilhado", + "share.open": "Abrir KisAssistant", + "share.gone": "Este chat partilhado sumiu", + "share.goneSub": "Link inválido, expirado ou removido pelo autor.", + "share.back": "Voltar", + "share.loading": "A carregar chat…", + "share.by": "por {name}", + + "reset.checking": "A verificar link…", + "reset.invalid": "Link inválido", + "reset.invalidSub": "Link desconhecido, usado ou expirado. Pede outro no login.", + "reset.readyTitle": "Nova chave para @{user}?", + "reset.readySub": "Emite já uma chave nova. A antiga morre.", + "reset.issue": "Emitir nova chave", + "reset.issuing": "A emitir…", + "reset.doneTitle": "Nova chave pronta", + "reset.doneSub": "Copia agora — não será mostrada de novo. Entra com ela.", + "reset.goLogin": "Ir ao login", + "reset.failed": "Falha na recuperação", + + "app.deleteConvTitle": "Eliminar conversa?", + "app.deleteConvMsg": "«{title}» e mensagens serão eliminados.", +}; + +const dicts: Record = { en, es, fr, ru, it, pt }; + +const STORE_KEY = "kisassistant_lang"; + +function detectLang(): LangId { + try { + const saved = localStorage.getItem(STORE_KEY); + if (saved === "en" || saved === "es" || saved === "fr" || saved === "ru" || saved === "it" || saved === "pt") { + return saved; + } + } catch { + // storage unavailable — fall through to browser detection + } + const nav = (navigator.language || "en").toLowerCase().split("-")[0]; + if (nav === "es" || nav === "fr" || nav === "ru" || nav === "it" || nav === "pt") return nav; + return "en"; +} + +let current: LangId = detectLang(); +if (typeof document !== "undefined") document.documentElement.lang = current; + +const listeners = new Set<() => void>(); + +export function getLang(): LangId { + return current; +} + +/** Persist the language (per browser) and re-render every subscriber. */ +export function setLang(lang: LangId): void { + if (current === lang) return; + current = lang; + try { + localStorage.setItem(STORE_KEY, lang); + } catch { + // ignore + } + if (typeof document !== "undefined") document.documentElement.lang = lang; + listeners.forEach((fn) => fn()); +} + +/** Global translate function: t("login.welcome"), t("chat.greet", { name }). */ +export function t(key: StringKey, vars?: Record): string { + let s: string = dicts[current][key] ?? dicts.en[key] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v)); + } + return s; +} + +/** Reactive hook — components using translated strings must call it so they re-render on language switch. */ +export function useT(): { t: typeof t; lang: LangId } { + useSyncExternalStore( + (fn) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + () => current, + ); + return { t, lang: current }; +} + diff --git a/src/index.ts b/src/index.ts index 2dc5f36..e6bddeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,16 @@ import { updateUser, } from "./db.ts"; import type { ChatMessage } from "./drivers/types.ts"; +import { + consumeTotpChallenge, + createTotpChallenge, + getTotpSecret, + initTotpTables, + newTotpSecret, + setTotpSecret, + totpAuthUrl, + verifyTotp, +} from "./totp.ts"; import { addAccessMessage, createAccessRequest, @@ -83,6 +93,7 @@ const registry = new DriverRegistry(); const ollamaDriver = registry.get("ollama") as OllamaDriver; await tools.init(); initAccessTables(); +initTotpTables(); function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -297,10 +308,35 @@ const server = Bun.serve({ return json({ error: "invalid credentials" }, 401); } clearAttempts(ip); + // Second factor: correct key but TOTP enabled -> short-lived challenge, + // the session is only issued after POST /api/auth/totp verifies a code. + if (getTotpSecret(user.id)) { + const totpToken = createTotpChallenge(user.id); + return json({ totpRequired: true, totpToken, username: user.username }); + } const { token, expiresAt } = issueToken(user); return json({ token, expiresAt, user }); } + // --- auth: TOTP second step (rate limited, challenge is single-use) --- + if (path === "/api/auth/totp" && req.method === "POST") { + const ip = clientIp(req, server); + if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429); + const { ok, body } = await readJson(req); + const userId = + ok && typeof body.totpToken === "string" ? consumeTotpChallenge(body.totpToken) : null; + const code = ok && typeof body.code === "string" ? body.code : ""; + const target = userId ? getUserById(userId) : null; + if (!target || !verifyTotp(getTotpSecret(target.id), code)) { + recordAttempt(ip); + await Bun.sleep(400); + return json({ error: "invalid code" }, 401); + } + clearAttempts(ip); + const { token, expiresAt } = issueToken(toPublicUser(target)); + return json({ token, expiresAt, user: toPublicUser(target) }); + } + if (path === "/api/auth/verify" && req.method === "GET") { const user = requireAuth(req); return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401); @@ -506,6 +542,48 @@ const server = Bun.serve({ return json({ user: toPublicUser(updated) }); } + // --- self-service security: rotate access key (old sessions revoked) --- + if (path === "/api/me/key/rotate" && req.method === "POST") { + const key = newAccessKey(); + updateUser(user.id, { keyHash: hashSecret(key) }); + deleteUserSessions(user.id); + return json({ key }); + } + + // --- self-service: delete your own account (chats, shares, sessions gone) --- + if (path === "/api/me" && req.method === "DELETE") { + if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403); + deleteUser(user.id); + getDb().query("DELETE FROM totp_challenges WHERE user_id = ?").run(user.id); + return json({ ok: true }); + } + + // --- self-service security: TOTP two-factor --- + if (path === "/api/me/totp" && req.method === "GET") { + return json({ enabled: getTotpSecret(user.id).length > 0 }); + } + if (path === "/api/me/totp/setup" && req.method === "POST") { + if (getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409); + const secret = newTotpSecret(); + return json({ secret, otpauthUrl: totpAuthUrl(secret, user.username) }); + } + if (path === "/api/me/totp/verify" && req.method === "POST") { + const { ok, body } = await readJson(req); + const secret = ok && typeof body.secret === "string" ? body.secret : ""; + const code = ok && typeof body.code === "string" ? body.code : ""; + if (!/^[A-Z2-7]{16,64}$/.test(secret.trim().toUpperCase())) return json({ error: "invalid secret" }, 400); + if (!verifyTotp(secret, code)) return json({ error: "invalid code" }, 401); + setTotpSecret(user.id, secret.trim().toUpperCase()); + return json({ enabled: true }); + } + if (path === "/api/me/totp" && req.method === "DELETE") { + const { ok, body } = await readJson(req); + const code = ok && typeof body.code === "string" ? body.code : ""; + if (!verifyTotp(getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401); + setTotpSecret(user.id, ""); + return json({ enabled: false }); + } + // --- admin: user management (No-KYC accounts, keys issued by admin) --- if (path === "/api/admin/users" && req.method === "GET") { if (!admin) return json({ error: "forbidden" }, 403); diff --git a/src/totp.ts b/src/totp.ts new file mode 100644 index 0000000..a2dbc7b --- /dev/null +++ b/src/totp.ts @@ -0,0 +1,139 @@ +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { getDb } from "./db.ts"; + +/** + * TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window). + * The per-user secret lives in `users.totp_secret` (empty = disabled); + * login challenges live in `totp_challenges`. Tables/columns are created + * idempotently here so db.ts stays untouched. + */ + +const STEP_SEC = 30; +const DIGITS = 6; +const WINDOW = 1; +const CHALLENGE_TTL_MS = 5 * 60_000; + +export function initTotpTables(): void { + const d = getDb(); + try { + d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run(); + } catch { + // column already exists — fine + } + d.query(`CREATE TABLE IF NOT EXISTS totp_challenges ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + )`).run(); +} + +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function newTotpSecret(bytes = 20): string { + const buf = randomBytes(bytes); + let out = ""; + let bits = 0; + let value = 0; + for (const b of buf) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { + out += B32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += B32[(value << (5 - bits)) & 31]; + return out; +} + +export function totpAuthUrl(secret: string, username: string, issuer = "KisAssistant"): string { + return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(username)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; +} + +function base32Decode(s: string): Buffer { + const clean = s.trim().replace(/=+$/, "").toUpperCase(); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of clean) { + const idx = B32.indexOf(ch); + if (idx < 0) throw new Error("invalid base32"); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(out); +} + +function hotp(key: Buffer, counter: bigint): string { + const msg = Buffer.alloc(8); + msg.writeBigUInt64BE(counter); + const h = createHmac("sha1", key).update(msg).digest(); + const offset = h[h.length - 1]! & 0x0f; + const code = + ((h[offset]! & 0x7f) << 24) | ((h[offset + 1]! << 16) | ((h[offset + 2]! << 8) | h[offset + 3]!)); + return String(code % 10 ** DIGITS).padStart(DIGITS, "0"); +} + +/** Timing-safe comparison against the current ±1 window. */ +export function verifyTotp(secret: string, code: string): boolean { + const c = code.trim(); + if (!/^\d{6}$/.test(c)) return false; + let key: Buffer; + try { + key = base32Decode(secret); + } catch { + return false; + } + if (key.length < 10) return false; + const now = Math.floor(Date.now() / 1000 / STEP_SEC); + let ok = false; + for (let w = -WINDOW; w <= WINDOW; w++) { + const expected = hotp(key, BigInt(now + w)); + const a = Buffer.from(expected); + const b = Buffer.from(c); + if (a.length === b.length && createHash("sha256").update(a).digest().equals(createHash("sha256").update(b).digest())) { + ok = true; + } + } + // Constant-time-ish: always hash-compare all window slots (loop above). + return ok; +} + +export function getTotpSecret(userId: string): string { + const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null; + return row?.s ?? ""; +} + +export function setTotpSecret(userId: string, secret: string): void { + getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId); +} + +/** Short-lived login challenge after a correct key; returns the raw token. */ +export function createTotpChallenge(userId: string): string { + const token = "tc_" + randomBytes(24).toString("base64url"); + const hash = createHash("sha256").update(token).digest("hex"); + const now = Date.now(); + getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run( + hash, userId, now, now + CHALLENGE_TTL_MS, + ); + // Opportunistic cleanup. + getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now); + return token; +} + +export function consumeTotpChallenge(token: string): string | null { + const hash = createHash("sha256").update(token).digest("hex"); + const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as { + user_id: string; + expires_at: number; + } | null; + if (!row) return null; + getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash); + if (row.expires_at < Date.now()) return null; + return row.user_id; +}