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.

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