Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

274 lines · 12.4 KBRaw History
1/** @jsxImportSource hono/jsx */
2import type { Child, FC } from "hono/jsx";
3import { deleteCookie } from "hono/cookie";
4import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js";
5import { SESSION_COOKIE } from "../auth/sessions.js";
6import type { Account } from "../lib/directory.js";
7import { HttpError } from "../lib/util.js";
8import { EXPORT_DAYS, exportArchive, exportsFor, requestExport, type ExportRow } from "../accounts/export.js";
9import { LOGIN_COOLDOWN_DAYS, deleteAccount, deletionBlockers, setSiteAdmin } from "../accounts/lifecycle.js";
10import { ADMIN_TABS, SettingsNav, type SettingsTab } from "./account.js";
11import { ComputePausedBanner } from "./kill-switches.js";
12import { csrfToken } from "./csrf.js";
13import { 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
21const 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
34const 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. */
41async 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
47const 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
56const 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. */
66const 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
76const 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
112export 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}