diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2466cca..e8a6aa4 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -74,6 +74,10 @@ connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`, - `reports_enabled` (default on): admin kill-switch for message reports — the chat flag button turns grey with a "Report disabled" tooltip and `POST /api/reports` answers 403. Surfaced to clients via `GET /api/tools`. +- `username_change_enabled` (default on): lets users rename themselves + (`PATCH /api/me` `username`); off answers 403 and locks the Settings field. + Admins bypass it (`PATCH /api/admin/users/:id` still works). Surfaced to + clients via `GET /api/tools`. - Flags live in the `settings` SQLite table, edited via `GET/PATCH /api/admin/settings` (admin Bearer only). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9736a16..2e03ea0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -115,6 +115,7 @@ export function App() { const [previewBusy, setPreviewBusy] = useState(false); const [ocrAvailable, setOcrAvailable] = useState(false); const [reportsEnabled, setReportsEnabled] = useState(true); + const [usernameChangeEnabled, setUsernameChangeEnabled] = useState(true); // Model-error retry: which trailing message is the error bubble (if any), // its conversation, and how many times this prompt was already retried. const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null); @@ -185,10 +186,12 @@ export function App() { .catch(() => ({ tools: [] as { name: string; available: boolean }[], reportsEnabled: true, + usernameChangeEnabled: true, })), ]); setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)); setReportsEnabled(t.reportsEnabled ?? true); + setUsernameChangeEnabled(t.usernameChangeEnabled ?? true); setModels(m.models); if (m.models.length > 0) setModel(m.models[0]!.id); setConvs(c.conversations); @@ -312,6 +315,7 @@ export function App() { .then((t) => { setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)); setReportsEnabled(t.reportsEnabled ?? true); + setUsernameChangeEnabled(t.usernameChangeEnabled ?? true); }) .catch(() => {}); // Land directly on the latest conversation's own URL. @@ -722,6 +726,7 @@ export function App() { /> setSettingsOpen(false)} onSaved={(u) => { if (u.theme !== user?.theme) { diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 4b163fd..5b71a00 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -24,7 +24,7 @@ export function AdminCenter() { const { t } = useT(); const [allowed, setAllowed] = useState(null); const [tab, setTab] = useState("accounts"); - const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } | null>(null); + const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } | null>(null); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const [openAccessCount, setOpenAccessCount] = useState(0); @@ -100,7 +100,7 @@ export function AdminCenter() { }); }, [allowed]); - async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean }) { + async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) { setSaving(true); setError(""); try { @@ -251,6 +251,13 @@ export function AdminCenter() { toggle({ reportsEnabled: v })} /> +
+
+

{t("center.usernameChangeTitle")}

+

{t("center.usernameChangeDesc")}

+
+ toggle({ usernameChangeEnabled: v })} /> +
{saving &&

{t("common.saving")}

} {!settings && !error &&

{t("common.loading")}

} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 34963d2..065cce8 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -417,6 +417,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) { const { t } = useT(); + const [username, setUsername] = useState(""); const [displayName, setDisplayName] = useState(""); const [avatarUrl, setAvatarUrl] = useState(""); const [email, setEmail] = useState(""); @@ -425,6 +426,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose useEffect(() => { if (user) { + setUsername(user.username); setDisplayName(user.displayName); setAvatarUrl(user.avatarUrl); setEmail(user.email); @@ -437,7 +439,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose e.preventDefault(); if (!user) return; try { - await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme }); + await api.adminPatch(user.id, { username: username.trim().toLowerCase(), displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme }); onSaved(); onClose(); } catch (err) { @@ -448,6 +450,9 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose return (
+ + setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} /> + setDisplayName(e.target.value)} maxLength={60} required /> setAvatarUrl(e.target.value)} placeholder="https://…" /> setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" /> diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index f7c1c7a..bf7b82f 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -259,6 +259,7 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo export function SettingsModal({ user, + usernameChangeEnabled = true, onClose, onSaved, onKeyRotated, @@ -267,6 +268,7 @@ export function SettingsModal({ onProvidersChanged, }: { user: User | null; + usernameChangeEnabled?: boolean; onClose: () => void; onSaved: (u: User) => void; onKeyRotated?: () => void; @@ -533,8 +535,8 @@ export function SettingsModal({ /> {uploadError &&

{uploadError}

} - - setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} /> + + setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin" || !usernameChangeEnabled} /> setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" /> diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 24cc13a..5fde6b9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -185,12 +185,12 @@ export const api = { method: "PATCH", body: JSON.stringify({ model, ...patch }), }), - adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) => + adminPatch: (id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) => req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), - adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } }>("/api/admin/settings"), - adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean }) => - req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }), + adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings"), + adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) => + req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }), /** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */ adminMailStatus: () => @@ -328,7 +328,7 @@ export const api = { deleteProvider: (provider: string) => req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }), - tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean }>("/api/tools"), + tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean }>("/api/tools"), /** Send an image to the platform OCR tool. Returns editable text (never the raw image). */ ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 55a17ff..b29fe47 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -345,6 +345,10 @@ const en = { "center.reportsTitle": "Message reports", "center.reportsDesc": "Flag button under assistant responses. Off = the flag turns grey with a “Report disabled” tooltip and new reports are rejected.", + "center.usernameChangeTitle": "Username changes", + "center.usernameChangeDesc": + "Lets users rename themselves in Settings → Profile. Off = the field is locked (admins can still rename accounts).", + "settings.usernameDisabled": "Username changes are disabled by the administrator.", "center.mailTitle": "Key recovery emails", "center.mailChecking": "Checking…", "center.mailFrom": "Enabled — sent from {from}", @@ -913,6 +917,10 @@ const es: Dict = { "center.reportsTitle": "Denuncias de mensajes", "center.reportsDesc": "Botón de bandera bajo las respuestas. Off = bandera gris con aviso y se rechazan nuevas denuncias.", + "center.usernameChangeTitle": "Cambios de nombre", + "center.usernameChangeDesc": + "Permite renombrarse en Ajustes → Perfil. Off = campo bloqueado (el admin aún puede renombrar).", + "settings.usernameDisabled": "El administrador ha desactivado los cambios de nombre.", "center.mailTitle": "Emails de recuperación", "center.mailChecking": "Verificando…", "center.mailFrom": "Activado — enviados desde {from}", @@ -1475,6 +1483,10 @@ const fr: Dict = { "center.reportsTitle": "Signalements de messages", "center.reportsDesc": "Bouton drapeau sous les réponses. Off = drapeau grisé avec infobulle et nouveaux signalements refusés.", + "center.usernameChangeTitle": "Changements de pseudo", + "center.usernameChangeDesc": + "Permet de se renommer dans Réglages → Profil. Off = champ verrouillé (l'admin peut toujours renommer).", + "settings.usernameDisabled": "Changements de pseudo désactivés par l'administrateur.", "center.mailTitle": "Emails de récupération", "center.mailChecking": "Vérification…", "center.mailFrom": "Activé — envoyés depuis {from}", @@ -2028,6 +2040,10 @@ const ru: Dict = { "center.reportsTitle": "Жалобы на сообщения", "center.reportsDesc": "Кнопка-флажок под ответами. Выкл = серый флажок с подсказкой, новые жалобы отклоняются.", + "center.usernameChangeTitle": "Смена имени", + "center.usernameChangeDesc": + "Позволяет переименоваться в Настройки → Профиль. Выкл = поле закрыто (админ всё равно может).", + "settings.usernameDisabled": "Смена имени отключена администратором.", "center.mailTitle": "Письма восстановления", "center.mailChecking": "Проверяем…", "center.mailFrom": "Включено — шлём от {from}", @@ -2581,6 +2597,10 @@ const it: Dict = { "center.reportsTitle": "Segnalazioni messaggi", "center.reportsDesc": "Bandierina sotto le risposte. Off = bandierina grigia con tooltip e nuove segnalazioni rifiutate.", + "center.usernameChangeTitle": "Cambi di nome", + "center.usernameChangeDesc": + "Permette di rinominarsi in Impostazioni → Profilo. Off = campo bloccato (l'admin può ancora rinominare).", + "settings.usernameDisabled": "Cambi di nome disabilitati dall'amministratore.", "center.mailTitle": "Email di recupero", "center.mailChecking": "Verifica…", "center.mailFrom": "Attivo — inviate da {from}", @@ -3129,6 +3149,10 @@ const pt: Dict = { "center.reportsTitle": "Denúncias de mensagens", "center.reportsDesc": "Bandeira sob as respostas. Off = bandeira cinzenta com aviso e novas denúncias recusadas.", + "center.usernameChangeTitle": "Mudanças de nome", + "center.usernameChangeDesc": + "Permite renomear-se em Definições → Perfil. Off = campo bloqueado (o admin continua a poder renomear).", + "settings.usernameDisabled": "Mudanças de nome desativadas pelo administrador.", "center.mailTitle": "Emails de recuperação", "center.mailChecking": "A verificar…", "center.mailFrom": "Ativo — enviados de {from}", diff --git a/src/db.ts b/src/db.ts index a054280..1a9b84b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -399,6 +399,11 @@ export function isReportsEnabled(): boolean { return getSetting("reports_enabled", "1") === "1"; } +/** Admin kill-switch for self-service username changes (default on; admins bypass). */ +export function isUsernameChangeEnabled(): boolean { + return getSetting("username_change_enabled", "1") === "1"; +} + // --- public shares --- export function ensureShare(convId: string): string { diff --git a/src/index.ts b/src/index.ts index 55638d1..a42d87b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { getUserByUsername, isRegistrationEnabled, isReportsEnabled, + isUsernameChangeEnabled, listConversations, listArchivedConversations, listUsers, @@ -625,6 +626,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ 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); + if (!isUsernameChangeEnabled()) return json({ error: "username changes are disabled" }, 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); @@ -806,7 +808,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (req.method === "PATCH" && !adminUserMatch[2]) { 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 (target.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 !== target.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); @@ -841,6 +851,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1", ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1", reportsEnabled: isReportsEnabled(), + usernameChangeEnabled: isUsernameChangeEnabled(), }, }); } @@ -872,12 +883,17 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (typeof body.reportsEnabled !== "boolean") return json({ error: "reportsEnabled must be boolean" }, 400); setSetting("reports_enabled", body.reportsEnabled ? "1" : "0"); } + if (body.usernameChangeEnabled !== undefined) { + if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400); + setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0"); + } return json({ settings: { registrationEnabled: isRegistrationEnabled(), accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1", ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1", reportsEnabled: isReportsEnabled(), + usernameChangeEnabled: isUsernameChangeEnabled(), }, }); } @@ -1368,7 +1384,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ // --- platform tools (uploads always go through tools) --- if (path === "/api/tools" && req.method === "GET") { - return json({ tools: tools.list(), reportsEnabled: isReportsEnabled() }); + return json({ tools: tools.list(), reportsEnabled: isReportsEnabled(), usernameChangeEnabled: isUsernameChangeEnabled() }); } if (path === "/api/tools/ocr" && req.method === "POST") {