Public: anyone can read this; the crew can change it.

From a person's profile an admin opens their account: reset a password, see sign-ins, verify, suspend, and what they own #11696

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11688-from-a-person-s-profile-an-admin-opens-t → main 13 commits fork

  • Merged. mason/11688-from-a-person-s-profile-an-admin-opens-t was merged into main as d78ace5.

Review

Sign in to review this change.
38 files changed +1295 −69
docs/MCP.md +1
@@ -42,6 +42,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
4242 | Tool | Actions / kinds | Purpose | Bytes |
4343 | --- | --- | --- | ---: |
4444 | `access` | — | Non-browser public-path requests, redacted, newest first (max 500) | 235 |
45 | `account_access` | `reset`, `end_sessions` | Reset a person's access with a one-time link, or end their sessions | 339 |
4546 | `account_requests` | — | List who asked for an account and wait; `answer: allow\|decline` answers one | 250 |
4647 | `account_role` | — | Make a person a member or a guest; promoting may grant `usdPerMonth` | 374 |
4748 | `account_suspend` | — | Suspend an account; `suspended: false` lifts | 231 |
docs/features/account-access.md added +19
@@ -0,0 +1,19 @@
1 # Manage account: reset a person's access
2
3 An admin opens a person's page (`/settings/accounts/<name>`, linked as **Manage account** from their profile and from `/settings/accounts`, which searches by name) and sees, on one screen: role and join date, email and whether it is verified, passkey count and whether a password is set (never the secrets), the browsers they are signed in on now (when, from where; a person's sign-in history is not kept), what they own, their model allowance and ceilings, and the actions.
4
5 - **Reset access** (after a confirm) makes a link, `/signin/access/<secret>`, good for one hour and one use. The person opens it, sets a new password (the admin never sees or sets it) and is signed in, then can add a passkey at `/settings/passkeys`. The admin copies the link from the page; when the forge can send mail and the address is verified, it is mailed too. Tick *Also sign them out everywhere* to end every browser in the same step. A new link withdraws the older ones. Only the link's hash is kept.
6 - **End all sessions** (after a confirm) signs them out of every browser and does nothing else.
7 - Verify email, make member or guest, suspend, unsuspend, set the allowance and ceilings, and remove (with the existing confirm) are the same acts the API has, as buttons.
8 - Every act is an event with the admin as actor (`account.access_reset`, `account.sessions_ended`, `account.access_redeemed`). The person reads "An admin reset your access on …" on the next page they open in any browser; it is marked read only when a page shows it, so a redirect or an API call does not use it up.
9 - Only the forge's owner (the first admin still here) may reset another admin's access; any other admin is refused. A person who is not an admin is a 404 on all of it.
10
11 ## API and MCP
12
13 - `POST /api/v1/admin/accounts/{username}/reset` with `{ endSessions?, dryRun? }` answers `{ username, url, expiresAt, sessionsEnded, mailed }`.
14 - `POST /api/v1/admin/accounts/{username}/end-sessions` with `{ dryRun? }` answers `{ username, sessionsEnded }`.
15 - MCP `account_access` with `{ username, action: "reset" | "end_sessions", endSessions?, dryRun? }`; it plans unless `dryRun` is `false`, and the link is shown once.
16
17 ## Terminal
18
19 The palette's **Account access** (admins only) lists people with the browsers they are signed in on; `/` searches by name, `r` resets access (confirm, with or without signing them out) and shows the link, `e` ends all sessions (confirm). The gates are the page's, and each act is an event, so a web viewer sees it live.
src/actions/account_access.ts added +222
@@ -0,0 +1,222 @@
1 /**
2 * An admin's hand on a person's access (#11688): a one-time link that lets
3 * them set a new password (and add a passkey), and ending every browser they
4 * are signed in on. The admin never sees or sets the password. Both are on
5 * the audit trail with who did them, and the person is told by a notice the
6 * next time they are here.
7 *
8 * Whom an admin may manage: a person who is still here, not themselves and
9 * not an agent. Another admin only for the forge's owner (the first admin
10 * still here); any other admin is told it is not allowed.
11 */
12
13 import type { App } from "../app.ts";
14 import {
15 accessLinkOwner,
16 ACCESS_LINK_TTL_MS,
17 leaveNotice,
18 makeAccessLink,
19 spendAccessLink,
20 } from "../core/access_link.ts";
21 import { withinPasswordCap } from "../core/hash-gate.ts";
22 import { listSessions } from "../core/sessions.ts";
23 import {
24 getUserById,
25 getUserByName,
26 isActive,
27 hashPassword,
28 isStandInEmail,
29 MIN_PASSWORD,
30 setPasswordHash,
31 type User,
32 } from "../core/users.ts";
33 import { emailVerified } from "../core/verify.ts";
34 import { err, ok, type Result } from "../kernel/result.ts";
35 import { requireUser } from "./access.ts";
36 import { mailerOf } from "./verify.ts";
37 import { mayMail, sendCapped } from "./mail.ts";
38
39 export type AccessError = "not_found" | "not_allowed";
40
41 /** The first admin still here: the one person who may manage another admin. */
42 export function forgeOwner(app: App): string | undefined {
43 return (
44 app.db
45 .query<{ id: string }, []>(
46 `SELECT id FROM user WHERE is_admin = 1 AND kind = 'user' AND is_agent = 0
47 AND removed_at IS NULL ORDER BY created_at, id LIMIT 1`,
48 )
49 .get()?.id ?? undefined
50 );
51 }
52
53 /** The person `actor` may manage by name, with the admin acting. */
54 export function manageable(
55 app: App,
56 actor: User | undefined,
57 username: string,
58 ): Result<{ admin: User; user: User }, AccessError> {
59 const asker = requireUser(actor);
60 if (!asker.ok || !asker.value.isAdmin || asker.value.kind !== "user") return err("not_found");
61 const user = getUserByName(app.db, username);
62 if (user === undefined || user.kind !== "user" || user.removedAt !== null) return err("not_found");
63 if (user.id === asker.value.id) return err("not_allowed");
64 if (user.isAdmin && forgeOwner(app) !== asker.value.id) return err("not_allowed");
65 return ok({ admin: asker.value, user });
66 }
67
68 /** The words an unlisted refusal carries on every surface. */
69 export const ACCESS_WORDS = {
70 notAllowed: "You cannot do that to yourself or an agent, and only the forge's owner may do it to another admin.",
71 invalid: "That link has been used or has expired. Ask an admin for a new one.",
72 } as const;
73
74 export interface AccessReset {
75 readonly username: string;
76 /** The secret of the link, shown once; absent on a dry run. */
77 readonly token: string | undefined;
78 readonly expiresAt: string | undefined;
79 readonly sessionsEnded: number;
80 /** Whether the forge mailed the link to the person's verified address. */
81 readonly mailed: boolean;
82 readonly dryRun: boolean;
83 }
84
85 const dateWords = (at: Date): string => at.toISOString().slice(0, 16).replace("T", " ") + " UTC";
86
87 function endSessions(app: App, userId: string): number {
88 return app.db.query("DELETE FROM session WHERE user_id = ?1").run(userId).changes;
89 }
90
91 function linkUrl(app: App, token: string): string {
92 const base = app.config.url ?? `http://localhost:${app.config.port}`;
93 return new URL(`/signin/access/${token}`, base).toString();
94 }
95
96 /** The address of a link, from its secret. */
97 export const accessUrl = linkUrl;
98
99 /**
100 * Make a person a one-time link, good for an hour, to set a new password
101 * and add a passkey; `endAll` also ends every browser they are signed in
102 * on. Mailed when the forge can send mail and the address is proven. With
103 * `dryRun`, says what would happen and changes nothing.
104 */
105 export async function resetAccess(
106 app: App,
107 actor: User | undefined,
108 username: string,
109 options: { endAll?: boolean; dryRun?: boolean; now?: number } = {},
110 ): Promise<Result<AccessReset, AccessError>> {
111 const found = manageable(app, actor, username);
112 if (!found.ok) return found;
113 const { admin, user } = found.value;
114 const now = options.now ?? Date.now();
115 const endAll = options.endAll === true;
116 if (options.dryRun === true)
117 return ok({
118 username: user.username,
119 token: undefined,
120 expiresAt: new Date(now + ACCESS_LINK_TTL_MS).toISOString(),
121 sessionsEnded: endAll ? listSessions(app.db, user.id).length : 0,
122 mailed: false,
123 dryRun: true,
124 });
125 const made = makeAccessLink(app.db, user.id, admin.id, now);
126 const sessionsEnded = endAll ? endSessions(app, user.id) : 0;
127 leaveNotice(app.db, user.id, `An admin reset your access on ${dateWords(new Date(now))}.`, new Date(now));
128 app.bus.append({
129 kind: "account.access_reset",
130 actorId: admin.id,
131 payload: { username: user.username, sessionsEnded, expiresAt: made.expiresAt },
132 });
133 const mailer = emailVerified(app.db, user.id) && !isStandInEmail(user.email) ? mailerOf(app, user) : undefined;
134 let mailed = false;
135 if (mailer !== undefined && mayMail(app, user.email, now))
136 mailed =
137 (await sendCapped(
138 app,
139 mailer,
140 {
141 to: user.email,
142 subject: "Set a new password for your forge account",
143 text: [
144 `Hello ${user.username},`,
145 "",
146 "An admin made you a link to set a new password and add a passkey. It works once, for an hour:",
147 "",
148 linkUrl(app, made.token),
149 "",
150 "If you were not expecting this, tell the admin.",
151 ].join("\n"),
152 },
153 now,
154 ).catch(() => "failed")) === "sent";
155 return ok({ username: user.username, token: made.token, expiresAt: made.expiresAt, sessionsEnded, mailed, dryRun: false });
156 }
157
158 /** End every browser a person is signed in on, and nothing else. */
159 export function endAllSessions(
160 app: App,
161 actor: User | undefined,
162 username: string,
163 options: { dryRun?: boolean } = {},
164 ): Result<{ username: string; sessionsEnded: number; dryRun: boolean }, AccessError> {
165 const found = manageable(app, actor, username);
166 if (!found.ok) return found;
167 const { admin, user } = found.value;
168 if (options.dryRun === true)
169 return ok({ username: user.username, sessionsEnded: listSessions(app.db, user.id).length, dryRun: true });
170 const sessionsEnded = endSessions(app, user.id);
171 leaveNotice(app.db, user.id, `An admin signed you out everywhere on ${dateWords(new Date())}.`, new Date());
172 app.bus.append({
173 kind: "account.sessions_ended",
174 actorId: admin.id,
175 payload: { username: user.username, sessionsEnded },
176 });
177 return ok({ username: user.username, sessionsEnded, dryRun: false });
178 }
179
180 /** The person a link is for while it still works; nothing is spent. */
181 export function accessLinkPerson(app: App, token: string, now: number = Date.now()): User | undefined {
182 const id = accessLinkOwner(app.db, token, now);
183 const user = id === undefined ? undefined : getUserById(app.db, id);
184 return user !== undefined && isActive(user) ? user : undefined;
185 }
186
187 class NotRedeemed extends Error {}
188
189 export type RedeemError = "invalid" | "weak_password" | "password_too_long";
190
191 /**
192 * Spend a link to set a new password. The password is checked and hashed
193 * before the link is touched, so a short one, or a hash that fails, costs
194 * nothing; the link is then spent and the password stored in one transaction,
195 * so two at once cannot both set a password and no link is spent for nothing.
196 */
197 export async function redeemAccessLink(
198 app: App,
199 token: string,
200 password: string,
201 now: number = Date.now(),
202 ): Promise<Result<User, RedeemError>> {
203 if (accessLinkPerson(app, token, now) === undefined) return err("invalid");
204 if (password.length < MIN_PASSWORD) return err("weak_password");
205 if (!withinPasswordCap(password)) return err("password_too_long");
206 const hash = await hashPassword(password);
207 let user: User | undefined;
208 try {
209 user = app.db.transaction((): User => {
210 const id = spendAccessLink(app.db, token, now);
211 const owner = id === undefined ? undefined : getUserById(app.db, id);
212 // Throwing rolls the spend back: the link stays as it was.
213 if (owner === undefined || !isActive(owner) || !setPasswordHash(app.db, owner.id, hash)) throw new NotRedeemed();
214 return owner;
215 })();
216 } catch (error) {
217 if (error instanceof NotRedeemed) return err("invalid");
218 throw error;
219 }
220 app.bus.append({ kind: "account.access_redeemed", actorId: user.id, payload: { username: user.username } });
221 return ok(user);
222 }
src/contract/openapi/routes/index.ts +2
@@ -248,7 +248,9 @@ export { route as patchApiV1ReposOwnerNameLabelsId } from "./patchApiV1ReposOwne
248248 export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts";
249249 export { route as patchApiV1User } from "./patchApiV1User.ts";
250250 export { route as postApiV1AdminAccountsUsernameCeiling } from "./postApiV1AdminAccountsUsernameCeiling.ts";
251 export { route as postApiV1AdminAccountsUsernameEndSessions } from "./postApiV1AdminAccountsUsernameEndSessions.ts";
251252 export { route as postApiV1AdminAccountsUsernameModelGrant } from "./postApiV1AdminAccountsUsernameModelGrant.ts";
253 export { route as postApiV1AdminAccountsUsernameReset } from "./postApiV1AdminAccountsUsernameReset.ts";
252254 export { route as postApiV1AdminAccountsUsernameRole } from "./postApiV1AdminAccountsUsernameRole.ts";
253255 export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1AdminAccountsUsernameSuspend.ts";
254256 export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts";
src/contract/openapi/routes/postApiV1AdminAccountsUsernameEndSessions.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/accounts/{username}/end-sessions",
6 summary: "Sign a person out of every browser (admin scope); body { dryRun? }; answers { username, sessionsEnded }",
7 auth: true,
8 role: "admin",
9 refusals: { not_allowed: "the target is yourself or an agent, or another admin and you are not the forge's owner (422)" },
10 };
src/contract/openapi/routes/postApiV1AdminAccountsUsernameReset.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/accounts/{username}/reset",
6 summary: "Make a person a one-time link, good for an hour, to set a new password and add a passkey (admin scope); body { endSessions?, dryRun? }; answers { username, url, expiresAt, sessionsEnded, mailed }",
7 auth: true,
8 role: "admin",
9 refusals: { not_allowed: "the target is yourself or an agent, or another admin and you are not the forge's owner (422)" },
10 };
src/core/accounts.ts +5 −5
@@ -47,8 +47,8 @@ export function originOf(a: Pick<AccountRow, "kind" | "via" | "signsInThrough">)
4747 return a.via ?? (a.kind === "agent" ? "crew" : "");
4848 }
4949
50 /** Every person and agent, newest first. Organizations are not accounts. */
51 export function listAccounts(db: Database): AccountRow[] {
50 /** Every person and agent, newest first, or just `only` by name. Organizations are not accounts. */
51 export function listAccounts(db: Database, only = ""): AccountRow[] {
5252 const rows = db
5353 .query<
5454 {
@@ -67,7 +67,7 @@ export function listAccounts(db: Database): AccountRow[] {
6767 spent: number | null;
6868 payload: string | null;
6969 },
70 []
70 [string]
7171 >(
7272 `SELECT u.id, u.username, u.display_name, u.is_admin, u.is_agent,
7373 u.created_at, u.suspended_at, u.removed_at, u.waiting_at,
@@ -80,10 +80,10 @@ export function listAccounts(db: Database): AccountRow[] {
8080 (SELECT e.payload FROM event e
8181 WHERE e.kind = 'user.created' AND e.actor_id = u.id
8282 ORDER BY e.id LIMIT 1) AS payload
83 FROM user u WHERE u.kind = 'user'
83 FROM user u WHERE u.kind = 'user' AND (?1 = '' OR u.username = ?1)
8484 ORDER BY u.created_at DESC, u.id DESC`,
8585 )
86 .all();
86 .all(only);
8787 return rows.map((r) => {
8888 const origin = parseOrigin(r.payload);
8989 return {
src/core/event_audience.ts +3
@@ -159,6 +159,9 @@ const READER_KINDS: ReadonlySet<string> = new Set([
159159 "label.deleted",
160160 "telemetry.granted",
161161 "telemetry.revoked",
162 "account.access_reset",
163 "account.sessions_ended",
164 "account.access_redeemed",
162165 "account.suspended",
163166 "account.unsuspended",
164167 "org.retired",
src/core/sessions.ts +17 −8
@@ -20,6 +20,8 @@ export interface SessionInfo {
2020 readonly createdAt: string;
2121 readonly lastSeenAt: string | null;
2222 readonly expiresAt: string;
23 /** Where it was first seen from, when that was recorded. */
24 readonly address: string | null;
2325 /** The browser asking: signing it out signs the asker out. */
2426 readonly current: boolean;
2527 }
@@ -48,7 +50,8 @@ export function sessionUser(
4850 export function sessionOf(
4951 db: Database,
5052 token: string,
51 ): Result<{ user: User; id: string }, "invalid_session"> {
53 address?: string,
54 ): Result<{ user: User; id: string; notice: boolean }, "invalid_session"> {
5255 const hash = sha256(token);
5356 const row = db
5457 .query<
@@ -57,10 +60,13 @@ export function sessionOf(
5760 user_id: string;
5861 expires_at: string;
5962 last_seen_at: string | null;
63 notice: number;
6064 },
6165 [string]
6266 >(
63 "SELECT id, user_id, expires_at, last_seen_at FROM session WHERE token_hash = ?1",
67 `SELECT id, user_id, expires_at, last_seen_at,
68 EXISTS (SELECT 1 FROM account_notice n WHERE n.user_id = session.user_id AND n.seen_at IS NULL) AS notice
69 FROM session WHERE token_hash = ?1`,
6470 )
6571 .get(hash);
6672 const now = new Date();
@@ -72,12 +78,13 @@ export function sessionOf(
7278 const stale = new Date(now.getTime() - SEEN_EVERY_MS).toISOString();
7379 if (row.last_seen_at === null || row.last_seen_at < stale)
7480 quietly(db, () =>
75 db.query("UPDATE session SET last_seen_at = ?1 WHERE token_hash = ?2").run(
76 now.toISOString(),
77 hash,
78 ),
81 db
82 .query(
83 "UPDATE session SET last_seen_at = ?1, address = COALESCE(address, ?3) WHERE token_hash = ?2",
84 )
85 .run(now.toISOString(), hash, address === undefined || address === "unknown" ? null : address),
7986 );
80 return ok({ user, id: row.id });
87 return ok({ user, id: row.id, notice: row.notice === 1 });
8188 }
8289
8390 export function destroySession(db: Database, token: string): void {
@@ -102,10 +109,11 @@ export function listSessions(
102109 created_at: string;
103110 last_seen_at: string | null;
104111 expires_at: string;
112 address: string | null;
105113 },
106114 [string, string]
107115 >(
108 `SELECT id, token_hash, created_at, last_seen_at, expires_at FROM session
116 `SELECT id, token_hash, created_at, last_seen_at, expires_at, address FROM session
109117 WHERE user_id = ?1 AND expires_at > ?2 ORDER BY created_at DESC, id`,
110118 )
111119 .all(userId, new Date().toISOString())
@@ -114,6 +122,7 @@ export function listSessions(
114122 createdAt: r.created_at,
115123 lastSeenAt: r.last_seen_at,
116124 expiresAt: r.expires_at,
125 address: r.address,
117126 current: r.token_hash === mine,
118127 }));
119128 }
src/core/users.ts +11 −5
@@ -479,11 +479,17 @@ export async function changePassword(
479479 ): Promise<Result<void, "weak_password" | "password_too_long" | "no_such_user">> {
480480 if (next.length < MIN_PASSWORD) return err("weak_password");
481481 if (!withinPasswordCap(next)) return err("password_too_long");
482 const hash = await Bun.password.hash(next, "argon2id");
483 const changed = db
484 .query("UPDATE user SET password_hash = ?1 WHERE id = ?2 AND kind = 'user'")
485 .run(hash, userId).changes;
486 return changed > 0 ? ok() : err("no_such_user");
482 return setPasswordHash(db, userId, await hashPassword(next)) ? ok() : err("no_such_user");
483 }
484
485 /** A password's stored form, made apart from the write so a caller can hash first and write inside its own transaction. */
486 export function hashPassword(password: string): Promise<string> {
487 return Bun.password.hash(password, "argon2id");
488 }
489
490 /** Store a hash made by `hashPassword`; whether a person was there to take it. */
491 export function setPasswordHash(db: Database, userId: string, hash: string): boolean {
492 return db.query("UPDATE user SET password_hash = ?1 WHERE id = ?2 AND kind = 'user'").run(hash, userId).changes > 0;
487493 }
488494
489495 export function getUserById(db: Database, userId: string): User | undefined {
src/crew/conversation_confirm.ts +4
@@ -117,6 +117,10 @@ export const ASKING: Readonly<Record<string, Asking>> = {
117117 seal: { does: "seal or unseal an issue or pull" },
118118 migrate: { does: "bring a repository in from a public address" },
119119 account_suspend: { does: "suspend an account or lift its suspension" },
120 account_access: {
121 does: "reset a person's access with a one-time link, or sign them out",
122 once: "The link was shown once, to you, and is not kept here.",
123 },
120124 account_role: { does: "make a person a member or a guest" },
121125 // Every admin-scope tool.
122126 agent: { does: "create, change or remove a crew agent" },
src/http/api/admin.ts +30
@@ -15,6 +15,7 @@ import { getReturning } from "../../settings/returning.ts";
1515 import { kpiProbe } from "../../web/studio/kpi_probe.ts";
1616 import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts";
1717 import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts";
18 import { ACCESS_WORDS, accessUrl, endAllSessions, resetAccess } from "../../actions/account_access.ts";
1819 import { ADMIN_WORDS } from "../../actions/admin_words.ts";
1920 import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts";
2021 import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts";
@@ -586,6 +587,35 @@ export function adminRoutes(router: Router<Handler>): void {
586587 return done.ok ? json({ username: done.value.username, suspendedAt: null }) : accountError(done.error);
587588 });
588589
590 // A one-time link, good for an hour, for a person to set a new password and add a passkey;
591 // `endSessions` also signs them out. The admin never sees the password (#11688).
592 router.add("POST", "/api/v1/admin/accounts/:username/reset", async (ctx) => {
593 const denied = accountAdmin(ctx, "admin");
594 if (denied) return denied;
595 const input = await body(ctx.request);
596 if ((input.endSessions !== undefined && typeof input.endSessions !== "boolean") || (input.dryRun !== undefined && typeof input.dryRun !== "boolean"))
597 return apiError("invalid", 422, "endSessions and dryRun are true or false");
598 const done = await resetAccess(ctx.app, ctx.auth.user, ctx.params.username!, {
599 endAll: input.endSessions === true,
600 dryRun: input.dryRun === true,
601 });
602 if (!done.ok) return done.error === "not_found" ? notFound() : apiError("not_allowed", 422, ACCESS_WORDS.notAllowed);
603 const { token, ...rest } = done.value;
604 return json(token === undefined ? rest : { ...rest, url: accessUrl(ctx.app, token) });
605 });
606
607 // End every browser a person is signed in on.
608 router.add("POST", "/api/v1/admin/accounts/:username/end-sessions", async (ctx) => {
609 const denied = accountAdmin(ctx, "admin");
610 if (denied) return denied;
611 const input = await body(ctx.request);
612 if (input.dryRun !== undefined && typeof input.dryRun !== "boolean")
613 return apiError("invalid", 422, "dryRun is true or false");
614 const done = endAllSessions(ctx.app, ctx.auth.user, ctx.params.username!, { dryRun: input.dryRun === true });
615 if (!done.ok) return done.error === "not_found" ? notFound() : apiError("not_allowed", 422, ACCESS_WORDS.notAllowed);
616 return json(done.value);
617 });
618
589619 router.add("POST", "/api/v1/admin/accounts/:username/verify", (ctx) => {
590620 const denied = accountAdmin(ctx, "admin");
591621 if (denied) return denied;
src/http/context.ts +2
@@ -20,6 +20,8 @@ export interface Auth {
2020 readonly via: "session" | "edge" | "token" | "password" | "none";
2121 /** Which signed-in browser asked, when `via` is `session`. */
2222 readonly sessionId?: string;
23 /** A notice from an admin waits for this person; `page()` says it and marks it said (`core/access_link.ts`). */
24 readonly noticeWaiting?: true;
2325 /** Set for a token: the name its owner gave it, never its secret. */
2426 readonly tokenName?: string;
2527 /** Set for a proposal token: all it may do (`http/proposal_gate.ts`). */
src/http/mcp/tools/account_access.ts added +45
@@ -0,0 +1,45 @@
1 import { ACCESS_WORDS, accessUrl, endAllSessions, resetAccess } from "../../../actions/account_access.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { good, bad, type Tool, mayBoards } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "account_access",
7 doc: "`{ username, action, endSessions?, dryRun? }`: `reset` makes a one-hour, one-use link for a person to set a new password and add a passkey (`endSessions` also signs them out; mailed when the forge can); `end_sessions` signs them out alone; planned first (`dryRun` is true unless `false`); an admin token",
8 description: "Reset a person's access with a one-time link, or end their sessions.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 username: { type: "string" },
13 action: { type: "string", enum: ["reset", "end_sessions"] },
14 endSessions: { type: "boolean" },
15 dryRun: { type: "boolean" },
16 },
17 required: ["username", "action"],
18 },
19 writes: true,
20 dryByDefault: true,
21 async run(ctx, user, args) {
22 const { username, action, endSessions } = args;
23 if (typeof username !== "string" || (action !== "reset" && action !== "end_sessions"))
24 return bad("invalid", 'send a username and an action, "reset" or "end_sessions"');
25 if (endSessions !== undefined && typeof endSessions !== "boolean")
26 return bad("invalid", "endSessions is true or false");
27 if (!mayBoards(user)) return bad("forbidden", "a person's access is a forge admin's to reset");
28 const dryRun = args["dryRun"] !== false;
29 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
30 return bad("forbidden", "resetting access needs an admin token");
31 if (action === "end_sessions") {
32 const done = endAllSessions(ctx.app, user, username, { dryRun });
33 if (!done.ok) return done.error === "not_found" ? bad("not_found", "no such person") : bad("not_allowed", ACCESS_WORDS.notAllowed);
34 return good(dryRun ? { ...done.value, says: "This is the plan; nothing changed. Send dryRun: false to do it." } : done.value);
35 }
36 const done = await resetAccess(ctx.app, user, username, { endAll: endSessions === true, dryRun });
37 if (!done.ok) return done.error === "not_found" ? bad("not_found", "no such person") : bad("not_allowed", ACCESS_WORDS.notAllowed);
38 const { token, ...rest } = done.value;
39 return good(
40 token === undefined
41 ? { ...rest, says: "This is the plan; nothing changed. Send dryRun: false to make the link." }
42 : { ...rest, url: accessUrl(ctx.app, token) },
43 );
44 },
45 };
src/http/mcp/tools/index.ts +1
@@ -1,6 +1,7 @@
11 // Generated by `bun scripts/mcp-tools.ts` from the files beside it; do not edit.
22 // One line per tool, sorted: a new tool is a new file and one new line here.
33 export { tool as access } from "./access.ts";
4 export { tool as account_access } from "./account_access.ts";
45 export { tool as account_requests } from "./account_requests.ts";
56 export { tool as account_role } from "./account_role.ts";
67 export { tool as account_suspend } from "./account_suspend.ts";
src/http/server.ts +6 −1
@@ -157,6 +157,8 @@ import {
157157 recoverFinish,
158158 recoverOptions,
159159 recoverPage,
160 accessPage,
161 accessSubmit,
160162 recoverRequest,
161163 signinOptions,
162164 signinPasskey,
@@ -660,13 +662,14 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
660662
661663 const session = cookieValue(request, "forge_session");
662664 if (session !== undefined) {
663 const found = sessionOf(app.db, session);
665 const found = sessionOf(app.db, session, clientAddress(request));
664666 if (found.ok)
665667 return {
666668 user: found.value.user,
667669 scope: "admin",
668670 via: "session",
669671 sessionId: found.value.id,
672 ...(found.value.notice ? { noticeWaiting: true as const } : {}),
670673 };
671674 }
672675
@@ -915,6 +918,8 @@ export function buildRouter(): Router<Handler> {
915918 router.add("GET", "/signin/parent", parentStart);
916919 router.add("GET", "/signin/parent/callback", parentCallback);
917920 router.add("GET", "/signup", signupPage);
921 router.add("GET", "/signin/access/:token", accessPage);
922 router.add("POST", "/signin/access/:token", accessSubmit);
918923 router.add("GET", "/claim/start/:token", startClaimPage);
919924 router.add("POST", "/claim/start/:token", startClaimSubmit);
920925 router.add("POST", "/claim/start/:token/account", startClaimAccountSubmit);
src/http/settings_routes.ts +3
@@ -32,6 +32,7 @@ import { sourcesPage } from "../web/pages/sources.ts";
3232 import { requestAct } from "../web/requests_card.ts";
3333 import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts";
3434 import {
35 accessAct,
3536 accountAct,
3637 retireAct,
3738 retirePage,
@@ -270,6 +271,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
270271 router.add("POST", "/settings/accounts/:username/model", grantAct);
271272 router.add("POST", "/settings/accounts/:username/ceiling", ceilingAct);
272273 router.add("GET", "/settings/accounts/:username", personSignInsPage);
274 router.add("POST", "/settings/accounts/:username/reset", accessAct("reset"));
275 router.add("POST", "/settings/accounts/:username/end-sessions", accessAct("end-sessions"));
273276 router.add(
274277 "POST",
275278 "/settings/accounts/:username/tokens/:id/delete",
src/tui/screens/account_access.ts added +177
@@ -0,0 +1,177 @@
1 /**
2 * A person's access, in the terminal (#11688), as `/settings/accounts/<name>`
3 * has it: every person with their role, the browsers they are signed in on
4 * now and when one was last seen. `/` searches by name, `c` clears it; `r`
5 * resets the selected person's access (a one-hour, one-use link, shown on a
6 * screen of its own; `r` asks whether to sign them out everywhere too) and
7 * `e` signs them out everywhere, each after a confirm. Every rule is the
8 * action's (`actions/account_access.ts`): only an admin sees or does any of
9 * it, never to themselves or an agent, and another admin only for the forge's
10 * owner. Each act is an event, so web viewers see it live.
11 */
12
13 import { ACCESS_WORDS, accessUrl, endAllSessions, resetAccess } from "../../actions/account_access.ts";
14 import { listAccounts } from "../../actions/accounts.ts";
15 import { listSessions } from "../../core/sessions.ts";
16 import { roleOf } from "../../core/roles.ts";
17 import { getUserByName } from "../../core/users.ts";
18 import type { Ctx, Screen } from "../app/app.ts";
19 import { binding } from "../app/keys.ts";
20 import type { Session } from "../session.ts";
21 import { style } from "../term/style.ts";
22 import { truncate } from "../term/width.ts";
23 import { fill, len, split } from "../ui/layout.ts";
24 import { ListState, ago, confirmScreen, drawList, linkScreen, live, promptScreen, runAsync, type ListRow } from "./common.ts";
25 import type { PaletteEntry } from "./entries.ts";
26
27 /** What a search may be: names are short. */
28 const MAX_QUERY = 64;
29
30 interface Line {
31 readonly username: string;
32 readonly row: ListRow;
33 }
34
35 export function accountAccessScreen(f: Session, ctx: Ctx): Screen {
36 const list = new ListState();
37 let query = "";
38
39 /** The people the viewer may see, searched; empty for anyone who is not an admin. */
40 const lines = (): Line[] => {
41 const listed = listAccounts(f.app, f.user);
42 if (!listed.ok) return [];
43 const needle = query.toLowerCase();
44 return listed.value
45 .filter((a) => a.kind === "user" && a.removedAt === null)
46 .filter((a) => needle === "" || a.username.toLowerCase().includes(needle) || a.displayName.toLowerCase().includes(needle))
47 .map((a): Line => {
48 const user = getUserByName(f.db, a.username);
49 const browsers = user === undefined ? [] : listSessions(f.db, user.id);
50 const seen = browsers.map((b) => b.lastSeenAt ?? b.createdAt).sort().at(-1);
51 return {
52 username: a.username,
53 row: {
54 title: `${a.username} ${a.isAdmin ? "admin" : user === undefined ? "" : roleOf(f.db, user)}${a.suspendedAt === null ? "" : " suspended"}`,
55 meta: `${browsers.length === 0 ? "not signed in" : `signed in on ${browsers.length} ${browsers.length === 1 ? "browser" : "browsers"}, last seen ${ago(seen ?? "")}`}`,
56 },
57 };
58 });
59 };
60
61 const refused = (error: "not_found" | "not_allowed"): void =>
62 ctx.toast(error === "not_found" ? "No such person." : ACCESS_WORDS.notAllowed, "danger");
63
64 const reset = (username: string, endAll: boolean): void =>
65 runAsync(ctx, async () => {
66 const done = await resetAccess(f.app, f.user, username, { endAll });
67 if (!done.ok) return refused(done.error);
68 const url = done.value.token === undefined ? undefined : accessUrl(f.app, done.value.token);
69 const ended = done.value.sessionsEnded > 0 ? ` Signed out of ${done.value.sessionsEnded}.` : "";
70 ctx.toast(`Link made for ${username}; it works once, for an hour.${done.value.mailed ? " Mailed to them." : ""}${ended}`);
71 if (url !== undefined) ctx.push(linkScreen(ctx, url, screen));
72 });
73
74 const askReset = (username: string): void =>
75 ctx.push(
76 confirmScreen(ctx, screen, {
77 title: `Reset ${username}'s access`,
78 body: [`Makes a one-hour, one-use link for ${username} to set a new password; whoever opens it takes the account.`],
79 actions: [{ label: "Reset and sign out", variant: "danger" }, { label: "Reset only", variant: "danger" }, { label: "Cancel", variant: "ghost" }],
80 selected: 2,
81 width: 70,
82 onChoose: (i) => {
83 if (i === 0 || i === 1) reset(username, i === 0);
84 },
85 }),
86 );
87
88 const askEnd = (username: string): void =>
89 ctx.push(
90 confirmScreen(ctx, screen, {
91 title: `Sign ${username} out everywhere`,
92 body: [`Every browser ${username} is signed in on stops working at once. Sign them out?`],
93 actions: [{ label: "Sign out", variant: "danger" }, { label: "Cancel", variant: "ghost" }],
94 selected: 1,
95 yesNo: true,
96 onChoose: (i) => {
97 if (i !== 0) return;
98 const done = endAllSessions(f.app, f.user, username);
99 if (!done.ok) return refused(done.error);
100 ctx.toast(`${username} is signed out of ${done.value.sessionsEnded} ${done.value.sessionsEnded === 1 ? "browser" : "browsers"}.`);
101 },
102 }),
103 );
104
105 const screen: Screen = {
106 title: "Account access",
107 keys: [
108 binding(["j", "k"], "move cursor"),
109 binding(["/"], "search by name"),
110 binding(["c"], "clear search"),
111 binding(["r"], "reset access"),
112 binding(["e"], "end all sessions"),
113 ],
114 load(signal) {
115 live(f, signal, { where: (e) => e.kind.startsWith("account.") }, () => ctx.invalidate());
116 return Promise.resolve();
117 },
118 render(area, s) {
119 const c = ctx.theme.c;
120 const all = lines();
121 list.clamp(all.length);
122 const [head, body] = split(area, "v", [len(1), fill(1)]);
123 const note =
124 f.user?.isAdmin !== true || f.user.kind !== "user"
125 ? "Only a forge admin sees this."
126 : all.length === 0
127 ? query === "" ? "No one is here." : `No one matches ${query}.`
128 : query === "" ? "Everyone on this forge. r resets access, e ends all sessions." : `${all.length} matching ${query}.`;
129 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
130 drawList(s, body!, c, list, all.map((l) => l.row));
131 },
132 key(e) {
133 const all = lines();
134 if (list.key(e, all.length)) return true;
135 if (e.ctrl || e.alt) return false;
136 if (e.key === "/") {
137 ctx.push(
138 promptScreen(ctx, screen, {
139 title: "Search accounts",
140 label: "name contains",
141 value: query,
142 onSubmit: (raw) => {
143 const word = raw.trim();
144 if (word.length > MAX_QUERY) return "Keep it to 64 characters.";
145 query = word;
146 list.index = 0;
147 ctx.invalidate();
148 return undefined;
149 },
150 }),
151 );
152 return true;
153 }
154 if (e.key === "c") {
155 query = "";
156 ctx.invalidate();
157 return true;
158 }
159 const picked = all[list.index];
160 if (e.key === "r" && picked !== undefined) askReset(picked.username);
161 else if (e.key === "e" && picked !== undefined) askEnd(picked.username);
162 else return false;
163 return true;
164 },
165 };
166 return screen;
167 }
168
169 /** A forge admin's: reset a person's access and end their sessions, as the account page does. */
170 export const entry: PaletteEntry = {
171 id: "account-access",
172 title: "Account access",
173 hint: "search people, reset access, end sessions",
174 after: ["people"],
175 offered: (session) => session.user?.isAdmin === true && session.user.kind === "user",
176 run: (session, ctx) => ctx.push(accountAccessScreen(session, ctx)),
177 };
src/tui/screens/entries.ts +2
@@ -9,6 +9,7 @@ import type { Ctx } from "../app/app.ts";
99 import type { Command } from "../app/palette.ts";
1010 import type { Session } from "../session.ts";
1111 import { entry as access } from "./access.ts";
12 import { entry as accountAccess } from "./account_access.ts";
1213 import { entry as agents } from "./agents.ts";
1314 import { entry as askData } from "./ask_data.ts";
1415 import { entry as archived } from "./archived.ts";
@@ -150,6 +151,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
150151 modelGrantEntry,
151152 modelCeilingEntry,
152153 people,
154 accountAccess,
153155 quotas,
154156 limits,
155157 plans,
src/web/layout.ts +9 −1
@@ -64,6 +64,7 @@ import { prefersJson } from "./form.ts";
6464 import { notePhase } from "../core/route_timing.ts";
6565 import { html, raw, when, type Html } from "./html.ts";
6666 import { noRoom } from "../spawn/door.ts";
67 import { takeNotice } from "../core/access_link.ts";
6768 import { quotaFull } from "../spawn/quota.ts";
6869 import { icon, inlineIcons, pointAtSprite, themeGlyph } from "./icons.ts";
6970 import { btn, copyField, hintAttr, kbd, menu, type MenuItem } from "./ui.ts";
@@ -772,7 +773,14 @@ export function page(options: PageOptions, status = 200): Response {
772773 const shareImage = `${ctx.url.origin}${options.ogImage !== undefined && options.ogImage.startsWith("/og/") ? options.ogImage : "/og/site.png"}`;
773774 const theme = ctx.theme ?? options.theme;
774775 const cookieFlash = readFlash(ctx.request);
775 const flash = options.flash ?? cookieFlash;
776 // What an admin did to this account is told on the first page a signed-in
777 // browser renders, and marked told only here, where it is shown.
778 const notice =
779 options.flash === undefined && cookieFlash === undefined && ctx.auth.noticeWaiting === true && ctx.auth.user !== undefined && ctx.request.method === "GET"
780 ? takeNotice(ctx.app.db, ctx.auth.user.id, new Date())
781 : undefined;
782 const flash: Flash | undefined =
783 options.flash ?? cookieFlash ?? (notice === undefined ? undefined : { tone: "attn", message: notice });
776784 const links = pageLinks(ctx);
777785
778786 // The body's markup. Every `icon()` left a `<use>` behind; they are aimed
src/web/pages/account.ts +1 −1
@@ -700,7 +700,7 @@ export function sessionsCard(
700700 ${sessions.map((x) =>
701701 row({
702702 title: html`${time(x.createdAt)} ${x.current ? badge("This browser") : null}`,
703 meta: html`last seen ${x.lastSeenAt ? time(x.lastSeenAt) : "not since"}`,
703 meta: html`last seen ${x.lastSeenAt ? time(x.lastSeenAt) : "not since"}${x.address === null ? null : html` · from ${x.address}`}`,
704704 trail: html`${confirmAction({
705705 action: `${base}/sessions/${x.id}/delete`,
706706 verb: "Sign out",
src/web/pages/accounts.ts +137 −36
@@ -14,19 +14,21 @@ import {
1414 unsuspendAccount,
1515 verifyAccount,
1616 } from "../../actions/accounts.ts";
17 import {
18 accountSignIns,
19 endAccountSession,
20 revokeAccountToken,
21 } from "../../actions/signins.ts";
17 import { ACCESS_WORDS, accessUrl, endAllSessions, manageable, resetAccess } from "../../actions/account_access.ts";
18 import { endAccountSession, revokeAccountToken } from "../../actions/signins.ts";
19 import { listPasskeys } from "../../core/passkeys.ts";
20 import { roleOf } from "../../core/roles.ts";
21 import { listSessions } from "../../core/sessions.ts";
22 import { listTokensWithClients } from "../../core/oauth.ts";
23 import { emailVerified } from "../../core/verify.ts";
2224 import { mailConfigured } from "../../actions/verify.ts";
23 import { originOf, signinReach, type AccountRow } from "../../core/accounts.ts";
25 import { listAccounts as readAccounts, originOf, signinReach, type AccountRow } from "../../core/accounts.ts";
2426 import {
2527 OWNERS_TEAM,
2628 ownerlessOrgs,
2729 soleOwnedOrgs,
2830 } from "../../core/orgs.ts";
29 import { getUserByName } from "../../core/users.ts";
31 import { getUserByName, hasPassword, isStandInEmail } from "../../core/users.ts";
3032 import type { Ctx } from "../../http/context.ts";
3133 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3234 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
@@ -37,6 +39,7 @@ import {
3739 badge,
3840 btn,
3941 confirmAction,
42 field as formField,
4043 row,
4144 time,
4245 userLink,
@@ -97,7 +100,7 @@ function personRow(a: AccountRow, sole: readonly string[]): Html {
97100 : null}`,
98101 ...(actionable
99102 ? { after: html`
100 ${btn("Sign-ins", { href: at, size: "sm", variant: "ghost" })}
103 ${btn("Manage account", { href: at, size: "sm", variant: "ghost" })}
101104 ${a.verified
102105 ? null
103106 : html`<form method="post" action="${at}/verify">
@@ -128,7 +131,8 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
128131 listAccounts(ctx.app, user),
129132 );
130133 if (!listed.ok) return notFoundPage(ctx);
131 const people = listed.value;
134 const query = (ctx.url.searchParams.get("q") ?? "").trim().slice(0, 64).toLowerCase();
135 const people = query === "" ? listed.value : listed.value.filter((a) => a.username.toLowerCase().includes(query) || a.displayName.toLowerCase().includes(query));
132136 // Without mail a newcomer cannot prove an address; the owner's word is
133137 // the only way in, and the page says so where it is given.
134138 const mail = timePhase(ctx.request, "mail", () => mailConfigured(ctx.app));
@@ -202,7 +206,11 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
202206 ctx,
203207 html`${notice ?? null}${requestsCard(ctx)}${ownerless}${edgeOnly}${noMail}<section class="settings-block" id="accounts">
204208 <h2>Who is on this forge</h2>
205 <p class="muted">Newest first.</p>
209 <form method="get" action="/settings/accounts" class="form-actions" role="search">
210 <input class="input" name="q" type="search" maxlength="64" value="${query}" placeholder="Search by name" aria-label="Search accounts" autocomplete="off" />
211 ${btn("Search", { type: "submit", size: "sm" })}
212 </form>
213 <p class="muted">${query === "" ? "Newest first." : `${people.length} matching.`}</p>
206214 <ul class="list setting-list" id="accounts-list">
207215 ${people.map((a, i) => personRow(a, sole[i] ?? []))}
208216 </ul>
@@ -264,51 +272,144 @@ function refuseRemove(username: string): Html {
264272 </section>`;
265273 }
266274
267 // GET /settings/accounts/:username — where one person is signed in
275 /** One labelled line of the account card. */
276 function fact(label: string, value: Html, meta: Html | null): Html {
277 return row({ title: html`${label}: ${value}`, ...(meta === null ? {} : { meta }) });
278 }
279
280 /** Whether `username` is another admin, which only the forge's owner may manage. */
281 function isOtherAdmin(ctx: Ctx, username: string): boolean {
282 const named = getUserByName(ctx.app.db, username);
283 return named !== undefined && named.kind === "user" && named.isAdmin && named.id !== ctx.auth.user?.id;
284 }
285
286 // GET /settings/accounts/:username — one person, on one screen, with what an admin may do
268287 export function personSignInsPage(ctx: Ctx): Response {
269288 if (!ctx.auth.user) return signInFirst(ctx);
270289 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
271 const named = getUserByName(ctx.app.db, ctx.params.username ?? "");
290 const username = ctx.params.username ?? "";
291 const named = getUserByName(ctx.app.db, username);
272292 // An organization has no sign-ins: its page is its model grant and spend ceilings.
273293 if (named?.kind === "org" && ctx.auth.user.kind === "user")
274294 return shell(ctx, grantCard(ctx, named, `/settings/accounts/${encodeURIComponent(named.username)}`));
275 const seen = accountSignIns(ctx.app, ctx.auth.user, ctx.params.username ?? "");
276 // Yourself is your own settings; another admin, an agent, nobody: no page.
277 if (!seen.ok || seen.value.user.id === ctx.auth.user.id)
278 return notFoundPage(ctx);
279 const { user, tokens, sessions } = seen.value;
295 // Yourself is your own settings; an agent or a removed account, nobody: no page.
296 const found = manageable(ctx.app, ctx.auth.user, username);
297 if (!found.ok && !(found.error === "not_allowed" && isOtherAdmin(ctx, username))) return notFoundPage(ctx);
298 if (!found.ok)
299 return shell(ctx, html`<section class="settings-block"><h2>${userLink(username)}</h2><p class="muted">${ACCESS_WORDS.notAllowed}</p></section>`);
300 const user = found.value.user;
280301 const at = `/settings/accounts/${encodeURIComponent(user.username)}`;
281 return shell(
282 ctx,
283 html`<section class="settings-block" id="tokens">
284 <h2>${userLink(user.username)}: where they are signed in</h2>
285 <p class="muted">
286 Revoking a token or signing out a browser ends it at once; the person
287 can sign in again. To stop them signing in, suspend the account.
288 </p>
289 <h3>Access tokens</h3>
302 const db = ctx.app.db;
303 const owned = readAccounts(db, user.username)[0];
304 const sessions = listSessions(db, user.id);
305 const passkeys = listPasskeys(db, user.id);
306 const tokens = listTokensWithClients(db, user.id);
307 const verified = emailVerified(db, user.id);
308 const role = roleOf(db, user);
309 const account = html`<section class="settings-block" id="account">
310 <h2>${userLink(user.username)}</h2>
311 <ul class="list setting-list">
312 ${fact("Role", html`${role} ${user.suspendedAt === null ? null : badge("Suspended", { tone: "danger" })}`, html`joined ${time(user.createdAt)}`)}
313 ${fact("Email", html`${isStandInEmail(user.email) ? "none" : user.email} ${verified ? badge("Verified") : badge("Not verified", { variant: "outline" })}`, null)}
314 ${fact("Signs in with", html`${passkeys.length === 0 ? "no passkeys" : `${passkeys.length} ${passkeys.length === 1 ? "passkey" : "passkeys"}`} · ${hasPassword(db, user.id) ? "a password is set" : "no password"}`, html`never the secrets themselves`)}
315 ${owned === undefined
316 ? null
317 : fact(
318 "Owns",
319 html`${owned.repos} ${owned.repos === 1 ? "repository" : "repositories"} · ${owned.apps} ${owned.apps === 1 ? "app" : "apps"}`,
320 html`${owned.spentUsd > 0 ? `$${owned.spentUsd.toFixed(2)} spent by their crew` : "no crew spend"}`,
321 )}
322 </ul>
323 </section>`;
324 const signIns = html`<section class="settings-block" id="sign-ins">
325 <h2>Signed in now</h2>
326 ${sessions.length === 0
327 ? html`<p class="muted">Not signed in on any browser.</p>`
328 : html`<ul class="list setting-list">
329 ${sessions.slice(0, 5).map((x) =>
330 row({
331 title: html`${time(x.createdAt)}`,
332 meta: html`${x.address === null ? "from an unknown address" : `from ${x.address}`} · last seen ${x.lastSeenAt ? time(x.lastSeenAt) : "not since"}`,
333 }),
334 )}
335 </ul>`}
336 </section>`;
337 const ordinary = !user.isAdmin;
338 const actions = html`<section class="settings-block" id="actions">
339 <h2>Actions</h2>
340 <form method="get" action="/-/confirm" class="stack">
341 <input type="hidden" name="to" value="${at}/reset" />
342 ${formField({ label: "Also sign them out everywhere", name: "endSessions", type: "checkbox", value: "1", checked: true })}
343 <div class="form-actions">
344 ${btn("Reset access", { type: "submit", variant: "danger", size: "sm" })}
345 ${confirmAction({ action: `${at}/end-sessions`, verb: "End all sessions", size: "sm" })}
346 </div>
347 </form>
348 ${ordinary
349 ? html`<div class="form-actions">
350 ${verified
351 ? null
352 : html`<form method="post" action="${at}/verify">${btn("Verify email", { type: "submit", size: "sm" })}</form>`}
353 <form method="post" action="/settings/people/${encodeURIComponent(user.username)}/role">
354 <input type="hidden" name="role" value="${role === "member" ? "guest" : "member"}" />
355 <input type="hidden" name="back" value="${at}" />
356 ${btn(role === "member" ? "Make guest" : "Make member", { type: "submit", size: "sm" })}
357 </form>
358 ${user.suspendedAt === null
359 ? confirmAction({ action: `${at}/suspend`, verb: "Suspend", size: "sm" })
360 : html`<form method="post" action="${at}/unsuspend">${btn("Unsuspend", { type: "submit", size: "sm" })}</form>`}
361 ${confirmAction({ action: `${at}/remove`, verb: "Remove account", size: "sm" })}
362 </div>`
363 : null}
364 </section>`;
365 const tokenList = html`<section class="settings-block" id="tokens">
366 <h2>Access tokens</h2>
367 <p class="muted">Revoking one ends it at once; the person can sign in again.</p>
290368 ${tokens.length === 0
291369 ? html`<p class="muted">No access tokens.</p>`
292370 : html`<ul class="list setting-list">
293371 ${tokens.map((t) =>
294372 row({
295373 title: html`${t.name} ${badge(t.scope)}`,
296 meta: html`made ${time(t.createdAt)} · last used
297 ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}`,
298 after: confirmAction({
299 action: `${at}/tokens/${t.id}/delete`,
300 verb: "Revoke",
301 size: "sm",
302 }),
374 meta: html`made ${time(t.createdAt)} · last used ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}`,
375 after: confirmAction({ action: `${at}/tokens/${t.id}/delete`, verb: "Revoke", size: "sm" }),
303376 }),
304377 )}
305378 </ul>`}
306 </section>
307 ${grantCard(ctx, user, at)}
308 ${sessionsCard(sessions, at)}`,
379 </section>`;
380 return shell(
381 ctx,
382 html`${account}${actions}${signIns}${ordinary ? html`${grantCard(ctx, user, at)}${tokenList}${sessionsCard(sessions, at)}` : null}`,
309383 );
310384 }
311385
386 // POST /settings/accounts/:username/(reset|end-sessions)
387 export function accessAct(act: "reset" | "end-sessions") {
388 return async (ctx: Ctx): Promise<Response> => {
389 if (!ctx.auth.user) return signInFirst(ctx);
390 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
391 const form = await readForm(ctx, "admin");
392 if (form instanceof Response) return form;
393 const username = ctx.params.username ?? "";
394 const back = `/settings/accounts/${encodeURIComponent(username)}`;
395 const refused = (error: "not_found" | "not_allowed"): Response =>
396 error === "not_found" ? notFoundPage(ctx) : flashTo(ctx, back, { tone: "danger", message: ACCESS_WORDS.notAllowed });
397 const browsers = (n: number): string => `${n} ${n === 1 ? "browser" : "browsers"}`;
398 if (act === "end-sessions") {
399 const done = endAllSessions(ctx.app, ctx.auth.user, username);
400 if (!done.ok) return refused(done.error);
401 return flashTo(ctx, back, { message: `${username} is signed out of ${browsers(done.value.sessionsEnded)}.` });
402 }
403 const done = await resetAccess(ctx.app, ctx.auth.user, username, { endAll: field(form, "endSessions") === "1" });
404 if (!done.ok) return refused(done.error);
405 const link = done.value.token === undefined ? undefined : accessUrl(ctx.app, done.value.token);
406 return flashTo(ctx, back, {
407 message: `Link made for ${username}; it works once, for an hour.${done.value.mailed ? " It was also mailed to them." : ""}${done.value.sessionsEnded > 0 ? ` Signed out of ${browsers(done.value.sessionsEnded)}.` : ""}`,
408 ...(link === undefined ? {} : { code: link }),
409 });
410 };
411 }
412
312413 // POST /settings/accounts/:username/(tokens|sessions)/:id/delete
313414 export function signInAct(what: "token" | "session") {
314415 return async (ctx: Ctx): Promise<Response> => {
src/web/pages/org.ts +13
@@ -401,6 +401,19 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> {
401401 ${time(owner.createdAt)}
402402 </p>
403403 </div>
404 ${when(
405 ctx.auth.user?.isAdmin === true &&
406 ctx.auth.user.kind === "user" &&
407 owner.kind === "user" &&
408 owner.id !== ctx.auth.user.id &&
409 owner.removedAt === null,
410 () =>
411 btn("Manage account", {
412 href: `/settings/accounts/${encodeURIComponent(owner.username)}`,
413 variant: "ghost",
414 size: "sm",
415 }),
416 )}
404417 ${when(isSelf, () =>
405418 btn("New repository", {
406419 href: `/new?owner=${owner.username}`,
src/web/pages/passkeys.ts +58 −3
@@ -24,7 +24,9 @@ import { requestOf } from "../../core/requests.ts";
2424 import { createSession } from "../../core/sessions.ts";
2525 import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts";
2626 import { recoveryAccount, requestRecovery, spendRecoveryLink } from "../../actions/verify.ts";
27 import { getUserById, isActive } from "../../core/users.ts";
27 import { getUserById, isActive, MIN_PASSWORD } from "../../core/users.ts";
28 import { accessLinkPerson, redeemAccessLink } from "../../actions/account_access.ts";
29 import { CHANGE_PASSWORD_WORDS } from "../../actions/users.ts";
2830 import { crossOrigin, type Ctx } from "../../http/context.ts";
2931 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3032 import { clientAddress, clientBucket } from "../../http/peer.ts";
@@ -33,10 +35,10 @@ import { boundedBody, json, str } from "../../http/respond.ts";
3335 import { CAME_FROM_ELSEWHERE, signInFirst } from "../chrome.ts";
3436 import { field, readForm } from "../form.ts";
3537 import { html } from "../html.ts";
36 import { flashTo, page } from "../layout.ts";
38 import { flashTo, page, redirect } from "../layout.ts";
3739 import { passkeyJs } from "../passkey-client.ts";
3840 import { passkeyNote } from "../passkey-state.ts";
39 import { confirmAction, row, time } from "../ui.ts";
41 import { btn, confirmAction, row, time } from "../ui.ts";
4042 import { accountShell } from "./account.ts";
4143 import { safeNext, sessionCookie, signinAllowed } from "./site.ts";
4244 import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts";
@@ -353,3 +355,56 @@ export async function recoverFinish(ctx: Ctx): Promise<Response> {
353355 "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
354356 });
355357 }
358
359 const ACCESS_NOT_VALID = "This link has been used or has expired. Ask the admin who sent it for a new one.";
360
361 function accessForm(ctx: Ctx, token: string, username: string, said?: string, status = 200): Response {
362 return page(
363 {
364 ctx,
365 title: "Set a new password",
366 body: html`<div class="page-head"><h1>Set a new password</h1></div>
367 <p class="muted small mb">An admin made this one-use link for ${username}; choose a new password and you are signed in.</p>
368 ${said === undefined ? null : html`<p role="alert">${said}</p>`}
369 <form method="post" action="/signin/access/${encodeURIComponent(token)}" class="stack">
370 <div class="field"><label for="access-password">New password</label>
371 <input class="input" id="access-password" name="password" type="password" minlength="${MIN_PASSWORD}"
372 autocomplete="new-password" required />
373 <span class="hint">At least ${MIN_PASSWORD} characters.</span></div>
374 <div class="form-actions">${btn("Set password and sign in", { type: "submit", variant: "primary" })}</div>
375 </form>`,
376 },
377 status,
378 );
379 }
380
381 // GET /signin/access/:token — the admin's one-time link: a form for a new password.
382 export function accessPage(ctx: Ctx): Response {
383 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
384 const token = ctx.params.token ?? "";
385 const user = timePhase(ctx.request, "link", () => accessLinkPerson(ctx.app, token));
386 if (user === undefined)
387 return page({ ctx, title: "Link not valid", body: html`<div class="page-head"><h1>Link not valid</h1></div><p>${ACCESS_NOT_VALID}</p>` }, 404);
388 return timePhase(ctx.request, "render", () => accessForm(ctx, token, user.username));
389 }
390
391 // POST /signin/access/:token — spend the link: the new password is set and the person signed in.
392 export async function accessSubmit(ctx: Ctx): Promise<Response> {
393 const form = await readForm(ctx, undefined, STRANGER_MAX);
394 if (form instanceof Response) return form;
395 const token = ctx.params.token ?? "";
396 const done = await redeemAccessLink(ctx.app, token, field(form, "password"));
397 if (!done.ok) {
398 if (done.error === "invalid")
399 return page({ ctx, title: "Link not valid", body: html`<div class="page-head"><h1>Link not valid</h1></div><p>${ACCESS_NOT_VALID}</p>` }, 404);
400 const user = accessLinkPerson(ctx.app, token);
401 return accessForm(ctx, token, user?.username ?? "", CHANGE_PASSWORD_WORDS[done.error], 422);
402 }
403 const session = createSession(ctx.app.db, done.value.id);
404 return redirect("/settings/passkeys", {
405 cookie: sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
406 flash: { message: "Password set; you are signed in. You can add a passkey here too." },
407 url: ctx.url,
408 forgeUrl: ctx.app.config.url,
409 });
410 }
src/web/pages/people.ts +3 −1
@@ -15,6 +15,7 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t
1515 import type { Ctx } from "../../http/context.ts";
1616 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1717 import { field, readForm } from "../form.ts";
18 import { isLocalPath } from "../flash.ts";
1819 import { html } from "../html.ts";
1920 import { flashTo, page } from "../layout.ts";
2021 import { badge, btn, empty, userLink } from "../ui.ts";
@@ -114,7 +115,8 @@ export async function roleAct(ctx: Ctx): Promise<Response> {
114115 const raw = field(form, "usd").trim().replace(/^\$/, "");
115116 const usd = raw === "" || Number(raw) === 0 ? null : /^\d{1,6}(\.\d{1,2})?$/.test(raw) ? Number(raw) : NaN;
116117 const done = timePhase(ctx.request, "role", () => setRole(ctx.app, ctx.auth.user as User, username, role, usd));
117 const back = "/settings/people";
118 const wanted = field(form, "back");
119 const back = isLocalPath(wanted) ? wanted : "/settings/people";
118120 if (!done.ok) {
119121 if (done.error === "not_found" || getUserByName(ctx.app.db, username) === undefined) return notFoundPage(ctx);
120122 return flashTo(ctx, back, {
src/web/pages/search.ts +19
@@ -856,6 +856,25 @@ const DESTRUCTIVE: {
856856 phrase: "",
857857 }),
858858 },
859 {
860 pattern: /^\/settings\/accounts\/([^/]+)\/reset$/,
861 describe: (m) => ({
862 verb: "Reset access",
863 title: `Reset ${decodeURIComponent(m[1] ?? "")}'s access?`,
864 detail: "Makes a one-hour, one-use link that lets them set a new password and sign in; whoever opens it first takes the account, and they are told you did it.",
865 phrase: "",
866 carries: "endSessions",
867 }),
868 },
869 {
870 pattern: /^\/settings\/accounts\/([^/]+)\/end-sessions$/,
871 describe: (m) => ({
872 verb: "End all sessions",
873 title: `Sign ${decodeURIComponent(m[1] ?? "")} out everywhere?`,
874 detail: "Every browser they are signed in on stops working at once; they can sign in again.",
875 phrase: "",
876 }),
877 },
859878 {
860879 pattern: /^\/settings\/accounts\/([^/]+)\/remove$/,
861880 describe: (m) => {
test/account-access.test.ts added +249
@@ -0,0 +1,249 @@
1 /**
2 * An admin opens a person's account from their profile and resets their
3 * access (#11688): a one-time link, good for an hour, that sets a new
4 * password; sessions ended; audited; the person told on their next page.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { resetAccess } from "../src/actions/account_access.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { listEvents } from "../src/core/events.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, getUserByName, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { adminCookie } from "./helpers/admin.ts";
18 import { promote } from "./helpers/members.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 describe("an admin resets a person's access", () => {
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let base: string;
28 let owner = "";
29 let adminTwo = "";
30 let bob: User;
31 let bobCookie = "";
32
33 const get = (path: string, cookie = "") =>
34 fetch(`${base}${path}`, { headers: { cookie, accept: "text/html" }, redirect: "manual" });
35 const post = (path: string, cookie: string, form: Record<string, string> = {}) =>
36 fetch(`${base}${path}`, {
37 method: "POST",
38 headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
39 body: new URLSearchParams(form),
40 redirect: "manual",
41 });
42 const api = (path: string, token: string, body: unknown = {}) =>
43 fetch(`${base}${path}`, {
44 method: "POST",
45 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
46 body: JSON.stringify(body),
47 });
48 /** The link a flash carries: the one place the secret is shown. */
49 const flashLink = (res: Response): string => {
50 const set = res.headers.getSetCookie().find((c) => c.startsWith("forge_flash="));
51 const flash = JSON.parse(decodeURIComponent(set!.split(";")[0]!.slice("forge_flash=".length))) as { code: string };
52 return new URL(flash.code).pathname;
53 };
54
55 beforeAll(async () => {
56 dataDir = await mkdtemp(join(tmpdir(), "forge-access-"));
57 app = createApp({ dataDir, port: 0 });
58 servers = await serve(app);
59 base = servers.url;
60 owner = await adminCookie(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
61 const two = await createUser(app.db, {
62 origin: "seed",
63 username: "carol",
64 email: "[email protected]",
65 password: "carol-passphrase",
66 isAdmin: true,
67 trusted: true,
68 });
69 const b = await createUser(app.db, {
70 origin: "seed",
71 username: "bob",
72 email: "[email protected]",
73 password: "bob-old-passphrase",
74 trusted: true,
75 });
76 if (!two.ok || !b.ok) throw new Error("users");
77 promote(app.db, b.value);
78 bob = b.value;
79 adminTwo = createToken(app.db, two.value.id, "t", "admin").token;
80 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
81 }, 30_000);
82
83 afterAll(async () => {
84 servers.stop();
85 app.close();
86 await rm(dataDir, { recursive: true, force: true });
87 });
88
89 test("a profile offers Manage account to an admin only, and the page answers 404 to anyone else", async () => {
90 const asAdmin = await (await get("/bob", owner)).text();
91 expect(asAdmin).toContain("/settings/accounts/bob");
92 expect(asAdmin).toContain("Manage account");
93 expect(await (await get("/bob", bobCookie)).text()).not.toContain("Manage account");
94 expect(await (await get("/bob")).text()).not.toContain("Manage account");
95 expect((await get("/settings/accounts/bob", bobCookie)).status).toBe(404);
96 const page = await get("/settings/accounts/bob", owner);
97 expect(page.status).toBe(200);
98 const text = await page.text();
99 for (const word of ["Role", "[email protected]", "Verified", "a password is set", "Owns", "Signed in now", "Reset access", "End all sessions", "Model budget"])
100 expect(text).toContain(word);
101 expect(text).not.toContain("bob-old-passphrase");
102 expect((await post("/settings/accounts/bob/reset", bobCookie)).status).toBe(404);
103 });
104
105 test("the accounts list searches by name", async () => {
106 const hit = await (await get("/settings/accounts?q=bo", owner)).text();
107 expect(hit).toContain("/settings/accounts/bob");
108 expect(hit).not.toContain("/settings/accounts/carol");
109 });
110
111 test("search narrows the list and says when nothing matches", async () => {
112 const none = await (await get("/settings/accounts?q=zzzz", owner)).text();
113 expect(none).not.toContain("/settings/accounts/bob");
114 expect(none).not.toContain("/settings/accounts/carol");
115 expect(none).toContain("0 matching");
116 });
117
118 test("a role change goes back only to a path on this forge", async () => {
119 const go = async (back: string) =>
120 (await post("/settings/people/bob/role", owner, { role: "member", back })).headers.get("location");
121 expect(await go("/settings/accounts/bob")).toBe("/settings/accounts/bob");
122 expect(await go("https://evil.example/x")).toBe("/settings/people");
123 expect(await go("//evil.example/x")).toBe("/settings/people");
124 expect(await go("/\\evil.example")).toBe("/settings/people");
125 });
126
127 test("reset and end-all each go through a confirm, which carries the sign-out choice", async () => {
128 const page = await (await get("/settings/accounts/bob", owner)).text();
129 expect(page).toContain('action="/-/confirm"');
130 expect(page).toContain("/-/confirm?to=%2Fsettings%2Faccounts%2Fbob%2Fend-sessions");
131 expect(page).not.toContain('action="/settings/accounts/bob/reset"');
132 const confirm = await (await get("/-/confirm?to=%2Fsettings%2Faccounts%2Fcarol%2Freset&endSessions=1", owner)).text();
133 expect(confirm).toContain('action="/settings/accounts/carol/reset"');
134 expect(confirm).toContain('name="endSessions" value="1"');
135 expect((await get("/-/confirm?to=%2Fsettings%2Faccounts%2Fbob%2Fend-sessions", owner)).status).toBe(200);
136 });
137
138 test("a refused password leaves the link unspent", async () => {
139 const made = await resetAccess(app, getUserByName(app.db, "olive")!, "bob");
140 if (!made.ok || made.value.token === undefined) throw new Error("link");
141 const link = `/signin/access/${made.value.token}`;
142 const before = app.db.query<{ h: string }, []>("SELECT password_hash AS h FROM user WHERE username = 'bob'").get()!.h;
143 expect((await post(link, "", { password: "short" })).status).toBe(422);
144 expect((await post(link, "", { password: "x".repeat(2000) })).status).toBe(422);
145 expect(app.db.query<{ h: string }, []>("SELECT password_hash AS h FROM user WHERE username = 'bob'").get()!.h).toBe(before);
146 expect((await get(link)).status).toBe(200);
147 expect((await post(link, "", { password: "a-good-new-passphrase" })).status).toBe(303);
148 expect((await get(link)).status).toBe(404);
149 });
150
151 test("the notice is shown on a page, not lost on a redirect, and reaches a browser already signed in", async () => {
152 const cookie = `forge_session=${createSession(app.db, bob.id).token}`;
153 await get("/settings/passkeys", cookie); // makes the browser no longer fresh
154 const olive = getUserByName(app.db, "olive")!;
155 const done = await resetAccess(app, olive, "bob");
156 expect(done.ok).toBe(true);
157 // A response that is not a rendered page does not consume it.
158 expect((await fetch(`${base}/api/v1/user`, { headers: { cookie } })).status).toBeLessThan(500);
159 expect((await fetch(`${base}/settings/passkeys`, { method: "HEAD", headers: { cookie, accept: "text/html" } })).status).toBeLessThan(500);
160 const first = await (await get("/settings/passkeys", cookie)).text();
161 expect(first).toContain("An admin reset your access on");
162 expect(await (await get("/settings/passkeys", cookie)).text()).not.toContain("An admin reset your access on");
163 });
164
165 test("reset: a link that works once, ends the old sessions, is audited, and sets the password", async () => {
166 const res = await post("/settings/accounts/bob/reset", owner, { endSessions: "1" });
167 expect(res.status).toBe(303);
168 const link = flashLink(res);
169 expect(link).toStartWith("/signin/access/");
170 // Old sessions are gone.
171 expect((await get("/settings/accounts", bobCookie)).status).not.toBe(200);
172 // The audit trail names who did it.
173 const event = listEvents(app.db, { limit: 50 }).find((e) => e.kind === "account.access_reset");
174 expect(event?.actorId).toBe(getUserByName(app.db, "olive")!.id);
175 expect(JSON.stringify(event?.payload)).not.toContain(link.split("/")[2]!);
176 // The page asks for a password; a short one spends nothing.
177 expect((await get(link)).status).toBe(200);
178 expect((await post(link, "", { password: "short" })).status).toBe(422);
179 const done = await post(link, "", { password: "bob-new-passphrase" });
180 expect(done.status).toBe(303);
181 expect(done.headers.get("location")).toBe("/settings/passkeys");
182 const session = done.headers.getSetCookie().find((c) => c.startsWith("forge_session="))!;
183 expect(session).toContain("HttpOnly");
184 expect(await Bun.password.verify("bob-new-passphrase", app.db.query<{ h: string }, []>("SELECT password_hash AS h FROM user WHERE username = 'bob'").get()!.h)).toBe(true);
185 // Second use fails.
186 expect((await get(link)).status).toBe(404);
187 expect((await post(link, "", { password: "another-passphrase" })).status).toBe(404);
188 // The person is told once, on their first page.
189 const cookie = session.split(";")[0]!;
190 const first = await (await get("/settings/passkeys", cookie)).text();
191 expect(first).toContain("An admin reset your access on");
192 const next = await (await get("/settings/passkeys", cookie)).text();
193 expect(next).not.toContain("An admin reset your access on");
194 });
195
196 test("a link fails after an hour", async () => {
197 const made = await resetAccess(app, getUserByName(app.db, "olive")!, "bob", { now: Date.now() - 2 * 3_600_000 });
198 expect(made.ok && made.value.token).toBeTruthy();
199 if (made.ok) expect((await get(`/signin/access/${made.value.token}`)).status).toBe(404);
200 const fresh = await resetAccess(app, getUserByName(app.db, "olive")!, "bob", { now: Date.now() - 59 * 60_000 });
201 if (fresh.ok) expect((await get(`/signin/access/${fresh.value.token}`)).status).toBe(200);
202 });
203
204 test("end all sessions alone, and the API with a dry run", async () => {
205 const olive = createToken(app.db, getUserByName(app.db, "olive")!.id, "t", "admin").token;
206 const cookie = `forge_session=${createSession(app.db, bob.id).token}`;
207 const dry = (await (await api("/api/v1/admin/accounts/bob/end-sessions", olive, { dryRun: true })).json()) as { sessionsEnded: number };
208 expect(dry.sessionsEnded).toBeGreaterThan(0);
209 expect((await get("/settings/passkeys", cookie)).status).toBe(200);
210 const planned = (await (await api("/api/v1/admin/accounts/bob/reset", olive, { dryRun: true })).json()) as Record<string, unknown>;
211 expect(planned["url"]).toBeUndefined();
212 const real = (await (await api("/api/v1/admin/accounts/bob/reset", olive, { endSessions: true })).json()) as { url: string; sessionsEnded: number };
213 expect(real.url).toContain("/signin/access/");
214 expect(real.sessionsEnded).toBeGreaterThan(0);
215 expect((await get("/settings/passkeys", cookie)).status).not.toBe(200);
216 const ended = (await (await api("/api/v1/admin/accounts/bob/end-sessions", olive)).json()) as { sessionsEnded: number };
217 expect(ended.sessionsEnded).toBe(0);
218 });
219
220 test("only the forge's owner manages another admin; a member is told 404", async () => {
221 const olive = createToken(app.db, getUserByName(app.db, "olive")!.id, "t", "admin").token;
222 const refused = await api("/api/v1/admin/accounts/olive/reset", adminTwo);
223 expect(refused.status).toBe(422);
224 expect(((await refused.json()) as { error: string }).error).toBe("not_allowed");
225 expect((await api("/api/v1/admin/accounts/carol/reset", olive)).status).toBe(200);
226 expect((await api("/api/v1/admin/accounts/olive/reset", olive)).status).toBe(422);
227 const member = createToken(app.db, bob.id, "t", "admin").token;
228 expect((await api("/api/v1/admin/accounts/carol/reset", member)).status).toBeGreaterThanOrEqual(403);
229 });
230
231 test("MCP account_access plans first, then makes the link once", async () => {
232 const olive = createToken(app.db, getUserByName(app.db, "olive")!.id, "t", "admin").token;
233 const call = async (args: unknown) => {
234 const res = await fetch(`${base}/mcp`, {
235 method: "POST",
236 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${olive}` },
237 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "account_access", arguments: args } }),
238 });
239 return JSON.parse(((await res.json()) as any).result.content[0].text) as Record<string, unknown>;
240 };
241 const before = listEvents(app.db, { limit: 500 }).filter((e) => e.kind === "account.access_reset").length;
242 const plan = await call({ username: "bob", action: "reset" });
243 expect(plan["url"]).toBeUndefined();
244 expect(listEvents(app.db, { limit: 500 }).filter((e) => e.kind === "account.access_reset").length).toBe(before);
245 const done = await call({ username: "bob", action: "reset", dryRun: false });
246 expect(String(done["url"])).toContain("/signin/access/");
247 expect(listEvents(app.db, { limit: 500 }).filter((e) => e.kind === "account.access_reset").length).toBe(before + 1);
248 });
249 });
test/mcp.test.ts +1
@@ -270,6 +270,7 @@ describe("the forge over MCP", () => {
270270 "account_role",
271271 "account_suspend",
272272 "account_requests",
273 "account_access",
273274 "flag_winner",
274275 "restore_drill",
275276 "model_ceiling",
test/no-polling.test.ts +1
@@ -311,6 +311,7 @@ const NOT_WALKED: Record<string, string> = {
311311 "/now/:stage": "the stage word is one the seed cannot guess",
312312 "/resilience/nightly/:job": "needs a nightly job name",
313313 "/runs/:id": "the seed creates no run",
314 "/signin/access/:token": "needs a one-time access link an admin minted",
314315 "/settings/features/:slug": "needs a feature slug",
315316 "/settings/origins/:id/archived": ORIGIN,
316317 "/settings/origins/:id/mirror/:job": ORIGIN,
test/pages-budget.test.ts +4 −1
@@ -23,6 +23,7 @@ import { pin } from "../src/core/pins.ts";
2323 import { getRepo } from "../src/core/repos.ts";
2424 import { recordStart } from "../src/core/starts.ts";
2525 import { createUser, type User } from "../src/core/users.ts";
26 import { perfRecord } from "../src/perf/live.ts";
2627 import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts";
2728 import { quietLogs } from "./helpers/globals.ts";
2829 import { promote } from "./helpers/members.ts";
@@ -43,7 +44,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = {
4344 };
4445
4546 /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */
46 const MAX_UNFIXTURED = 146;
47 const MAX_UNFIXTURED = 147;
4748
4849 /** Streams and live fragments: no page. */
4950 const NOT_PAGES = new Set([
@@ -155,6 +156,8 @@ beforeAll(async () => {
155156 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
156157 }, 60_000);
157158 afterAll(() => {
159 // The walk fills the shared recorder to its pattern cap; leave it empty for the next file in this process.
160 perfRecord.clear();
158161 servers.stop();
159162 app.close();
160163 rmSync(root, { recursive: true, force: true });
test/palette-roster.test.ts +1
@@ -153,6 +153,7 @@ test("a site-admin person is offered the whole roster", () => {
153153 ["model-ceiling", "Model ceiling", "set an account's dollars a day and a run"],
154154 ["can", "What I can do", "your role and what it allows here"],
155155 ["people", "People", "who is a guest, a member or an admin; promote or demote"],
156 ["account-access", "Account access", "search people, reset access, end sessions"],
156157 ["quotas", "Account quotas", "what one account may own, and who changed it"],
157158 ["limits", "Limits", "what the forge caps, with default and bounds; change one"],
158159 ["plans", "Plans", "free, small, medium and large: size, price, crew credit, forges; change one"],
test/security-floor-routes.test.ts +1
@@ -63,6 +63,7 @@ const CART = "the seed pins no build with a cart (test/build-cart.test.ts covers
6363 const UNSEEDED: Record<string, string> = {
6464 "/:owner/-/teams/:team": ORG,
6565 "/claim/start/:token": "needs a one-time start claim token, which the seed does not mint (test/start.test.ts covers it)",
66 "/signin/access/:token": "needs a one-time access link an admin minted",
6667 "/talk/:id": TALK,
6768 "/docs/:file": "a file name of the documentation folder; the seed supplies no :file",
6869 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
test/signins.test.ts +8 −6
@@ -288,13 +288,15 @@ describe("sign-ins: tokens and browsers, seen and ended", () => {
288288 expect(await signedIn({ cookie: browser })).toBe(false);
289289 });
290290
291 test("the owner has no such page for another admin", async () => {
291 // The account page now opens for another admin, for the forge's owner alone, to reset their
292 // access (#11688); the list of their tokens and browsers stays closed.
293 test("the owner has no sign-ins list for another admin", async () => {
292294 const ownerToken = createToken(app.db, owner.id, "o2", "admin").token;
293295 expect((await api("/api/v1/users/hal/signins", ownerToken)).status).toBe(404);
294 expect(
295 (await fetch(`${base}/settings/accounts/hal`, {
296 headers: { cookie: cookieFor(owner) },
297 })).status,
298 ).toBe(404);
296 const page = await fetch(`${base}/settings/accounts/hal`, { headers: { cookie: cookieFor(owner) } });
297 expect(page.status).toBe(200);
298 const text = await page.text();
299 expect(text).not.toContain("Access tokens");
300 expect(text).not.toContain("Signed-in browsers");
299301 });
300302 });
test/tui-account-access.test.ts added +105
@@ -0,0 +1,105 @@
1 /** The terminal's Account access screen: the page's gates, and a write that reaches the bus (#11688). */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createSession, sessionUser } from "../src/core/sessions.ts";
9 import { createUser, getUserByName } from "../src/core/users.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { headlessTerminal } from "../src/tui/term/terminal.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { settleApp, settleUntil } from "./helpers/tui.ts";
14
15 quietLogs();
16
17 describe("account access in the terminal", () => {
18 let dataDir = "";
19 let forge: App;
20
21 function boot(username: string) {
22 const terminal = headlessTerminal(140, 32);
23 const user = getUserByName(forge.db, username)!;
24 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
25 app.start();
26 const press = async (...keys: string[]) => {
27 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
28 await settleApp(app);
29 };
30 const type = async (text: string) => press(...text.split(""));
31 return { app, press, type, text: () => app.screen.toText() };
32 }
33
34 async function open(h: ReturnType<typeof boot>, name: string): Promise<void> {
35 h.app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
36 await settleApp(h.app);
37 await h.type("Account access");
38 await h.press("enter");
39 await h.press("/");
40 await h.type(name);
41 await h.press("enter");
42 await settleUntil(h.app, name);
43 }
44
45 beforeAll(async () => {
46 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-access-"));
47 forge = createApp({ dataDir, port: 0 });
48 for (const [username, isAdmin] of [["alice", true], ["dave", true], ["bob", false]] as const) {
49 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-access-passphrase", isAdmin });
50 if (!made.ok) throw new Error(username);
51 }
52 }, 30_000);
53
54 afterAll(async () => {
55 forge.close();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 test("a person who is not an admin is offered nothing and sees nobody", async () => {
60 const h = boot("bob");
61 h.app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
62 await settleApp(h.app);
63 await h.type("Account access");
64 expect(h.text()).not.toContain("search people, reset access");
65 h.app.stop();
66 });
67
68 test("search narrows the list; a reset after a confirm makes a link, ends sessions and reaches the bus", async () => {
69 const bob = getUserByName(forge.db, "bob")!;
70 const token = createSession(forge.db, bob.id).token;
71 const kinds: string[] = [];
72 const off = forge.bus.subscribe((e) => void kinds.push(e.kind));
73 const h = boot("alice");
74 await open(h, "bob");
75 expect(h.text()).not.toContain("dave");
76 await h.press("r");
77 expect(h.text()).toContain("one-hour, one-use link");
78 await h.press("esc");
79 expect(sessionUser(forge.db, token).ok).toBe(true);
80 await h.press("r", "left", "left", "enter");
81 await settleUntil(h.app, "/signin/access/");
82 expect(sessionUser(forge.db, token).ok).toBe(false);
83 expect(kinds).toContain("account.access_reset");
84 off();
85 h.app.stop();
86 });
87
88 test("end all sessions asks first, and another admin is the owner's alone", async () => {
89 const dave = boot("dave");
90 await open(dave, "alice");
91 await dave.press("e", "y");
92 await settleUntil(dave.app, "only the forge's owner");
93 dave.app.stop();
94
95 const token = createSession(forge.db, getUserByName(forge.db, "bob")!.id).token;
96 const h = boot("alice");
97 await open(h, "bob");
98 await h.press("e", "esc");
99 expect(sessionUser(forge.db, token).ok).toBe(true);
100 await h.press("e", "y");
101 await settleUntil(h.app, "bob is signed out of");
102 expect(sessionUser(forge.db, token).ok).toBe(false);
103 h.app.stop();
104 });
105 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs