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) {