Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 d70a7c9403 chore: v2.8.39 — no one can take a colleague's display name 2026-10-29 13:47:31 +07:00
nntrivi2001 63eaa739d4 fix(accounts): a display name may not read as another account's
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.
2026-10-29 13:47:20 +07:00
12 changed files with 86 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.38 (2026-10-29) — every user row (CPU/RAM card, editor, pools) shows its avatar._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.39 (2026-10-29) — a display name may not read as another account's (ENAMETAKEN)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+1
View File
@@ -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.",
+1
View File
@@ -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.",
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.38 (rebuild client to match)
UI build v2.8.39 (rebuild client to match)
</span>
</p>
</div>
+3 -3
View File
@@ -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: {<id>: {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: {<id>: {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: {<id>: {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: {<id>: {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: "<name>" \| 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=<avatarAt>`), `404 ENOAVATAR` when it has none |
+7 -1
View File
@@ -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/<host>`), where a
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.38",
"version": "2.8.39",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.38",
"version": "2.8.39",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.38",
"version": "2.8.39",
"bin": {
"agenthub": "bin/agenthub.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");
+45 -1
View File
@@ -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,
+2
View File
@@ -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" };
})
+2
View File
@@ -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");