This commit is contained in:
k
2026-09-22 08:29:46 +02:00
parent b5d17d8944
commit e9695c0239
8 changed files with 85 additions and 15 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>KisAssistant — private AI chat</title> <title>KisAssistant — private AI chat</title>
<meta <meta
name="description" name="description"
+7 -2
View File
@@ -275,6 +275,7 @@ export function SettingsModal({
onProvidersChanged?: () => void; onProvidersChanged?: () => void;
}) { }) {
const { t } = useT(); const { t } = useT();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState(""); const [avatarUrl, setAvatarUrl] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -301,7 +302,7 @@ export function SettingsModal({
// Two-column settings: categories on the left, content on the right. // Two-column settings: categories on the left, content on the right.
// Searching filters the category list and jumps to the first match. // Searching filters the category list and jumps to the first match.
const CATS = [ const CATS = [
{ id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] }, { id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "username", "login", "display", "email", "account"] },
{ id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] }, { id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] },
{ id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] }, { id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
{ id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] }, { id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
@@ -320,6 +321,7 @@ export function SettingsModal({
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setUsername(user.username);
setDisplayName(user.displayName); setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl); setAvatarUrl(user.avatarUrl);
setEmail(user.email); setEmail(user.email);
@@ -384,7 +386,7 @@ export function SettingsModal({
} }
discardStaged(); discardStaged();
} }
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: url, email: email.trim(), theme }); const res = await api.updateMe({ username: username.trim().toLowerCase(), displayName: displayName.trim(), avatarUrl: url, email: email.trim(), theme });
onSaved(res.user); onSaved(res.user);
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -531,6 +533,9 @@ export function SettingsModal({
/> />
{uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>} {uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
</div> </div>
<Field label={t("settings.username")} hint={t("settings.usernameHint")}>
<Input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} />
</Field>
<Field label={t("common.displayName")}> <Field label={t("common.displayName")}>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" /> <Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" />
</Field> </Field>
+3 -2
View File
@@ -31,8 +31,9 @@ html, body, #root {
body { body {
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100; @apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
/* Kill the 300ms tap delay, keep gestures app-like. */ /* App-like viewport: pan + scroll only — no double-tap or pinch zoom.
touch-action: manipulation; (Kills the 300ms tap delay as a side effect, keeps gestures app-like.) */
touch-action: pan-x pan-y;
overscroll-behavior-y: none; overscroll-behavior-y: none;
} }
+1 -1
View File
@@ -138,7 +138,7 @@ export const api = {
resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false), resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false),
me: () => req<{ user: User }>("/api/me"), me: () => req<{ user: User }>("/api/me"),
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) => updateMe: (patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }), req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
/** Rotate your own access key (old sessions revoked, key shown once). */ /** Rotate your own access key (old sessions revoked, key shown once). */
+18
View File
@@ -185,6 +185,9 @@ const en = {
"settings.dropActive": "Drop it!", "settings.dropActive": "Drop it!",
"settings.uploading": "Uploading…", "settings.uploading": "Uploading…",
"settings.avatarHint": "jpg · png · webp — max 5MB — 5 changes per 2h", "settings.avatarHint": "jpg · png · webp — max 5MB — 5 changes per 2h",
"settings.username": "Username",
"settings.usernameHint":
"Login name. 2–32 chars: lowercase letters, digits, . _ -. Takes effect immediately.",
"settings.email": "Recovery email", "settings.email": "Recovery email",
"settings.emailHint": "settings.emailHint":
"Optional. Used only to send you a fresh access key if you lose it.", "Optional. Used only to send you a fresh access key if you lose it.",
@@ -751,6 +754,9 @@ const es: Dict = {
"settings.dropActive": "¡Suéltala!", "settings.dropActive": "¡Suéltala!",
"settings.uploading": "Subiendo…", "settings.uploading": "Subiendo…",
"settings.avatarHint": "jpg · png · webp — máx. 5MB — 5 cambios por 2h", "settings.avatarHint": "jpg · png · webp — máx. 5MB — 5 cambios por 2h",
"settings.username": "Nombre de usuario",
"settings.usernameHint":
"Nombre de acceso. 2–32 caracteres: minúsculas, dígitos, . _ -. Efecto inmediato.",
"settings.email": "Email de recuperación", "settings.email": "Email de recuperación",
"settings.emailHint": "settings.emailHint":
"Opcional. Solo para enviarte una clave nueva si la pierdes.", "Opcional. Solo para enviarte una clave nueva si la pierdes.",
@@ -1313,6 +1319,9 @@ const fr: Dict = {
"settings.dropActive": "Lâchez !", "settings.dropActive": "Lâchez !",
"settings.uploading": "Envoi…", "settings.uploading": "Envoi…",
"settings.avatarHint": "jpg · png · webp — 5 Mo max — 5 changements par 2 h", "settings.avatarHint": "jpg · png · webp — 5 Mo max — 5 changements par 2 h",
"settings.username": "Nom d'utilisateur",
"settings.usernameHint":
"Identifiant de connexion. 2–32 car. : minuscules, chiffres, . _ -. Effet immédiat.",
"settings.email": "Email de récupération", "settings.email": "Email de récupération",
"settings.emailHint": "Optionnel. Sert uniquement à vous renvoyer une clé.", "settings.emailHint": "Optionnel. Sert uniquement à vous renvoyer une clé.",
"settings.accent": "Couleur d'accent", "settings.accent": "Couleur d'accent",
@@ -1868,6 +1877,9 @@ const ru: Dict = {
"settings.dropActive": "Отпускайте!", "settings.dropActive": "Отпускайте!",
"settings.uploading": "Загрузка…", "settings.uploading": "Загрузка…",
"settings.avatarHint": "jpg · png · webp — до 5 МБ — 5 смен за 2 ч", "settings.avatarHint": "jpg · png · webp — до 5 МБ — 5 смен за 2 ч",
"settings.username": "Имя пользователя",
"settings.usernameHint":
"Логин. 2–32 символа: строчные буквы, цифры, . _ -. Вступает в силу сразу.",
"settings.email": "Email для восстановления", "settings.email": "Email для восстановления",
"settings.emailHint": "Необязательно. Только для отправки нового ключа.", "settings.emailHint": "Необязательно. Только для отправки нового ключа.",
"settings.accent": "Акцентный цвет", "settings.accent": "Акцентный цвет",
@@ -2415,6 +2427,9 @@ const it: Dict = {
"settings.dropActive": "Lascia!", "settings.dropActive": "Lascia!",
"settings.uploading": "Caricamento…", "settings.uploading": "Caricamento…",
"settings.avatarHint": "jpg · png · webp — max 5MB — 5 cambi per 2h", "settings.avatarHint": "jpg · png · webp — max 5MB — 5 cambi per 2h",
"settings.username": "Nome utente",
"settings.usernameHint":
"Nome di accesso. 2–32 caratteri: minuscole, cifre, . _ -. Effetto immediato.",
"settings.email": "Email di recupero", "settings.email": "Email di recupero",
"settings.emailHint": "Opzionale. Solo per inviarti una chiave nuova.", "settings.emailHint": "Opzionale. Solo per inviarti una chiave nuova.",
"settings.accent": "Colore accento", "settings.accent": "Colore accento",
@@ -2966,6 +2981,9 @@ const pt: Dict = {
"settings.dropActive": "Larga!", "settings.dropActive": "Larga!",
"settings.uploading": "A enviar…", "settings.uploading": "A enviar…",
"settings.avatarHint": "jpg · png · webp — máx. 5MB — 5 mudanças por 2h", "settings.avatarHint": "jpg · png · webp — máx. 5MB — 5 mudanças por 2h",
"settings.username": "Nome de utilizador",
"settings.usernameHint":
"Nome de acesso. 2–32 caracteres: minúsculas, dígitos, . _ -. Efeito imediato.",
"settings.email": "Email de recuperação", "settings.email": "Email de recuperação",
"settings.emailHint": "Opcional. Só para te enviar uma chave nova.", "settings.emailHint": "Opcional. Só para te enviar uma chave nova.",
"settings.accent": "Cor de destaque", "settings.accent": "Cor de destaque",
+36 -3
View File
@@ -1,11 +1,44 @@
import React from "react"; import React, { useEffect } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "@fontsource-variable/inter"; import "@fontsource-variable/inter";
import "./index.css"; import "./index.css";
import { App } from "./App.tsx"; import { App } from "./App.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render( /**
* Lock page zoom app-wide (viewport meta + CSS touch-action cover most
* browsers; these listeners catch the rest):
* - iOS Safari ignores `user-scalable=no`, so `gesturestart` is cancelled.
* - Desktop: Ctrl+wheel (incl. trackpad pinch) and Ctrl + +/-/0 are blocked.
*/
function useZoomLock() {
useEffect(() => {
const stop = (e: Event) => e.preventDefault();
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey) e.preventDefault();
};
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey && (e.key === "+" || e.key === "-" || e.key === "=" || e.key === "0")) {
e.preventDefault();
}
};
document.addEventListener("gesturestart", stop);
document.addEventListener("wheel", onWheel, { passive: false });
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("gesturestart", stop);
document.removeEventListener("wheel", onWheel);
document.removeEventListener("keydown", onKey);
};
}, []);
}
function Root() {
useZoomLock();
return (
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>
); );
}
ReactDOM.createRoot(document.getElementById("root")!).render(<Root />);
+6 -1
View File
@@ -187,10 +187,11 @@ export function createUser(opts: { username: string; displayName: string; avatar
return row; return row;
} }
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null { export function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
const d = getDb(); const d = getDb();
const sets: string[] = []; const sets: string[] = [];
const vals: (string | number)[] = []; const vals: (string | number)[] = [];
if (patch.username !== undefined) { sets.push("username = ?"); vals.push(patch.username); }
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); } if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); } if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); }
if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); } if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); }
@@ -199,6 +200,10 @@ export function updateUser(id: string, patch: { displayName?: string; avatarUrl?
if (sets.length > 0) { if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id); d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
} }
if (patch.username !== undefined) {
// Username is denormalized onto past reports — keep the triage list consistent.
d.query("UPDATE reports SET reporter_name = ? WHERE reporter_id = ?").run(patch.username, id);
}
return getUserById(id); return getUserById(id);
} }
+10 -2
View File
@@ -622,7 +622,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (path === "/api/me" && req.method === "PATCH") { if (path === "/api/me" && req.method === "PATCH") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {}; const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
if (body.username !== undefined) {
if (user.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
const u = validUsername(body.username);
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
const taken = getUserByUsername(u);
if (taken && taken.id !== user.id) return json({ error: "username taken" }, 409);
patch.username = u;
}
if (body.displayName !== undefined) { if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60); const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400); if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
@@ -810,7 +818,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
patch.avatarUrl = a; patch.avatarUrl = a;
} }
if (body.theme !== undefined) { if (body.theme !== undefined) {
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark|sunset|sunset" }, 400); if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark|sunset" }, 400);
patch.theme = body.theme; patch.theme = body.theme;
} }
if (body.email !== undefined) { if (body.email !== undefined) {