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.

2431 lines · 108.2 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child, FC } from "hono/jsx";
4import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js";
5import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js";
6import { loadRepo } from "../http/common.js";
7import { movedLoginPath } from "../lib/redirects.js";
8import { GHOST, HttpError } from "../lib/util.js";
9import type { Role } from "../repo/types.js";
10import { ICONS } from "./assets.js";
11import { KCheckbox, KRadio, KSelect } from "./kumo.js";
12import { csrfToken } from "./csrf.js";
13import { Auth } from "../auth/sessions.js";
14import { repoCreated } from "../mail/notify.js";
15import { browserAccount, deviceLabel, invitePasskeyPage, localPath } from "./auth.js";
16import { deleteCookie, setCookie } from "hono/cookie";
17import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, LOGIN_PATTERN, PendingInvites, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
18import type { FormData } from "./csrf.js";
19import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
20import { MAX_AVATAR_BYTES } from "../lib/image.js";
21import { gatherToday } from "../agentic/today.js";
22import { TodayView } from "../agentic/web.js";
23import { serverErrors } from "../lib/server-errors.js";
24import { ComputePausedBanner, KillSwitchesPage } from "./kill-switches.js";
25import { changePause, changeSuspension, cleanReason, type Stopped } from "../lib/killswitch.js";
26import { blockList, cleanResolution, openReportCount, report, reports, setReportState, suspend, suspendReason, unsuspend, visibleTo } from "../lib/moderation.js";
27import { BlockList, hasBlocked, ProfileMenu, ReportDetail, ReportsList, SuspendControl } from "./moderation.js";
28
29// ---------------------------------------------------------------- shared bits
30
31const RepoList: FC<{ repos: Repo[]; showOwner?: boolean; empty?: Child }> = ({ repos, showOwner = true, empty }) =>
32 repos.length ? (
33 <ul class="list">
34 {repos.map((r) => (
35 <li>
36 <span class="st faint">{raw(r.visibility === "private" ? ICONS.lock : ICONS.repo)}</span>
37 <div class="grow">
38 <a class="title" href={repoUrl(r)}>
39 {showOwner ? `${r.owner_login}/` : ""}
40 {r.name}
41 </a>{" "}
42 <span class="badge">{r.visibility}</span> {r.archived ? <span class="badge amber">archived</span> : null}
43 {r.description ? <div class="meta">{r.description}</div> : null}
44 </div>
45 <span class="small muted nowrap">Updated {ago(r.pushed_at ?? r.updated_at)}</span>
46 </li>
47 ))}
48 </ul>
49 ) : (
50 <div class="empty">{empty ?? "No repositories yet."}</div>
51 );
52
53function parsePolicy(a: Account): { maxRole?: Role } {
54 try {
55 return a.agent_policy ? (JSON.parse(a.agent_policy) as { maxRole?: Role }) : {};
56 } catch {
57 return {};
58 }
59}
60
61async function adminOrgs(c: AppContext, me: Account): Promise<Array<Account & { role: string }>> {
62 return (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin");
63}
64
65/** Whether `me` may manage an agent (its owner, an admin of its owning org, or a site admin). */
66async function canManageAgent(c: AppContext, me: Account, agent: Account): Promise<boolean> {
67 if (agent.kind !== "agent" || !agent.owner_id) return false;
68 if (me.site_admin || agent.owner_id === me.id) return true;
69 const role = await c.get("dir").orgRole(agent.owner_id, me.id);
70 return role === "owner" || role === "admin";
71}
72
73// ---------------------------------------------------------------- profile pictures (people and organizations)
74
75const TOO_BIG = "That picture is larger than 5 MB. Use a smaller file.";
76
77/** Current picture, an upload control and a Remove button. Uploads go to `action`, removal to `${action}/delete`. */
78const AvatarSection: FC<{ account: Account; action: string; t: string }> = ({ account, action, t }) => (
79 <div class="box">
80 <div class="head">
81 <h2>Profile picture</h2>
82 </div>
83 <div class="body avpick">
84 <span data-avatar-preview>
85 <Avatar login={account.login} kind={account.kind} size="xl" version={account.avatar_version} />
86 </span>
87 <div class="grow">
88 <Form action={action} t={t} avatar>
89 <div class="field">
90 <label for="avatar-file">Upload a new picture</label>
91 <input id="avatar-file" name="avatar" type="file" accept="image/jpeg,image/png,image/webp" required />
92 <div class="hint">JPEG, PNG or WebP, up to 5 MB. It is cropped to a centred square.</div>
93 </div>
94 <div class="row">
95 <button class="btn primary">Save picture</button>
96 <span class="muted small" data-avatar-status aria-live="polite"></span>
97 </div>
98 </Form>
99 {account.avatar_version ? (
100 <Form action={`${action}/delete`} t={t} class="mt" confirm={account.kind === "org" ? `Remove the picture of ${account.login}?` : "Remove your profile picture?"}>
101 <button class="btn sm danger">Remove picture</button>
102 <span class="muted small" style="margin-left:8px">
103 Shows initials instead.
104 </span>
105 </Form>
106 ) : null}
107 </div>
108 </div>
109 </div>
110);
111
112/** Refuse oversized uploads before the form body is parsed. */
113function avatarSizeGuard(web: WebApp, path: string, back: (c: AppContext) => string): void {
114 web.use(path, async (c, next) => {
115 if (c.req.method === "POST" && Number(c.req.header("content-length") ?? 0) > MAX_AVATAR_BYTES + 64 * 1024) {
116 flash(c, "error", TOO_BIG);
117 return c.redirect(back(c), 303);
118 }
119 await next();
120 });
121}
122
123async function uploadAvatar(c: AppContext, f: FormData, me: Account, account: Account): Promise<void> {
124 const file = f.file("avatar");
125 if (!file) throw new HttpError(422, "Choose a picture to upload.");
126 if (file.size > MAX_AVATAR_BYTES) throw new HttpError(413, TOO_BIG);
127 await setAvatar(c, account, new Uint8Array(await file.arrayBuffer()));
128 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "avatar.update", target: account.login, detail: { kind: account.kind, via: "web" } }));
129}
130
131async function deleteAvatar(c: AppContext, me: Account, account: Account): Promise<void> {
132 await removeAvatar(c, account);
133 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "avatar.remove", target: account.login, detail: { kind: account.kind, via: "web" } }));
134}
135
136const SCOPES: TokenScope[] = ["read", "write", "admin"];
137const BASE_ROLE_OPTIONS = [
138 { value: "none", label: "No permission: members see only repositories they're given" },
139 { value: "read", label: "Read: members can clone and view" },
140 { value: "write", label: "Write: members can also push" },
141 { value: "admin", label: "Admin: members can also change settings" },
142];
143
144// ---------------------------------------------------------------- landing / dashboard
145
146const Landing: FC = () => (
147 <div>
148 <section class="hero">
149 <h1>Diffinery</h1>
150 <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p>
151 <p class="row" style="justify-content:center">
152 <a class="btn primary" href="/login">
153 Sign in with a passkey
154 </a>
155 <a class="btn" href="/docs">
156 Read the docs
157 </a>
158 </p>
159 <p class="muted small">Diffinery is invite-only. Open the invite link someone sent you to create your passkey; there are no passwords.</p>
160 </section>
161 <div class="features">
162 <div class="box">
163 <h3>{raw(ICONS.repo)} Git, natively</h3>
164 <p class="muted m0">Clone, fetch and push over smart HTTP. Every repository is its own strongly consistent Durable Object.</p>
165 </div>
166 <div class="box">
167 <h3>{raw(ICONS.pr)} Reviews with intent</h3>
168 <p class="muted m0">Pull requests carry what a change is meant to do, so human and AI reviewers check the right thing.</p>
169 </div>
170 <div class="box">
171 <h3>{raw(ICONS.play)} Actions-compatible CI</h3>
172 <p class="muted m0">Bring your existing workflow YAML. Jobs run in containers with live logs, caches and artifacts.</p>
173 </div>
174 <div class="box">
175 <h3>{raw(ICONS.bot)} Agents with guardrails</h3>
176 <p class="muted m0">Agents get their own accounts, tokens and policies. Protected branches keep a human in the loop.</p>
177 </div>
178 </div>
179 </div>
180);
181
182/** The icon for a notification, by the event that raised it. */
183function noteIcon(kind: string): string {
184 if (kind === "run_failed") return ICONS.failure;
185 if (kind.startsWith("pull_request")) return ICONS.pr;
186 if (kind === "issues") return ICONS.issueOpen;
187 if (kind.endsWith("comment")) return ICONS.comment;
188 if (kind === "org_membership") return ICONS.org;
189 if (kind === "repo_access") return ICONS.repo;
190 if (kind === "invitation") return ICONS.envelope;
191 return ICONS.bell;
192}
193
194/** A person's pending invitations to organizations and repositories, each with Accept and Decline. */
195const MyInvitations: FC<{ invites: Awaited<ReturnType<Directory["invitesFor"]>>; t: string }> = ({ invites, t }) => (
196 <div class="box">
197 {invites.length ? (
198 <ul class="list">
199 {invites.map((i) => (
200 <li>
201 <div class="grow">
202 <b>{i.org ?? i.repo}</b>
203 <span class="badge" style="margin-left:6px">
204 {cap(i.role)}
205 </span>
206 <div class="muted small">
207 {i.org ? "Organization" : "Repository"} · invited {i.invitedBy ? `by ${i.invitedBy} ` : ""}
208 <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}
209 </div>
210 </div>
211 <Form action={`/settings/invitations/${encodeURIComponent(i.id)}/accept`} t={t} class="inline">
212 <button class="btn sm primary">Accept</button>
213 </Form>
214 <Form action={`/settings/invitations/${encodeURIComponent(i.id)}/decline`} t={t} class="inline">
215 <button class="btn sm">Decline</button>
216 </Form>
217 </li>
218 ))}
219 </ul>
220 ) : (
221 <div class="empty">No pending invitations.</div>
222 )}
223 </div>
224);
225
226export function registerAccount(web: WebApp): void {
227 web.get("/", async (c) => {
228 const p = c.get("principal");
229 if (p.type !== "account") return render(c, { title: "", home: true }, <Landing />);
230 const me = p.account;
231 const t = csrfToken(c);
232 if (me.status === "disabled") {
233 return render(
234 c,
235 { title: "Account disabled", status: 403 },
236 <div class="blank" style="max-width:560px;margin:48px auto">
237 <h1>Your account is disabled</h1>
238 <p class="muted m0">Contact an administrator of this Diffinery instance if you think this is a mistake.</p>
239 </div>,
240 );
241 }
242 if (me.status === "pending") {
243 const invites = await c.get("dir").invitesFor(me);
244 return render(
245 c,
246 { title: "Waiting for an invite" },
247 <div class="box" style="max-width:600px;margin:48px auto">
248 <div class="body">
249 <h1>Waiting for an invite</h1>
250 <p class="muted">
251 You are signed in as <b>{me.email ?? me.login}</b>, but this Diffinery instance is invite-only. Ask someone with access to invite your email address, or send a request to the administrators.
252 </p>
253 {invites.length ? (
254 <>
255 <h3>Your invitations</h3>
256 <MyInvitations invites={invites} t={t} />
257 </>
258 ) : null}
259 <Form action="/request-access" t={t}>
260 <div class="field">
261 <label for="note">Note to the administrators</label>
262 <textarea id="note" name="note" maxlength={500} placeholder="Who are you, and what would you like to work on?" style="min-height:90px"></textarea>
263 </div>
264 <button class="btn primary">Request access</button>
265 </Form>
266 </div>
267 </div>,
268 );
269 }
270 const dir = c.get("dir");
271 const [repos, orgs, notes, today] = await Promise.all([
272 dir.reposVisibleTo(me),
273 dir.orgsFor(me.id),
274 dir.notifications(me.id, true),
275 // The morning view (agentic/today.ts); the dashboard still renders if it fails.
276 gatherToday(c.env, dir, me).catch((e: unknown) => {
277 console.error("today failed", e);
278 return null;
279 }),
280 ]);
281 const mine = repos.filter((r) => r.owner_id === me.id || orgs.some((o) => o.id === r.owner_id));
282 const others = repos.filter((r) => !mine.includes(r));
283 // Someone who owns nothing yet but works in others' repositories sees those instead.
284 const side = mine.length ? mine : others;
285 return render(
286 c,
287 { title: "Today" },
288 <div class="grid left">
289 {/* On phones, Today comes first and the repository list after it. */}
290 <aside class="side after">
291 <section>
292 <div class="row between mb">
293 <h3 class="m0">{mine.length || !others.length ? "Your repositories" : "Repositories"}</h3>
294 <a class="btn sm primary" href="/new">
295 {raw(ICONS.plus)} New
296 </a>
297 </div>
298 {side.length ? (
299 <div class="nav">
300 {side.slice(0, 40).map((r) => (
301 <a href={repoUrl(r)} class="nowrap" style="overflow:hidden;text-overflow:ellipsis">
302 <Avatar login={r.owner_login} /> {r.owner_login}/<b>{r.name}</b>
303 </a>
304 ))}
305 </div>
306 ) : (
307 <p class="muted small">You have no repositories yet.</p>
308 )}
309 </section>
310 <section>
311 <h3>Organizations</h3>
312 <div class="nav">
313 {orgs.map((o) => (
314 <a href={`/${o.login}`}>
315 <Avatar login={o.login} version={o.avatar_version} /> {o.login} <span class="faint small">{o.role}</span>
316 </a>
317 ))}
318 <a href="/settings/orgs" class="muted">
319 {raw(ICONS.plus)} New organization
320 </a>
321 </div>
322 </section>
323 </aside>
324 <div>
325 {repos.length === 0 ? (
326 <div class="box">
327 <div class="body">
328 <h2>Welcome to Diffinery, {me.name || me.login}</h2>
329 <ol class="muted" style="padding-left:20px">
330 <li>
331 <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends).
332 </li>
333 <li>
334 <a href="/settings/tokens">Create a personal access token</a> — Git uses it as the password over HTTPS.
335 </li>
336 <li>Push your code, open pull requests and add workflows under <code>.github/workflows</code>.</li>
337 <li>
338 Invite people from an organization's <b>People</b> page or a repository's <b>Settings → Collaborators</b>.
339 </li>
340 </ol>
341 </div>
342 </div>
343 ) : today ? (
344 <TodayView today={today} name={me.name || me.login} />
345 ) : null}
346 {notes.length ? (
347 <div class="box">
348 <div class="head">
349 <h2>{raw(ICONS.bell)} Unread notifications</h2>
350 <a class="small" href="/notifications" style="margin-left:auto">
351 View all
352 </a>
353 </div>
354 <ul class="list">
355 {notes.slice(0, 8).map((n) => (
356 <li>
357 <div class="grow">
358 <a class="title" href={`/notifications/${n.id}`}>
359 {n.title}
360 </a>
361 <div class="meta">
362 {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
363 </div>
364 </div>
365 </li>
366 ))}
367 </ul>
368 </div>
369 ) : null}
370 <div class="box">
371 <div class="head">
372 <h2>Repositories</h2>
373 <span class="muted small">{repos.length} you can access</span>
374 </div>
375 <RepoList repos={[...mine, ...others].slice(0, 100)} empty={<a href="/new">Create your first repository</a>} />
376 </div>
377 </div>
378 </div>,
379 );
380 });
381
382 post(web, "/request-access", async (c, f) => {
383 const p = c.get("principal");
384 if (p.type !== "account") throw new HttpError(401, "sign in required");
385 if (p.account.status !== "pending") return c.redirect("/", 303);
386 return act(c, "/", async () => c.get("dir").requestAccess(p.account.id, f.s("note")), "Request sent. An administrator will review it.");
387 });
388
389 // ---------------------------------------------------------------- new repository
390
391 const NewRepoForm: FC<{ t: string; me: Account; orgs: Array<Account & { role: string }>; values: Record<string, string>; error?: string }> = ({ t, me, orgs, values, error }) => (
392 <div style="max-width:720px;margin:0 auto">
393 <h1>Create a new repository</h1>
394 <p class="muted">
395 A repository holds your project's files, history, issues, pull requests and workflows. Already on GitHub? <a href="/new/import">Import a repository</a>.
396 </p>
397 {error ? <div class="flash error">{error}</div> : null}
398 <Form action="/new" t={t} class="box">
399 <div class="body">
400 <div class="row" style="align-items:flex-end">
401 <div class="field" style="min-width:180px">
402 <label for="owner">Owner</label>
403 <KSelect
404 id="owner"
405 name="owner"
406 block
407 label="Owner"
408 value={orgs.some((o) => o.login === values.owner) ? values.owner : me.login}
409 options={[me.login, ...orgs.map((o) => o.login)].map((l) => ({ value: l, label: l }))}
410 />
411 </div>
412 <div class="field" style="padding-bottom:8px">/</div>
413 <div class="field grow">
414 <label for="name">Repository name</label>
415 <input id="name" name="name" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" value={values.name} autofocus autocomplete="off" />
416 </div>
417 </div>
418 <div class="field">
419 <label for="description">
420 Description <span class="faint">(optional)</span>
421 </label>
422 <input id="description" name="description" type="text" value={values.description} maxlength={350} />
423 </div>
424 <div class="field">
425 <label>Visibility</label>
426 <KRadio
427 name="visibility"
428 label="Visibility"
429 value={values.visibility || "private"}
430 items={(
431 [
432 ["private", "Private", "Only you, collaborators and organization members can see it."],
433 ["public", "Public", "Anyone on the internet can see and clone it."],
434 ] as const
435 ).map(([v, l, d]) => ({ value: v, label: l, description: d }))}
436 />
437 </div>
438 <div class="field" style="max-width:280px">
439 <label for="branch">Default branch</label>
440 <input id="branch" name="default_branch" type="text" value={values.default_branch || "main"} />
441 </div>
442 <button class="btn primary">Create repository</button>
443 </div>
444 </Form>
445 </div>
446 );
447
448 web.get("/new", async (c) => {
449 const me = requireAccount(c);
450 const orgs = await c.get("dir").orgsFor(me.id);
451 return render(c, { title: "New repository" }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={{ owner: c.req.query("owner") ?? me.login }} />);
452 });
453
454 post(web, "/new", async (c, f) => {
455 const me = requireAccount(c);
456 const dir = c.get("dir");
457 const values = { owner: f.s("owner"), name: f.s("name").trim(), description: f.s("description").trim(), visibility: f.s("visibility"), default_branch: f.s("default_branch").trim() };
458 try {
459 const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner);
460 if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner");
461 const visibility = (["private", "public"] as const).find((v) => v === values.visibility) ?? "private";
462 const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" });
463 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` }));
464 c.executionCtx.waitUntil(repoCreated(c.env, dir, repo, me, { viaToken: false }));
465 return c.redirect(repoUrl(repo), 303);
466 } catch (e) {
467 if (!(e instanceof HttpError) || e.status >= 500) throw e;
468 const orgs = await dir.orgsFor(me.id);
469 return render(c, { title: "New repository", status: e.status }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={values} error={e.detail} />);
470 }
471 });
472
473 // ---------------------------------------------------------------- notifications
474
475 web.get("/notifications", async (c) => {
476 const me = requireAccount(c);
477 const t = csrfToken(c);
478 const all = c.req.query("all") === "1";
479 const notes = await c.get("dir").notifications(me.id, !all);
480 return render(
481 c,
482 { title: "Notifications" },
483 <div style="max-width:900px;margin:0 auto">
484 <div class="row between mb">
485 <h1 class="m0">Notifications</h1>
486 <div class="row">
487 <div class="subnav">
488 <a href="/notifications" class={!all ? "on" : ""}>
489 Unread
490 </a>
491 <a href="/notifications?all=1" class={all ? "on" : ""}>
492 All
493 </a>
494 </div>
495 <Form action="/notifications/read" t={t} class="inline">
496 <button class="btn">{raw(ICONS.check)} Mark all as read</button>
497 </Form>
498 <a class="btn icon" href="/settings/notifications" title="Notification settings" aria-label="Notification settings">
499 {raw(ICONS.gear)}
500 </a>
501 </div>
502 </div>
503 <div class="box">
504 {notes.length ? (
505 <ul class="list">
506 {notes.map((n) => (
507 <li>
508 <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : n.kind === "run_failed" ? "color:var(--red)" : "color:var(--accent)"}>
509 {raw(noteIcon(n.kind))}
510 </span>
511 <div class="grow">
512 <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}>
513 {n.title}
514 </a>
515 <div class="meta">
516 {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
517 </div>
518 </div>
519 {!n.readAt ? (
520 <Form action="/notifications/read" t={t} class="inline">
521 <input type="hidden" name="id" value={n.id} />
522 <button class="btn sm" title="Mark as read">
523 {raw(ICONS.check)}
524 </button>
525 </Form>
526 ) : null}
527 </li>
528 ))}
529 </ul>
530 ) : (
531 <div class="empty">{all ? "No notifications." : "All caught up — no unread notifications."}</div>
532 )}
533 </div>
534 </div>,
535 );
536 });
537
538 web.get("/notifications/:id", async (c) => {
539 const me = requireAccount(c);
540 const dir = c.get("dir");
541 const id = prm(c, "id");
542 const n = (await dir.notifications(me.id)).find((x) => x.id === id);
543 if (!n) throw new HttpError(404, "notification not found");
544 await dir.markRead(me.id, id);
545 const base = publicUrl(c);
546 let to = n.url;
547 if (to.startsWith(base)) to = to.slice(base.length) || "/";
548 return c.redirect(localPath(to) ?? "/notifications", 302);
549 });
550
551 post(web, "/notifications/read", async (c, f) => {
552 const me = requireAccount(c);
553 const id = f.s("id");
554 return act(c, c.req.header("referer")?.includes("/notifications") ? "/notifications" : "/", async () => c.get("dir").markRead(me.id, id || undefined));
555 });
556
557 // ---------------------------------------------------------------- invites
558
559 web.get("/invite/:token", async (c) => {
560 const p = c.get("principal");
561 if (p.type !== "account") return invitePasskeyPage(c, prm(c, "token"));
562 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
563 return render(
564 c,
565 { title: "Accept invite" },
566 <div class="box" style="max-width:560px;margin:48px auto">
567 <div class="body">
568 <h1>You have been invited</h1>
569 <p class="muted">
570 Accept this invite as <b>{p.account.login}</b> ({p.account.email ?? "no email"}). Invites only work for the email address they were sent to.
571 </p>
572 <Form action={`/invite/${encodeURIComponent(prm(c, "token"))}`} t={csrfToken(c)}>
573 <button class="btn primary">Accept invite</button>
574 </Form>
575 </div>
576 </div>,
577 );
578 });
579
580 post(web, "/invite/:token", async (c) => {
581 const p = c.get("principal");
582 if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
583 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
584 const dir = c.get("dir");
585 const res = await dir.acceptInvite(prm(c, "token"), p.account);
586 let to = "/";
587 if (res.repoId) {
588 const r = await dir.repoById(res.repoId);
589 if (r) to = repoUrl(r);
590 } else if (res.orgId) {
591 const o = await dir.account(res.orgId);
592 if (o) to = `/${o.login}`;
593 }
594 c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: "invite.accept", target: to }));
595 return c.redirect(to, 303);
596 });
597
598 // ---------------------------------------------------------------- palette data
599
600 web.get("/-/palette.json", async (c) => {
601 const me = requireAccount(c);
602 const repos = (await c.get("dir").reposVisibleTo(me)).slice(0, 300).map((r) => `${r.owner_login}/${r.name}`);
603 const param = c.req.query("repo") ?? "";
604 let files: string[] = [];
605 let ref: string | undefined;
606 const m = /^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(param);
607 if (m) {
608 try {
609 const { stub, repo } = await loadRepo(c, m[1]!, m[2]!, "read");
610 ref = c.req.query("ref") || repo.default_branch;
611 files = await listFiles(stub, ref);
612 } catch {
613 files = [];
614 }
615 }
616 return c.json({ repos, files, ref }, 200, { "cache-control": "private, max-age=30" });
617 });
618
619 // ---------------------------------------------------------------- personal settings
620
621 registerSettings(web);
622 registerProfiles(web);
623}
624
625/** Breadth-first file listing for "go to file" (bounded). */
626async function listFiles(stub: Stub, ref: string): Promise<string[]> {
627 const out: string[] = [];
628 let queue = [""];
629 let reads = 0;
630 while (queue.length && reads < 120 && out.length < 5000) {
631 const batch = queue.slice(0, 12);
632 queue = queue.slice(12);
633 reads += batch.length;
634 const trees = await Promise.all(batch.map((p) => stub.tree(ref, p).catch(() => null)));
635 for (const t of trees) {
636 if (!t) continue;
637 for (const it of t.items) {
638 if (it.type === "tree") {
639 if (!/^(node_modules|\.git|vendor|dist|target)$/.test(it.name)) queue.push(it.path);
640 } else if (it.type === "blob") out.push(it.path);
641 }
642 }
643 }
644 return out.slice(0, 5000);
645}
646
647// ---------------------------------------------------------------- /settings
648
649export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status";
650/** The site administration pages: they show when hosted compute is paused. */
651export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status"];
652
653export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
654 <aside class="side vt-side">
655 <section>
656 <div class="row mb">
657 <Avatar login={me.login} size="lg" version={me.avatar_version} />
658 <div>
659 <b>{me.name || me.login}</b>
660 <div class="muted small">{me.login}</div>
661 </div>
662 </div>
663 <div class="nav">
664 <a href="/settings" class={tab === "profile" ? "on" : ""}>
665 Profile
666 </a>
667 <a href="/settings/account" class={tab === "account" ? "on" : ""}>
668 Account
669 </a>
670 <a href="/settings/appearance" class={tab === "appearance" ? "on" : ""}>
671 Appearance
672 </a>
673 <a href="/settings/security" class={tab === "security" ? "on" : ""}>
674 Passkeys and sessions
675 </a>
676 <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}>
677 Access tokens
678 </a>
679 <a href="/settings/agents" class={tab === "agents" ? "on" : ""}>
680 Agents
681 </a>
682 <a href="/settings/model-access" class={tab === "model-access" ? "on" : ""}>
683 Model access
684 </a>
685 <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}>
686 Organizations
687 </a>
688 <a href="/settings/invitations" class={tab === "invitations" ? "on" : ""}>
689 Invitations
690 </a>
691 <a href="/settings/notifications" class={tab === "notifications" ? "on" : ""}>
692 Notifications
693 </a>
694 <a href="/settings/email" class={tab === "email-address" ? "on" : ""}>
695 Email
696 </a>
697 <a href="/settings/blocked-users" class={tab === "blocked" ? "on" : ""}>
698 Blocked users
699 </a>
700 <a href="/settings/linked-accounts" class={tab === "linked" ? "on" : ""}>
701 Linked accounts
702 </a>
703 <a href="/imports">Imports</a>
704 {me.site_admin ? (
705 <>
706 <h4>Site administration</h4>
707 <a href="/settings/admin" class={tab === "admin" ? "on" : ""}>
708 People and invitations
709 </a>
710 <a href="/settings/admin/reports" class={tab === "reports" ? "on" : ""}>
711 Reports
712 </a>
713 <a href="/settings/admin/email" class={tab === "email" ? "on" : ""}>
714 Email
715 </a>
716 <a href="/settings/admin/kill-switches" class={tab === "kill-switches" ? "on" : ""}>
717 Kill switches
718 </a>
719 <a href="/settings/admin/security-reports" class={tab === "security-reports" ? "on" : ""}>
720 Security reports
721 </a>
722 <a href="/settings/admin/status" class={tab === "status" ? "on" : ""}>
723 Status
724 </a>
725 <a href="/settings/audit" class={tab === "audit" ? "on" : ""}>
726 Audit log
727 </a>
728 <a href="/settings/admin/errors" class={tab === "errors" ? "on" : ""}>
729 Server errors
730 </a>
731 </>
732 ) : null}
733 </div>
734 </section>
735 </aside>
736);
737
738const TokenOnce: FC<{ token: string; title: string; c: AppContext; note?: Child }> = ({ token, title, c, note }) => (
739 <div class="box" style="border-color:var(--amber)">
740 <div class="body">
741 <h3>{title}</h3>
742 <p class="muted">Copy it now — it will not be shown again.</p>
743 <div class="row">
744 <div class="secret grow">{token}</div>
745 <CopyButton text={token} size="base" />
746 </div>
747 {note}
748 <details class="mt">
749 <summary class="small" style="cursor:pointer">
750 How to use it
751 </summary>
752 <pre class="cmd mt">
753 {`# Git over HTTPS: use your username and this token as the password\ngit clone ${publicUrl(c)}/OWNER/REPO.git\n\n# Remember it (or use your OS keychain helper)\ngit config --global credential.helper store\n\n# HTTP APIs and MCP clients\nAuthorization: Bearer <token>`}
754 </pre>
755 </details>
756 </div>
757 </div>
758);
759
760function registerSettings(web: WebApp): void {
761 const page = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child, status?: number) =>
762 render(
763 c,
764 { title, status },
765 <div class="grid left">
766 <SettingsNav me={me} tab={tab} />
767 <div>
768 {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
769 {body}
770 </div>
771 </div>,
772 );
773
774 // Profile
775 web.get("/settings", async (c) => {
776 const me = requireAccount(c);
777 const t = csrfToken(c);
778 return page(
779 c,
780 me,
781 "profile",
782 "Settings",
783 <>
784 <h1>Profile</h1>
785 <AvatarSection account={me} action="/settings/avatar" t={t} />
786 <Form action="/settings/profile" t={t} class="box">
787 <div class="body">
788 <div class="field">
789 <label for="login">Username</label>
790 <input id="login" name="login" type="text" value={me.login} required pattern={LOGIN_PATTERN} />
791 <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div>
792 </div>
793 <div class="field">
794 <label for="name">Display name</label>
795 <input id="name" name="name" type="text" value={me.name ?? ""} />
796 </div>
797 <dl class="kv mb">
798 <dt>Email</dt>
799 <dd>{me.email ?? "—"}</dd>
800 <dt>Account type</dt>
801 <dd>{me.kind}</dd>
802 <dt>Joined</dt>
803 <dd>
804 <Time at={me.created_at} />
805 </dd>
806 <dt>Site admin</dt>
807 <dd>{me.site_admin ? "yes" : "no"}</dd>
808 </dl>
809 <button class="btn primary">Save profile</button>
810 </div>
811 </Form>
812 </>,
813 );
814 });
815
816 post(web, "/settings/profile", async (c, f) => {
817 const me = requireAccount(c);
818 return act(
819 c,
820 "/settings",
821 async () => {
822 const login = f.s("login").trim();
823 if (login && login !== me.login) requireRecentSignIn(c, "/settings");
824 await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() });
825 // The old login's cached picture must not outlive the rename.
826 if (login && login !== me.login) c.executionCtx.waitUntil(purgeAvatarCache(c, me.login, [me.avatar_version]));
827 },
828 "Profile saved.",
829 );
830 });
831
832 // Appearance
833 web.get("/settings/appearance", async (c) => {
834 const me = requireAccount(c);
835 const current = me.appearance === "light" || me.appearance === "dark" ? me.appearance : "auto";
836 return page(
837 c,
838 me,
839 "appearance",
840 "Appearance",
841 <>
842 <h1>Appearance</h1>
843 <p class="muted">Choose how Diffinery looks to you. The choice follows your account to every browser you sign in to.</p>
844 <Form action="/settings/appearance" t={csrfToken(c)} class="box">
845 <div class="body">
846 <div class="field">
847 <label>Theme</label>
848 <KRadio
849 name="appearance"
850 label="Theme"
851 value={current}
852 items={(
853 [
854 ["auto", "Sync with system", "Light or dark, following your device's setting."],
855 ["light", "Light", "Always light."],
856 ["dark", "Dark", "Always dark."],
857 ] as const
858 ).map(([v, l, d]) => ({ value: v, label: <span class="theme-opt"><span class={`theme-prev ${v}`} aria-hidden="true"><i /><i /><i /></span>{l}</span>, description: d }))}
859 />
860 </div>
861 <button class="btn primary">Save appearance</button>
862 </div>
863 </Form>
864 </>,
865 );
866 });
867
868 post(web, "/settings/appearance", async (c, f) => {
869 const me = requireAccount(c);
870 const v = f.s("appearance");
871 const appearance = v === "light" || v === "dark" ? v : null;
872 await c.get("dir").setAppearance(me.id, appearance);
873 // Signed-out pages (sign-in, invites) in this browser keep the choice too.
874 if (appearance) setCookie(c, APPEARANCE_COOKIE, appearance, { path: "/", secure: true, sameSite: "Lax", maxAge: 400 * 86400 });
875 else deleteCookie(c, APPEARANCE_COOKIE, { path: "/", secure: true });
876 flash(c, "notice", "Appearance saved.");
877 return c.redirect("/settings/appearance", 303);
878 });
879
880 avatarSizeGuard(web, "/settings/avatar", () => "/settings");
881 post(web, "/settings/avatar", async (c, f) => {
882 const me = requireAccount(c);
883 return act(c, "/settings", () => uploadAvatar(c, f, me, me), "Profile picture updated.");
884 });
885
886 post(web, "/settings/avatar/delete", async (c) => {
887 const me = requireAccount(c);
888 return act(c, "/settings", () => deleteAvatar(c, me, me), "Profile picture removed.");
889 });
890
891 // Passkeys and sessions
892 web.get("/settings/security", async (c) => {
893 const me = requireAccount(c);
894 const t = csrfToken(c);
895 const auth = new Auth(c.env);
896 const p = c.get("principal");
897 const current = p.type === "account" ? p.sessionId : undefined;
898 const [keys, sessions] = await Promise.all([auth.passkeys(me.id), auth.sessions(me.id)]);
899 const recovery = !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
900 return page(
901 c,
902 me,
903 "security",
904 "Passkeys and sessions",
905 <>
906 <h1>Passkeys and sessions</h1>
907 <p class="muted">You sign in to Diffinery with passkeys instead of a password. A passkey lives on your device or in your password manager and is unlocked with your fingerprint, face or screen lock.</p>
908 <div class="box">
909 <div class="head">
910 <h2>Passkeys</h2>
911 <span class="badge">{keys.length}</span>
912 </div>
913 {keys.length ? (
914 <ul class="list">
915 {keys.map((k) => (
916 <li>
917 <span class="st faint">{raw(ICONS.key)}</span>
918 <div class="grow">
919 <b>{k.name}</b> {k.backed_up ? <span class="badge blue" title="Backed up by your password manager or platform, so it syncs to your other devices">synced</span> : <span class="badge" title="Stored only on one device or security key">this device only</span>}
920 <div class="meta">
921 Added <Time at={k.created_at} /> · {k.last_used_at ? <>last used <Time at={k.last_used_at} /></> : "never used to sign in"}
922 </div>
923 </div>
924 {keys.length > 1 ? (
925 <Form action={`/settings/security/passkeys/${encodeURIComponent(k.id)}/delete`} t={t} class="inline" confirm={`Remove the passkey "${k.name}"? Sessions it signed in are signed out.`}>
926 <button class="btn sm danger">Remove</button>
927 </Form>
928 ) : (
929 <span class="small faint" title="Add another passkey before removing this one">Only passkey</span>
930 )}
931 </li>
932 ))}
933 </ul>
934 ) : (
935 <div class="empty">No passkeys yet. Add one so you can sign in.</div>
936 )}
937 <div class="body" style="border-top:1px solid var(--border)">
938 <form data-passkey="register" data-next="/settings/security">
939 <div class="formrow">
940 <div class="field grow">
941 <label for="pklabel">Add a passkey</label>
942 <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
943 </div>
944 <button class="btn primary">{raw(ICONS.key)} Add a passkey</button>
945 </div>
946 <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div>
947 </form>
948 <div class="mt">
949 <div class="flash error" data-passkey-msg role="alert" hidden></div>
950 </div>
951 </div>
952 </div>
953 <div class="box">
954 <div class="head">
955 <h2>Signed-in browsers</h2>
956 <span class="badge">{sessions.length}</span>
957 </div>
958 {sessions.length ? (
959 <ul class="list">
960 {sessions.map((s) => (
961 <li>
962 <div class="grow">
963 <b>{deviceLabel(s.user_agent ?? undefined)}</b> {s.id === current ? <span class="badge green">this browser</span> : null}
964 <div class="meta">
965 Signed in <Time at={s.created_at} /> with {s.method === "access" ? "email recovery" : s.method === "link" ? "a sign-in link" : "a passkey"} · active <Time at={s.last_seen_at} />
966 </div>
967 </div>
968 {s.id !== current ? (
969 <Form action={`/settings/security/sessions/${encodeURIComponent(s.id)}/revoke`} t={t} class="inline" confirm={`Sign out ${deviceLabel(s.user_agent ?? undefined)}?`}>
970 <button class="btn sm">Sign out</button>
971 </Form>
972 ) : null}
973 </li>
974 ))}
975 </ul>
976 ) : (
977 <div class="empty">No browser sessions.</div>
978 )}
979 <div class="body small muted" style="border-top:1px solid var(--border)">
980 A browser stays signed in for 30 days after you last use it, and at most 90 days after you sign in. Adding passkeys, creating tokens and agents, and deleting or transferring repositories ask you to sign in again when your sign-in is more than 2 hours old. Recovering your account by email or with a sign-in link signs out every other browser; access tokens keep working.
981 </div>
982 {sessions.some((s) => s.id !== current) ? (
983 <div class="body" style="border-top:1px solid var(--border)">
984 <Form action="/settings/security/sessions/others" t={t} class="inline" confirm="Sign out every other browser?">
985 <button class="btn">Sign out everywhere else</button>
986 </Form>
987 </div>
988 ) : null}
989 </div>
990 <p class="muted small">
991 Lost every passkey? {recovery ? <>Use <a href="/auth/access">email recovery</a>, then add a new passkey here.</> : "Ask a site administrator for a one-time sign-in link."} Git, the CLI and agents use <a href="/settings/tokens">access tokens</a> instead of passkeys.
992 </p>
993 </>,
994 );
995 });
996
997 post(web, "/settings/security/passkeys/:id/delete", async (c) => {
998 const me = browserAccount(c);
999 const auth = new Auth(c.env);
1000 const id = prm(c, "id");
1001 return act(
1002 c,
1003 "/settings/security",
1004 async () => {
1005 const keys = await auth.passkeys(me.id);
1006 const key = keys.find((k) => k.id === id);
1007 if (!key) throw new HttpError(404, "passkey not found");
1008 if (keys.length < 2) throw new HttpError(422, "Add another passkey before removing your last one.");
1009 requireRecentSignIn(c, "/settings/security");
1010 const p = c.get("principal");
1011 await auth.deletePasskey(me.id, id, p.type === "account" ? (p.sessionId ?? null) : null);
1012 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "passkey.remove", detail: { name: key.name } }));
1013 },
1014 "Passkey removed.",
1015 );
1016 });
1017
1018 post(web, "/settings/security/sessions/:id/revoke", async (c) => {
1019 const me = browserAccount(c);
1020 const id = prm(c, "id");
1021 return act(
1022 c,
1023 "/settings/security",
1024 async () => {
1025 if (!(await new Auth(c.env).endSessionById(me.id, id))) throw new HttpError(404, "That browser is already signed out.");
1026 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "session.revoke" }));
1027 },
1028 "Signed out of that browser.",
1029 );
1030 });
1031
1032 post(web, "/settings/security/sessions/others", async (c) => {
1033 const me = browserAccount(c);
1034 const p = c.get("principal");
1035 return act(c, "/settings/security", async () => void (await new Auth(c.env).endOtherSessions(me.id, p.type === "account" ? (p.sessionId ?? null) : null)), "Signed out of every other browser.");
1036 });
1037
1038 // Tokens
1039 const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => {
1040 const dir = c.get("dir");
1041 const t = csrfToken(c);
1042 const [tokens, repos] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me)]);
1043 const repoName = new Map(repos.map((r) => [r.id, `${r.owner_login}/${r.name}`]));
1044 return page(
1045 c,
1046 me,
1047 "tokens",
1048 "Access tokens",
1049 <>
1050 <h1>Personal access tokens</h1>
1051 <p class="muted">Tokens authenticate Git over HTTPS, the REST API, the CLI and MCP clients. Use your username (or anything) as the Git username and the token as the password.</p>
1052 {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null}
1053 <div class="box">
1054 <div class="head">
1055 <h2>Active tokens</h2>
1056 </div>
1057 {tokens.length ? (
1058 <table class="tbl">
1059 <thead>
1060 <tr>
1061 <th>Name</th>
1062 <th>Scopes</th>
1063 <th>Repositories</th>
1064 <th>Expires</th>
1065 <th>Last used</th>
1066 <th />
1067 </tr>
1068 </thead>
1069 <tbody>
1070 {tokens.map((k) => (
1071 <tr>
1072 <td>
1073 <b>{k.name}</b>
1074 <div class="faint mono small">{k.prefix}…</div>
1075 </td>
1076 <td>{k.scopes.join(", ")}</td>
1077 <td class="small">{k.repoIds ? k.repoIds.map((id) => repoName.get(id) ?? id.slice(0, 8)).join(", ") : <span class="muted">all</span>}</td>
1078 <td class="small">{new Date(k.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}</td>
1079 <td class="small">{k.lastUsedAt ? ago(k.lastUsedAt) : <span class="muted">never</span>}</td>
1080 <td class="right">
1081 <Form action={`/settings/tokens/${k.id}/revoke`} t={t} class="inline" confirm={`Revoke token "${k.name}"? Anything using it stops working.`}>
1082 <button class="btn sm danger">Revoke</button>
1083 </Form>
1084 </td>
1085 </tr>
1086 ))}
1087 </tbody>
1088 </table>
1089 ) : (
1090 <div class="empty">No tokens yet.</div>
1091 )}
1092 </div>
1093 <Form action="/settings/tokens" t={t} class="box">
1094 <div class="head">
1095 <h2>Create a token</h2>
1096 </div>
1097 <div class="body">
1098 <div class="row" style="align-items:flex-start">
1099 <div class="field grow">
1100 <label for="tname">Name</label>
1101 <input id="tname" name="name" type="text" required placeholder="laptop, ci, claude-code…" maxlength={80} />
1102 </div>
1103 <div class="field" style="width:140px">
1104 <label for="days">Expires in (days)</label>
1105 <input id="days" name="days" type="number" min={1} max={366} value="90" />
1106 </div>
1107 </div>
1108 <div class="field">
1109 <label>Scope</label>
1110 <KRadio name="scope" label="Scope" orientation="horizontal" value="write" items={SCOPES.map((s) => ({ value: s, label: cap(s) }))} />
1111 <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div>
1112 </div>
1113 <div class="field">
1114 <label for="repos">
1115 Restrict to repositories <span class="faint">(optional)</span>
1116 </label>
1117 <KSelect
1118 id="repos"
1119 name="repos"
1120 multiple
1121 block
1122 label="Restrict to repositories"
1123 placeholder="Every repository you can access"
1124 options={repos.map((r) => ({ value: String(r.id), label: `${r.owner_login}/${r.name}` }))}
1125 />
1126 <div class="hint">Leave empty for access to every repository you can access.</div>
1127 </div>
1128 <button class="btn primary">Create token</button>
1129 </div>
1130 </Form>
1131 </>,
1132 );
1133 };
1134
1135 web.get("/settings/tokens", async (c) => tokensPage(c, requireAccount(c)));
1136
1137 post(web, "/settings/tokens", async (c, f) => {
1138 const me = requireAccount(c);
1139 requireRecentSignIn(c, "/settings/tokens");
1140 const dir = c.get("dir");
1141 const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
1142 const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id));
1143 const repoIds = f.all("repos").filter((id) => visible.has(id));
1144 const name = f.s("name").trim() || "token";
1145 const created = await dir.createToken(me, { name, scopes: [scope], repoIds: repoIds.length ? repoIds : null, expiresInDays: parseInt(f.s("days"), 10) || 90 });
1146 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "token.create", target: me.login, detail: { name, scope, repos: repoIds.length } }));
1147 return tokensPage(c, me, created);
1148 });
1149
1150 post(web, "/settings/tokens/:id/revoke", async (c) => {
1151 const me = requireAccount(c);
1152 return act(
1153 c,
1154 "/settings/tokens",
1155 async () => {
1156 await c.get("dir").revokeToken(me.id, prm(c, "id"));
1157 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: prm(c, "id") }));
1158 },
1159 "Token revoked.",
1160 );
1161 });
1162
1163 // Agents
1164 const agentsPage = async (c: AppContext, me: Account, created?: { token: string; agent: string }) => {
1165 const dir = c.get("dir");
1166 const t = csrfToken(c);
1167 const orgs = await adminOrgs(c, me);
1168 const agents = await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)]);
1169 const ownerName = new Map<string, string>([[me.id, me.login], ...orgs.map((o) => [o.id, o.login] as [string, string])]);
1170 const tokenLists = await Promise.all(agents.map((a) => dir.tokens(a.id)));
1171 return page(
1172 c,
1173 me,
1174 "agents",
1175 "Agents",
1176 <>
1177 <h1>Agents</h1>
1178 <p class="muted">
1179 Agents are accounts for AI tools and automation. They act on behalf of their owner, are capped by a policy, cannot push to protected or default branches, and their pull requests need a human approval. Give each agent its own token.
1180 </p>
1181 {created ? <TokenOnce c={c} token={created.token} title={`Token for ${created.agent}`} /> : null}
1182 <div class="box">
1183 <div class="head">
1184 <h2>Your agents</h2>
1185 </div>
1186 {agents.length ? (
1187 <ul class="list">
1188 {agents.map((a, i) => {
1189 const policy = parsePolicy(a);
1190 const toks = tokenLists[i] ?? [];
1191 return (
1192 <li style="display:block">
1193 <div class="row">
1194 <Actor login={a.login} kind="agent" version={a.avatar_version} />
1195 <span class="muted small">
1196 owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status}
1197 </span>
1198 <span class="grow" />
1199 <Form action={`/settings/agents/${a.id}/status`} t={t} class="inline">
1200 <input type="hidden" name="status" value={a.status === "active" ? "disabled" : "active"} />
1201 <button class={`btn sm ${a.status === "active" ? "danger" : ""}`}>{a.status === "active" ? "Disable" : "Enable"}</button>
1202 </Form>
1203 </div>
1204 {toks.length ? (
1205 <div class="small muted mt" style="margin-top:6px">
1206 Tokens:{" "}
1207 {toks.map((k) => (
1208 <span class="row" style="display:inline-flex;gap:4px;margin-right:10px">
1209 <span class="mono">{k.name}</span> ({k.scopes.join(",")}, last used {k.lastUsedAt ? ago(k.lastUsedAt) : "never"})
1210 <Form action={`/settings/agents/${a.id}/tokens/${k.id}/revoke`} t={t} class="inline" confirm="Revoke this agent token?">
1211 <button class="btn sm link bad">revoke</button>
1212 </Form>
1213 </span>
1214 ))}
1215 </div>
1216 ) : null}
1217 <details style="margin-top:6px">
1218 <summary class="small" style="cursor:pointer;color:var(--accent)">
1219 Create a token for {a.login}
1220 </summary>
1221 <Form action={`/settings/agents/${a.id}/token`} t={t} class="row mt" style="align-items:flex-end">
1222 <div class="field grow m0">
1223 <label>Name</label>
1224 <input name="name" type="text" required value="default" />
1225 </div>
1226 <div class="field m0">
1227 <label>Scope</label>
1228 <KSelect name="scope" label="Scope" value="write" options={SCOPES.map((s) => ({ value: s, label: cap(s) }))} />
1229 </div>
1230 <div class="field m0" style="width:110px">
1231 <label>Days</label>
1232 <input name="days" type="number" min={1} max={366} value="30" />
1233 </div>
1234 <button class="btn primary">Create token</button>
1235 </Form>
1236 </details>
1237 </li>
1238 );
1239 })}
1240 </ul>
1241 ) : (
1242 <div class="empty">No agents yet.</div>
1243 )}
1244 </div>
1245 <Form action="/settings/agents" t={t} class="box">
1246 <div class="head">
1247 <h2>Create an agent</h2>
1248 </div>
1249 <div class="body">
1250 <div class="row" style="align-items:flex-start">
1251 <div class="field grow">
1252 <label for="alogin">Agent username</label>
1253 <input id="alogin" name="login" type="text" required pattern={LOGIN_PATTERN} placeholder="review-bot" />
1254 </div>
1255 <div class="field grow">
1256 <label for="aname">Display name</label>
1257 <input id="aname" name="name" type="text" placeholder="Review bot" />
1258 </div>
1259 </div>
1260 <div class="row" style="align-items:flex-start">
1261 <div class="field grow">
1262 <label for="aowner">Owner</label>
1263 <KSelect
1264 id="aowner"
1265 name="owner"
1266 block
1267 label="Owner"
1268 value="me"
1269 options={[{ value: "me", label: `${me.login} (you)` }, ...orgs.map((o) => ({ value: String(o.id), label: `${o.login} (organization)` }))]}
1270 />
1271 <div class="hint">An agent can never do more than its owner.</div>
1272 </div>
1273 <div class="field grow">
1274 <label for="arole">Maximum role</label>
1275 <KSelect id="arole" name="maxRole" block label="Maximum role" value="write" options={ROLES.map((r) => ({ value: r, label: cap(r) }))} />
1276 </div>
1277 </div>
1278 <button class="btn primary">Create agent</button>
1279 </div>
1280 </Form>
1281 </>,
1282 );
1283 };
1284
1285 web.get("/settings/agents", async (c) => agentsPage(c, requireAccount(c)));
1286
1287 post(web, "/settings/agents", async (c, f) => {
1288 const me = requireAccount(c);
1289 requireRecentSignIn(c, "/settings/agents");
1290 const dir = c.get("dir");
1291 return act(
1292 c,
1293 "/settings/agents",
1294 async () => {
1295 const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write";
1296 const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") && f.s("owner") !== "me" ? f.s("owner") : undefined, policy: { maxRole } });
1297 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } }));
1298 },
1299 "Agent created. Create a token for it below.",
1300 );
1301 });
1302
1303 const loadAgent = async (c: AppContext, me: Account): Promise<Account> => {
1304 const agent = await c.get("dir").account(prm(c, "id"));
1305 if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found");
1306 return agent;
1307 };
1308
1309 post(web, "/settings/agents/:id/token", async (c, f) => {
1310 const me = requireAccount(c);
1311 requireRecentSignIn(c, "/settings/agents");
1312 const agent = await loadAgent(c, me);
1313 const dir = c.get("dir");
1314 const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
1315 const created = await dir.createToken(agent, { name: f.s("name").trim() || "agent", scopes: [scope], expiresInDays: parseInt(f.s("days"), 10) || 30 });
1316 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.token.create", target: agent.login, detail: { scope } }));
1317 return agentsPage(c, me, { token: created.token, agent: agent.login });
1318 });
1319
1320 post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => {
1321 const me = requireAccount(c);
1322 const agent = await loadAgent(c, me);
1323 return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, prm(c, "tid")), "Token revoked.");
1324 });
1325
1326 post(web, "/settings/agents/:id/status", async (c, f) => {
1327 const me = requireAccount(c);
1328 const agent = await loadAgent(c, me);
1329 const status = f.s("status") === "active" ? "active" : "disabled";
1330 return act(c, "/settings/agents", async () => {
1331 await c.get("dir").setStatus(agent.id, status);
1332 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: `agent.${status === "active" ? "enable" : "disable"}`, target: agent.login }));
1333 });
1334 });
1335
1336 // Organizations
1337 // Invitations to organizations and repositories: someone added by username joins once they accept.
1338 // Blocked users (lib/moderation.ts): the people you blocked, and a form to block someone.
1339 web.get("/settings/blocked-users", async (c) => {
1340 const me = requireAccount(c);
1341 const t = csrfToken(c);
1342 return page(
1343 c,
1344 me,
1345 "blocked",
1346 "Blocked users",
1347 <>
1348 <h1>Blocked users</h1>
1349 <BlockList
1350 blocks={await blockList(c.env, me.id)}
1351 add="/settings/blocked-users"
1352 unblock={(login) => `/settings/blocked-users/${encodeURIComponent(login)}/unblock`}
1353 t={t}
1354 title="People you blocked"
1355 hint="They can still see your public repositories, but can't open issues or pull requests, comment or react in them, aren't collaborators on them, and can't invite you. You get no notifications from them. They aren't told."
1356 empty="You haven't blocked anyone."
1357 />
1358 </>,
1359 );
1360 });
1361
1362 web.get("/settings/invitations", async (c) => {
1363 const p = c.get("principal");
1364 // Someone still waiting for an invite sees theirs on the home page.
1365 if (p.type === "account" && p.account.status === "pending") return c.redirect("/", 303);
1366 const me = requireAccount(c);
1367 const invites = await c.get("dir").invitesFor(me);
1368 return page(
1369 c,
1370 me,
1371 "invitations",
1372 "Invitations",
1373 <>
1374 <h1>Invitations</h1>
1375 <p class="muted">Invitations to join an organization or collaborate on a repository. You get access once you accept; declining tells nobody, and the invitation goes away.</p>
1376 <MyInvitations invites={invites} t={csrfToken(c)} />
1377 </>,
1378 );
1379 });
1380
1381 for (const accept of [true, false]) {
1382 post(web, `/settings/invitations/:id/${accept ? "accept" : "decline"}`, async (c) => {
1383 const p = c.get("principal");
1384 if (p.type !== "account") throw new HttpError(401, "sign in required");
1385 // Someone waiting for an invite may accept one; that lets them in.
1386 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
1387 const dir = c.get("dir");
1388 const back = p.account.status === "pending" ? "/" : "/settings/invitations";
1389 return act(c, back, async () => {
1390 const res = await dir.answerInvite(prm(c, "id"), p.account, accept);
1391 c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: accept ? "invite.accept" : "invite.decline", detail: { id: prm(c, "id") } }));
1392 if (!accept) {
1393 flash(c, "notice", "Invitation declined.");
1394 return;
1395 }
1396 if (res.repoId) {
1397 const r = await dir.repoById(res.repoId);
1398 if (r) return repoUrl(r);
1399 } else if (res.orgId) {
1400 const o = await dir.account(res.orgId);
1401 if (o) return `/${o.login}`;
1402 }
1403 });
1404 });
1405 }
1406
1407 web.get("/settings/orgs", async (c) => {
1408 const me = requireAccount(c);
1409 const t = csrfToken(c);
1410 const orgs = await c.get("dir").orgsFor(me.id);
1411 return page(
1412 c,
1413 me,
1414 "orgs",
1415 "Organizations",
1416 <>
1417 <h1>Organizations</h1>
1418 <p class="muted">Organizations group people, agents and repositories. Members get the organization's base permission (read for new organizations) on its repositories; owners and admins manage everything.</p>
1419 <div class="box">
1420 {orgs.length ? (
1421 <ul class="list">
1422 {orgs.map((o) => (
1423 <li>
1424 <Avatar login={o.login} version={o.avatar_version} />
1425 <div class="grow">
1426 <a class="title" href={`/${o.login}`}>
1427 {o.login}
1428 </a>
1429 {o.name && o.name !== o.login ? <span class="muted"> · {o.name}</span> : null}
1430 </div>
1431 <span class="badge">{o.role}</span>
1432 </li>
1433 ))}
1434 </ul>
1435 ) : (
1436 <div class="empty">You are not a member of any organization.</div>
1437 )}
1438 </div>
1439 <Form action="/settings/orgs" t={t} class="box">
1440 <div class="head">
1441 <h2>New organization</h2>
1442 </div>
1443 <div class="body">
1444 <div class="row" style="align-items:flex-start">
1445 <div class="field grow">
1446 <label for="olog">Organization name</label>
1447 <input id="olog" name="login" type="text" required pattern={LOGIN_PATTERN} placeholder="my-team" />
1448 </div>
1449 <div class="field grow">
1450 <label for="oname">Display name</label>
1451 <input id="oname" name="name" type="text" />
1452 </div>
1453 </div>
1454 <button class="btn primary">Create organization</button>
1455 </div>
1456 </Form>
1457 </>,
1458 );
1459 });
1460
1461 post(web, "/settings/orgs", async (c, f) => {
1462 const me = requireAccount(c);
1463 const dir = c.get("dir");
1464 return act(c, "/settings/orgs", async () => {
1465 const org = await dir.createOrg(f.s("login").trim(), f.s("name").trim(), me);
1466 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.create", target: org.login }));
1467 return `/${org.login}?tab=people`;
1468 });
1469 });
1470
1471 // Site administration
1472 const requireSiteAdmin = (c: AppContext): Account => {
1473 const me = requireAccount(c);
1474 if (!me.site_admin) throw new HttpError(403, "site administrators only");
1475 return me;
1476 };
1477
1478 const adminPage = async (c: AppContext, me: Account, invite?: { url: string; title: string; note: string }) => {
1479 const dir = c.get("dir");
1480 const t = csrfToken(c);
1481 const [users, requests, invites] = await Promise.all([dir.users(), dir.accessRequests(), dir.invites({ site: true, pending: true })]);
1482 return page(
1483 c,
1484 me,
1485 "admin",
1486 "Site administration",
1487 <>
1488 <h1>Site administration</h1>
1489 {invite ? (
1490 <div class="box" style="border-color:var(--amber)">
1491 <div class="body">
1492 <h3>{invite.title}</h3>
1493 <p class="muted">{invite.note}</p>
1494 <div class="row">
1495 <div class="secret grow">{invite.url}</div>
1496 <CopyButton text={invite.url} size="base" />
1497 </div>
1498 </div>
1499 </div>
1500 ) : null}
1501 <div class="box">
1502 <div class="head">
1503 <h2>Pending access requests</h2>
1504 <span class="badge">{requests.length}</span>
1505 </div>
1506 {requests.length ? (
1507 <ul class="list">
1508 {requests.map((r) => (
1509 <li>
1510 <Avatar login={r.account.login} version={r.account.avatar_version} />
1511 <div class="grow">
1512 <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span>
1513 {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null}
1514 </div>
1515 <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1516 <input type="hidden" name="status" value="active" />
1517 <button class="btn sm green">Approve</button>
1518 </Form>
1519 <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1520 <input type="hidden" name="status" value="disabled" />
1521 <button class="btn sm danger">Deny</button>
1522 </Form>
1523 </li>
1524 ))}
1525 </ul>
1526 ) : (
1527 <div class="empty">No pending requests.</div>
1528 )}
1529 </div>
1530 <Form action="/settings/admin/invite" t={t} class="box">
1531 <div class="head">
1532 <h2>Invite someone to this instance</h2>
1533 </div>
1534 <div class="body">
1535 <div class="formrow">
1536 <div class="field grow">
1537 <label for="iemail">Email address</label>
1538 <input id="iemail" name="email" type="email" required placeholder="friend@example.com" />
1539 </div>
1540 <button class="btn primary">{raw(ICONS.envelope)} Send invitation</button>
1541 </div>
1542 <div class="hint">We email them a link. Opening it lets them pick a username and create a passkey.</div>
1543 </div>
1544 </Form>
1545 <PendingInvites invites={invites} revoke={(id) => `/settings/admin/invites/${id}/revoke`} t={t} roles={false} />
1546 <div class="box">
1547 <div class="head">
1548 <h2>Users</h2>
1549 <span class="badge">{users.length}</span>
1550 </div>
1551 <table class="tbl">
1552 <thead>
1553 <tr>
1554 <th>User</th>
1555 <th>Email</th>
1556 <th>Joined</th>
1557 <th>Status</th>
1558 <th>Site admin</th>
1559 <th />
1560 </tr>
1561 </thead>
1562 <tbody>
1563 {users.map((u) => (
1564 <tr>
1565 <td>
1566 <Actor login={u.login} kind="user" version={u.avatar_version} /> {u.site_admin ? <span class="badge blue">admin</span> : null}
1567 </td>
1568 <td class="small">{u.email ?? "—"}</td>
1569 <td class="small">{ago(u.created_at)}</td>
1570 <td>
1571 {u.suspended_at ? (
1572 <span class="badge red" title={`Suspended by ${u.suspended_by ?? "a site administrator"}: ${u.suspended_reason ?? ""}`}>
1573 suspended
1574 </span>
1575 ) : u.id === me.id ? (
1576 <span class="badge green">{u.status}</span>
1577 ) : (
1578 <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1579 <KSelect name="status" size="sm" label={`Status of ${u.login}`} value={u.status} options={(["active", "pending", "disabled"] as const).map((s) => ({ value: s, label: cap(s) }))} />
1580 <button class="btn sm">Save</button>
1581 </Form>
1582 )}
1583 </td>
1584 <td>
1585 {u.site_admin ? (
1586 <Form action={`/settings/admin/users/${u.id}/site-admin`} t={t} class="inline" confirm={u.id === me.id ? "Stop being a site administrator? You lose access to these pages." : `Remove ${u.login} from the site administrators?`}>
1587 <input type="hidden" name="grant" value="0" />
1588 <button class="btn sm">Remove admin</button>
1589 </Form>
1590 ) : u.status === "active" ? (
1591 <Form action={`/settings/admin/users/${u.id}/site-admin`} t={t} class="inline" confirm={`Make ${u.login} a site administrator? They get admin on every repository and organization, and these pages.`}>
1592 <input type="hidden" name="grant" value="1" />
1593 <button class="btn sm">Make admin</button>
1594 </Form>
1595 ) : (
1596 <span class="faint small">—</span>
1597 )}
1598 </td>
1599 <td class="right nowrap">
1600 {u.id === me.id ? null : <SuspendControl u={u} t={t} />}{" "}
1601 <Form action={`/settings/admin/users/${u.id}/signin-link`} t={t} class="inline" confirm={`Create a one-time link that adds a passkey to ${u.login}? Only send it to them.`}>
1602 <button class="btn sm" title="For a lost passkey or a first sign-in">Sign-in link</button>
1603 </Form>{" "}
1604 <a class="btn sm danger" href={`/settings/admin/users/${u.id}/delete`}>
1605 Delete
1606 </a>
1607 </td>
1608 </tr>
1609 ))}
1610 </tbody>
1611 </table>
1612 </div>
1613 </>,
1614 );
1615 };
1616
1617 web.get("/settings/admin", async (c) => adminPage(c, requireSiteAdmin(c)));
1618
1619 post(web, "/settings/admin/users/:id/status", async (c, f) => {
1620 const me = requireSiteAdmin(c);
1621 const dir = c.get("dir");
1622 const status = (["active", "pending", "disabled"] as const).find((s) => s === f.s("status"));
1623 return act(
1624 c,
1625 "/settings/admin",
1626 async () => {
1627 if (!status) throw new HttpError(422, "invalid status");
1628 const target = await dir.account(prm(c, "id"));
1629 if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1630 if (target.id === me.id) throw new HttpError(422, "you cannot change your own status");
1631 await dir.setStatus(target.id, status);
1632 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: `user.${status}`, target: target.login }));
1633 },
1634 "User updated.",
1635 );
1636 });
1637
1638 post(web, "/settings/admin/users/:id/signin-link", async (c) => {
1639 const me = requireSiteAdmin(c);
1640 const dir = c.get("dir");
1641 const target = await dir.account(prm(c, "id"));
1642 if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1643 requireRecentSignIn(c, "/settings/admin");
1644 if (target.status === "disabled") throw new HttpError(422, "enable this account first");
1645 const link = await new Auth(c.env).createSigninLink(me, target);
1646 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login }));
1647 return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: `We emailed it to ${target.email ?? "them"}; you can also send it yourself. Send it only to them: it works once within 24 hours and adds a passkey to their account.` });
1648 });
1649
1650 // Suspension (lib/moderation.ts): with a reason, from the Users table or a report.
1651 const suspendTarget = async (c: AppContext) => {
1652 const user = await c.get("dir").accountByLogin(prm(c, "login"));
1653 if (!user || user.kind !== "user") throw new HttpError(404, "user not found");
1654 return user;
1655 };
1656 const backFrom = (f: { s(name: string): string }) => (/^\/settings\/admin(\/|$)/.test(f.s("back")) ? f.s("back") : "/settings/admin");
1657 post(web, "/settings/admin/users/:login/suspend", async (c, f) => {
1658 const me = requireSiteAdmin(c);
1659 return act(c, backFrom(f), async () => {
1660 const user = await suspendTarget(c);
1661 const stopped = await suspend(c.env, (e) => c.get("dir").audit(e), me, user, suspendReason(f.s("reason")), "web");
1662 flash(c, "notice", `Suspended ${user.login}.${stoppedNote(stopped)}`);
1663 });
1664 });
1665 post(web, "/settings/admin/users/:login/unsuspend", async (c, f) => {
1666 const me = requireSiteAdmin(c);
1667 return act(c, backFrom(f), async () => {
1668 const user = await suspendTarget(c);
1669 if (!(await unsuspend(c.env, (e) => c.get("dir").audit(e), me, user, "web"))) throw new HttpError(422, `${user.login} isn't suspended`);
1670 flash(c, "notice", `${user.login} is no longer suspended.`);
1671 });
1672 });
1673
1674 // Reports (lib/moderation.ts): the queue, one report, resolving and reopening.
1675 web.get("/settings/admin/reports", async (c) => {
1676 const me = requireSiteAdmin(c);
1677 const state = c.req.query("state") === "resolved" ? "resolved" : "open";
1678 const [list, open, resolved] = await Promise.all([reports(c.env, state, 100), openReportCount(c.env), reports(c.env, "resolved", 1)]);
1679 return page(c, me, "reports", "Reports", <ReportsList items={list.items} state={state} counts={{ open, resolved: resolved.total }} />);
1680 });
1681 web.get("/settings/admin/reports/:id", async (c) => {
1682 const me = requireSiteAdmin(c);
1683 const r = await report(c.env, prm(c, "id"));
1684 if (!r) throw new HttpError(404, "no report with that id");
1685 const account = r.account_id ? await c.get("dir").account(r.account_id) : null;
1686 return page(c, me, "reports", "Report", <ReportDetail r={r} t={csrfToken(c)} suspended={account?.kind === "user" && !account.site_admin && account.id !== me.id ? !!account.suspended_at : null} />);
1687 });
1688 for (const [path, state] of [["resolve", "resolved"], ["reopen", "open"]] as const) {
1689 post(web, `/settings/admin/reports/:id/${path}`, async (c, f) => {
1690 const me = requireSiteAdmin(c);
1691 const back = `/settings/admin/reports/${encodeURIComponent(prm(c, "id"))}`;
1692 return act(c, back, async () => {
1693 const note = state === "resolved" ? cleanResolution(f.s("resolution")) : null;
1694 const r = await setReportState(c.env, prm(c, "id"), me, state, note);
1695 if (!r) throw new HttpError(404, "no report with that id");
1696 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: state === "resolved" ? "report.resolve" : "report.reopen", target: r.target, detail: { id: r.id, ...(note ? { resolution: note } : {}), via: "web" } }));
1697 return state === "resolved" ? "/settings/admin/reports" : back;
1698 }, state === "resolved" ? "Report resolved." : "Report reopened.");
1699 });
1700 }
1701
1702 post(web, "/settings/admin/invite", async (c, f) => {
1703 const me = requireSiteAdmin(c);
1704 const email = f.s("email").trim();
1705 if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
1706 const inv = await c.get("dir").createInvite(me, { email, role: "read" });
1707 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email }));
1708 return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: `We emailed it to ${email}; you can also send it yourself. It expires in 14 days and works once: they pick a username and create a passkey.` });
1709 });
1710
1711 post(web, "/settings/admin/invites/:id/revoke", async (c) => {
1712 const me = requireSiteAdmin(c);
1713 const dir = c.get("dir");
1714 return act(
1715 c,
1716 "/settings/admin",
1717 async () => {
1718 if (!(await dir.revokeInvite(prm(c, "id"), {}))) throw new HttpError(404, "this invitation was already used, revoked or never existed");
1719 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "invite.revoke", detail: { id: prm(c, "id") } }));
1720 },
1721 "Invitation revoked.",
1722 );
1723 });
1724
1725 // Kill switches (lib/killswitch.ts)
1726 web.get("/settings/admin/kill-switches", async (c) => {
1727 const me = requireSiteAdmin(c);
1728 return page(c, me, "kill-switches", "Kill switches", <KillSwitchesPage env={c.env} t={csrfToken(c)} />);
1729 });
1730 const KS = "/settings/admin/kill-switches";
1731 const auditFn = (c: AppContext) => (e: Parameters<Directory["audit"]>[0]) => c.get("dir").audit(e);
1732 const stoppedNote = (s: Stopped | null) => (s ? ` Stopped ${s.runs} runs, ${s.jobs} jobs and ${s.sandboxes} sandboxes${s.failed ? `; ${s.failed} repositories didn't answer, so their work stops when it next tries to start` : ""}.` : "");
1733 post(web, `${KS}/pause`, async (c, f) => {
1734 const me = requireSiteAdmin(c);
1735 return act(c, KS, async () => {
1736 const stopped = await changePause(c.env, auditFn(c), me, cleanReason(f.s("reason")), "web");
1737 flash(c, "notice", `Hosted compute is paused.${stoppedNote(stopped)}`);
1738 });
1739 });
1740 post(web, `${KS}/resume`, async (c) => {
1741 const me = requireSiteAdmin(c);
1742 return act(c, KS, async () => changePause(c.env, auditFn(c), me, null, "web").then(() => undefined), "Hosted compute is running again. Nothing that was stopped restarts.");
1743 });
1744 const accountTarget = async (c: AppContext, login: string) => {
1745 const account = await c.get("dir").accountByLogin(login.trim());
1746 if (!account || account.kind === "agent") throw new HttpError(404, "no person or organization with that name");
1747 return { kind: "account" as const, id: account.id, login: account.login };
1748 };
1749 const repoTarget = async (c: AppContext, owner: string, name: string) => {
1750 const repo = await c.get("dir").repo(owner.trim(), name.trim());
1751 if (!repo) throw new HttpError(404, "no repository with that name; use owner/name");
1752 return { kind: "repo" as const, id: repo.id, name: `${repo.owner_login}/${repo.name}` };
1753 };
1754 post(web, `${KS}/accounts`, async (c, f) => {
1755 const me = requireSiteAdmin(c);
1756 return act(c, KS, async () => {
1757 const target = await accountTarget(c, f.s("login"));
1758 const stopped = await changeSuspension(c.env, auditFn(c), me, target, cleanReason(f.s("reason")), "web");
1759 flash(c, "notice", `Actions are suspended for every repository of ${target.login}.${stoppedNote(stopped)}`);
1760 });
1761 });
1762 post(web, `${KS}/accounts/:login/lift`, async (c) => {
1763 const me = requireSiteAdmin(c);
1764 return act(c, KS, async () => {
1765 const target = await accountTarget(c, prm(c, "login"));
1766 await changeSuspension(c.env, auditFn(c), me, target, null, "web");
1767 flash(c, "notice", `Actions are no longer suspended for ${target.login}.`);
1768 });
1769 });
1770 post(web, `${KS}/repos`, async (c, f) => {
1771 const me = requireSiteAdmin(c);
1772 return act(c, KS, async () => {
1773 const [owner, name, extra] = f.s("repo").trim().replace(/^\/+|\/+$/g, "").split("/");
1774 if (!owner || !name || extra !== undefined) throw new HttpError(422, "enter the repository as owner/name");
1775 const target = await repoTarget(c, owner, name);
1776 const stopped = await changeSuspension(c.env, auditFn(c), me, target, cleanReason(f.s("reason")), "web");
1777 flash(c, "notice", `Actions are suspended for ${target.name}.${stoppedNote(stopped)}`);
1778 });
1779 });
1780 post(web, `${KS}/repos/:owner/:repo/lift`, async (c) => {
1781 const me = requireSiteAdmin(c);
1782 return act(c, KS, async () => {
1783 const target = await repoTarget(c, prm(c, "owner"), prm(c, "repo"));
1784 await changeSuspension(c.env, auditFn(c), me, target, null, "web");
1785 flash(c, "notice", `Actions are no longer suspended for ${target.name}.`);
1786 });
1787 });
1788
1789 web.get("/settings/audit", async (c) => {
1790 const me = requireSiteAdmin(c);
1791 const rows = await c.get("dir").auditLog(200);
1792 return page(
1793 c,
1794 me,
1795 "audit",
1796 "Audit log",
1797 <>
1798 <h1>Audit log</h1>
1799 <p class="muted">The 200 most recent security-relevant events.</p>
1800 <div class="box" style="overflow:auto">
1801 <table class="tbl">
1802 <thead>
1803 <tr>
1804 <th>When</th>
1805 <th>Actor</th>
1806 <th>Action</th>
1807 <th>Target</th>
1808 <th>Detail</th>
1809 </tr>
1810 </thead>
1811 <tbody>
1812 {rows.map((r) => (
1813 <tr>
1814 <td class="small nowrap">
1815 <Time at={r.at} />
1816 </td>
1817 <td class="small">
1818 {r.actor_login ?? "system"}
1819 {r.on_behalf_of ? <span class="muted"> for {r.on_behalf_of}</span> : null}
1820 </td>
1821 <td>
1822 <span class="badge">{r.action}</span>
1823 </td>
1824 <td class="small mono">{r.target ?? ""}</td>
1825 <td class="small mono muted" style="max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title={r.detail ?? ""}>
1826 {r.detail ?? ""}
1827 </td>
1828 </tr>
1829 ))}
1830 </tbody>
1831 </table>
1832 {rows.length === 0 ? <div class="empty">No events yet.</div> : null}
1833 </div>
1834 </>,
1835 );
1836 });
1837
1838 // Unexpected server errors (lib/server-errors.ts); ?id= finds the one a 5xx answer's request ID names.
1839 web.get("/settings/admin/errors", async (c) => {
1840 const me = requireSiteAdmin(c);
1841 const id = c.req.query("id")?.trim() || undefined;
1842 const rows = await serverErrors(c.env, { id, limit: 200 });
1843 return page(
1844 c,
1845 me,
1846 "errors",
1847 "Server errors",
1848 <>
1849 <h1>Server errors</h1>
1850 <p class="muted">
1851 {id ? (
1852 <>
1853 The error with request ID <span class="mono">{id}</span>.
1854 </>
1855 ) : (
1856 <>
1857 The 200 most recent answers with a 5xx status. Each such answer carries its request ID (<span class="mono">request_id</span> and the <span class="mono">x-request-id</span> header).
1858 </>
1859 )}{" "}
1860 No request bodies, query strings or headers are kept.
1861 </p>
1862 <form method="get" action="/settings/admin/errors" class="row" style="gap:8px;margin-bottom:12px">
1863 <input type="search" name="id" placeholder="Request ID" value={id ?? ""} class="mono" style="flex:1;max-width:360px" />
1864 <button class="btn" type="submit">
1865 Find
1866 </button>
1867 {id ? (
1868 <a class="btn" href="/settings/admin/errors">
1869 Show all
1870 </a>
1871 ) : null}
1872 </form>
1873 <div class="box" style="overflow:auto">
1874 <table class="tbl">
1875 <thead>
1876 <tr>
1877 <th>When</th>
1878 <th>Request</th>
1879 <th>Status</th>
1880 <th>Error</th>
1881 </tr>
1882 </thead>
1883 <tbody>
1884 {rows.map((r) => (
1885 <tr>
1886 <td class="small nowrap">
1887 <Time at={r.at} />
1888 </td>
1889 <td class="small mono">
1890 <div>
1891 {r.method} {r.route ?? r.path}
1892 </div>
1893 <div class="muted">{r.id}</div>
1894 </td>
1895 <td>
1896 <span class="badge">{r.status}</span>
1897 </td>
1898 <td class="small mono" style="max-width:520px">
1899 {r.stack ? (
1900 <details>
1901 <summary style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{r.message}</summary>
1902 <pre class="muted" style="white-space:pre-wrap">{r.stack}</pre>
1903 </details>
1904 ) : (
1905 r.message
1906 )}
1907 </td>
1908 </tr>
1909 ))}
1910 </tbody>
1911 </table>
1912 {rows.length === 0 ? <div class="empty">{id ? "No error with that request ID; only the newest 500 are kept." : "No server errors."}</div> : null}
1913 </div>
1914 </>,
1915 );
1916 });
1917}
1918
1919// ---------------------------------------------------------------- /:owner profiles
1920
1921type OrgTab = "repositories" | "people" | "agents" | "settings";
1922
1923/** An organization's name and tab bar, shared by its profile tabs and its packages pages. */
1924export function OrgHeader({ org, role, isOwner, tab }: { org: Account; role: "owner" | "admin" | "member" | null; isOwner: boolean; tab: OrgTab | "packages" }) {
1925 const base = `/${org.login}`;
1926 return (
1927 <>
1928 <div class="row mb" style="gap:16px">
1929 <Avatar login={org.login} size="lg" version={org.avatar_version} />
1930 <div>
1931 <h1 class="m0">{org.name || org.login}</h1>
1932 <div class="muted small">
1933 Organization · {org.login}
1934 {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""}
1935 </div>
1936 </div>
1937 </div>
1938 <nav class="tabs mb vt-tabs" style="border-bottom:1px solid var(--border)">
1939 {(
1940 [
1941 ["repositories", "Repositories", true],
1942 ["people", "People", true],
1943 ["agents", "Agents", true],
1944 ["settings", "Settings", isOwner],
1945 ] as const
1946 )
1947 .filter((x) => x[2])
1948 .map(([k, label]) => (
1949 <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}>
1950 {label}
1951 </a>
1952 ))}
1953 <a href={`${base}/-/packages`} class={tab === "packages" ? "on" : ""}>
1954 Packages
1955 </a>
1956 </nav>
1957 </>
1958 );
1959}
1960
1961const ROLE_LABEL: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" };
1962
1963/** The invitation link right after inviting someone by email (it's also in their inbox). */
1964const InviteOnce: FC<{ email: string; url: string }> = ({ email, url }) => (
1965 <div class="box" style="border-color:var(--amber)">
1966 <div class="body">
1967 <h3>Invitation sent to {email}</h3>
1968 <p class="muted">We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they join when they accept it, creating an account and passkey first if they don't have one.</p>
1969 <div class="row nowrap">
1970 <div class="secret grow">{url}</div>
1971 <CopyButton text={url} size="base" />
1972 </div>
1973 </div>
1974 </div>
1975);
1976
1977function registerProfiles(web: WebApp): void {
1978 const orgContext = async (c: AppContext) => {
1979 const me = requireAccount(c);
1980 const dir = c.get("dir");
1981 const org = await dir.accountByLogin(prm(c, "owner"));
1982 if (!org || (org.kind === "org" && org.status === "disabled")) throw new HttpError(404, "not found");
1983 if (org.kind !== "org") throw new HttpError(404, "not an organization");
1984 const role = await dir.orgRole(org.id, me.id);
1985 const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
1986 const isOwner = !!me.site_admin || role === "owner";
1987 return { me, org, role, isAdmin, isOwner, dir };
1988 };
1989
1990 const profile = async (c: AppContext, invite?: { url: string; email: string }) => {
1991 const me = requireAccount(c);
1992 const dir = c.get("dir");
1993 const t = csrfToken(c);
1994 const acct = await dir.accountByLogin(prm(c, "owner"));
1995 if (!acct && prm(c, "owner").toLowerCase() === GHOST.login) {
1996 return render(
1997 c,
1998 { title: GHOST.login },
1999 <div style="max-width:720px;margin:24px auto" class="box">
2000 <div class="body row" style="gap:18px">
2001 <Avatar login={GHOST.login} size="xl" />
2002 <div>
2003 <h1 class="m0">{GHOST.login}</h1>
2004 <p class="muted">This is a placeholder for people who deleted their accounts. Issues, pull requests and comments they wrote in other people's repositories are shown as written by ghost.</p>
2005 </div>
2006 </div>
2007 </div>,
2008 );
2009 }
2010 if (!acct) {
2011 // A renamed person's or organization's old login leads to the new one.
2012 const renamed = await dir.renamedAccount(prm(c, "owner"));
2013 const url = new URL(c.req.url);
2014 if (renamed) return c.redirect(`${movedLoginPath(url.pathname, prm(c, "owner"), renamed.login) ?? `/${renamed.login}`}${url.search}`, 301);
2015 throw new HttpError(404, "not found");
2016 }
2017 // A deleted organization keeps its account row for the audit log (Directory.deleteOrg); its page is gone.
2018 if (acct.kind === "org" && acct.status === "disabled") throw new HttpError(404, "not found");
2019 // So does a deleted person's (accounts/lifecycle.ts).
2020 if (acct.deleted_at) throw new HttpError(404, "not found");
2021 // A suspended person's profile is hidden from everyone but site administrators (lib/moderation.ts).
2022 if (!visibleTo(acct, me)) throw new HttpError(404, "not found");
2023 if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301);
2024
2025 if (acct.kind === "agent") {
2026 const owner = acct.owner_id ? await dir.account(acct.owner_id) : null;
2027 const policy = parsePolicy(acct);
2028 return render(
2029 c,
2030 { title: acct.login },
2031 <div style="max-width:720px;margin:24px auto" class="box">
2032 <div class="body row" style="gap:18px">
2033 <Avatar login={acct.login} kind="agent" size="xl" version={acct.avatar_version} />
2034 <div>
2035 <h1 class="m0">
2036 {acct.name || acct.login} <span class="badge agent">agent</span>
2037 </h1>
2038 <p class="muted">{acct.login}</p>
2039 <dl class="kv">
2040 <dt>Acts on behalf of</dt>
2041 <dd>{owner ? <a href={`/${owner.login}`}>{owner.login}</a> : "—"}</dd>
2042 <dt>Maximum role</dt>
2043 <dd>{policy.maxRole ?? "write"}</dd>
2044 <dt>Status</dt>
2045 <dd>{acct.status}</dd>
2046 <dt>Created</dt>
2047 <dd>
2048 <Time at={acct.created_at} />
2049 </dd>
2050 </dl>
2051 </div>
2052 </div>
2053 </div>,
2054 );
2055 }
2056
2057 const repos = await dir.reposVisibleTo(me, acct.id);
2058 if (acct.kind === "user") {
2059 const orgs = await dir.orgsFor(acct.id);
2060 return render(
2061 c,
2062 { title: acct.login },
2063 <div class="grid left">
2064 <aside class="side">
2065 <section>
2066 <Avatar login={acct.login} size="xl" version={acct.avatar_version} />
2067 <h1 class="mt m0">{acct.name || acct.login}</h1>
2068 <p class="muted">
2069 {acct.login} {acct.suspended_at ? <span class="badge red">suspended</span> : null}
2070 </p>
2071 {acct.id === me.id ? (
2072 <a class="btn sm" href="/settings">
2073 Edit profile
2074 </a>
2075 ) : me.kind === "user" ? (
2076 <ProfileMenu c={c} user={acct} blocked={await hasBlocked(c, me, acct)} />
2077 ) : null}
2078 </section>
2079 {orgs.length ? (
2080 <section>
2081 <h3>Organizations</h3>
2082 <div class="nav">
2083 {orgs.map((o) => (
2084 <a href={`/${o.login}`}>
2085 <Avatar login={o.login} version={o.avatar_version} /> {o.login}
2086 </a>
2087 ))}
2088 </div>
2089 </section>
2090 ) : null}
2091 </aside>
2092 <div>
2093 <div class="row between mb">
2094 <h2 class="m0">Repositories</h2>
2095 <div class="row">
2096 <a class="btn sm" href={`/${acct.login}/-/packages`}>
2097 {raw(ICONS.package)} Packages
2098 </a>
2099 {acct.id === me.id ? (
2100 <a class="btn sm primary" href="/new">
2101 {raw(ICONS.plus)} New
2102 </a>
2103 ) : null}
2104 </div>
2105 </div>
2106 <div class="box">
2107 <RepoList repos={repos} showOwner={false} />
2108 </div>
2109 </div>
2110 </div>,
2111 );
2112 }
2113
2114 // Organization
2115 const role = await dir.orgRole(acct.id, me.id);
2116 const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
2117 const isOwner = !!me.site_admin || role === "owner";
2118 const requested = (c.req.query("tab") ?? "repositories") as OrgTab;
2119 // The old Invites tab is part of People now.
2120 const tab: OrgTab = invite || (requested as string) === "invites" ? "people" : (["repositories", "people", "agents", "settings"] as const).includes(requested) ? requested : "repositories";
2121 if (tab === "settings" && !isOwner) throw new HttpError(403, "organization owners only");
2122 let body: Child = null;
2123 const base = `/${acct.login}`;
2124 if (tab === "repositories") {
2125 body = (
2126 <>
2127 <div class="row between mb">
2128 <h2 class="m0">Repositories</h2>
2129 {role ? (
2130 <span class="row" style="gap:6px">
2131 {isAdmin ? (
2132 <a class="btn sm" href={`/new/import?owner=${encodeURIComponent(acct.login)}&mode=owner`}>
2133 Import from GitHub
2134 </a>
2135 ) : null}
2136 <a class="btn sm primary" href={`/new?owner=${encodeURIComponent(acct.login)}`}>
2137 {raw(ICONS.plus)} New repository
2138 </a>
2139 </span>
2140 ) : null}
2141 </div>
2142 <div class="box">
2143 <RepoList repos={repos} showOwner={false} />
2144 </div>
2145 </>
2146 );
2147 } else if (tab === "people") {
2148 const [members, invites] = await Promise.all([dir.orgMembers(acct.id), isAdmin ? dir.invites({ orgId: acct.id, pending: true }) : Promise.resolve([])]);
2149 const roles = (isOwner ? ["member", "admin", "owner"] : ["member", "admin"]).map((r) => ({ value: r, label: ROLE_LABEL[r]! }));
2150 body = (
2151 <>
2152 <div class="row between mb">
2153 <h2 class="m0">People</h2>
2154 <span class="muted small">
2155 {members.length} member{members.length === 1 ? "" : "s"}
2156 {invites.length ? ` · ${invites.length} pending invitation${invites.length === 1 ? "" : "s"}` : ""}
2157 </span>
2158 </div>
2159 {invite ? <InviteOnce email={invite.email} url={invite.url} /> : null}
2160 {isAdmin ? (
2161 <Form action={`${base}/-/org/people`} t={t} class="box">
2162 <div class="head">
2163 <h3>{raw(ICONS.userPlus)} Add people</h3>
2164 </div>
2165 <div class="body">
2166 <div class="formrow">
2167 <div class="field grow">
2168 <label for="who">Username or email</label>
2169 <input id="who" name="who" type="text" required autocomplete="off" spellcheck={false} placeholder="diffy or name@example.com" />
2170 </div>
2171 <div class="field">
2172 <label for="role">Role</label>
2173 <KSelect id="role" name="role" label="Role" value="member" options={roles} />
2174 </div>
2175 <button class="btn primary">Add</button>
2176 </div>
2177 <div class="hint">Everyone gets an invitation and joins {acct.login} when they accept it: a username in their Settings → Invitations (with an email and a notification), an email address by a link that also creates an account if they don't have one.</div>
2178 </div>
2179 </Form>
2180 ) : null}
2181 <div class="box">
2182 <table class="tbl">
2183 <thead>
2184 <tr>
2185 <th>Member</th>
2186 <th>Role</th>
2187 {isAdmin ? <th /> : null}
2188 </tr>
2189 </thead>
2190 <tbody>
2191 {members.map((m) => (
2192 <tr>
2193 <td>
2194 <Actor login={m.login} kind="user" version={m.avatar_version} /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null}
2195 </td>
2196 <td>
2197 {isAdmin && (isOwner || m.role !== "owner") ? (
2198 <Form action={`${base}/-/org/members`} t={t} class="row nowrap" style="gap:6px">
2199 <input type="hidden" name="login" value={m.login} />
2200 <KSelect name="role" size="sm" label={`Role of ${m.login}`} value={m.role} options={(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => ({ value: r, label: ROLE_LABEL[r]! }))} />
2201 <button class="btn sm">Save</button>
2202 </Form>
2203 ) : (
2204 <span class="badge">{ROLE_LABEL[m.role] ?? m.role}</span>
2205 )}
2206 </td>
2207 {isAdmin ? (
2208 <td class="right">
2209 {isOwner || m.role !== "owner" ? (
2210 <Form action={`${base}/-/org/members`} t={t} class="inline" confirm={`Remove ${m.login} from ${acct.login}?`}>
2211 <input type="hidden" name="login" value={m.login} />
2212 <input type="hidden" name="role" value="remove" />
2213 <button class="btn sm danger">Remove</button>
2214 </Form>
2215 ) : null}
2216 </td>
2217 ) : null}
2218 </tr>
2219 ))}
2220 </tbody>
2221 </table>
2222 </div>
2223 <PendingInvites invites={invites} revoke={(id) => `${base}/-/org/invites/${id}/revoke`} t={t} />
2224 </>
2225 );
2226 } else if (tab === "agents") {
2227 const agents = await dir.agentsOwnedBy([acct.id]);
2228 body = (
2229 <>
2230 <div class="row between mb">
2231 <h2 class="m0">Agents</h2>
2232 {isAdmin ? (
2233 <a class="btn sm primary" href="/settings/agents">
2234 Manage agents
2235 </a>
2236 ) : null}
2237 </div>
2238 <div class="box">
2239 {agents.length ? (
2240 <ul class="list">
2241 {agents.map((a) => (
2242 <li>
2243 <Actor login={a.login} kind="agent" version={a.avatar_version} />
2244 <span class="grow" />
2245 <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span>
2246 <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span>
2247 </li>
2248 ))}
2249 </ul>
2250 ) : (
2251 <div class="empty">This organization has no agents.</div>
2252 )}
2253 </div>
2254 </>
2255 );
2256 } else {
2257 body = (
2258 <>
2259 <h2>Settings</h2>
2260 <AvatarSection account={acct} action={`${base}/-/org/avatar`} t={t} />
2261 <Form action={`${base}/-/org/settings`} t={t} class="box">
2262 <div class="body">
2263 <div class="field">
2264 <label>Organization name</label>
2265 <input name="login" type="text" value={acct.login} required pattern={LOGIN_PATTERN} />
2266 <div class="hint">Renaming changes every repository URL in this organization.</div>
2267 </div>
2268 <div class="field">
2269 <label>Display name</label>
2270 <input name="name" type="text" value={acct.name ?? ""} />
2271 </div>
2272 <div class="field">
2273 <label for="base_role">Base permission</label>
2274 <KSelect id="base_role" name="base_role" block label="Base permission" value={baseRole(acct)} options={BASE_ROLE_OPTIONS} />
2275 <div class="hint">What every member can do in all of this organization's repositories. Owners and admins always have admin access, and repository settings can give people more.</div>
2276 </div>
2277 <button class="btn primary">Save</button>
2278 </div>
2279 </Form>
2280 <BlockList
2281 blocks={await blockList(c.env, acct.id)}
2282 add={`${base}/-/org/blocks`}
2283 unblock={(login) => `${base}/-/org/blocks/${encodeURIComponent(login)}/unblock`}
2284 t={t}
2285 title="Blocked users"
2286 hint={`They can't open issues or pull requests, comment or react in ${acct.login}'s repositories, be added as collaborators or be invited to ${acct.login}. Members can't be blocked; remove them first.`}
2287 empty="This organization hasn't blocked anyone."
2288 />
2289 <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)">
2290 <div class="head" style="background:var(--red-soft)">
2291 <h2 class="bad">Danger zone</h2>
2292 </div>
2293 <Form action={`${base}/-/org/delete`} t={t} match={acct.login} class="body">
2294 <b>Delete this organization</b>
2295 <p class="muted small">
2296 Transfer or delete its repositories and container images first. Its members, invitations, secrets and variables are removed and its agents disabled; repositories transferred out of it keep their old URLs working. Type <b class="mono">{acct.login}</b> to confirm.
2297 </p>
2298 <div class="row">
2299 <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm organization name" />
2300 <button class="btn danger">Delete this organization</button>
2301 </div>
2302 </Form>
2303 </div>
2304 </>
2305 );
2306 }
2307 return render(
2308 c,
2309 { title: acct.login },
2310 <>
2311 <OrgHeader org={acct} role={role} isOwner={isOwner} tab={tab} />
2312 {body}
2313 </>,
2314 );
2315 };
2316
2317 web.get("/:owner", async (c) => profile(c));
2318
2319 // Add people: a username or an email. Both get an invitation to accept (Directory.addOrgPerson).
2320 post(web, "/:owner/-/org/people", async (c, f) => {
2321 const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
2322 const back = `/${org.login}?tab=people`;
2323 const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member";
2324 let invited: { email: string; url: string } | undefined;
2325 const res = await act(c, back, async () => {
2326 if (!isAdmin) throw new HttpError(403, "organization admins only");
2327 if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can add owners");
2328 const r = await dir.addOrgPerson(org, me, f.s("who"), role);
2329 if ("added" in r) {
2330 flash(c, "notice", `Added ${r.added.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. We let them know.`);
2331 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.member.set", target: org.login, detail: { user: r.added.login, role } }));
2332 } else if ("invitedUser" in r) {
2333 flash(c, "notice", r.renewed ? `${r.invitedUser.login} already had a pending invitation; it now offers the ${ROLE_LABEL[role]!.toLowerCase()} role.` : `Invited ${r.invitedUser.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. They join once they accept.`);
2334 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { user: r.invitedUser.login, role } }));
2335 } else {
2336 invited = r.invited;
2337 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email: r.invited.email, role } }));
2338 }
2339 });
2340 return invited ? profile(c, invited) : res;
2341 });
2342
2343 // The old invite form posts here.
2344 post(web, "/:owner/-/org/invites", async (c) => c.redirect(`/${prm(c, "owner")}?tab=people`, 303));
2345
2346 post(web, "/:owner/-/org/invites/:id/revoke", async (c) => {
2347 const { me, org, isAdmin, dir } = await orgContext(c);
2348 return act(
2349 c,
2350 `/${org.login}?tab=people`,
2351 async () => {
2352 if (!isAdmin) throw new HttpError(403, "organization admins only");
2353 if (!(await dir.revokeInvite(prm(c, "id"), { orgId: org.id }))) throw new HttpError(404, "this invitation was already used, revoked or never existed");
2354 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite.revoke", target: org.login, detail: { id: prm(c, "id") } }));
2355 },
2356 "Invitation revoked.",
2357 );
2358 });
2359
2360 post(web, "/:owner/-/org/members", async (c, f) => {
2361 const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
2362 const back = `/${org.login}?tab=people`;
2363 return act(c, back, async () => {
2364 if (!isAdmin) throw new HttpError(403, "organization admins only");
2365 const user = await dir.accountByLogin(f.s("login").trim());
2366 if (!user || user.kind !== "user") throw new HttpError(404, "user not found");
2367 const current = await dir.orgRole(org.id, user.id);
2368 const role = f.s("role");
2369 if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners");
2370 // Someone who isn't a member yet gets an invitation, as with Add people.
2371 if (!current && role !== "remove") {
2372 if (role !== "owner" && role !== "admin" && role !== "member") throw new HttpError(422, "invalid role");
2373 const r = await dir.addOrgPerson(org, me, user.login, role);
2374 if ("invitedUser" in r) flash(c, "notice", r.renewed ? `${user.login} already had a pending invitation; it now offers the ${ROLE_LABEL[role]!.toLowerCase()} role.` : `Invited ${user.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. They join once they accept.`);
2375 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "added" in r ? "org.member.set" : "org.invite", target: org.login, detail: { user: user.login, role } }));
2376 return;
2377 }
2378 if (role === "remove") await dir.setOrgMember(org.id, user.id, null, me);
2379 else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role, me);
2380 else throw new HttpError(422, "invalid role");
2381 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: role === "remove" ? "org.member.remove" : "org.member.set", target: org.login, detail: { user: user.login, role } }));
2382 });
2383 });
2384
2385 post(web, "/:owner/-/org/settings", async (c, f) => {
2386 const { me, org, isOwner, dir } = await orgContext(c);
2387 return act(c, `/${org.login}?tab=settings`, async () => {
2388 if (!isOwner) throw new HttpError(403, "organization owners only");
2389 const login = f.s("login").trim();
2390 // One validated write: a bad base permission doesn't leave a half-applied rename behind.
2391 await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim(), baseRole: f.s("base_role") || undefined });
2392 if (login && login !== org.login) c.executionCtx.waitUntil(purgeAvatarCache(c, org.login, [org.avatar_version]));
2393 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login, base_role: f.s("base_role") || undefined } }));
2394 return `/${login || org.login}?tab=settings`;
2395 }, "Organization updated.");
2396 });
2397
2398 post(web, "/:owner/-/org/delete", async (c, f) => {
2399 const { me, org, isOwner, dir } = await orgContext(c);
2400 return act(
2401 c,
2402 `/${org.login}?tab=settings`,
2403 async () => {
2404 if (!isOwner) throw new HttpError(403, "organization owners only");
2405 if (f.s("confirm").trim() !== org.login) throw new HttpError(422, `type ${org.login} to confirm deletion`);
2406 requireRecentSignIn(c, `/${org.login}?tab=settings`);
2407 await dir.deleteOrg(org);
2408 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.delete", target: org.login }));
2409 return "/";
2410 },
2411 `Deleted ${org.login}.`,
2412 );
2413 });
2414
2415 avatarSizeGuard(web, "/:owner/-/org/avatar", (c) => `/${c.req.param("owner")}?tab=settings`);
2416 post(web, "/:owner/-/org/avatar", async (c, f) => {
2417 const { me, org, isOwner } = await orgContext(c);
2418 return act(c, `/${org.login}?tab=settings`, async () => {
2419 if (!isOwner) throw new HttpError(403, "organization owners only");
2420 await uploadAvatar(c, f, me, org);
2421 }, "Organization picture updated.");
2422 });
2423
2424 post(web, "/:owner/-/org/avatar/delete", async (c) => {
2425 const { me, org, isOwner } = await orgContext(c);
2426 return act(c, `/${org.login}?tab=settings`, async () => {
2427 if (!isOwner) throw new HttpError(403, "organization owners only");
2428 await deleteAvatar(c, me, org);
2429 }, "Organization picture removed.");
2430 });
2431}