diff --git a/frontend/index.html b/frontend/index.html index 66ff87c..fc57179 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + KisAssistant — private AI chat void; }) { const { t } = useT(); + const [username, setUsername] = useState(""); const [displayName, setDisplayName] = useState(""); const [avatarUrl, setAvatarUrl] = useState(""); const [email, setEmail] = useState(""); @@ -301,7 +302,7 @@ export function SettingsModal({ // Two-column settings: categories on the left, content on the right. // Searching filters the category list and jumps to the first match. 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: "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"] }, @@ -320,6 +321,7 @@ export function SettingsModal({ useEffect(() => { if (user) { + setUsername(user.username); setDisplayName(user.displayName); setAvatarUrl(user.avatarUrl); setEmail(user.email); @@ -384,7 +386,7 @@ export function SettingsModal({ } 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); onClose(); } catch (err) { @@ -531,6 +533,9 @@ export function SettingsModal({ /> {uploadError &&

{uploadError}

} + + setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} /> + setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" /> diff --git a/frontend/src/index.css b/frontend/src/index.css index a3c9c72..2f51e11 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -31,8 +31,9 @@ html, body, #root { body { @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. */ - touch-action: manipulation; + /* App-like viewport: pan + scroll only — no double-tap or pinch zoom. + (Kills the 300ms tap delay as a side effect, keeps gestures app-like.) */ + touch-action: pan-x pan-y; overscroll-behavior-y: none; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8bd8f7d..24cc13a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -138,7 +138,7 @@ export const api = { resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false), 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) }), /** Rotate your own access key (old sessions revoked, key shown once). */ diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 633ad42..55a17ff 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -185,6 +185,9 @@ const en = { "settings.dropActive": "Drop it!", "settings.uploading": "Uploading…", "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.emailHint": "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.uploading": "Subiendo…", "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.emailHint": "Opcional. Solo para enviarte una clave nueva si la pierdes.", @@ -1313,6 +1319,9 @@ const fr: Dict = { "settings.dropActive": "Lâchez !", "settings.uploading": "Envoi…", "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.emailHint": "Optionnel. Sert uniquement à vous renvoyer une clé.", "settings.accent": "Couleur d'accent", @@ -1868,6 +1877,9 @@ const ru: Dict = { "settings.dropActive": "Отпускайте!", "settings.uploading": "Загрузка…", "settings.avatarHint": "jpg · png · webp — до 5 МБ — 5 смен за 2 ч", + "settings.username": "Имя пользователя", + "settings.usernameHint": + "Логин. 2–32 символа: строчные буквы, цифры, . _ -. Вступает в силу сразу.", "settings.email": "Email для восстановления", "settings.emailHint": "Необязательно. Только для отправки нового ключа.", "settings.accent": "Акцентный цвет", @@ -2415,6 +2427,9 @@ const it: Dict = { "settings.dropActive": "Lascia!", "settings.uploading": "Caricamento…", "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.emailHint": "Opzionale. Solo per inviarti una chiave nuova.", "settings.accent": "Colore accento", @@ -2966,6 +2981,9 @@ const pt: Dict = { "settings.dropActive": "Larga!", "settings.uploading": "A enviar…", "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.emailHint": "Opcional. Só para te enviar uma chave nova.", "settings.accent": "Cor de destaque", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3916540..55718b2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,11 +1,44 @@ -import React from "react"; +import React, { useEffect } from "react"; import ReactDOM from "react-dom/client"; import "@fontsource-variable/inter"; import "./index.css"; 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 ( + + + + ); +} + +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/src/db.ts b/src/db.ts index c276a71..a054280 100644 --- a/src/db.ts +++ b/src/db.ts @@ -187,10 +187,11 @@ export function createUser(opts: { username: string; displayName: string; avatar 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 sets: string[] = []; 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.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); } 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) { 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); } diff --git a/src/index.ts b/src/index.ts index c81cd10..55638d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -622,7 +622,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (path === "/api/me" && req.method === "PATCH") { const { ok, body } = await readJson(req); 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) { const d = cleanStr(body.displayName, 60); 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; } 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; } if (body.email !== undefined) {