From 63eaa739d403e251358a02af1b148b2042052026 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 29 Oct 2026 13:47:20 +0700 Subject: [PATCH] fix(accounts): a display name may not read as another account's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member kept renaming themselves to a colleague's display name, plus "(you)", so the CPU/RAM card and every owner list showed two of that person. A display name is now refused (409 ENAMETAKEN) when, with marks, case, spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters ignored, it equals another account's display name or username — in Settings → Account and on the Users page alike. Keeping the name one already has stays allowed, so an older clash does not block an admin's other edits. --- client/src/i18n/locales/en/users.json | 1 + client/src/i18n/locales/vi/users.json | 1 + docs/API.md | 6 +-- docs/SERVER-MODE.md | 8 +++- .../__tests__/server-mode-user-edit.test.js | 20 ++++++++ server/lib/auth.js | 46 ++++++++++++++++++- server/routes/me.js | 2 + server/routes/users.js | 2 + 8 files changed, 81 insertions(+), 5 deletions(-) diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index b7bddef2..4ed7a535 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -79,6 +79,7 @@ "EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).", "EBADPASSWORD": "Password cannot be empty.", "EBADEMAIL": "That email address does not look valid.", + "ENAMETAKEN": "Another account already uses that name. Pick one that is yours.", "EBADFIELD": "One of the values is not valid.", "ENOLANE": "That workspace no longer exists.", "ENOTOWNED": "That workspace is not this user's any more.", diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 2ab01eec..908468b1 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -79,6 +79,7 @@ "EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).", "EBADPASSWORD": "Mật khẩu không được để trống.", "EBADEMAIL": "Địa chỉ email không hợp lệ.", + "ENAMETAKEN": "Tên này đã có tài khoản khác dùng. Hãy chọn tên của chính bạn.", "EBADFIELD": "Có một giá trị không hợp lệ.", "ENOLANE": "Workspace đó không còn tồn tại.", "ENOTOWNED": "Workspace đó không còn thuộc người này.", diff --git a/docs/API.md b/docs/API.md index ebd267bd..465ffff8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -89,9 +89,9 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S | `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared | -In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). +In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). -An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. +An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. `GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login). @@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude | `PUT /api/me/pool-accounts` `{tool, account: "" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited | | `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) | | `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` | -| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` | +| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL`; `409 ENAMETAKEN` for a display name that reads as another account's display name or username (marks, case, punctuation, bracketed parts like "(you)" and look-alike letters ignored; keeping the name you have is always allowed) | | `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` | | `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends | | `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=`), `404 ENOAVATAR` when it has none | diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 4c49c40f..7263269e 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -372,7 +372,13 @@ Wherever the dashboard names a person — the sidebar's sign-out tooltip, the workspace list's owner groups, the Users page and its audit log, the Edit user form — it shows their display name when one is set, else the username (the Users page keeps `@username` beside a display name). Logging in, the CLI and -the Linux account always use the username. +the Linux account always use the username. No one can pose as a colleague: a +display name that reads as another account's display name or username — marks, +case, spaces, punctuation, a bracketed part like "(you)" and look-alike letters +from other alphabets ignored — is refused (`409 ENAMETAKEN`), whether the user +sets it in Settings → Account or an admin does on the Users page. A name an +account already has is kept on an unrelated save; rename an old clash from the +Users page. Under S3 an admin's folder browser opens on their own home too; ticking **Server folder** opens the host account's home (`/home/`), where a diff --git a/server/__tests__/server-mode-user-edit.test.js b/server/__tests__/server-mode-user-edit.test.js index 89b38f4a..c4509e93 100644 --- a/server/__tests__/server-mode-user-edit.test.js +++ b/server/__tests__/server-mode-user-edit.test.js @@ -222,6 +222,26 @@ describe("Settings → Account (/api/me)", () => { assert.equal(bad.body.error.code, "EBADEMAIL"); }); + it("refuses a display name that reads as another account's (ENAMETAKEN)", async () => { + assert.equal(auth.nameKey("Nguyễn Ngọc Trí Vĩ (you)"), auth.nameKey("nguyen.ngoc-tri VI")); + assert.equal(auth.nameKey("Đào"), "dao"); + // Cyrillic о and а in place of Latin letters. + assert.equal(auth.nameKey("Mіа Trоn"), auth.nameKey("Mia Tron")); + const leo = await login("leo", "member-pass-2"); + // mia's display name is "Mia Tran"; boss has none, only a username. + for (const name of ["MIA TRẦN (you)", "Mia\u200b Tran", "mia", "Bóss"]) { + const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } }); + assert.equal(r.status, 409, name); + assert.equal(r.body.error.code, "ENAMETAKEN"); + } + assert.equal(auth.findUser("leo").display_name, "Leo N"); + // Keeping one's own name, or a name of one's own, still works. + for (const name of ["Leo N", "Leo Nguyen"]) { + const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } }); + assert.equal(r.status, 200, name); + } + }); + it("changes its own password only with the old one, keeping this login", async () => { const leo = await login("leo", "member-pass-2"); const other = await login("leo", "member-pass-2"); diff --git a/server/lib/auth.js b/server/lib/auth.js index 07bcc1d7..0433c9e6 100644 --- a/server/lib/auth.js +++ b/server/lib/auth.js @@ -20,7 +20,8 @@ * admin on the Users page (`server/routes/users.js`), where every change * needs the admin's own password again (`confirmPassword`, throttled). * An account also carries an optional display name and email - * (`validateProfile`), which its owner edits in Settings → Account without + * (`validateProfile`; no two accounts may read as one name — `assertNameFree`, + * ENAMETAKEN), which its owner edits in Settings → Account without * that step-up; changing their OWN password there needs the old one and * keeps the login it was changed from (`setPassword`'s `keepToken`). * - Two password bars: an admin may GIVE any non-empty password (a create or a @@ -247,6 +248,47 @@ function validateProfile(body) { return out; } +// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides. +const CONFUSABLE = { + а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t", + у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e", + η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i", +}; + +/** + * The name two accounts may not share: no marks (Vĩ = Vi), đ = d, any case, + * look-alike letters folded, and only letters and digits outside brackets — + * so "Nguyễn Ngọc Trí Vĩ (you)" and "nguyen.ngoc-tri vi" are both one name. + */ +function nameKey(s) { + return String(s || "") + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLowerCase() + .replace(/\([^)]*\)|\[[^\]]*\]/g, "") + .replace(/[đð]/g, "d") + .replace(/./gu, (c) => CONFUSABLE[c] ?? c) + .replace(/[^\p{L}\p{N}]/gu, ""); +} + +/** + * Refuse (ENAMETAKEN) a display name that reads as another account's display + * name or username, so no one can pose as a colleague. Keeping the name one + * already has is always allowed — a clash from before this rule is no reason + * to block an admin's unrelated edit. + */ +function assertNameFree(userId, fields) { + if (!("display_name" in fields) || !fields.display_name) return; + const key = nameKey(fields.display_name); + if (!key) return; + const own = sql("SELECT display_name FROM users WHERE id = ?").get(userId); + if (own && own.display_name === fields.display_name) return; + const rows = sql("SELECT id, username, display_name FROM users WHERE id != ?").all(userId); + if (rows.some((u) => nameKey(u.username) === key || nameKey(u.display_name) === key)) { + throw err("ENAMETAKEN", "displayName: another account already uses that name"); + } +} + /** Write `validateProfile`'s result for one account. */ function setProfile(userId, fields) { for (const [col, value] of Object.entries(fields)) { @@ -812,6 +854,8 @@ module.exports = { unassignWorkspace, ownedWorkspaces, validateProfile, + nameKey, + assertNameFree, setProfile, validatePassword, validateOwnPassword, diff --git a/server/routes/me.js b/server/routes/me.js index c5672d34..64283230 100644 --- a/server/routes/me.js +++ b/server/routes/me.js @@ -197,6 +197,7 @@ const { recordAccountChange } = require("../lib/audit"); const ACCOUNT_STATUS = { EBADFIELD: 400, EBADEMAIL: 400, + ENAMETAKEN: 409, EBADPASSWORD: 400, EWEAKPASSWORD: 400, EBADIMAGE: 400, @@ -262,6 +263,7 @@ router.patch( sameOriginGuard, accountWrite(async (_req, user, body) => { const fields = auth.validateProfile({ displayName: body.displayName, email: body.email }); + auth.assertNameFree(user.id, fields); auth.setProfile(user.id, fields); return { out: accountView(user.id), action: "own profile" }; }) diff --git a/server/routes/users.js b/server/routes/users.js index a57e0244..2b2587ae 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -171,6 +171,7 @@ router.get("/tmux", (_req, res) => { const STATUS = { EBADUSER: 400, EBADEMAIL: 400, + ENAMETAKEN: 409, ENOTOWNED: 409, EBADPASSWORD: 400, EWEAKPASSWORD: 400, @@ -562,6 +563,7 @@ router.patch( // 1. Validate everything — nothing is written until all of it passes. const profile = auth.validateProfile(body); + auth.assertNameFree(u.id, profile); const roleChange = body.role !== undefined && body.role !== u.role; if (roleChange) { if (self) throw ESELF("you cannot change your own role");