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.

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