| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import type { Child, FC } from "hono/jsx"; |
| 3 | import { deleteCookie } from "hono/cookie"; |
| 4 | import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; |
| 5 | import { SESSION_COOKIE } from "../auth/sessions.js"; |
| 6 | import type { Account } from "../lib/directory.js"; |
| 7 | import { HttpError } from "../lib/util.js"; |
| 8 | import { EXPORT_DAYS, exportArchive, exportsFor, requestExport, type ExportRow } from "../accounts/export.js"; |
| 9 | import { LOGIN_COOLDOWN_DAYS, deleteAccount, deletionBlockers, setSiteAdmin } from "../accounts/lifecycle.js"; |
| 10 | import { ADMIN_TABS, SettingsNav, type SettingsTab } from "./account.js"; |
| 11 | import { ComputePausedBanner } from "./kill-switches.js"; |
| 12 | import { csrfToken } from "./csrf.js"; |
| 13 | import { Form, Time, act, flash, fmtBytes, post, prm, render, type WebApp } from "./ui.js"; |
| 14 | |
| 15 | /** |
| 16 | * The account lifecycle in the web UI (accounts/): Settings → Account (export your data, delete your account), |
| 17 | * and in Site administration deleting someone's account and making people site administrators. Deleting, |
| 18 | * exporting and changing site administrators all ask for a sign-in from the last 2 hours. |
| 19 | */ |
| 20 | |
| 21 | const settingsPage = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child) => |
| 22 | render( |
| 23 | c, |
| 24 | { title }, |
| 25 | <div class="grid left"> |
| 26 | <SettingsNav me={me} tab={tab} db={c.env.DB} /> |
| 27 | <div> |
| 28 | {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null} |
| 29 | {body} |
| 30 | </div> |
| 31 | </div>, |
| 32 | ); |
| 33 | |
| 34 | const requireSiteAdmin = (c: AppContext): Account => { |
| 35 | const me = requireAccount(c); |
| 36 | if (!me.site_admin) throw new HttpError(403, "site administrators only"); |
| 37 | return me; |
| 38 | }; |
| 39 | |
| 40 | /** A person (not an organization, agent or deleted account), by id. */ |
| 41 | async function person(c: AppContext, id: string): Promise<Account> { |
| 42 | const a = await c.get("dir").account(id); |
| 43 | if (!a || a.kind !== "user" || a.deleted_at) throw new HttpError(404, "user not found"); |
| 44 | return a; |
| 45 | } |
| 46 | |
| 47 | const DangerBox: FC<{ title: string; children?: Child }> = ({ title, children }) => ( |
| 48 | <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)"> |
| 49 | <div class="head" style="background:var(--red-soft)"> |
| 50 | <h2 class="bad">{title}</h2> |
| 51 | </div> |
| 52 | {children} |
| 53 | </div> |
| 54 | ); |
| 55 | |
| 56 | const Blockers: FC<{ blockers: string[] }> = ({ blockers }) => |
| 57 | blockers.length ? ( |
| 58 | <div class="flash error" role="alert" data-blockers> |
| 59 | {blockers.map((b) => ( |
| 60 | <div>{b}</div> |
| 61 | ))} |
| 62 | </div> |
| 63 | ) : null; |
| 64 | |
| 65 | /** What deleting an account removes and keeps, for the person and for administrators. */ |
| 66 | const WhatGoes: FC<{ who: string; repos: number }> = ({ who, repos }) => ( |
| 67 | <ul class="small muted"> |
| 68 | <li> |
| 69 | {who} {repos === 1 ? "repository" : `${repos} repositories`} with their history, issues, pull requests, runs and secrets, {who === "Your" ? "your" : "their"} container images, access tokens, passkeys, agents, settings and memberships are deleted. Every browser is signed out. |
| 70 | </li> |
| 71 | <li>Issues, pull requests and comments written in other people's repositories stay, shown as written by ghost.</li> |
| 72 | <li>The username can be used again, by anyone, {LOGIN_COOLDOWN_DAYS} days later.</li> |
| 73 | </ul> |
| 74 | ); |
| 75 | |
| 76 | const ExportStatus: FC<{ e: ExportRow }> = ({ e }) => { |
| 77 | if (e.status === "ready") |
| 78 | return ( |
| 79 | <li data-export="ready"> |
| 80 | <div class="grow"> |
| 81 | <b>Export ready</b> <span class="badge green">{fmtBytes(e.size ?? 0)}</span> |
| 82 | <div class="meta"> |
| 83 | Made <Time at={e.finished_at} /> · available until {e.expires_at?.slice(0, 10)} |
| 84 | </div> |
| 85 | </div> |
| 86 | <a class="btn sm primary" href={`/settings/account/exports/${e.id}/download`}> |
| 87 | Download |
| 88 | </a> |
| 89 | </li> |
| 90 | ); |
| 91 | if (e.status === "failed") |
| 92 | return ( |
| 93 | <li data-export="failed"> |
| 94 | <div class="grow"> |
| 95 | <b class="bad">The export failed</b> |
| 96 | <div class="meta">{e.error}</div> |
| 97 | </div> |
| 98 | </li> |
| 99 | ); |
| 100 | return ( |
| 101 | <li data-export="building"> |
| 102 | <div class="grow"> |
| 103 | <b>Preparing your export…</b> |
| 104 | <div class="meta"> |
| 105 | Asked <Time at={e.created_at} />. We'll email you when it's ready; this page shows it too. |
| 106 | </div> |
| 107 | </div> |
| 108 | </li> |
| 109 | ); |
| 110 | }; |
| 111 | |
| 112 | export function registerAccountLifecycle(web: WebApp): void { |
| 113 | // ------------------------------------------------------------ Settings → Account |
| 114 | web.get("/settings/account", async (c) => { |
| 115 | const me = requireAccount(c); |
| 116 | if (me.kind !== "user") throw new HttpError(403, "only people have these settings"); |
| 117 | const t = csrfToken(c); |
| 118 | const [exports, blockers, repos] = await Promise.all([ |
| 119 | exportsFor(c.env, me.id), |
| 120 | deletionBlockers(c.env, me, true), |
| 121 | c.env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(me.id).first<{ n: number }>(), |
| 122 | ]); |
| 123 | const building = exports.some((e) => e.status === "queued" || e.status === "running"); |
| 124 | return render( |
| 125 | c, |
| 126 | { title: "Account", refresh: building ? 10 : undefined }, |
| 127 | <div class="grid left"> |
| 128 | <SettingsNav me={me} tab="account" db={c.env.DB} /> |
| 129 | <div> |
| 130 | <h1>Account</h1> |
| 131 | <div class="box"> |
| 132 | <div class="head"> |
| 133 | <h2>Export your data</h2> |
| 134 | </div> |
| 135 | <div class="body"> |
| 136 | <p class="muted m0"> |
| 137 | Download an archive of your account: your profile and settings, each repository you own as a Git bundle (clone it with <code>git clone name.bundle</code>), and the issues, pull requests, comments and reviews you wrote. We build it in the background and email you when it's ready; you can download it here for {EXPORT_DAYS} days. Tokens and passkeys are listed by name only. |
| 138 | </p> |
| 139 | </div> |
| 140 | {exports.length ? ( |
| 141 | <ul class="list"> |
| 142 | {exports.map((e) => ( |
| 143 | <ExportStatus e={e} /> |
| 144 | ))} |
| 145 | </ul> |
| 146 | ) : null} |
| 147 | <div class="body" style="border-top:1px solid var(--border)"> |
| 148 | <Form action="/settings/account/export" t={t} class="inline"> |
| 149 | <button class="btn" disabled={building}> |
| 150 | {exports.some((e) => e.status === "ready") ? "Start a new export" : "Start export"} |
| 151 | </button> |
| 152 | </Form> |
| 153 | </div> |
| 154 | </div> |
| 155 | <DangerBox title="Delete your account"> |
| 156 | <Form action="/settings/account/delete" t={t} match={me.login} class="body"> |
| 157 | <Blockers blockers={blockers} /> |
| 158 | <WhatGoes who="Your" repos={repos?.n ?? 0} /> |
| 159 | <p class="muted small"> |
| 160 | This can't be undone. Type your username, <b class="mono">{me.login}</b>, to confirm. |
| 161 | </p> |
| 162 | <div class="row"> |
| 163 | <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm your username" /> |
| 164 | <button class="btn danger" disabled={blockers.length > 0}> |
| 165 | Delete your account |
| 166 | </button> |
| 167 | </div> |
| 168 | </Form> |
| 169 | </DangerBox> |
| 170 | </div> |
| 171 | </div>, |
| 172 | ); |
| 173 | }); |
| 174 | |
| 175 | post(web, "/settings/account/export", async (c) => { |
| 176 | const me = requireAccount(c); |
| 177 | return act(c, "/settings/account", async () => { |
| 178 | if (me.kind !== "user") throw new HttpError(403, "only people can export their account"); |
| 179 | requireRecentSignIn(c, "/settings/account"); |
| 180 | const e = await requestExport(c.env, me); |
| 181 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "account.export", detail: { id: e.id } })); |
| 182 | flash(c, "notice", e.status === "ready" ? "Your export is ready." : "We're preparing your export. We'll email you when it's ready."); |
| 183 | }); |
| 184 | }); |
| 185 | |
| 186 | web.get("/settings/account/exports/:id/download", async (c) => { |
| 187 | const me = requireAccount(c); |
| 188 | // The archive holds every repository and the account's data: only the person in a browser, never a token |
| 189 | // (a repository-limited or read-only token would otherwise reach all of it). |
| 190 | const p = c.get("principal"); |
| 191 | if (p.type !== "account" || (p.via !== "session" && p.via !== "dev")) throw new HttpError(403, "downloading an account export needs a browser session"); |
| 192 | requireRecentSignIn(c, "/settings/account"); |
| 193 | const a = await exportArchive(c.env, me.id, prm(c, "id")); |
| 194 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "account.export_download", detail: { id: prm(c, "id"), size: a.size } })); |
| 195 | return new Response(a.body, { headers: { "content-type": "application/x-tar", "content-length": String(a.size), "content-disposition": `attachment; filename="${a.name}"`, "cache-control": "private, no-store" } }); |
| 196 | }); |
| 197 | |
| 198 | post(web, "/settings/account/delete", async (c, f) => { |
| 199 | const me = requireAccount(c); |
| 200 | return act(c, "/settings/account", async () => { |
| 201 | if (me.kind !== "user") throw new HttpError(403, "only people can delete their account here"); |
| 202 | if (f.s("confirm").trim() !== me.login) throw new HttpError(422, `type your username, ${me.login}, to confirm`); |
| 203 | requireRecentSignIn(c, "/settings/account"); |
| 204 | await deleteAccount(c.env, c.get("dir"), me, me, "settings"); |
| 205 | deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true }); |
| 206 | flash(c, "notice", `Your account ${me.login} was deleted.`); |
| 207 | return "/login"; |
| 208 | }); |
| 209 | }); |
| 210 | |
| 211 | // ------------------------------------------------------------ Site administration |
| 212 | web.get("/settings/admin/users/:id/delete", async (c) => { |
| 213 | const me = requireSiteAdmin(c); |
| 214 | const target = await person(c, prm(c, "id")); |
| 215 | const [blockers, repos] = await Promise.all([deletionBlockers(c.env, target, target.id === me.id), c.env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(target.id).first<{ n: number }>()]); |
| 216 | return settingsPage( |
| 217 | c, |
| 218 | me, |
| 219 | "admin", |
| 220 | `Delete ${target.login}`, |
| 221 | <> |
| 222 | <h1>Delete {target.login}</h1> |
| 223 | <p class="muted"> |
| 224 | <a href="/settings/admin">Site administration</a> · {target.email ?? "no email address"} · joined <Time at={target.created_at} /> |
| 225 | </p> |
| 226 | <DangerBox title={`Delete the account ${target.login}`}> |
| 227 | <Form action={`/settings/admin/users/${target.id}/delete`} t={csrfToken(c)} match={target.login} class="body"> |
| 228 | <Blockers blockers={blockers} /> |
| 229 | <WhatGoes who={target.id === me.id ? "Your" : `${target.login}'s`} repos={repos?.n ?? 0} /> |
| 230 | <p class="muted small"> |
| 231 | They get an email saying a site administrator deleted their account. This can't be undone. Type <b class="mono">{target.login}</b> to confirm. |
| 232 | </p> |
| 233 | <div class="row"> |
| 234 | <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm the username" /> |
| 235 | <button class="btn danger" disabled={blockers.length > 0}> |
| 236 | Delete this account |
| 237 | </button> |
| 238 | </div> |
| 239 | </Form> |
| 240 | </DangerBox> |
| 241 | </>, |
| 242 | ); |
| 243 | }); |
| 244 | |
| 245 | post(web, "/settings/admin/users/:id/delete", async (c, f) => { |
| 246 | const me = requireSiteAdmin(c); |
| 247 | const back = `/settings/admin/users/${prm(c, "id")}/delete`; |
| 248 | return act(c, back, async () => { |
| 249 | const target = await person(c, prm(c, "id")); |
| 250 | if (f.s("confirm").trim() !== target.login) throw new HttpError(422, `type ${target.login} to confirm deletion`); |
| 251 | requireRecentSignIn(c, back); |
| 252 | await deleteAccount(c.env, c.get("dir"), target, me, "admin"); |
| 253 | if (target.id === me.id) { |
| 254 | deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true }); |
| 255 | return "/login"; |
| 256 | } |
| 257 | flash(c, "notice", `Deleted the account ${target.login}.`); |
| 258 | return "/settings/admin"; |
| 259 | }); |
| 260 | }); |
| 261 | |
| 262 | post(web, "/settings/admin/users/:id/site-admin", async (c, f) => { |
| 263 | const me = requireSiteAdmin(c); |
| 264 | return act(c, "/settings/admin", async () => { |
| 265 | const target = await person(c, prm(c, "id")); |
| 266 | const grant = f.s("grant") === "1"; |
| 267 | requireRecentSignIn(c, "/settings/admin"); |
| 268 | const changed = await setSiteAdmin(c.env, c.get("dir"), target, grant, me); |
| 269 | flash(c, "notice", !changed ? `${target.login} ${grant ? "already is" : "isn't"} a site administrator.` : grant ? `${target.login} is now a site administrator.` : `${target.login} is no longer a site administrator.`); |
| 270 | // Removing yourself takes you out of these pages. |
| 271 | return !grant && changed && target.id === me.id ? "/settings" : undefined; |
| 272 | }); |
| 273 | }); |
| 274 | } |