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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11688-from-a-person-s-profile-an-admin-opens-t → main 13 commits fork
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`; | ||
| 42 | 42 | | Tool | Actions / kinds | Purpose | Bytes | |
| 43 | 43 | | --- | --- | --- | ---: | |
| 44 | 44 | | `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 | | |
| 45 | 46 | | `account_requests` | — | List who asked for an account and wait; `answer: allow\|decline` answers one | 250 | |
| 46 | 47 | | `account_role` | — | Make a person a member or a guest; promoting may grant `usdPerMonth` | 374 | |
| 47 | 48 | | `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 | ||
| 248 | 248 | export { route as patchApiV1ReposOwnerNamePullsNumber } from "./patchApiV1ReposOwnerNamePullsNumber.ts"; |
| 249 | 249 | export { route as patchApiV1User } from "./patchApiV1User.ts"; |
| 250 | 250 | export { route as postApiV1AdminAccountsUsernameCeiling } from "./postApiV1AdminAccountsUsernameCeiling.ts"; |
| 251 | export { route as postApiV1AdminAccountsUsernameEndSessions } from "./postApiV1AdminAccountsUsernameEndSessions.ts"; | |
| 251 | 252 | export { route as postApiV1AdminAccountsUsernameModelGrant } from "./postApiV1AdminAccountsUsernameModelGrant.ts"; |
| 253 | export { route as postApiV1AdminAccountsUsernameReset } from "./postApiV1AdminAccountsUsernameReset.ts"; | |
| 252 | 254 | export { route as postApiV1AdminAccountsUsernameRole } from "./postApiV1AdminAccountsUsernameRole.ts"; |
| 253 | 255 | export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1AdminAccountsUsernameSuspend.ts"; |
| 254 | 256 | 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/access_link.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * An admin's one-time access link for a person, and the notices a person is | |
| 3 | * told the next time they are here. Pure data: `actions/account_access.ts` | |
| 4 | * decides who may make or spend one. | |
| 5 | * | |
| 6 | * Only the hash of a link's secret is kept. A link is good for an hour and | |
| 7 | * is spent by exactly one redemption, in the statement that checks it, so | |
| 8 | * two at once cannot both win. Making a new link for a person withdraws | |
| 9 | * their older ones. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { sha256 } from "../kernel/hash.ts"; | |
| 14 | import { id as newId, secret } from "../kernel/id.ts"; | |
| 15 | ||
| 16 | export const ACCESS_LINK_TTL_MS = 3_600_000; | |
| 17 | ||
| 18 | /** Make a link for `userId`; its secret is returned once and never kept. */ | |
| 19 | export function makeAccessLink( | |
| 20 | db: Database, | |
| 21 | userId: string, | |
| 22 | createdBy: string, | |
| 23 | now: number, | |
| 24 | ): { token: string; expiresAt: string } { | |
| 25 | const token = secret(); | |
| 26 | const expiresAt = new Date(now + ACCESS_LINK_TTL_MS).toISOString(); | |
| 27 | db.transaction(() => { | |
| 28 | db.query("UPDATE access_link SET used_at = ?2 WHERE user_id = ?1 AND used_at IS NULL").run( | |
| 29 | userId, | |
| 30 | new Date(now).toISOString(), | |
| 31 | ); | |
| 32 | db.query( | |
| 33 | `INSERT INTO access_link (id, user_id, token_hash, created_by, created_at, expires_at) | |
| 34 | VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, | |
| 35 | ).run(newId(), userId, sha256(token), createdBy, new Date(now).toISOString(), expiresAt); | |
| 36 | })(); | |
| 37 | return { token, expiresAt }; | |
| 38 | } | |
| 39 | ||
| 40 | /** Whose link this is while it still works; nothing is spent. */ | |
| 41 | export function accessLinkOwner(db: Database, token: string, now: number): string | undefined { | |
| 42 | if (token === "") return undefined; | |
| 43 | return ( | |
| 44 | db | |
| 45 | .query<{ user_id: string }, [string, string]>( | |
| 46 | "SELECT user_id FROM access_link WHERE token_hash = ?1 AND used_at IS NULL AND expires_at > ?2", | |
| 47 | ) | |
| 48 | .get(sha256(token), new Date(now).toISOString())?.user_id ?? undefined | |
| 49 | ); | |
| 50 | } | |
| 51 | ||
| 52 | /** Spend a link: whose it was, once; undefined after, or when it has lapsed. */ | |
| 53 | export function spendAccessLink(db: Database, token: string, now: number): string | undefined { | |
| 54 | if (token === "") return undefined; | |
| 55 | const at = new Date(now).toISOString(); | |
| 56 | return ( | |
| 57 | db | |
| 58 | .query<{ user_id: string }, [string, string]>( | |
| 59 | `UPDATE access_link SET used_at = ?2 | |
| 60 | WHERE token_hash = ?1 AND used_at IS NULL AND expires_at > ?2 | |
| 61 | RETURNING user_id`, | |
| 62 | ) | |
| 63 | .get(sha256(token), at)?.user_id ?? undefined | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | /** Leave a notice for a person to read the next time they are here. */ | |
| 68 | export function leaveNotice(db: Database, userId: string, text: string, at: Date): void { | |
| 69 | db.query("INSERT INTO account_notice (user_id, text, at) VALUES (?1, ?2, ?3)").run( | |
| 70 | userId, | |
| 71 | text, | |
| 72 | at.toISOString(), | |
| 73 | ); | |
| 74 | } | |
| 75 | ||
| 76 | /** The oldest notice not yet shown, marked shown by being taken; undefined when there is none. */ | |
| 77 | export function takeNotice(db: Database, userId: string, now: Date): string | undefined { | |
| 78 | return ( | |
| 79 | db | |
| 80 | .query<{ text: string }, [string, string]>( | |
| 81 | `UPDATE account_notice SET seen_at = ?2 | |
| 82 | WHERE id = (SELECT id FROM account_notice WHERE user_id = ?1 AND seen_at IS NULL ORDER BY id LIMIT 1) | |
| 83 | RETURNING text`, | |
| 84 | ) | |
| 85 | .get(userId, now.toISOString())?.text ?? undefined | |
| 86 | ); | |
| 87 | } |
src/core/accounts.ts +5 −5
| @@ -47,8 +47,8 @@ export function originOf(a: Pick<AccountRow, "kind" | "via" | "signsInThrough">) | ||
| 47 | 47 | return a.via ?? (a.kind === "agent" ? "crew" : ""); |
| 48 | 48 | } |
| 49 | 49 | |
| 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[] { | |
| 52 | 52 | const rows = db |
| 53 | 53 | .query< |
| 54 | 54 | { |
| @@ -67,7 +67,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 67 | 67 | spent: number | null; |
| 68 | 68 | payload: string | null; |
| 69 | 69 | }, |
| 70 | [] | |
| 70 | [string] | |
| 71 | 71 | >( |
| 72 | 72 | `SELECT u.id, u.username, u.display_name, u.is_admin, u.is_agent, |
| 73 | 73 | u.created_at, u.suspended_at, u.removed_at, u.waiting_at, |
| @@ -80,10 +80,10 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 80 | 80 | (SELECT e.payload FROM event e |
| 81 | 81 | WHERE e.kind = 'user.created' AND e.actor_id = u.id |
| 82 | 82 | 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) | |
| 84 | 84 | ORDER BY u.created_at DESC, u.id DESC`, |
| 85 | 85 | ) |
| 86 | .all(); | |
| 86 | .all(only); | |
| 87 | 87 | return rows.map((r) => { |
| 88 | 88 | const origin = parseOrigin(r.payload); |
| 89 | 89 | return { |
src/core/event_audience.ts +3
| @@ -159,6 +159,9 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 159 | 159 | "label.deleted", |
| 160 | 160 | "telemetry.granted", |
| 161 | 161 | "telemetry.revoked", |
| 162 | "account.access_reset", | |
| 163 | "account.sessions_ended", | |
| 164 | "account.access_redeemed", | |
| 162 | 165 | "account.suspended", |
| 163 | 166 | "account.unsuspended", |
| 164 | 167 | "org.retired", |
src/core/migration-pins/20261112100000-access-links.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | e7ec1142078a97f50d3032ef799e8da29f1e87927cd5c674c8045c91620477fb |
src/core/migrations/20261112100000-access-links.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | // An admin's one-time access link for a person (`access_link`, #11688): only its hash is kept, with who | |
| 2 | // made it, when it lapses and when it was spent. `account_notice` holds what a person is told the next | |
| 3 | // time they are here ("an admin reset your access on …"); `seen_at` is set once it has been shown. | |
| 4 | // `session.address` is where a browser was first seen from, for the admin's list of recent sign-ins. | |
| 5 | // New tables and one nullable column. Additive. | |
| 6 | export default ` | |
| 7 | CREATE TABLE access_link ( | |
| 8 | id TEXT PRIMARY KEY, | |
| 9 | user_id TEXT NOT NULL, | |
| 10 | token_hash TEXT NOT NULL UNIQUE, | |
| 11 | created_by TEXT NOT NULL, | |
| 12 | created_at TEXT NOT NULL, | |
| 13 | expires_at TEXT NOT NULL, | |
| 14 | used_at TEXT | |
| 15 | ); | |
| 16 | CREATE INDEX access_link_user ON access_link (user_id); | |
| 17 | CREATE TABLE account_notice ( | |
| 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 19 | user_id TEXT NOT NULL, | |
| 20 | text TEXT NOT NULL, | |
| 21 | at TEXT NOT NULL, | |
| 22 | seen_at TEXT | |
| 23 | ); | |
| 24 | CREATE INDEX account_notice_unseen ON account_notice (user_id) WHERE seen_at IS NULL; | |
| 25 | ALTER TABLE session ADD COLUMN address TEXT; | |
| 26 | `; |
src/core/sessions.ts +17 −8
| @@ -20,6 +20,8 @@ export interface SessionInfo { | ||
| 20 | 20 | readonly createdAt: string; |
| 21 | 21 | readonly lastSeenAt: string | null; |
| 22 | 22 | readonly expiresAt: string; |
| 23 | /** Where it was first seen from, when that was recorded. */ | |
| 24 | readonly address: string | null; | |
| 23 | 25 | /** The browser asking: signing it out signs the asker out. */ |
| 24 | 26 | readonly current: boolean; |
| 25 | 27 | } |
| @@ -48,7 +50,8 @@ export function sessionUser( | ||
| 48 | 50 | export function sessionOf( |
| 49 | 51 | db: Database, |
| 50 | 52 | token: string, |
| 51 | ): Result<{ user: User; id: string }, "invalid_session"> { | |
| 53 | address?: string, | |
| 54 | ): Result<{ user: User; id: string; notice: boolean }, "invalid_session"> { | |
| 52 | 55 | const hash = sha256(token); |
| 53 | 56 | const row = db |
| 54 | 57 | .query< |
| @@ -57,10 +60,13 @@ export function sessionOf( | ||
| 57 | 60 | user_id: string; |
| 58 | 61 | expires_at: string; |
| 59 | 62 | last_seen_at: string | null; |
| 63 | notice: number; | |
| 60 | 64 | }, |
| 61 | 65 | [string] |
| 62 | 66 | >( |
| 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`, | |
| 64 | 70 | ) |
| 65 | 71 | .get(hash); |
| 66 | 72 | const now = new Date(); |
| @@ -72,12 +78,13 @@ export function sessionOf( | ||
| 72 | 78 | const stale = new Date(now.getTime() - SEEN_EVERY_MS).toISOString(); |
| 73 | 79 | if (row.last_seen_at === null || row.last_seen_at < stale) |
| 74 | 80 | 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), | |
| 79 | 86 | ); |
| 80 | return ok({ user, id: row.id }); | |
| 87 | return ok({ user, id: row.id, notice: row.notice === 1 }); | |
| 81 | 88 | } |
| 82 | 89 | |
| 83 | 90 | export function destroySession(db: Database, token: string): void { |
| @@ -102,10 +109,11 @@ export function listSessions( | ||
| 102 | 109 | created_at: string; |
| 103 | 110 | last_seen_at: string | null; |
| 104 | 111 | expires_at: string; |
| 112 | address: string | null; | |
| 105 | 113 | }, |
| 106 | 114 | [string, string] |
| 107 | 115 | >( |
| 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 | |
| 109 | 117 | WHERE user_id = ?1 AND expires_at > ?2 ORDER BY created_at DESC, id`, |
| 110 | 118 | ) |
| 111 | 119 | .all(userId, new Date().toISOString()) |
| @@ -114,6 +122,7 @@ export function listSessions( | ||
| 114 | 122 | createdAt: r.created_at, |
| 115 | 123 | lastSeenAt: r.last_seen_at, |
| 116 | 124 | expiresAt: r.expires_at, |
| 125 | address: r.address, | |
| 117 | 126 | current: r.token_hash === mine, |
| 118 | 127 | })); |
| 119 | 128 | } |
src/core/users.ts +11 −5
| @@ -479,11 +479,17 @@ export async function changePassword( | ||
| 479 | 479 | ): Promise<Result<void, "weak_password" | "password_too_long" | "no_such_user">> { |
| 480 | 480 | if (next.length < MIN_PASSWORD) return err("weak_password"); |
| 481 | 481 | 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; | |
| 487 | 493 | } |
| 488 | 494 | |
| 489 | 495 | 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>> = { | ||
| 117 | 117 | seal: { does: "seal or unseal an issue or pull" }, |
| 118 | 118 | migrate: { does: "bring a repository in from a public address" }, |
| 119 | 119 | 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 | }, | |
| 120 | 124 | account_role: { does: "make a person a member or a guest" }, |
| 121 | 125 | // Every admin-scope tool. |
| 122 | 126 | 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"; | ||
| 15 | 15 | import { kpiProbe } from "../../web/studio/kpi_probe.ts"; |
| 16 | 16 | import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts"; |
| 17 | 17 | import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; |
| 18 | import { ACCESS_WORDS, accessUrl, endAllSessions, resetAccess } from "../../actions/account_access.ts"; | |
| 18 | 19 | import { ADMIN_WORDS } from "../../actions/admin_words.ts"; |
| 19 | 20 | import { DERIVED_WORDS, REASON_WORDS, changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts"; |
| 20 | 21 | import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts"; |
| @@ -586,6 +587,35 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 586 | 587 | return done.ok ? json({ username: done.value.username, suspendedAt: null }) : accountError(done.error); |
| 587 | 588 | }); |
| 588 | 589 | |
| 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 | ||
| 589 | 619 | router.add("POST", "/api/v1/admin/accounts/:username/verify", (ctx) => { |
| 590 | 620 | const denied = accountAdmin(ctx, "admin"); |
| 591 | 621 | if (denied) return denied; |
src/http/context.ts +2
| @@ -20,6 +20,8 @@ export interface Auth { | ||
| 20 | 20 | readonly via: "session" | "edge" | "token" | "password" | "none"; |
| 21 | 21 | /** Which signed-in browser asked, when `via` is `session`. */ |
| 22 | 22 | 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; | |
| 23 | 25 | /** Set for a token: the name its owner gave it, never its secret. */ |
| 24 | 26 | readonly tokenName?: string; |
| 25 | 27 | /** 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 @@ | ||
| 1 | 1 | // Generated by `bun scripts/mcp-tools.ts` from the files beside it; do not edit. |
| 2 | 2 | // One line per tool, sorted: a new tool is a new file and one new line here. |
| 3 | 3 | export { tool as access } from "./access.ts"; |
| 4 | export { tool as account_access } from "./account_access.ts"; | |
| 4 | 5 | export { tool as account_requests } from "./account_requests.ts"; |
| 5 | 6 | export { tool as account_role } from "./account_role.ts"; |
| 6 | 7 | export { tool as account_suspend } from "./account_suspend.ts"; |
src/http/server.ts +6 −1
| @@ -157,6 +157,8 @@ import { | ||
| 157 | 157 | recoverFinish, |
| 158 | 158 | recoverOptions, |
| 159 | 159 | recoverPage, |
| 160 | accessPage, | |
| 161 | accessSubmit, | |
| 160 | 162 | recoverRequest, |
| 161 | 163 | signinOptions, |
| 162 | 164 | signinPasskey, |
| @@ -660,13 +662,14 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 660 | 662 | |
| 661 | 663 | const session = cookieValue(request, "forge_session"); |
| 662 | 664 | if (session !== undefined) { |
| 663 | const found = sessionOf(app.db, session); | |
| 665 | const found = sessionOf(app.db, session, clientAddress(request)); | |
| 664 | 666 | if (found.ok) |
| 665 | 667 | return { |
| 666 | 668 | user: found.value.user, |
| 667 | 669 | scope: "admin", |
| 668 | 670 | via: "session", |
| 669 | 671 | sessionId: found.value.id, |
| 672 | ...(found.value.notice ? { noticeWaiting: true as const } : {}), | |
| 670 | 673 | }; |
| 671 | 674 | } |
| 672 | 675 | |
| @@ -915,6 +918,8 @@ export function buildRouter(): Router<Handler> { | ||
| 915 | 918 | router.add("GET", "/signin/parent", parentStart); |
| 916 | 919 | router.add("GET", "/signin/parent/callback", parentCallback); |
| 917 | 920 | router.add("GET", "/signup", signupPage); |
| 921 | router.add("GET", "/signin/access/:token", accessPage); | |
| 922 | router.add("POST", "/signin/access/:token", accessSubmit); | |
| 918 | 923 | router.add("GET", "/claim/start/:token", startClaimPage); |
| 919 | 924 | router.add("POST", "/claim/start/:token", startClaimSubmit); |
| 920 | 925 | 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"; | ||
| 32 | 32 | import { requestAct } from "../web/requests_card.ts"; |
| 33 | 33 | import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts"; |
| 34 | 34 | import { |
| 35 | accessAct, | |
| 35 | 36 | accountAct, |
| 36 | 37 | retireAct, |
| 37 | 38 | retirePage, |
| @@ -270,6 +271,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 270 | 271 | router.add("POST", "/settings/accounts/:username/model", grantAct); |
| 271 | 272 | router.add("POST", "/settings/accounts/:username/ceiling", ceilingAct); |
| 272 | 273 | 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")); | |
| 273 | 276 | router.add( |
| 274 | 277 | "POST", |
| 275 | 278 | "/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"; | ||
| 9 | 9 | import type { Command } from "../app/palette.ts"; |
| 10 | 10 | import type { Session } from "../session.ts"; |
| 11 | 11 | import { entry as access } from "./access.ts"; |
| 12 | import { entry as accountAccess } from "./account_access.ts"; | |
| 12 | 13 | import { entry as agents } from "./agents.ts"; |
| 13 | 14 | import { entry as askData } from "./ask_data.ts"; |
| 14 | 15 | import { entry as archived } from "./archived.ts"; |
| @@ -150,6 +151,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 150 | 151 | modelGrantEntry, |
| 151 | 152 | modelCeilingEntry, |
| 152 | 153 | people, |
| 154 | accountAccess, | |
| 153 | 155 | quotas, |
| 154 | 156 | limits, |
| 155 | 157 | plans, |
src/web/layout.ts +9 −1
| @@ -64,6 +64,7 @@ import { prefersJson } from "./form.ts"; | ||
| 64 | 64 | import { notePhase } from "../core/route_timing.ts"; |
| 65 | 65 | import { html, raw, when, type Html } from "./html.ts"; |
| 66 | 66 | import { noRoom } from "../spawn/door.ts"; |
| 67 | import { takeNotice } from "../core/access_link.ts"; | |
| 67 | 68 | import { quotaFull } from "../spawn/quota.ts"; |
| 68 | 69 | import { icon, inlineIcons, pointAtSprite, themeGlyph } from "./icons.ts"; |
| 69 | 70 | import { btn, copyField, hintAttr, kbd, menu, type MenuItem } from "./ui.ts"; |
| @@ -772,7 +773,14 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 772 | 773 | const shareImage = `${ctx.url.origin}${options.ogImage !== undefined && options.ogImage.startsWith("/og/") ? options.ogImage : "/og/site.png"}`; |
| 773 | 774 | const theme = ctx.theme ?? options.theme; |
| 774 | 775 | 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 }); | |
| 776 | 784 | const links = pageLinks(ctx); |
| 777 | 785 | |
| 778 | 786 | // 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( | ||
| 700 | 700 | ${sessions.map((x) => |
| 701 | 701 | row({ |
| 702 | 702 | 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}`}`, | |
| 704 | 704 | trail: html`${confirmAction({ |
| 705 | 705 | action: `${base}/sessions/${x.id}/delete`, |
| 706 | 706 | verb: "Sign out", |
src/web/pages/accounts.ts +137 −36
| @@ -14,19 +14,21 @@ import { | ||
| 14 | 14 | unsuspendAccount, |
| 15 | 15 | verifyAccount, |
| 16 | 16 | } 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"; | |
| 22 | 24 | 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"; | |
| 24 | 26 | import { |
| 25 | 27 | OWNERS_TEAM, |
| 26 | 28 | ownerlessOrgs, |
| 27 | 29 | soleOwnedOrgs, |
| 28 | 30 | } from "../../core/orgs.ts"; |
| 29 | import { getUserByName } from "../../core/users.ts"; | |
| 31 | import { getUserByName, hasPassword, isStandInEmail } from "../../core/users.ts"; | |
| 30 | 32 | import type { Ctx } from "../../http/context.ts"; |
| 31 | 33 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 32 | 34 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| @@ -37,6 +39,7 @@ import { | ||
| 37 | 39 | badge, |
| 38 | 40 | btn, |
| 39 | 41 | confirmAction, |
| 42 | field as formField, | |
| 40 | 43 | row, |
| 41 | 44 | time, |
| 42 | 45 | userLink, |
| @@ -97,7 +100,7 @@ function personRow(a: AccountRow, sole: readonly string[]): Html { | ||
| 97 | 100 | : null}`, |
| 98 | 101 | ...(actionable |
| 99 | 102 | ? { after: html` |
| 100 | ${btn("Sign-ins", { href: at, size: "sm", variant: "ghost" })} | |
| 103 | ${btn("Manage account", { href: at, size: "sm", variant: "ghost" })} | |
| 101 | 104 | ${a.verified |
| 102 | 105 | ? null |
| 103 | 106 | : html`<form method="post" action="${at}/verify"> |
| @@ -128,7 +131,8 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { | ||
| 128 | 131 | listAccounts(ctx.app, user), |
| 129 | 132 | ); |
| 130 | 133 | 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)); | |
| 132 | 136 | // Without mail a newcomer cannot prove an address; the owner's word is |
| 133 | 137 | // the only way in, and the page says so where it is given. |
| 134 | 138 | const mail = timePhase(ctx.request, "mail", () => mailConfigured(ctx.app)); |
| @@ -202,7 +206,11 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { | ||
| 202 | 206 | ctx, |
| 203 | 207 | html`${notice ?? null}${requestsCard(ctx)}${ownerless}${edgeOnly}${noMail}<section class="settings-block" id="accounts"> |
| 204 | 208 | <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> | |
| 206 | 214 | <ul class="list setting-list" id="accounts-list"> |
| 207 | 215 | ${people.map((a, i) => personRow(a, sole[i] ?? []))} |
| 208 | 216 | </ul> |
| @@ -264,51 +272,144 @@ function refuseRemove(username: string): Html { | ||
| 264 | 272 | </section>`; |
| 265 | 273 | } |
| 266 | 274 | |
| 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 | |
| 268 | 287 | export function personSignInsPage(ctx: Ctx): Response { |
| 269 | 288 | if (!ctx.auth.user) return signInFirst(ctx); |
| 270 | 289 | 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); | |
| 272 | 292 | // An organization has no sign-ins: its page is its model grant and spend ceilings. |
| 273 | 293 | if (named?.kind === "org" && ctx.auth.user.kind === "user") |
| 274 | 294 | 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; | |
| 280 | 301 | 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> | |
| 290 | 368 | ${tokens.length === 0 |
| 291 | 369 | ? html`<p class="muted">No access tokens.</p>` |
| 292 | 370 | : html`<ul class="list setting-list"> |
| 293 | 371 | ${tokens.map((t) => |
| 294 | 372 | row({ |
| 295 | 373 | 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" }), | |
| 303 | 376 | }), |
| 304 | 377 | )} |
| 305 | 378 | </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}`, | |
| 309 | 383 | ); |
| 310 | 384 | } |
| 311 | 385 | |
| 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 | ||
| 312 | 413 | // POST /settings/accounts/:username/(tokens|sessions)/:id/delete |
| 313 | 414 | export function signInAct(what: "token" | "session") { |
| 314 | 415 | return async (ctx: Ctx): Promise<Response> => { |
src/web/pages/org.ts +13
| @@ -401,6 +401,19 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> { | ||
| 401 | 401 | ${time(owner.createdAt)} |
| 402 | 402 | </p> |
| 403 | 403 | </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 | )} | |
| 404 | 417 | ${when(isSelf, () => |
| 405 | 418 | btn("New repository", { |
| 406 | 419 | href: `/new?owner=${owner.username}`, |
src/web/pages/passkeys.ts +58 −3
| @@ -24,7 +24,9 @@ import { requestOf } from "../../core/requests.ts"; | ||
| 24 | 24 | import { createSession } from "../../core/sessions.ts"; |
| 25 | 25 | import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts"; |
| 26 | 26 | 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"; | |
| 28 | 30 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 29 | 31 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 30 | 32 | import { clientAddress, clientBucket } from "../../http/peer.ts"; |
| @@ -33,10 +35,10 @@ import { boundedBody, json, str } from "../../http/respond.ts"; | ||
| 33 | 35 | import { CAME_FROM_ELSEWHERE, signInFirst } from "../chrome.ts"; |
| 34 | 36 | import { field, readForm } from "../form.ts"; |
| 35 | 37 | import { html } from "../html.ts"; |
| 36 | import { flashTo, page } from "../layout.ts"; | |
| 38 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 37 | 39 | import { passkeyJs } from "../passkey-client.ts"; |
| 38 | 40 | import { passkeyNote } from "../passkey-state.ts"; |
| 39 | import { confirmAction, row, time } from "../ui.ts"; | |
| 41 | import { btn, confirmAction, row, time } from "../ui.ts"; | |
| 40 | 42 | import { accountShell } from "./account.ts"; |
| 41 | 43 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; |
| 42 | 44 | import { PASSKEY_EXPIRED, PASSKEY_REJECTED, PASSKEY_TAKEN } from "../passkey-words.ts"; |
| @@ -353,3 +355,56 @@ export async function recoverFinish(ctx: Ctx): Promise<Response> { | ||
| 353 | 355 | "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), |
| 354 | 356 | }); |
| 355 | 357 | } |
| 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 | ||
| 15 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 16 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 17 | 17 | import { field, readForm } from "../form.ts"; |
| 18 | import { isLocalPath } from "../flash.ts"; | |
| 18 | 19 | import { html } from "../html.ts"; |
| 19 | 20 | import { flashTo, page } from "../layout.ts"; |
| 20 | 21 | import { badge, btn, empty, userLink } from "../ui.ts"; |
| @@ -114,7 +115,8 @@ export async function roleAct(ctx: Ctx): Promise<Response> { | ||
| 114 | 115 | const raw = field(form, "usd").trim().replace(/^\$/, ""); |
| 115 | 116 | const usd = raw === "" || Number(raw) === 0 ? null : /^\d{1,6}(\.\d{1,2})?$/.test(raw) ? Number(raw) : NaN; |
| 116 | 117 | 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"; | |
| 118 | 120 | if (!done.ok) { |
| 119 | 121 | if (done.error === "not_found" || getUserByName(ctx.app.db, username) === undefined) return notFoundPage(ctx); |
| 120 | 122 | return flashTo(ctx, back, { |
src/web/pages/search.ts +19
| @@ -856,6 +856,25 @@ const DESTRUCTIVE: { | ||
| 856 | 856 | phrase: "", |
| 857 | 857 | }), |
| 858 | 858 | }, |
| 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 | }, | |
| 859 | 878 | { |
| 860 | 879 | pattern: /^\/settings\/accounts\/([^/]+)\/remove$/, |
| 861 | 880 | 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/cookie-flags.test.ts +1 −1
| @@ -35,7 +35,7 @@ const WALKED: Record<string, number> = { | ||
| 35 | 35 | "src/http/server.ts": 1, // the mode switch |
| 36 | 36 | "src/http/rp.ts": 6, // parent sign-in and link: state cookie set and cleared |
| 37 | 37 | "src/apps/proxy.ts": 2, // app grant, app sign-out |
| 38 | "src/web/pages/passkeys.ts": 4, // import, passkey sign-in, passkey add/recovery-link sign-in (+1 mention) | |
| 38 | "src/web/pages/passkeys.ts": 5, // import, passkey sign-in, passkey add/recovery-link sign-in, access-link sign-in (+1 mention) | |
| 39 | 39 | "src/web/pages/site.ts": 4, // sessionCookie() itself and its uses: password sign-in/sign-up, logout; the theme cookie |
| 40 | 40 | "src/web/layout.ts": 4, // the flash set on, and cleared from, a redirect |
| 41 | 41 | "src/web/flash.ts": 2, // forge_flash |
test/mcp.test.ts +1
| @@ -270,6 +270,7 @@ describe("the forge over MCP", () => { | ||
| 270 | 270 | "account_role", |
| 271 | 271 | "account_suspend", |
| 272 | 272 | "account_requests", |
| 273 | "account_access", | |
| 273 | 274 | "flag_winner", |
| 274 | 275 | "restore_drill", |
| 275 | 276 | "model_ceiling", |
test/no-polling.test.ts +1
| @@ -311,6 +311,7 @@ const NOT_WALKED: Record<string, string> = { | ||
| 311 | 311 | "/now/:stage": "the stage word is one the seed cannot guess", |
| 312 | 312 | "/resilience/nightly/:job": "needs a nightly job name", |
| 313 | 313 | "/runs/:id": "the seed creates no run", |
| 314 | "/signin/access/:token": "needs a one-time access link an admin minted", | |
| 314 | 315 | "/settings/features/:slug": "needs a feature slug", |
| 315 | 316 | "/settings/origins/:id/archived": ORIGIN, |
| 316 | 317 | "/settings/origins/:id/mirror/:job": ORIGIN, |
test/pages-budget.test.ts +4 −1
| @@ -23,6 +23,7 @@ import { pin } from "../src/core/pins.ts"; | ||
| 23 | 23 | import { getRepo } from "../src/core/repos.ts"; |
| 24 | 24 | import { recordStart } from "../src/core/starts.ts"; |
| 25 | 25 | import { createUser, type User } from "../src/core/users.ts"; |
| 26 | import { perfRecord } from "../src/perf/live.ts"; | |
| 26 | 27 | import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts"; |
| 27 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 28 | 29 | import { promote } from "./helpers/members.ts"; |
| @@ -43,7 +44,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = { | ||
| 43 | 44 | }; |
| 44 | 45 | |
| 45 | 46 | /** 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; | |
| 47 | 48 | |
| 48 | 49 | /** Streams and live fragments: no page. */ |
| 49 | 50 | const NOT_PAGES = new Set([ |
| @@ -155,6 +156,8 @@ beforeAll(async () => { | ||
| 155 | 156 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 156 | 157 | }, 60_000); |
| 157 | 158 | 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(); | |
| 158 | 161 | servers.stop(); |
| 159 | 162 | app.close(); |
| 160 | 163 | 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", () => { | ||
| 153 | 153 | ["model-ceiling", "Model ceiling", "set an account's dollars a day and a run"], |
| 154 | 154 | ["can", "What I can do", "your role and what it allows here"], |
| 155 | 155 | ["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"], | |
| 156 | 157 | ["quotas", "Account quotas", "what one account may own, and who changed it"], |
| 157 | 158 | ["limits", "Limits", "what the forge caps, with default and bounds; change one"], |
| 158 | 159 | ["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 | ||
| 63 | 63 | const UNSEEDED: Record<string, string> = { |
| 64 | 64 | "/:owner/-/teams/:team": ORG, |
| 65 | 65 | "/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", | |
| 66 | 67 | "/talk/:id": TALK, |
| 67 | 68 | "/docs/:file": "a file name of the documentation folder; the seed supplies no :file", |
| 68 | 69 | "/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", () => { | ||
| 288 | 288 | expect(await signedIn({ cookie: browser })).toBe(false); |
| 289 | 289 | }); |
| 290 | 290 | |
| 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 () => { | |
| 292 | 294 | const ownerToken = createToken(app.db, owner.id, "o2", "admin").token; |
| 293 | 295 | 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"); | |
| 299 | 301 | }); |
| 300 | 302 | }); |
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 | }); |
new above
offline