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.

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