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.

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