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