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.

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