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.

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