update
This commit is contained in:
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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
@@ -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 />);
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user