diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 065cce8..7c22bb7 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -439,7 +439,18 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose e.preventDefault(); if (!user) return; try { - await api.adminPatch(user.id, { username: username.trim().toLowerCase(), displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme }); + const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {}; + const normalizedUsername = username.trim().toLowerCase(); + if (normalizedUsername !== (user.username ?? "").toLowerCase()) patch.username = normalizedUsername; + if (displayName.trim() !== (user.displayName ?? "")) patch.displayName = displayName.trim(); + if (avatarUrl.trim() !== (user.avatarUrl ?? "")) patch.avatarUrl = avatarUrl.trim(); + if (email.trim() !== (user.email ?? "")) patch.email = email.trim(); + if (theme !== (user.theme ?? "auto")) patch.theme = theme; + if (Object.keys(patch).length === 0) { + onClose(); + return; + } + await api.adminPatch(user.id, patch); onSaved(); onClose(); } catch (err) { diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index bf7b82f..5b56810 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -388,7 +388,21 @@ export function SettingsModal({ } discardStaged(); } - const res = await api.updateMe({ username: username.trim().toLowerCase(), displayName: displayName.trim(), avatarUrl: url, email: email.trim(), theme }); + const normalizedUsername = username.trim().toLowerCase(); + // Only send fields that actually changed: this isolates a + // username error (e.g. admin rename guard) from the rest of the + // profile so one rejected field can't block the whole save. + const patch: { username?: string; displayName?: string; avatarUrl?: string; email?: string; theme?: string } = {}; + if (normalizedUsername !== (user.username ?? "").toLowerCase()) patch.username = normalizedUsername; + if (displayName.trim() !== (user.displayName ?? "")) patch.displayName = displayName.trim(); + if (url !== (user.avatarUrl ?? "")) patch.avatarUrl = url; + if (email.trim() !== (user.email ?? "")) patch.email = email.trim(); + if (theme !== (user.theme ?? "auto")) patch.theme = theme; + if (Object.keys(patch).length === 0) { + onClose(); + return; + } + const res = await api.updateMe(patch); onSaved(res.user); onClose(); } catch (err) { diff --git a/src/index.ts b/src/index.ts index a0ae19d..4be527a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -628,13 +628,19 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (!ok) return json({ error: "invalid JSON" }, 400); 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 (!await 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 = await getUserByUsername(u); - if (taken && taken.id !== user.id) return json({ error: "username taken" }, 409); - patch.username = u; + // No-op when unchanged (settings forms send the whole profile): + // don't fail the entire save for the admin account when the + // username field just echoes the current value. + const raw = typeof body.username === "string" ? body.username.trim().toLowerCase() : ""; + if (raw !== user.username) { + if (user.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403); + if (!await 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 = await 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); @@ -813,12 +819,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (!ok) return json({ error: "invalid JSON" }, 400); 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 = await getUserByUsername(u); - if (taken && taken.id !== target.id) return json({ error: "username taken" }, 409); - patch.username = u; + const raw = typeof body.username === "string" ? body.username.trim().toLowerCase() : ""; + if (raw !== target.username) { + 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 = await 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);