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">
<head>
<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>
<meta
name="description"
+7 -2
View File
@@ -275,6 +275,7 @@ export function SettingsModal({
onProvidersChanged?: () => 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 && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
</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")}>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" />
</Field>
+3 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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). */
+18
View File
@@ -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",
+36 -3
View File
@@ -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 (
<React.StrictMode>
<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;
}
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);
}
+10 -2
View File
@@ -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) {