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.

1620 lines · 65.7 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 type { Account, Repo, TokenScope } from "../lib/directory.js";
6import { loadRepo } from "../http/common.js";
7import { HttpError } from "../lib/util.js";
8import { ROLE_LEVEL, type Role } from "../repo/types.js";
9import { ICONS } from "./assets.js";
10import { csrfToken } from "./csrf.js";
11import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp } from "./ui.js";
12
13// ---------------------------------------------------------------- shared bits
14
15const RepoList: FC<{ repos: Repo[]; showOwner?: boolean; empty?: Child }> = ({ repos, showOwner = true, empty }) =>
16 repos.length ? (
17 <ul class="list">
18 {repos.map((r) => (
19 <li>
20 <span class="st faint">{raw(r.visibility === "private" ? ICONS.lock : ICONS.repo)}</span>
21 <div class="grow">
22 <a class="title" href={repoUrl(r)}>
23 {showOwner ? `${r.owner_login}/` : ""}
24 {r.name}
25 </a>{" "}
26 <span class="badge">{r.visibility}</span> {r.archived ? <span class="badge amber">archived</span> : null}
27 {r.description ? <div class="meta">{r.description}</div> : null}
28 </div>
29 <span class="small muted nowrap">Updated {ago(r.pushed_at ?? r.updated_at)}</span>
30 </li>
31 ))}
32 </ul>
33 ) : (
34 <div class="empty">{empty ?? "No repositories yet."}</div>
35 );
36
37function parsePolicy(a: Account): { maxRole?: Role } {
38 try {
39 return a.agent_policy ? (JSON.parse(a.agent_policy) as { maxRole?: Role }) : {};
40 } catch {
41 return {};
42 }
43}
44
45async function adminOrgs(c: AppContext, me: Account): Promise<Array<Account & { role: string }>> {
46 return (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin");
47}
48
49/** Whether `me` may manage an agent (its owner, an admin of its owning org, or a site admin). */
50async function canManageAgent(c: AppContext, me: Account, agent: Account): Promise<boolean> {
51 if (agent.kind !== "agent" || !agent.owner_id) return false;
52 if (me.site_admin || agent.owner_id === me.id) return true;
53 const role = await c.get("dir").orgRole(agent.owner_id, me.id);
54 return role === "owner" || role === "admin";
55}
56
57const SCOPES: TokenScope[] = ["read", "write", "admin"];
58
59// ---------------------------------------------------------------- landing / dashboard
60
61const Landing: FC = () => (
62 <div>
63 <section class="hero">
64 <h1>Diffinery</h1>
65 <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>
66 <p class="row" style="justify-content:center">
67 <a class="btn primary" href="/settings">
68 Sign in
69 </a>
70 </p>
71 <p class="muted small">Access is through Cloudflare Access. Sign in with the email address you were invited with; new accounts wait for an invite from an administrator.</p>
72 </section>
73 <div class="features">
74 <div class="box">
75 <h3>{raw(ICONS.repo)} Git, natively</h3>
76 <p class="muted m0">Clone, fetch and push over smart HTTP. Every repository is its own strongly consistent Durable Object.</p>
77 </div>
78 <div class="box">
79 <h3>{raw(ICONS.pr)} Reviews with intent</h3>
80 <p class="muted m0">Pull requests carry what a change is meant to do, so human and AI reviewers check the right thing.</p>
81 </div>
82 <div class="box">
83 <h3>{raw(ICONS.play)} Actions-compatible CI</h3>
84 <p class="muted m0">Bring your existing workflow YAML. Jobs run in containers with live logs, caches and artifacts.</p>
85 </div>
86 <div class="box">
87 <h3>{raw(ICONS.bot)} Agents with guardrails</h3>
88 <p class="muted m0">Agents get their own accounts, tokens and policies. Protected branches keep a human in the loop.</p>
89 </div>
90 </div>
91 </div>
92);
93
94export function registerAccount(web: WebApp): void {
95 web.get("/", async (c) => {
96 const p = c.get("principal");
97 if (p.type !== "account") return render(c, { title: "" }, <Landing />);
98 const me = p.account;
99 const t = csrfToken(c);
100 if (me.status === "disabled") {
101 return render(
102 c,
103 { title: "Account disabled", status: 403 },
104 <div class="blank" style="max-width:560px;margin:48px auto">
105 <h1>Your account is disabled</h1>
106 <p class="muted m0">Contact an administrator of this Diffinery instance if you think this is a mistake.</p>
107 </div>,
108 );
109 }
110 if (me.status === "pending") {
111 return render(
112 c,
113 { title: "Waiting for an invite" },
114 <div class="box" style="max-width:600px;margin:48px auto">
115 <div class="body">
116 <h1>Waiting for an invite</h1>
117 <p class="muted">
118 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.
119 </p>
120 <Form action="/request-access" t={t}>
121 <div class="field">
122 <label for="note">Note to the administrators</label>
123 <textarea id="note" name="note" maxlength={500} placeholder="Who are you, and what would you like to work on?" style="min-height:90px"></textarea>
124 </div>
125 <button class="btn primary">Request access</button>
126 </Form>
127 </div>
128 </div>,
129 );
130 }
131 const dir = c.get("dir");
132 const [repos, orgs, notes] = await Promise.all([dir.reposVisibleTo(me), dir.orgsFor(me.id), dir.notifications(me.id, true)]);
133 const mine = repos.filter((r) => r.owner_id === me.id || orgs.some((o) => o.id === r.owner_id));
134 const others = repos.filter((r) => !mine.includes(r));
135 return render(
136 c,
137 { title: "Dashboard" },
138 <div class="grid left">
139 <aside class="side">
140 <section>
141 <div class="row between mb">
142 <h3 class="m0">Your repositories</h3>
143 <a class="btn sm primary" href="/new">
144 {raw(ICONS.plus)} New
145 </a>
146 </div>
147 {mine.length ? (
148 <div class="nav">
149 {mine.slice(0, 40).map((r) => (
150 <a href={repoUrl(r)} class="nowrap" style="overflow:hidden;text-overflow:ellipsis">
151 <Avatar login={r.owner_login} /> {r.owner_login}/<b>{r.name}</b>
152 </a>
153 ))}
154 </div>
155 ) : (
156 <p class="muted small">You have no repositories yet.</p>
157 )}
158 </section>
159 <section>
160 <h3>Organizations</h3>
161 <div class="nav">
162 {orgs.map((o) => (
163 <a href={`/${o.login}`}>
164 <Avatar login={o.login} /> {o.login} <span class="faint small">{o.role}</span>
165 </a>
166 ))}
167 <a href="/settings/orgs" class="muted">
168 {raw(ICONS.plus)} New organization
169 </a>
170 </div>
171 </section>
172 </aside>
173 <div>
174 {repos.length === 0 ? (
175 <div class="box">
176 <div class="body">
177 <h2>Welcome to Diffinery, {me.name ?? me.login}</h2>
178 <ol class="muted" style="padding-left:20px">
179 <li>
180 <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends).
181 </li>
182 <li>
183 <a href="/settings/tokens">Create a personal access token</a> — Git uses it as the password over HTTPS.
184 </li>
185 <li>Push your code, open pull requests and add workflows under <code>.github/workflows</code>.</li>
186 <li>
187 Invite people from an organization's <b>People</b> page or a repository's <b>Settings → Access</b>.
188 </li>
189 </ol>
190 </div>
191 </div>
192 ) : null}
193 {notes.length ? (
194 <div class="box">
195 <div class="head">
196 <h2>{raw(ICONS.bell)} Unread notifications</h2>
197 <a class="small" href="/notifications" style="margin-left:auto">
198 View all
199 </a>
200 </div>
201 <ul class="list">
202 {notes.slice(0, 8).map((n) => (
203 <li>
204 <div class="grow">
205 <a class="title" href={`/notifications/${n.id}`}>
206 {n.title}
207 </a>
208 <div class="meta">
209 {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
210 </div>
211 </div>
212 </li>
213 ))}
214 </ul>
215 </div>
216 ) : null}
217 <div class="box">
218 <div class="head">
219 <h2>Repositories</h2>
220 <span class="muted small">{repos.length} you can access</span>
221 </div>
222 <RepoList repos={[...mine, ...others].slice(0, 100)} empty={<a href="/new">Create your first repository</a>} />
223 </div>
224 </div>
225 </div>,
226 );
227 });
228
229 post(web, "/request-access", async (c, f) => {
230 const p = c.get("principal");
231 if (p.type !== "account") throw new HttpError(401, "sign in required");
232 if (p.account.status !== "pending") return c.redirect("/", 303);
233 return act(c, "/", async () => c.get("dir").requestAccess(p.account.id, f.s("note")), "Request sent. An administrator will review it.");
234 });
235
236 // ---------------------------------------------------------------- new repository
237
238 const NewRepoForm: FC<{ t: string; me: Account; orgs: Array<Account & { role: string }>; values: Record<string, string>; error?: string }> = ({ t, me, orgs, values, error }) => (
239 <div style="max-width:720px;margin:0 auto">
240 <h1>Create a new repository</h1>
241 <p class="muted">A repository holds your project's files, history, issues, pull requests and workflows.</p>
242 {error ? <div class="flash error">{error}</div> : null}
243 <Form action="/new" t={t} class="box">
244 <div class="body">
245 <div class="row" style="align-items:flex-end">
246 <div class="field" style="min-width:180px">
247 <label for="owner">Owner</label>
248 <select id="owner" name="owner">
249 <option value={me.login} selected={values.owner === me.login}>
250 {me.login}
251 </option>
252 {orgs.map((o) => (
253 <option value={o.login} selected={values.owner === o.login}>
254 {o.login}
255 </option>
256 ))}
257 </select>
258 </div>
259 <div class="field" style="padding-bottom:8px">/</div>
260 <div class="field grow">
261 <label for="name">Repository name</label>
262 <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" />
263 </div>
264 </div>
265 <div class="field">
266 <label for="description">
267 Description <span class="faint">(optional)</span>
268 </label>
269 <input id="description" name="description" type="text" value={values.description} maxlength={350} />
270 </div>
271 <div class="field">
272 <label>Visibility</label>
273 {(
274 [
275 ["private", "Private", "Only you, collaborators and organization members can see it."],
276 ["internal", "Internal", "Everyone signed in to this Diffinery instance can see it."],
277 ["public", "Public", "Anyone on the internet can see and clone it."],
278 ] as const
279 ).map(([v, l, d]) => (
280 <label class="inline" style="margin:4px 0">
281 <input type="radio" name="visibility" value={v} checked={(values.visibility || "private") === v} /> <b>{l}</b> <span class="muted small">— {d}</span>
282 </label>
283 ))}
284 </div>
285 <div class="field" style="max-width:280px">
286 <label for="branch">Default branch</label>
287 <input id="branch" name="default_branch" type="text" value={values.default_branch || "main"} />
288 </div>
289 <button class="btn primary">Create repository</button>
290 </div>
291 </Form>
292 </div>
293 );
294
295 web.get("/new", async (c) => {
296 const me = requireAccount(c);
297 const orgs = await c.get("dir").orgsFor(me.id);
298 return render(c, { title: "New repository" }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={{ owner: c.req.query("owner") ?? me.login }} />);
299 });
300
301 post(web, "/new", async (c, f) => {
302 const me = requireAccount(c);
303 const dir = c.get("dir");
304 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() };
305 try {
306 const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner);
307 if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner");
308 const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private";
309 const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" });
310 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` }));
311 return c.redirect(repoUrl(repo), 303);
312 } catch (e) {
313 if (!(e instanceof HttpError) || e.status >= 500) throw e;
314 const orgs = await dir.orgsFor(me.id);
315 return render(c, { title: "New repository", status: e.status }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={values} error={e.detail} />);
316 }
317 });
318
319 // ---------------------------------------------------------------- notifications
320
321 web.get("/notifications", async (c) => {
322 const me = requireAccount(c);
323 const t = csrfToken(c);
324 const all = c.req.query("all") === "1";
325 const notes = await c.get("dir").notifications(me.id, !all);
326 return render(
327 c,
328 { title: "Notifications" },
329 <div style="max-width:900px;margin:0 auto">
330 <div class="row between mb">
331 <h1 class="m0">Notifications</h1>
332 <div class="row">
333 <div class="subnav">
334 <a href="/notifications" class={!all ? "on" : ""}>
335 Unread
336 </a>
337 <a href="/notifications?all=1" class={all ? "on" : ""}>
338 All
339 </a>
340 </div>
341 <Form action="/notifications/read" t={t} class="inline">
342 <button class="btn sm">Mark all as read</button>
343 </Form>
344 </div>
345 </div>
346 <div class="box">
347 {notes.length ? (
348 <ul class="list">
349 {notes.map((n) => (
350 <li>
351 <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : "color:var(--accent)"}>
352 {raw(n.kind.startsWith("pull") ? ICONS.pr : n.kind.includes("run") || n.kind.includes("workflow") ? ICONS.play : ICONS.bell)}
353 </span>
354 <div class="grow">
355 <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}>
356 {n.title}
357 </a>
358 <div class="meta">
359 {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
360 </div>
361 </div>
362 {!n.readAt ? (
363 <Form action="/notifications/read" t={t} class="inline">
364 <input type="hidden" name="id" value={n.id} />
365 <button class="btn sm" title="Mark as read">
366 {raw(ICONS.check)}
367 </button>
368 </Form>
369 ) : null}
370 </li>
371 ))}
372 </ul>
373 ) : (
374 <div class="empty">{all ? "No notifications." : "All caught up — no unread notifications."}</div>
375 )}
376 </div>
377 </div>,
378 );
379 });
380
381 web.get("/notifications/:id", async (c) => {
382 const me = requireAccount(c);
383 const dir = c.get("dir");
384 const id = c.req.param("id");
385 const n = (await dir.notifications(me.id)).find((x) => x.id === id);
386 if (!n) throw new HttpError(404, "notification not found");
387 await dir.markRead(me.id, id);
388 const base = publicUrl(c);
389 let to = n.url;
390 if (to.startsWith(base)) to = to.slice(base.length) || "/";
391 if (!to.startsWith("/") || to.startsWith("//")) to = "/notifications";
392 return c.redirect(to, 302);
393 });
394
395 post(web, "/notifications/read", async (c, f) => {
396 const me = requireAccount(c);
397 const id = f.s("id");
398 return act(c, c.req.header("referer")?.includes("/notifications") ? "/notifications" : "/", async () => c.get("dir").markRead(me.id, id || undefined));
399 });
400
401 // ---------------------------------------------------------------- invites
402
403 web.get("/invite/:token", async (c) => {
404 const p = c.get("principal");
405 if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
406 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
407 return render(
408 c,
409 { title: "Accept invite" },
410 <div class="box" style="max-width:560px;margin:48px auto">
411 <div class="body">
412 <h1>You have been invited</h1>
413 <p class="muted">
414 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.
415 </p>
416 <Form action={`/invite/${encodeURIComponent(c.req.param("token"))}`} t={csrfToken(c)}>
417 <button class="btn primary">Accept invite</button>
418 </Form>
419 </div>
420 </div>,
421 );
422 });
423
424 post(web, "/invite/:token", async (c) => {
425 const p = c.get("principal");
426 if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
427 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
428 const dir = c.get("dir");
429 const res = await dir.acceptInvite(c.req.param("token"), p.account);
430 let to = "/";
431 if (res.repoId) {
432 const r = await dir.repoById(res.repoId);
433 if (r) to = repoUrl(r);
434 } else if (res.orgId) {
435 const o = await dir.account(res.orgId);
436 if (o) to = `/${o.login}`;
437 }
438 c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: "invite.accept", target: to }));
439 return c.redirect(to, 303);
440 });
441
442 // ---------------------------------------------------------------- palette data
443
444 web.get("/-/palette.json", async (c) => {
445 const me = requireAccount(c);
446 const repos = (await c.get("dir").reposVisibleTo(me)).slice(0, 300).map((r) => `${r.owner_login}/${r.name}`);
447 const param = c.req.query("repo") ?? "";
448 let files: string[] = [];
449 let ref: string | undefined;
450 const m = /^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(param);
451 if (m) {
452 try {
453 const { stub, repo } = await loadRepo(c, m[1]!, m[2]!, "read");
454 ref = c.req.query("ref") || repo.default_branch;
455 files = await listFiles(stub, ref);
456 } catch {
457 files = [];
458 }
459 }
460 return c.json({ repos, files, ref }, 200, { "cache-control": "private, max-age=30" });
461 });
462
463 // ---------------------------------------------------------------- personal settings
464
465 registerSettings(web);
466 registerProfiles(web);
467}
468
469/** Breadth-first file listing for "go to file" (bounded). */
470async function listFiles(stub: Stub, ref: string): Promise<string[]> {
471 const out: string[] = [];
472 let queue = [""];
473 let reads = 0;
474 while (queue.length && reads < 120 && out.length < 5000) {
475 const batch = queue.slice(0, 12);
476 queue = queue.slice(12);
477 reads += batch.length;
478 const trees = await Promise.all(batch.map((p) => stub.tree(ref, p).catch(() => null)));
479 for (const t of trees) {
480 if (!t) continue;
481 for (const it of t.items) {
482 if (it.type === "tree") {
483 if (!/^(node_modules|\.git|vendor|dist|target)$/.test(it.name)) queue.push(it.path);
484 } else if (it.type === "blob") out.push(it.path);
485 }
486 }
487 }
488 return out.slice(0, 5000);
489}
490
491// ---------------------------------------------------------------- /settings
492
493type SettingsTab = "profile" | "tokens" | "agents" | "orgs" | "admin" | "audit";
494
495const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
496 <aside class="side">
497 <section>
498 <div class="row mb">
499 <Avatar login={me.login} size="lg" />
500 <div>
501 <b>{me.name ?? me.login}</b>
502 <div class="muted small">{me.login}</div>
503 </div>
504 </div>
505 <div class="nav">
506 <a href="/settings" class={tab === "profile" ? "on" : ""}>
507 Profile
508 </a>
509 <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}>
510 Access tokens
511 </a>
512 <a href="/settings/agents" class={tab === "agents" ? "on" : ""}>
513 Agents
514 </a>
515 <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}>
516 Organizations
517 </a>
518 <a href="/notifications">Notifications</a>
519 {me.site_admin ? (
520 <>
521 <a href="/settings/admin" class={tab === "admin" ? "on" : ""}>
522 Site administration
523 </a>
524 <a href="/settings/audit" class={tab === "audit" ? "on" : ""}>
525 Audit log
526 </a>
527 </>
528 ) : null}
529 </div>
530 </section>
531 </aside>
532);
533
534const TokenOnce: FC<{ token: string; title: string; c: AppContext; note?: Child }> = ({ token, title, c, note }) => (
535 <div class="box" style="border-color:var(--amber)">
536 <div class="body">
537 <h3>{title}</h3>
538 <p class="muted">Copy it now — it will not be shown again.</p>
539 <div class="row">
540 <div class="secret grow">{token}</div>
541 <CopyButton text={token} />
542 </div>
543 {note}
544 <details class="mt">
545 <summary class="small" style="cursor:pointer">
546 How to use it
547 </summary>
548 <pre class="cmd mt">
549 {`# Git over HTTPS (any username, the token as password)\ngit clone https://x:${token.slice(0, 8)}…@${new URL(publicUrl(c)).host}/OWNER/REPO.git\n\n# Or store it once in your credential helper\ngit config --global credential.helper store\n\n# REST API / MCP\ncurl -H "Authorization: Bearer <token>" ${publicUrl(c)}/api/...`}
550 </pre>
551 </details>
552 </div>
553 </div>
554);
555
556function registerSettings(web: WebApp): void {
557 const page = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child, status?: number) =>
558 render(
559 c,
560 { title, status },
561 <div class="grid left">
562 <SettingsNav me={me} tab={tab} />
563 <div>{body}</div>
564 </div>,
565 );
566
567 // Profile
568 web.get("/settings", async (c) => {
569 const me = requireAccount(c);
570 const t = csrfToken(c);
571 return page(
572 c,
573 me,
574 "profile",
575 "Settings",
576 <>
577 <h1>Profile</h1>
578 <Form action="/settings/profile" t={t} class="box">
579 <div class="body">
580 <div class="field">
581 <label for="login">Username</label>
582 <input id="login" name="login" type="text" value={me.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" />
583 <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div>
584 </div>
585 <div class="field">
586 <label for="name">Display name</label>
587 <input id="name" name="name" type="text" value={me.name ?? ""} />
588 </div>
589 <dl class="kv mb">
590 <dt>Email</dt>
591 <dd>{me.email ?? "—"}</dd>
592 <dt>Account type</dt>
593 <dd>{me.kind}</dd>
594 <dt>Joined</dt>
595 <dd>
596 <Time at={me.created_at} />
597 </dd>
598 <dt>Site admin</dt>
599 <dd>{me.site_admin ? "yes" : "no"}</dd>
600 </dl>
601 <button class="btn primary">Save profile</button>
602 </div>
603 </Form>
604 </>,
605 );
606 });
607
608 post(web, "/settings/profile", async (c, f) => {
609 const me = requireAccount(c);
610 return act(
611 c,
612 "/settings",
613 async () => {
614 const login = f.s("login").trim();
615 await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() });
616 },
617 "Profile saved.",
618 );
619 });
620
621 // Tokens
622 const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => {
623 const dir = c.get("dir");
624 const t = csrfToken(c);
625 const [tokens, repos] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me)]);
626 const repoName = new Map(repos.map((r) => [r.id, `${r.owner_login}/${r.name}`]));
627 return page(
628 c,
629 me,
630 "tokens",
631 "Access tokens",
632 <>
633 <h1>Personal access tokens</h1>
634 <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>
635 {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null}
636 <div class="box">
637 <div class="head">
638 <h2>Active tokens</h2>
639 </div>
640 {tokens.length ? (
641 <table class="tbl">
642 <thead>
643 <tr>
644 <th>Name</th>
645 <th>Scopes</th>
646 <th>Repositories</th>
647 <th>Expires</th>
648 <th>Last used</th>
649 <th />
650 </tr>
651 </thead>
652 <tbody>
653 {tokens.map((k) => (
654 <tr>
655 <td>
656 <b>{k.name}</b>
657 <div class="faint mono small">{k.prefix}…</div>
658 </td>
659 <td>{k.scopes.join(", ")}</td>
660 <td class="small">{k.repoIds ? k.repoIds.map((id) => repoName.get(id) ?? id.slice(0, 8)).join(", ") : <span class="muted">all</span>}</td>
661 <td class="small">{new Date(k.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}</td>
662 <td class="small">{k.lastUsedAt ? ago(k.lastUsedAt) : <span class="muted">never</span>}</td>
663 <td class="right">
664 <Form action={`/settings/tokens/${k.id}/revoke`} t={t} class="inline" confirm={`Revoke token "${k.name}"? Anything using it stops working.`}>
665 <button class="btn sm danger">Revoke</button>
666 </Form>
667 </td>
668 </tr>
669 ))}
670 </tbody>
671 </table>
672 ) : (
673 <div class="empty">No tokens yet.</div>
674 )}
675 </div>
676 <Form action="/settings/tokens" t={t} class="box">
677 <div class="head">
678 <h2>Create a token</h2>
679 </div>
680 <div class="body">
681 <div class="row" style="align-items:flex-start">
682 <div class="field grow">
683 <label for="tname">Name</label>
684 <input id="tname" name="name" type="text" required placeholder="laptop, ci, claude-code…" maxlength={80} />
685 </div>
686 <div class="field" style="width:140px">
687 <label for="days">Expires in (days)</label>
688 <input id="days" name="days" type="number" min={1} max={366} value="90" />
689 </div>
690 </div>
691 <div class="field">
692 <label>Scope</label>
693 {SCOPES.map((s) => (
694 <label class="inline" style="margin-right:16px">
695 <input type="radio" name="scope" value={s} checked={s === "write"} /> {s}
696 </label>
697 ))}
698 <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div>
699 </div>
700 <div class="field">
701 <label for="repos">
702 Restrict to repositories <span class="faint">(optional)</span>
703 </label>
704 <select id="repos" name="repos" multiple size={Math.min(6, Math.max(3, repos.length))}>
705 {repos.map((r) => (
706 <option value={r.id}>
707 {r.owner_login}/{r.name}
708 </option>
709 ))}
710 </select>
711 <div class="hint">Leave empty for access to every repository you can access.</div>
712 </div>
713 <button class="btn primary">Create token</button>
714 </div>
715 </Form>
716 </>,
717 );
718 };
719
720 web.get("/settings/tokens", async (c) => tokensPage(c, requireAccount(c)));
721
722 post(web, "/settings/tokens", async (c, f) => {
723 const me = requireAccount(c);
724 const dir = c.get("dir");
725 const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
726 const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id));
727 const repoIds = f.all("repos").filter((id) => visible.has(id));
728 const name = f.s("name").trim() || "token";
729 const created = await dir.createToken(me, { name, scopes: [scope], repoIds: repoIds.length ? repoIds : null, expiresInDays: parseInt(f.s("days"), 10) || 90 });
730 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "token.create", target: me.login, detail: { name, scope, repos: repoIds.length } }));
731 return tokensPage(c, me, created);
732 });
733
734 post(web, "/settings/tokens/:id/revoke", async (c) => {
735 const me = requireAccount(c);
736 return act(
737 c,
738 "/settings/tokens",
739 async () => {
740 await c.get("dir").revokeToken(me.id, c.req.param("id"));
741 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: c.req.param("id") }));
742 },
743 "Token revoked.",
744 );
745 });
746
747 // Agents
748 const agentsPage = async (c: AppContext, me: Account, created?: { token: string; agent: string }) => {
749 const dir = c.get("dir");
750 const t = csrfToken(c);
751 const orgs = await adminOrgs(c, me);
752 const agents = await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)]);
753 const ownerName = new Map<string, string>([[me.id, me.login], ...orgs.map((o) => [o.id, o.login] as [string, string])]);
754 const tokenLists = await Promise.all(agents.map((a) => dir.tokens(a.id)));
755 return page(
756 c,
757 me,
758 "agents",
759 "Agents",
760 <>
761 <h1>Agents</h1>
762 <p class="muted">
763 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.
764 </p>
765 {created ? <TokenOnce c={c} token={created.token} title={`Token for ${created.agent}`} /> : null}
766 <div class="box">
767 <div class="head">
768 <h2>Your agents</h2>
769 </div>
770 {agents.length ? (
771 <ul class="list">
772 {agents.map((a, i) => {
773 const policy = parsePolicy(a);
774 const toks = tokenLists[i] ?? [];
775 return (
776 <li style="display:block">
777 <div class="row">
778 <Actor login={a.login} kind="agent" />
779 <span class="muted small">
780 owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status}
781 </span>
782 <span class="grow" />
783 <Form action={`/settings/agents/${a.id}/status`} t={t} class="inline">
784 <input type="hidden" name="status" value={a.status === "active" ? "disabled" : "active"} />
785 <button class={`btn sm ${a.status === "active" ? "danger" : ""}`}>{a.status === "active" ? "Disable" : "Enable"}</button>
786 </Form>
787 </div>
788 {toks.length ? (
789 <div class="small muted mt" style="margin-top:6px">
790 Tokens:{" "}
791 {toks.map((k) => (
792 <span class="row" style="display:inline-flex;gap:4px;margin-right:10px">
793 <span class="mono">{k.name}</span> ({k.scopes.join(",")}, last used {k.lastUsedAt ? ago(k.lastUsedAt) : "never"})
794 <Form action={`/settings/agents/${a.id}/tokens/${k.id}/revoke`} t={t} class="inline" confirm="Revoke this agent token?">
795 <button class="btn sm link bad">revoke</button>
796 </Form>
797 </span>
798 ))}
799 </div>
800 ) : null}
801 <details style="margin-top:6px">
802 <summary class="small" style="cursor:pointer;color:var(--accent)">
803 Create a token for {a.login}
804 </summary>
805 <Form action={`/settings/agents/${a.id}/token`} t={t} class="row mt" style="align-items:flex-end">
806 <div class="field grow m0">
807 <label>Name</label>
808 <input name="name" type="text" required value="default" />
809 </div>
810 <div class="field m0">
811 <label>Scope</label>
812 <select name="scope">
813 {SCOPES.map((s) => (
814 <option value={s} selected={s === "write"}>
815 {s}
816 </option>
817 ))}
818 </select>
819 </div>
820 <div class="field m0" style="width:110px">
821 <label>Days</label>
822 <input name="days" type="number" min={1} max={366} value="30" />
823 </div>
824 <button class="btn primary">Create token</button>
825 </Form>
826 </details>
827 </li>
828 );
829 })}
830 </ul>
831 ) : (
832 <div class="empty">No agents yet.</div>
833 )}
834 </div>
835 <Form action="/settings/agents" t={t} class="box">
836 <div class="head">
837 <h2>Create an agent</h2>
838 </div>
839 <div class="body">
840 <div class="row" style="align-items:flex-start">
841 <div class="field grow">
842 <label for="alogin">Agent username</label>
843 <input id="alogin" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="review-bot" />
844 </div>
845 <div class="field grow">
846 <label for="aname">Display name</label>
847 <input id="aname" name="name" type="text" placeholder="Review bot" />
848 </div>
849 </div>
850 <div class="row" style="align-items:flex-start">
851 <div class="field grow">
852 <label for="aowner">Owner</label>
853 <select id="aowner" name="owner">
854 <option value="">{me.login} (you)</option>
855 {orgs.map((o) => (
856 <option value={o.id}>{o.login} (organization)</option>
857 ))}
858 </select>
859 <div class="hint">An agent can never do more than its owner.</div>
860 </div>
861 <div class="field grow">
862 <label for="arole">Maximum role</label>
863 <select id="arole" name="maxRole">
864 {ROLES.map((r) => (
865 <option value={r} selected={r === "write"}>
866 {r}
867 </option>
868 ))}
869 </select>
870 </div>
871 </div>
872 <button class="btn primary">Create agent</button>
873 </div>
874 </Form>
875 </>,
876 );
877 };
878
879 web.get("/settings/agents", async (c) => agentsPage(c, requireAccount(c)));
880
881 post(web, "/settings/agents", async (c, f) => {
882 const me = requireAccount(c);
883 const dir = c.get("dir");
884 return act(
885 c,
886 "/settings/agents",
887 async () => {
888 const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write";
889 const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") || undefined, policy: { maxRole } });
890 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } }));
891 },
892 "Agent created. Create a token for it below.",
893 );
894 });
895
896 const loadAgent = async (c: AppContext, me: Account): Promise<Account> => {
897 const agent = await c.get("dir").account(c.req.param("id") ?? "");
898 if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found");
899 return agent;
900 };
901
902 post(web, "/settings/agents/:id/token", async (c, f) => {
903 const me = requireAccount(c);
904 const agent = await loadAgent(c, me);
905 const dir = c.get("dir");
906 const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
907 const created = await dir.createToken(agent, { name: f.s("name").trim() || "agent", scopes: [scope], expiresInDays: parseInt(f.s("days"), 10) || 30 });
908 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.token.create", target: agent.login, detail: { scope } }));
909 return agentsPage(c, me, { token: created.token, agent: agent.login });
910 });
911
912 post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => {
913 const me = requireAccount(c);
914 const agent = await loadAgent(c, me);
915 return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, c.req.param("tid")), "Token revoked.");
916 });
917
918 post(web, "/settings/agents/:id/status", async (c, f) => {
919 const me = requireAccount(c);
920 const agent = await loadAgent(c, me);
921 const status = f.s("status") === "active" ? "active" : "disabled";
922 return act(c, "/settings/agents", async () => {
923 await c.get("dir").setStatus(agent.id, status);
924 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: `agent.${status === "active" ? "enable" : "disable"}`, target: agent.login }));
925 });
926 });
927
928 // Organizations
929 web.get("/settings/orgs", async (c) => {
930 const me = requireAccount(c);
931 const t = csrfToken(c);
932 const orgs = await c.get("dir").orgsFor(me.id);
933 return page(
934 c,
935 me,
936 "orgs",
937 "Organizations",
938 <>
939 <h1>Organizations</h1>
940 <p class="muted">Organizations group people, agents and repositories. Members get write access to the organization's repositories; owners and admins manage everything.</p>
941 <div class="box">
942 {orgs.length ? (
943 <ul class="list">
944 {orgs.map((o) => (
945 <li>
946 <Avatar login={o.login} />
947 <div class="grow">
948 <a class="title" href={`/${o.login}`}>
949 {o.login}
950 </a>
951 {o.name && o.name !== o.login ? <span class="muted"> · {o.name}</span> : null}
952 </div>
953 <span class="badge">{o.role}</span>
954 </li>
955 ))}
956 </ul>
957 ) : (
958 <div class="empty">You are not a member of any organization.</div>
959 )}
960 </div>
961 <Form action="/settings/orgs" t={t} class="box">
962 <div class="head">
963 <h2>New organization</h2>
964 </div>
965 <div class="body">
966 <div class="row" style="align-items:flex-start">
967 <div class="field grow">
968 <label for="olog">Organization name</label>
969 <input id="olog" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="my-team" />
970 </div>
971 <div class="field grow">
972 <label for="oname">Display name</label>
973 <input id="oname" name="name" type="text" />
974 </div>
975 </div>
976 <button class="btn primary">Create organization</button>
977 </div>
978 </Form>
979 </>,
980 );
981 });
982
983 post(web, "/settings/orgs", async (c, f) => {
984 const me = requireAccount(c);
985 const dir = c.get("dir");
986 return act(c, "/settings/orgs", async () => {
987 const org = await dir.createOrg(f.s("login").trim(), f.s("name").trim(), me);
988 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.create", target: org.login }));
989 return `/${org.login}?tab=people`;
990 });
991 });
992
993 // Site administration
994 const requireSiteAdmin = (c: AppContext): Account => {
995 const me = requireAccount(c);
996 if (!me.site_admin) throw new HttpError(403, "site administrators only");
997 return me;
998 };
999
1000 const adminPage = async (c: AppContext, me: Account, invite?: { url: string; email: string }) => {
1001 const dir = c.get("dir");
1002 const t = csrfToken(c);
1003 const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]);
1004 return page(
1005 c,
1006 me,
1007 "admin",
1008 "Site administration",
1009 <>
1010 <h1>Site administration</h1>
1011 {invite ? (
1012 <div class="box" style="border-color:var(--amber)">
1013 <div class="body">
1014 <h3>Invite created for {invite.email}</h3>
1015 <p class="muted">Send this link. Signing in with that email through Cloudflare Access also activates the account automatically.</p>
1016 <div class="row">
1017 <div class="secret grow">{invite.url}</div>
1018 <CopyButton text={invite.url} />
1019 </div>
1020 </div>
1021 </div>
1022 ) : null}
1023 <div class="box">
1024 <div class="head">
1025 <h2>Pending access requests</h2>
1026 <span class="badge">{requests.length}</span>
1027 </div>
1028 {requests.length ? (
1029 <ul class="list">
1030 {requests.map((r) => (
1031 <li>
1032 <Avatar login={r.account.login} />
1033 <div class="grow">
1034 <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span>
1035 {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null}
1036 </div>
1037 <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1038 <input type="hidden" name="status" value="active" />
1039 <button class="btn sm green">Approve</button>
1040 </Form>
1041 <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1042 <input type="hidden" name="status" value="disabled" />
1043 <button class="btn sm danger">Deny</button>
1044 </Form>
1045 </li>
1046 ))}
1047 </ul>
1048 ) : (
1049 <div class="empty">No pending requests.</div>
1050 )}
1051 </div>
1052 <Form action="/settings/admin/invite" t={t} class="box">
1053 <div class="head">
1054 <h2>Invite someone to this instance</h2>
1055 </div>
1056 <div class="body row" style="align-items:flex-end">
1057 <div class="field grow m0">
1058 <label for="iemail">Email address</label>
1059 <input id="iemail" name="email" type="email" required placeholder="friend@example.com" />
1060 <div class="hint">They also need to be allowed by your Cloudflare Access policy.</div>
1061 </div>
1062 <button class="btn primary">Create invite</button>
1063 </div>
1064 </Form>
1065 <div class="box">
1066 <div class="head">
1067 <h2>Users</h2>
1068 <span class="badge">{users.length}</span>
1069 </div>
1070 <table class="tbl">
1071 <thead>
1072 <tr>
1073 <th>User</th>
1074 <th>Email</th>
1075 <th>Joined</th>
1076 <th>Status</th>
1077 </tr>
1078 </thead>
1079 <tbody>
1080 {users.map((u) => (
1081 <tr>
1082 <td>
1083 <Actor login={u.login} kind="user" /> {u.site_admin ? <span class="badge blue">admin</span> : null}
1084 </td>
1085 <td class="small">{u.email ?? "—"}</td>
1086 <td class="small">{ago(u.created_at)}</td>
1087 <td>
1088 {u.id === me.id ? (
1089 <span class="badge green">{u.status}</span>
1090 ) : (
1091 <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1092 <select name="status" style="width:auto;padding:3px 6px">
1093 {(["active", "pending", "disabled"] as const).map((s) => (
1094 <option value={s} selected={u.status === s}>
1095 {s}
1096 </option>
1097 ))}
1098 </select>
1099 <button class="btn sm">Save</button>
1100 </Form>
1101 )}
1102 </td>
1103 </tr>
1104 ))}
1105 </tbody>
1106 </table>
1107 </div>
1108 </>,
1109 );
1110 };
1111
1112 web.get("/settings/admin", async (c) => adminPage(c, requireSiteAdmin(c)));
1113
1114 post(web, "/settings/admin/users/:id/status", async (c, f) => {
1115 const me = requireSiteAdmin(c);
1116 const dir = c.get("dir");
1117 const status = (["active", "pending", "disabled"] as const).find((s) => s === f.s("status"));
1118 return act(
1119 c,
1120 "/settings/admin",
1121 async () => {
1122 if (!status) throw new HttpError(422, "invalid status");
1123 const target = await dir.account(c.req.param("id"));
1124 if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1125 if (target.id === me.id) throw new HttpError(422, "you cannot change your own status");
1126 await dir.setStatus(target.id, status);
1127 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: `user.${status}`, target: target.login }));
1128 },
1129 "User updated.",
1130 );
1131 });
1132
1133 post(web, "/settings/admin/invite", async (c, f) => {
1134 const me = requireSiteAdmin(c);
1135 const email = f.s("email").trim();
1136 if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
1137 const inv = await c.get("dir").createInvite(me, { email, role: "read" });
1138 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email }));
1139 return adminPage(c, me, { url: inv.url, email });
1140 });
1141
1142 web.get("/settings/audit", async (c) => {
1143 const me = requireSiteAdmin(c);
1144 const rows = await c.get("dir").auditLog(200);
1145 return page(
1146 c,
1147 me,
1148 "audit",
1149 "Audit log",
1150 <>
1151 <h1>Audit log</h1>
1152 <p class="muted">The 200 most recent security-relevant events.</p>
1153 <div class="box" style="overflow:auto">
1154 <table class="tbl">
1155 <thead>
1156 <tr>
1157 <th>When</th>
1158 <th>Actor</th>
1159 <th>Action</th>
1160 <th>Target</th>
1161 <th>Detail</th>
1162 </tr>
1163 </thead>
1164 <tbody>
1165 {rows.map((r) => (
1166 <tr>
1167 <td class="small nowrap">
1168 <Time at={r.at} />
1169 </td>
1170 <td class="small">
1171 {r.actor_login ?? "system"}
1172 {r.on_behalf_of ? <span class="muted"> for {r.on_behalf_of}</span> : null}
1173 </td>
1174 <td>
1175 <span class="badge">{r.action}</span>
1176 </td>
1177 <td class="small mono">{r.target ?? ""}</td>
1178 <td class="small mono muted" style="max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title={r.detail ?? ""}>
1179 {r.detail ?? ""}
1180 </td>
1181 </tr>
1182 ))}
1183 </tbody>
1184 </table>
1185 {rows.length === 0 ? <div class="empty">No events yet.</div> : null}
1186 </div>
1187 </>,
1188 );
1189 });
1190}
1191
1192// ---------------------------------------------------------------- /:owner profiles
1193
1194type OrgTab = "repositories" | "people" | "invites" | "agents" | "settings";
1195
1196function registerProfiles(web: WebApp): void {
1197 const orgContext = async (c: AppContext) => {
1198 const me = requireAccount(c);
1199 const dir = c.get("dir");
1200 const org = await dir.accountByLogin(c.req.param("owner") ?? "");
1201 if (!org) throw new HttpError(404, "not found");
1202 if (org.kind !== "org") throw new HttpError(404, "not an organization");
1203 const role = await dir.orgRole(org.id, me.id);
1204 const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
1205 const isOwner = !!me.site_admin || role === "owner";
1206 return { me, org, role, isAdmin, isOwner, dir };
1207 };
1208
1209 const profile = async (c: AppContext, invite?: { url: string; email: string }) => {
1210 const me = requireAccount(c);
1211 const dir = c.get("dir");
1212 const t = csrfToken(c);
1213 const acct = await dir.accountByLogin(c.req.param("owner") ?? "");
1214 if (!acct) throw new HttpError(404, "not found");
1215 if (acct.login !== c.req.param("owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301);
1216
1217 if (acct.kind === "agent") {
1218 const owner = acct.owner_id ? await dir.account(acct.owner_id) : null;
1219 const policy = parsePolicy(acct);
1220 return render(
1221 c,
1222 { title: acct.login },
1223 <div style="max-width:720px;margin:24px auto" class="box">
1224 <div class="body row" style="gap:18px">
1225 <Avatar login={acct.login} kind="agent" size="xl" />
1226 <div>
1227 <h1 class="m0">
1228 {acct.name ?? acct.login} <span class="badge agent">agent</span>
1229 </h1>
1230 <p class="muted">{acct.login}</p>
1231 <dl class="kv">
1232 <dt>Acts on behalf of</dt>
1233 <dd>{owner ? <a href={`/${owner.login}`}>{owner.login}</a> : "—"}</dd>
1234 <dt>Maximum role</dt>
1235 <dd>{policy.maxRole ?? "write"}</dd>
1236 <dt>Status</dt>
1237 <dd>{acct.status}</dd>
1238 <dt>Created</dt>
1239 <dd>
1240 <Time at={acct.created_at} />
1241 </dd>
1242 </dl>
1243 </div>
1244 </div>
1245 </div>,
1246 );
1247 }
1248
1249 const repos = await dir.reposVisibleTo(me, acct.id);
1250 if (acct.kind === "user") {
1251 const orgs = await dir.orgsFor(acct.id);
1252 return render(
1253 c,
1254 { title: acct.login },
1255 <div class="grid left">
1256 <aside class="side">
1257 <section>
1258 <Avatar login={acct.login} size="xl" />
1259 <h1 class="mt m0">{acct.name ?? acct.login}</h1>
1260 <p class="muted">{acct.login}</p>
1261 {acct.id === me.id ? (
1262 <a class="btn sm" href="/settings">
1263 Edit profile
1264 </a>
1265 ) : null}
1266 </section>
1267 {orgs.length ? (
1268 <section>
1269 <h3>Organizations</h3>
1270 <div class="nav">
1271 {orgs.map((o) => (
1272 <a href={`/${o.login}`}>
1273 <Avatar login={o.login} /> {o.login}
1274 </a>
1275 ))}
1276 </div>
1277 </section>
1278 ) : null}
1279 </aside>
1280 <div>
1281 <div class="row between mb">
1282 <h2 class="m0">Repositories</h2>
1283 {acct.id === me.id ? (
1284 <a class="btn sm primary" href="/new">
1285 {raw(ICONS.plus)} New
1286 </a>
1287 ) : null}
1288 </div>
1289 <div class="box">
1290 <RepoList repos={repos} showOwner={false} />
1291 </div>
1292 </div>
1293 </div>,
1294 );
1295 }
1296
1297 // Organization
1298 const role = await dir.orgRole(acct.id, me.id);
1299 const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
1300 const isOwner = !!me.site_admin || role === "owner";
1301 const requested = (c.req.query("tab") ?? "repositories") as OrgTab;
1302 const tab: OrgTab = invite ? "invites" : (["repositories", "people", "invites", "agents", "settings"] as const).includes(requested) ? requested : "repositories";
1303 if ((tab === "invites" && !isAdmin) || (tab === "settings" && !isOwner)) throw new HttpError(403, "organization admins only");
1304 let body: Child = null;
1305 const base = `/${acct.login}`;
1306 if (tab === "repositories") {
1307 body = (
1308 <>
1309 <div class="row between mb">
1310 <h2 class="m0">Repositories</h2>
1311 {role ? (
1312 <a class="btn sm primary" href={`/new?owner=${encodeURIComponent(acct.login)}`}>
1313 {raw(ICONS.plus)} New repository
1314 </a>
1315 ) : null}
1316 </div>
1317 <div class="box">
1318 <RepoList repos={repos} showOwner={false} />
1319 </div>
1320 </>
1321 );
1322 } else if (tab === "people") {
1323 const members = await dir.orgMembers(acct.id);
1324 body = (
1325 <>
1326 <h2>People</h2>
1327 <div class="box">
1328 <table class="tbl">
1329 <thead>
1330 <tr>
1331 <th>Member</th>
1332 <th>Role</th>
1333 {isAdmin ? <th /> : null}
1334 </tr>
1335 </thead>
1336 <tbody>
1337 {members.map((m) => (
1338 <tr>
1339 <td>
1340 <Actor login={m.login} kind="user" /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null}
1341 </td>
1342 <td>
1343 {isAdmin && (isOwner || m.role !== "owner") ? (
1344 <Form action={`${base}/-/org/members`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1345 <input type="hidden" name="login" value={m.login} />
1346 <select name="role" style="width:auto;padding:3px 6px">
1347 {(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => (
1348 <option value={r} selected={m.role === r}>
1349 {r}
1350 </option>
1351 ))}
1352 </select>
1353 <button class="btn sm">Save</button>
1354 </Form>
1355 ) : (
1356 <span class="badge">{m.role}</span>
1357 )}
1358 </td>
1359 {isAdmin ? (
1360 <td class="right">
1361 {isOwner || m.role !== "owner" ? (
1362 <Form action={`${base}/-/org/members`} t={t} class="inline" confirm={`Remove ${m.login} from ${acct.login}?`}>
1363 <input type="hidden" name="login" value={m.login} />
1364 <input type="hidden" name="role" value="remove" />
1365 <button class="btn sm danger">Remove</button>
1366 </Form>
1367 ) : null}
1368 </td>
1369 ) : null}
1370 </tr>
1371 ))}
1372 </tbody>
1373 </table>
1374 </div>
1375 {isAdmin ? (
1376 <div class="row" style="align-items:stretch">
1377 <Form action={`${base}/-/org/members`} t={t} class="box grow">
1378 <div class="head">
1379 <h3>Add an existing user</h3>
1380 </div>
1381 <div class="body row" style="align-items:flex-end">
1382 <div class="field grow m0">
1383 <label>Username</label>
1384 <input name="login" type="text" required />
1385 </div>
1386 <div class="field m0">
1387 <label>Role</label>
1388 <select name="role">
1389 <option value="member">member</option>
1390 <option value="admin">admin</option>
1391 {isOwner ? <option value="owner">owner</option> : null}
1392 </select>
1393 </div>
1394 <button class="btn primary">Add</button>
1395 </div>
1396 </Form>
1397 <div class="box grow">
1398 <div class="head">
1399 <h3>Invite by email</h3>
1400 </div>
1401 <div class="body">
1402 <p class="muted small">
1403 New people sign in through Cloudflare Access with their email and join automatically. <a href={`${base}?tab=invites`}>Create an invite →</a>
1404 </p>
1405 </div>
1406 </div>
1407 </div>
1408 ) : null}
1409 </>
1410 );
1411 } else if (tab === "invites") {
1412 const invites = await dir.invites({ orgId: acct.id });
1413 body = (
1414 <>
1415 <h2>Invites</h2>
1416 {invite ? (
1417 <div class="box" style="border-color:var(--amber)">
1418 <div class="body">
1419 <h3>Invite created for {invite.email}</h3>
1420 <p class="muted">Send them this link. It expires in 14 days and only works for that email address.</p>
1421 <div class="row">
1422 <div class="secret grow">{invite.url}</div>
1423 <CopyButton text={invite.url} />
1424 </div>
1425 </div>
1426 </div>
1427 ) : null}
1428 <Form action={`${base}/-/org/invites`} t={t} class="box">
1429 <div class="head">
1430 <h3>Invite someone</h3>
1431 </div>
1432 <div class="body row" style="align-items:flex-end">
1433 <div class="field grow m0">
1434 <label>Email address</label>
1435 <input name="email" type="email" required placeholder="friend@example.com" />
1436 </div>
1437 <div class="field m0">
1438 <label>Role</label>
1439 <select name="role">
1440 <option value="member">member</option>
1441 <option value="admin">admin</option>
1442 {isOwner ? <option value="owner">owner</option> : null}
1443 </select>
1444 </div>
1445 <button class="btn primary">Create invite</button>
1446 </div>
1447 <div class="body muted small" style="padding-top:0">The person must also be allowed by your Cloudflare Access policy to reach this site.</div>
1448 </Form>
1449 <div class="box">
1450 {invites.length ? (
1451 <table class="tbl">
1452 <thead>
1453 <tr>
1454 <th>Email</th>
1455 <th>Role</th>
1456 <th>Created</th>
1457 <th>Status</th>
1458 </tr>
1459 </thead>
1460 <tbody>
1461 {invites.map((i) => (
1462 <tr>
1463 <td>{i.email}</td>
1464 <td>{i.role}</td>
1465 <td class="small">{ago(i.createdAt)}</td>
1466 <td class="small">
1467 {i.acceptedAt ? (
1468 <span class="badge green">accepted</span>
1469 ) : Date.parse(i.expiresAt) < Date.now() ? (
1470 <span class="badge">expired</span>
1471 ) : (
1472 <span class="badge amber">pending</span>
1473 )}
1474 </td>
1475 </tr>
1476 ))}
1477 </tbody>
1478 </table>
1479 ) : (
1480 <div class="empty">No invites yet.</div>
1481 )}
1482 </div>
1483 </>
1484 );
1485 } else if (tab === "agents") {
1486 const agents = await dir.agentsOwnedBy([acct.id]);
1487 body = (
1488 <>
1489 <div class="row between mb">
1490 <h2 class="m0">Agents</h2>
1491 {isAdmin ? (
1492 <a class="btn sm primary" href="/settings/agents">
1493 Manage agents
1494 </a>
1495 ) : null}
1496 </div>
1497 <div class="box">
1498 {agents.length ? (
1499 <ul class="list">
1500 {agents.map((a) => (
1501 <li>
1502 <Actor login={a.login} kind="agent" />
1503 <span class="grow" />
1504 <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span>
1505 <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span>
1506 </li>
1507 ))}
1508 </ul>
1509 ) : (
1510 <div class="empty">This organization has no agents.</div>
1511 )}
1512 </div>
1513 </>
1514 );
1515 } else {
1516 body = (
1517 <>
1518 <h2>Settings</h2>
1519 <Form action={`${base}/-/org/settings`} t={t} class="box">
1520 <div class="body">
1521 <div class="field">
1522 <label>Organization name</label>
1523 <input name="login" type="text" value={acct.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" />
1524 <div class="hint">Renaming changes every repository URL in this organization.</div>
1525 </div>
1526 <div class="field">
1527 <label>Display name</label>
1528 <input name="name" type="text" value={acct.name ?? ""} />
1529 </div>
1530 <button class="btn primary">Save</button>
1531 </div>
1532 </Form>
1533 </>
1534 );
1535 }
1536 return render(
1537 c,
1538 { title: acct.login },
1539 <>
1540 <div class="row mb" style="gap:16px">
1541 <Avatar login={acct.login} size="lg" />
1542 <div>
1543 <h1 class="m0">{acct.name ?? acct.login}</h1>
1544 <div class="muted small">
1545 Organization · {acct.login}
1546 {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""}
1547 </div>
1548 </div>
1549 </div>
1550 <nav class="tabs mb" style="border-bottom:1px solid var(--border)">
1551 {(
1552 [
1553 ["repositories", "Repositories", true],
1554 ["people", "People", true],
1555 ["invites", "Invites", isAdmin],
1556 ["agents", "Agents", true],
1557 ["settings", "Settings", isOwner],
1558 ] as const
1559 )
1560 .filter((x) => x[2])
1561 .map(([k, label]) => (
1562 <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}>
1563 {label}
1564 </a>
1565 ))}
1566 </nav>
1567 {body}
1568 </>,
1569 );
1570 };
1571
1572 web.get("/:owner", async (c) => profile(c));
1573
1574 post(web, "/:owner/-/org/invites", async (c, f) => {
1575 const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
1576 if (!isAdmin) throw new HttpError(403, "organization admins only");
1577 const email = f.s("email").trim();
1578 const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member";
1579 if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can invite owners");
1580 if (!/^[^\s@]+@[^\s@]+$/.test(email)) {
1581 return act(c, `/${org.login}?tab=invites`, async () => {
1582 throw new HttpError(422, "enter a valid email address");
1583 });
1584 }
1585 const inv = await dir.createInvite(me, { email, orgId: org.id, role });
1586 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email, role } }));
1587 return profile(c, { url: inv.url, email });
1588 });
1589
1590 post(web, "/:owner/-/org/members", async (c, f) => {
1591 const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
1592 const back = `/${org.login}?tab=people`;
1593 return act(c, back, async () => {
1594 if (!isAdmin) throw new HttpError(403, "organization admins only");
1595 const user = await dir.accountByLogin(f.s("login").trim());
1596 if (!user || user.kind !== "user") throw new HttpError(404, "user not found");
1597 const current = await dir.orgRole(org.id, user.id);
1598 const role = f.s("role");
1599 if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners");
1600 if (role === "remove") await dir.setOrgMember(org.id, user.id, null);
1601 else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role);
1602 else throw new HttpError(422, "invalid role");
1603 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 } }));
1604 });
1605 });
1606
1607 post(web, "/:owner/-/org/settings", async (c, f) => {
1608 const { me, org, isOwner, dir } = await orgContext(c);
1609 return act(c, `/${org.login}?tab=settings`, async () => {
1610 if (!isOwner) throw new HttpError(403, "organization owners only");
1611 const login = f.s("login").trim();
1612 await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() });
1613 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } }));
1614 return `/${login || org.login}?tab=settings`;
1615 }, "Organization updated.");
1616 });
1617}
1618
1619/** Role helper for other modules. */
1620export const roleRank = (r: Role): number => ROLE_LEVEL[r];