Vectrum
Sign In

Add organizations with member roles and invitations

Organizations are accounts in `users` with kind "org", so they share the
username namespace and own repositories at /<org>/<repo> like people do.
Nobody signs in as one: it has no email, and the auth rule refuses it.

Whoever creates an organization is its owner. Owners invite people by
username; they join by accepting. Each member's role applies to all of
the organization's repositories through repoAccess(): read, write, admin
(creates, configures and deletes repositories) or owner (manages the
organization and its members). An organization keeps at least one owner,
and can only be deleted once it has no repositories.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent aebe5facommita27404b

Showing 36 changed files with 1391 additions and 101 deletions.

+20−3app/[owner]/[repo]/settings/access/page.tsxView File
@@ -8,6 +8,7 @@ import { SettingsSection } from "@/components/metal";
88 import { ListRow, PlateSection } from "@/components/settings-plate";
99 import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui";
1010 import { timeAgo } from "@/lib/format";
11+ import { orgRole } from "@/lib/orgs";
1112 import { getRepo } from "@/lib/repo";
1213 import { listCollaborators } from "@/lib/repos";
1314 import { AddCollaboratorForm } from "./form";
@@ -17,11 +18,15 @@ export const metadata: Metadata = { title: "Collaborators" };
1718 export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/settings/access">) {
1819 const { owner, repo: repoParam } = await params;
1920 const repo = await getRepo(owner, repoParam);
20- const collaborators = await listCollaborators(repo.id);
21+ const [collaborators, role] = await Promise.all([listCollaborators(repo.id), repo.ownerIsOrg ? orgRole(repo.ownerId, repo.viewer?.id) : null]);
22+ const orgOwner = role === "owner";
2123
2224 return (
2325 <div>
24- <PageTitle title="Collaborators" description="Collaborators can see this repository, push to it and edit files in the browser." />
26+ <PageTitle
27+ title="Collaborators"
28+ description={`Collaborators can see this repository, push to it and edit files in the browser${repo.ownerIsOrg ? `, whether or not they're members of ${repo.owner}` : ""}.`}
29+ />
2530 <div className="flex flex-col gap-7">
2631 <SettingsSection title={`People with access · ${collaborators.length + 1}`}>
2732 <ListRow
@@ -31,9 +36,21 @@ export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/
3136 <Link href={`/${repo.owner}`} className="truncate hover:underline">
3237 {repo.owner}
3338 </Link>
34- <Label tone="accent">Owner</Label>
39+ <Label tone="accent">{repo.ownerIsOrg ? "Organization" : "Owner"}</Label>
3540 </>
3641 }
42+ meta={
43+ repo.ownerIsOrg && (
44+ <MuLabel variant="engraved">
45+ Its members have the access their role gives them.{" "}
46+ {orgOwner && (
47+ <Link href={`/organizations/${repo.owner}/settings/members`} className="underline">
48+ Manage members
49+ </Link>
50+ )}
51+ </MuLabel>
52+ )
53+ }
3754 />
3855 {collaborators.map((c) => (
3956 <ListRow
+16−47app/[owner]/[repo]/settings/forms.tsxView File
@@ -3,7 +3,8 @@
33 import { useActionState, useEffect, useState } from "react";
44 import { Checkbox } from "@unlocalhosted/metalui";
55 import type { FormState } from "@/app/actions/types";
6- import { AlertIcon, BookIcon, GlobeIcon, LockIcon, PencilIcon, TrashIcon, UndoIcon, UploadIcon } from "@/components/icons";
6+ import { DeleteZone } from "@/components/delete-zone";
7+ import { BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons";
78 import { ChoiceCards, TextField } from "@/components/settings-fields";
89 import { PlateBody, PlateFooter } from "@/components/settings-plate";
910 import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui";
@@ -164,53 +165,21 @@ export function FeaturesForm({ action, wiki }: { action: Action; wiki: boolean }
164165 );
165166 }
166167
167- /** Deleting the repository: one red cap opens the type-to-confirm form, whose red cap does it. */
168+ /** Deleting the repository. */
168169 export function DangerZone({ action, fullName }: { action: Action; fullName: string }) {
169- const [state, formAction, pending] = useActionState(action, undefined);
170- const [confirm, setConfirm] = useState("");
171- const [open, setOpen] = useState(false);
172170 return (
173- <>
174- <div className="flex min-h-[52px] flex-wrap items-center gap-4 px-6 py-4">
175- <div className="flex min-w-0 flex-1 flex-col gap-0.5">
176- <p className="type-title text-ink">Delete this repository</p>
177- <p className="type-body text-ink2">Once you delete a repository, there is no going back. Please be certain.</p>
178- </div>
179- {!open && (
180- <Button type="button" variant="danger" icon={<TrashIcon />} onClick={() => setOpen(true)}>
181- Delete Repository
182- </Button>
183- )}
184- </div>
185- {open && (
186- <form action={formAction}>
187- <PlateBody className="pt-0">
188- <p className="flex items-start gap-2 type-body text-red">
189- <AlertIcon className="mt-px shrink-0" />
190- <span>
191- This will permanently delete <strong>{fullName}</strong>, its history, issues and access settings.
192- </span>
193- </p>
194- <TextField
195- className="max-w-xl"
196- label={`To confirm, type “${fullName}” below:`}
197- name="confirm"
198- value={confirm}
199- onChange={(e) => setConfirm(e.target.value)}
200- autoFocus
201- />
202- {state?.error && <Alert>{state.error}</Alert>}
203- </PlateBody>
204- <PlateFooter>
205- <Button type="button" onClick={() => setOpen(false)}>
206- Cancel
207- </Button>
208- <Button variant="danger" icon={<TrashIcon />} disabled={confirm !== fullName || pending}>
209- {pending ? "Deleting…" : "I Understand, Delete This Repository"}
210- </Button>
211- </PlateFooter>
212- </form>
213- )}
214- </>
171+ <DeleteZone
172+ action={action}
173+ confirmText={fullName}
174+ title="Delete this repository"
175+ description="Once you delete a repository, there is no going back. Please be certain."
176+ consequence={
177+ <>
178+ This will permanently delete <strong>{fullName}</strong>, its history, issues and access settings.
179+ </>
180+ }
181+ openLabel="Delete Repository"
182+ confirmLabel="I Understand, Delete This Repository"
183+ />
215184 );
216185 }
+135−0app/[owner]/org-profile.tsxaddedView File
@@ -0,0 +1,135 @@
1+ import Link from "next/link";
2+ import { Field } from "@unlocalhosted/metalui";
3+ import { CalendarIcon, GearIcon, OrganizationIcon, PeopleIcon, PlusIcon, RepoIcon, SearchIcon } from "@/components/icons";
4+ import { FieldIcon, FieldInput } from "@/components/metal";
5+ import { OrgInvitations } from "@/components/org-invitations";
6+ import { RepoList } from "@/components/repo-list";
7+ import { Avatar, Blankslate, Box, ButtonLink, Counter, Label } from "@/components/ui";
8+ import { formatDate } from "@/lib/format";
9+ import { withActivity } from "@/lib/listing";
10+ import { listInvitationsFor, listOrgMembers, orgRole, ROLE_INFO, roleAtLeast } from "@/lib/orgs";
11+ import { listReposByOwner } from "@/lib/repos";
12+ import type { User } from "@/lib/users";
13+
14+ const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink";
15+
16+ /** An organization's page: its repositories the viewer can see and, for its members, its people. */
17+ export async function OrgProfile({ org, viewer, q }: { org: User; viewer: User | null; q: string }) {
18+ const [role, invitations, all] = await Promise.all([
19+ orgRole(org.id, viewer?.id),
20+ viewer ? listInvitationsFor(viewer.id) : [],
21+ listReposByOwner(org.id, viewer?.id ?? null).then((repos) => withActivity(repos, viewer?.id)),
22+ ]);
23+ // Who belongs to an organization is for its members to see.
24+ const members = role ? await listOrgMembers(org.id) : [];
25+ const invitation = invitations.filter((i) => i.org.id === org.id);
26+ const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all;
27+ const publicCount = all.filter((r) => r.visibility === "public").length;
28+
29+ return (
30+ <div className="mx-auto grid w-full max-w-[1280px] gap-8 px-4 py-10 md:grid-cols-[300px_1fr] md:px-6">
31+ <aside className="flex flex-col gap-4">
32+ <Box className="p-6 md:sticky md:top-20">
33+ <Avatar name={org.name || org.username} size={88} />
34+ <h1 className="mt-5">
35+ {org.name && <span className={`block ${heading}`}>{org.name}</span>}
36+ <span className={`block ${org.name ? "mt-1 type-readout text-ink3" : heading}`}>@{org.username}</span>
37+ </h1>
38+ <div className="mt-3 flex flex-wrap gap-2">
39+ <Label>
40+ <OrganizationIcon /> Organization
41+ </Label>
42+ {role && <Label tone={role === "owner" ? "accent" : "default"}>You: {ROLE_INFO[role].title}</Label>}
43+ </div>
44+ {org.bio && <p className="mt-3 type-body text-ink2">{org.bio}</p>}
45+ <dl className="mt-5 grid grid-cols-2 gap-2">
46+ <div className="well flex flex-col-reverse gap-1 rounded-row px-3 py-2.5">
47+ <dt className="type-label engraved">Repositories</dt>
48+ <dd className="type-stat text-ink">{all.length}</dd>
49+ </div>
50+ <div className="well flex flex-col-reverse gap-1 rounded-row px-3 py-2.5">
51+ <dt className="type-label engraved">{role ? "Members" : "Public"}</dt>
52+ <dd className="type-stat text-ink">{role ? members.length : publicCount}</dd>
53+ </div>
54+ </dl>
55+ <p className="mt-4 flex items-center gap-2 type-meta text-ink3">
56+ <CalendarIcon width={14} height={14} /> Created {formatDate(org.createdAt)}
57+ </p>
58+ {role === "owner" && (
59+ <ButtonLink href={`/organizations/${org.username}/settings`} icon={<GearIcon />} className="mt-5 w-full">
60+ Settings
61+ </ButtonLink>
62+ )}
63+ </Box>
64+ </aside>
65+
66+ <main className="flex min-w-0 flex-col gap-8">
67+ {invitation.length > 0 && <OrgInvitations invitations={invitation} />}
68+
69+ <section>
70+ <div className="mb-5 flex flex-wrap items-center gap-3">
71+ <h2 className="flex items-center gap-2 type-display text-ink">
72+ <RepoIcon className="text-ink2" /> Repositories <Counter>{all.length}</Counter>
73+ </h2>
74+ <div className="ml-auto flex w-full items-center gap-2 sm:w-auto">
75+ <form role="search" className="flex-1 sm:w-72">
76+ <Field size="regular">
77+ <FieldIcon>
78+ <SearchIcon />
79+ </FieldIcon>
80+ <FieldInput name="q" defaultValue={q} placeholder="Find a repository…" aria-label="Find a repository" />
81+ </Field>
82+ </form>
83+ {roleAtLeast(role, "admin") && (
84+ <ButtonLink href={`/new?owner=${org.username}`} variant="primary" icon={<PlusIcon />}>
85+ New
86+ </ButtonLink>
87+ )}
88+ </div>
89+ </div>
90+ {repos.length > 0 ? (
91+ <RepoList repos={repos} showOwner={false} />
92+ ) : (
93+ <Box>
94+ <Blankslate icon={<RepoIcon />} title={q ? "No matching repositories" : `${org.username} doesn’t have any repositories yet.`} />
95+ </Box>
96+ )}
97+ </section>
98+
99+ {role && (
100+ <section>
101+ <div className="mb-5 flex flex-wrap items-center gap-3">
102+ <h2 className="flex items-center gap-2 type-display text-ink">
103+ <PeopleIcon className="text-ink2" /> People <Counter>{members.length}</Counter>
104+ </h2>
105+ {role === "owner" && (
106+ <ButtonLink href={`/organizations/${org.username}/settings/members`} icon={<PlusIcon />} className="ml-auto">
107+ Invite People
108+ </ButtonLink>
109+ )}
110+ </div>
111+ <Box className="p-1.5">
112+ <ul className="grid gap-0.5 sm:grid-cols-2">
113+ {members.map((m) => (
114+ <li key={m.id}>
115+ <Link
116+ href={`/${m.username}`}
117+ className="flex items-center gap-3 rounded-plate px-3 py-2.5 transition-row outline-none hover:recipe-row-list-hover focus-visible:focus-ring-flush"
118+ >
119+ <Avatar name={m.name || m.username} size={32} />
120+ <span className="min-w-0 flex-1">
121+ <span className="block truncate type-title text-ink">{m.name || m.username}</span>
122+ <span className="block truncate type-meta text-ink3">@{m.username}</span>
123+ </span>
124+ <Label tone={m.role === "owner" ? "accent" : "default"}>{ROLE_INFO[m.role].title}</Label>
125+ </Link>
126+ </li>
127+ ))}
128+ </ul>
129+ </Box>
130+ </section>
131+ )}
132+ </main>
133+ </div>
134+ );
135+ }
+2−0app/[owner]/page.tsxView File
@@ -9,6 +9,7 @@ import { formatDate } from "@/lib/format";
99 import { withActivity } from "@/lib/listing";
1010 import { listReposByOwner } from "@/lib/repos";
1111 import { getUserByUsername } from "@/lib/users";
12+ import { OrgProfile } from "./org-profile";
1213
1314 export async function generateMetadata({ params }: PageProps<"/[owner]">) {
1415 const user = await getUserByUsername((await params).owner);
@@ -22,6 +23,7 @@ export default async function ProfilePage({ params, searchParams }: PageProps<"/
2223 if (!user) notFound();
2324 const q = String((await searchParams).q ?? "").trim().toLowerCase();
2425 const viewer = await getViewer();
26+ if (user.isOrg) return <OrgProfile org={user} viewer={viewer} q={q} />;
2527 const all = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id);
2628 const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all;
2729 const isSelf = viewer?.id === user.id;
+11−0app/actions/guard.tsView File
@@ -1,5 +1,6 @@
11 import "server-only";
22 import { getViewer } from "@/lib/auth";
3+ import { getOrg, orgRole, roleAtLeast, type OrgRole } from "@/lib/orgs";
34 import { canWrite, findRepo, findRepoById, repoAccess, type Access } from "@/lib/repos";
45
56 export class ActionError extends Error {}
@@ -21,6 +22,16 @@ export async function repoForAction(owner: string, name: string, need: "read" |
2122 return { repo, viewer, access };
2223 }
2324
25+ /** Loads an organization for a server action and checks that the viewer has at least the role `need` in it. */
26+ export async function orgForAction(name: string, need: OrgRole) {
27+ const viewer = await viewerOrThrow();
28+ const org = await getOrg(name);
29+ const role = org ? await orgRole(org.id, viewer.id) : null;
30+ if (!org || !role) throw new ActionError("Organization not found.");
31+ if (!roleAtLeast(role, need)) throw new ActionError("Only the organization's owners can do that.");
32+ return { org, viewer, role };
33+ }
34+
2435 /** Of the repositories picked in a form, the ones the viewer may hand to a token or app with `permission`. */
2536 export async function grantableRepoIds(viewerId: string, ids: string[], permission: "read" | "write") {
2637 const out: string[] = [];
+140−0app/actions/orgs.tsaddedView File
@@ -0,0 +1,140 @@
1+ "use server";
2+
3+ import { revalidatePath } from "next/cache";
4+ import { redirect } from "next/navigation";
5+ import {
6+ answerInvitation,
7+ cancelInvitation,
8+ createOrg,
9+ deleteOrg,
10+ inviteMember,
11+ isOrgRole,
12+ OrgError,
13+ removeMember,
14+ ROLE_INFO,
15+ setMemberRole,
16+ updateOrgProfile,
17+ } from "@/lib/orgs";
18+ import { ActionError, orgForAction, viewerOrThrow } from "./guard";
19+ import type { FormState } from "./types";
20+
21+ async function attempt(fn: () => Promise<FormState | void>): Promise<FormState> {
22+ try {
23+ return (await fn()) ?? undefined;
24+ } catch (e) {
25+ if (e instanceof OrgError || e instanceof ActionError) return { error: e.message };
26+ throw e;
27+ }
28+ }
29+
30+ function roleOf(formData: FormData) {
31+ const role = formData.get("role");
32+ if (!isOrgRole(role)) throw new ActionError("Choose a role.");
33+ return role;
34+ }
35+
36+ // Memberships change what's in everyone's sidebar and which repositories they see.
37+ const refresh = () => revalidatePath("/", "layout");
38+
39+ export async function createOrgAction(_prev: FormState, formData: FormData): Promise<FormState> {
40+ let path = "";
41+ const result = await attempt(async () => {
42+ const viewer = await viewerOrThrow();
43+ const org = await createOrg(viewer, {
44+ name: String(formData.get("name") ?? ""),
45+ displayName: String(formData.get("displayName") ?? ""),
46+ description: String(formData.get("description") ?? ""),
47+ });
48+ path = `/organizations/${org.username}/settings/members`;
49+ });
50+ if (result?.error) return result;
51+ refresh();
52+ redirect(path);
53+ }
54+
55+ export async function updateOrgAction(name: string, _prev: FormState, formData: FormData) {
56+ return attempt(async () => {
57+ const { org } = await orgForAction(name, "owner");
58+ await updateOrgProfile(org.id, {
59+ displayName: String(formData.get("displayName") ?? ""),
60+ description: String(formData.get("description") ?? ""),
61+ });
62+ revalidatePath(`/${org.username}`);
63+ return { success: "Profile updated." };
64+ });
65+ }
66+
67+ export async function deleteOrgAction(name: string, _prev: FormState, formData: FormData) {
68+ const result = await attempt(async () => {
69+ const { org } = await orgForAction(name, "owner");
70+ if (formData.get("confirm") !== org.username) return { error: `Type ${org.username} to confirm.` };
71+ await deleteOrg(org);
72+ });
73+ if (result?.error) return result;
74+ refresh();
75+ redirect("/settings/organizations");
76+ }
77+
78+ export async function inviteMemberAction(name: string, _prev: FormState, formData: FormData) {
79+ return attempt(async () => {
80+ const { org, viewer } = await orgForAction(name, "owner");
81+ const role = roleOf(formData);
82+ const user = await inviteMember(org, viewer, String(formData.get("username") ?? ""), role);
83+ revalidatePath(`/organizations/${org.username}/settings/members`);
84+ return { success: `Invited ${user.username} as ${ROLE_INFO[role].title.toLowerCase()}. They’ll find the invitation on their dashboard.` };
85+ });
86+ }
87+
88+ export async function cancelInvitationAction(name: string, invitationId: string) {
89+ const { org } = await orgForAction(name, "owner");
90+ await cancelInvitation(org.id, String(invitationId));
91+ revalidatePath(`/organizations/${org.username}/settings/members`);
92+ }
93+
94+ export async function setMemberRoleAction(name: string, userId: string, _prev: FormState, formData: FormData) {
95+ return attempt(async () => {
96+ const { org } = await orgForAction(name, "owner");
97+ const role = roleOf(formData);
98+ await setMemberRole(org.id, String(userId), role);
99+ refresh();
100+ return { success: `Role changed to ${ROLE_INFO[role].title.toLowerCase()}.` };
101+ });
102+ }
103+
104+ export async function removeMemberAction(name: string, userId: string): Promise<FormState> {
105+ let left = false;
106+ const result = await attempt(async () => {
107+ const { org, viewer } = await orgForAction(name, "owner");
108+ await removeMember(org.id, String(userId));
109+ left = userId === viewer.id;
110+ });
111+ if (result?.error) return result;
112+ refresh();
113+ if (left) redirect("/settings/organizations");
114+ return { success: "Member removed." };
115+ }
116+
117+ /** Any member can leave, unless they're its last owner. */
118+ export async function leaveOrgAction(name: string): Promise<FormState> {
119+ const result = await attempt(async () => {
120+ const { org, viewer } = await orgForAction(name, "read");
121+ await removeMember(org.id, viewer.id);
122+ });
123+ if (result?.error) return result;
124+ refresh();
125+ return { success: `You left ${name}.` };
126+ }
127+
128+ export async function answerInvitationAction(invitationId: string, accept: boolean): Promise<FormState> {
129+ accept = accept === true;
130+ let path = "";
131+ const result = await attempt(async () => {
132+ const viewer = await viewerOrThrow();
133+ const invitation = await answerInvitation(viewer.id, String(invitationId), accept);
134+ if (accept) path = `/${invitation.org.username}`;
135+ });
136+ if (result?.error) return result;
137+ refresh();
138+ if (path) redirect(path);
139+ return { success: "Invitation declined." };
140+ }
+14−6app/actions/repo.tsView File
@@ -15,6 +15,7 @@ import {
1515 updateRepo,
1616 type Visibility,
1717 } from "@/lib/repos";
18+ import { getOrg, mayCreateRepos } from "@/lib/orgs";
1819 import { ActionError, repoForAction, viewerOrThrow } from "./guard";
1920 import type { FormState } from "./types";
2021
@@ -33,12 +34,19 @@ export async function createRepoAction(_prev: FormState, formData: FormData): Pr
3334 let path = "";
3435 const result = await attempt(async () => {
3536 const viewer = await viewerOrThrow();
36- const repo = await createRepo(viewer, {
37- name: String(formData.get("name") ?? ""),
38- description: String(formData.get("description") ?? ""),
39- visibility: visibilityOf(formData.get("visibility")),
40- readme: formData.get("readme") === "on",
41- });
37+ const ownerName = String(formData.get("owner") ?? viewer.username);
38+ const owner = ownerName.toLowerCase() === viewer.username.toLowerCase() ? viewer : await getOrg(ownerName);
39+ if (!owner || !(await mayCreateRepos(owner, viewer))) throw new ActionError(`You can't create repositories in ${ownerName}.`);
40+ const repo = await createRepo(
41+ owner,
42+ {
43+ name: String(formData.get("name") ?? ""),
44+ description: String(formData.get("description") ?? ""),
45+ visibility: visibilityOf(formData.get("visibility")),
46+ readme: formData.get("readme") === "on",
47+ },
48+ { author: viewer },
49+ );
4250 path = `/${repo.owner}/${repo.name}`;
4351 });
4452 if (result?.error) return result;
+12−6app/new/form.tsxView File
@@ -1,14 +1,14 @@
11 "use client";
22
33 import { useActionState } from "react";
4- import { Checkbox, Rule } from "@unlocalhosted/metalui";
4+ import { Checkbox, Rule, Select } from "@unlocalhosted/metalui";
55 import { createRepoAction } from "@/app/actions/repo";
66 import { BookIcon, GlobeIcon, LockIcon, PlusIcon } from "@/components/icons";
77 import { ChoiceCards, TextField } from "@/components/settings-fields";
88 import { PlateBody, PlateFooter } from "@/components/settings-plate";
99 import { Alert, Avatar, Box, Button } from "@/components/ui";
1010
11- export function NewRepoForm({ owner }: { owner: string }) {
11+ export function NewRepoForm({ owners, owner }: { owners: string[]; owner: string }) {
1212 const [state, action, pending] = useActionState(createRepoAction, undefined);
1313 return (
1414 <form action={action}>
@@ -17,10 +17,16 @@ export function NewRepoForm({ owner }: { owner: string }) {
1717 <div className="flex flex-wrap items-start gap-2">
1818 <div className="flex flex-col gap-1.5">
1919 <span className="type-ui text-ink">Owner</span>
20- {/* Not a choice here: the owner sits in a sunk, read-only well. */}
21- <span className="flex h-8 items-center gap-2 rounded-[11px] recipe-well-field pr-3.5 pl-2 type-ui text-ink2">
22- <Avatar name={owner} size={20} /> {owner}
23- </span>
20+ {owners.length > 1 ? (
21+ // You, or an organization you may create repositories in.
22+ <Select name="owner" aria-label="Owner" defaultValue={owner} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-36" />
23+ ) : (
24+ // Not a choice here: the owner sits in a sunk, read-only well.
25+ <span className="flex h-8 items-center gap-2 rounded-[11px] recipe-well-field pr-3.5 pl-2 type-ui text-ink2">
26+ <Avatar name={owner} size={20} /> {owner}
27+ <input type="hidden" name="owner" value={owner} />
28+ </span>
29+ )}
2430 </div>
2531 <span aria-hidden className="mt-[22px] flex h-8 items-center type-content text-ink3">
2632 /
+5−2app/new/page.tsxView File
@@ -1,12 +1,15 @@
11 import type { Metadata } from "next";
22 import { RepoIcon } from "@/components/icons";
33 import { requireViewer } from "@/lib/auth";
4+ import { repoOwnersFor } from "@/lib/orgs";
45 import { NewRepoForm } from "./form";
56
67 export const metadata: Metadata = { title: "New repository" };
78
8- export default async function NewRepoPage() {
9+ export default async function NewRepoPage({ searchParams }: PageProps<"/new">) {
910 const viewer = await requireViewer("/new");
11+ const owners = (await repoOwnersFor(viewer)).map((o) => o.username);
12+ const wanted = String((await searchParams).owner ?? "").toLowerCase();
1013 return (
1114 <main className="mx-auto w-full max-w-2xl px-4 py-12">
1215 <div className="mb-8 flex items-center gap-4">
@@ -18,7 +21,7 @@ export default async function NewRepoPage() {
1821 <p className="mt-1 type-lead text-ink2">A repository holds all your project’s files and their revision history.</p>
1922 </div>
2023 </div>
21- <NewRepoForm owner={viewer.username} />
24+ <NewRepoForm owners={owners} owner={owners.find((o) => o.toLowerCase() === wanted) ?? viewer.username} />
2225 </main>
2326 );
2427 }
+113−0app/organizations/[org]/settings/forms.tsxaddedView File
@@ -0,0 +1,113 @@
1+ "use client";
2+
3+ import { useActionState, useRef } from "react";
4+ import { FormField, Select, useToast } from "@unlocalhosted/metalui";
5+ import type { FormState } from "@/app/actions/types";
6+ import { DeleteZone } from "@/components/delete-zone";
7+ import { PersonIcon, PlusIcon } from "@/components/icons";
8+ import { ChoiceCards, TextareaField, TextField } from "@/components/settings-fields";
9+ import { PlateBody, PlateFooter } from "@/components/settings-plate";
10+ import { Alert, Button } from "@/components/ui";
11+ import { ORG_ROLES, ROLE_INFO, type OrgRole } from "@/lib/org-roles";
12+
13+ type Action = (prev: FormState, formData: FormData) => Promise<FormState>;
14+
15+ const roleOptions = [...ORG_ROLES].reverse().map((r) => ({ value: r, label: ROLE_INFO[r].title }));
16+
17+ export function OrgProfileForm({ action, org }: { action: Action; org: { username: string; name: string; bio: string } }) {
18+ const [state, formAction, pending] = useActionState(action, undefined);
19+ return (
20+ <form action={formAction}>
21+ <PlateBody className="max-w-xl">
22+ <TextField label="Organization name" description="Organization names can't be changed." value={org.username} disabled readOnly />
23+ <TextField label="Display name" name="displayName" defaultValue={org.name} maxLength={100} />
24+ <TextareaField label="Description" name="description" minRows={3} maxLength={300} defaultValue={org.bio} />
25+ {state?.error && <Alert>{state.error}</Alert>}
26+ {state?.success && <Alert kind="success">{state.success}</Alert>}
27+ </PlateBody>
28+ <PlateFooter>
29+ <Button variant="primary" disabled={pending}>
30+ {pending ? "Saving…" : "Update Profile"}
31+ </Button>
32+ </PlateFooter>
33+ </form>
34+ );
35+ }
36+
37+ export function DeleteOrgZone({ action, name }: { action: Action; name: string }) {
38+ return (
39+ <DeleteZone
40+ action={action}
41+ confirmText={name}
42+ title="Delete this organization"
43+ description="Only an organization without repositories can be deleted. Delete its repositories first."
44+ consequence={
45+ <>
46+ This will permanently delete <strong>{name}</strong>, its members and pending invitations. The name becomes free for anyone to take.
47+ </>
48+ }
49+ openLabel="Delete Organization"
50+ confirmLabel="I Understand, Delete This Organization"
51+ />
52+ );
53+ }
54+
55+ /** A member's role, changed as soon as another is picked; the outcome goes to the toast deck. */
56+ export function RoleSelect({ action, role, label }: { action: Action; role: OrgRole; label: string }) {
57+ const toast = useToast();
58+ const form = useRef<HTMLFormElement>(null);
59+ const input = useRef<HTMLInputElement>(null);
60+ const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => {
61+ const result = await action(prev, formData);
62+ if (result?.error) toast.show({ title: result.error, tone: "error" });
63+ else if (result?.success) toast.show({ title: result.success, tone: "success" });
64+ return result;
65+ }, undefined);
66+ return (
67+ <form ref={form} action={formAction}>
68+ <input ref={input} type="hidden" name="role" defaultValue={role} />
69+ <Select
70+ // Picks the saved role again after a refused change (e.g. demoting the last owner).
71+ key={`${role}-${state?.error ?? ""}`}
72+ size="compact"
73+ options={roleOptions}
74+ defaultValue={role}
75+ disabled={pending}
76+ aria-label={label}
77+ className="w-28"
78+ onValueChange={(value) => {
79+ if (!input.current || value === role) return;
80+ input.current.value = value;
81+ form.current?.requestSubmit();
82+ }}
83+ />
84+ </form>
85+ );
86+ }
87+
88+ export function InviteForm({ action }: { action: Action }) {
89+ const [state, formAction, pending] = useActionState(action, undefined);
90+ return (
91+ <form action={formAction}>
92+ <PlateBody className="gap-6">
93+ <TextField className="max-w-xl" label="Username" icon={<PersonIcon />} name="username" required autoComplete="off" />
94+ <FormField>
95+ <FormField.Label>Role</FormField.Label>
96+ <ChoiceCards
97+ name="role"
98+ aria-label="Role"
99+ defaultValue="write"
100+ choices={[...ORG_ROLES].map((r) => ({ value: r, title: ROLE_INFO[r].title, description: ROLE_INFO[r].description }))}
101+ />
102+ </FormField>
103+ {state?.error && <Alert>{state.error}</Alert>}
104+ {state?.success && <Alert kind="success">{state.success}</Alert>}
105+ </PlateBody>
106+ <PlateFooter>
107+ <Button variant="primary" icon={<PlusIcon />} disabled={pending}>
108+ {pending ? "Inviting…" : "Send Invitation"}
109+ </Button>
110+ </PlateFooter>
111+ </form>
112+ );
113+ }
+35−0app/organizations/[org]/settings/layout.tsxaddedView File
@@ -0,0 +1,35 @@
1+ import Link from "next/link";
2+ import { GearIcon, PeopleIcon } from "@/components/icons";
3+ import { SettingsNav } from "@/components/settings-nav";
4+ import { Avatar } from "@/components/ui";
5+ import { getOwnedOrg } from "./load";
6+
7+ export default async function OrgSettingsLayout({ params, children }: LayoutProps<"/organizations/[org]/settings">) {
8+ const { org } = await getOwnedOrg((await params).org);
9+ const base = `/organizations/${org.username}/settings`;
10+ return (
11+ <div className="mx-auto w-full max-w-[1100px] px-4 py-10 md:px-6">
12+ <div className="mb-8 flex items-center gap-4">
13+ <Avatar name={org.name || org.username} size={52} />
14+ <div>
15+ <h1 className="text-2xl font-semibold tracking-[-0.03em]">
16+ <Link href={`/${org.username}`} className="hover:underline">
17+ {org.name || org.username}
18+ </Link>
19+ </h1>
20+ <p className="text-muted">Organization settings</p>
21+ </div>
22+ </div>
23+ <div className="grid gap-8 md:grid-cols-[220px_1fr]">
24+ <SettingsNav
25+ title="Organization"
26+ items={[
27+ { href: base, label: "General", icon: <GearIcon /> },
28+ { href: `${base}/members`, label: "Members", icon: <PeopleIcon /> },
29+ ]}
30+ />
31+ <div className="min-w-0">{children}</div>
32+ </div>
33+ </div>
34+ );
35+ }
+15−0app/organizations/[org]/settings/load.tsaddedView File
@@ -0,0 +1,15 @@
1+ import "server-only";
2+ import { cache } from "react";
3+ import { forbidden, notFound } from "next/navigation";
4+ import { requireViewer } from "@/lib/auth";
5+ import { getOrg, orgRole } from "@/lib/orgs";
6+
7+ /** The organization whose settings are open; only its owners get past here, and non-members see the 404 page. */
8+ export const getOwnedOrg = cache(async (name: string) => {
9+ const viewer = await requireViewer(`/organizations/${name}/settings`);
10+ const org = await getOrg(name);
11+ const role = org ? await orgRole(org.id, viewer.id) : null;
12+ if (!org || !role) notFound();
13+ if (role !== "owner") forbidden();
14+ return { org, viewer };
15+ });
+116−0app/organizations/[org]/settings/members/page.tsxaddedView File
@@ -0,0 +1,116 @@
1+ import type { Metadata } from "next";
2+ import Link from "next/link";
3+ import { Label as MuLabel } from "@unlocalhosted/metalui";
4+ import { cancelInvitationAction, inviteMemberAction, removeMemberAction, setMemberRoleAction } from "@/app/actions/orgs";
5+ import { ActionButton } from "@/components/action-button";
6+ import { ConfirmButton } from "@/components/confirm-button";
7+ import { MailIcon } from "@/components/icons";
8+ import { SettingsSection } from "@/components/metal";
9+ import { ListRow, PlateSection } from "@/components/settings-plate";
10+ import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui";
11+ import { timeAgo } from "@/lib/format";
12+ import { listOrgInvitations, listOrgMembers, ROLE_INFO } from "@/lib/orgs";
13+ import { InviteForm, RoleSelect } from "../forms";
14+ import { getOwnedOrg } from "../load";
15+
16+ export const metadata: Metadata = { title: "Members" };
17+
18+ export default async function OrgMembersPage({ params }: PageProps<"/organizations/[org]/settings/members">) {
19+ const { org, viewer } = await getOwnedOrg((await params).org);
20+ const [members, invitations] = await Promise.all([listOrgMembers(org.id), listOrgInvitations(org.id)]);
21+
22+ return (
23+ <div>
24+ <PageTitle
25+ title="Members"
26+ description="A member’s role is their access to every repository of the organization. Give people outside it access to single repositories as collaborators."
27+ />
28+ <div className="flex flex-col gap-7">
29+ <SettingsSection title={`Members · ${members.length}`}>
30+ {members.map((m) => (
31+ <ListRow
32+ key={m.id}
33+ lead={<Avatar name={m.name || m.username} size={32} />}
34+ title={
35+ <>
36+ <Link href={`/${m.username}`} className="truncate hover:underline">
37+ {m.username}
38+ </Link>
39+ {m.id === viewer.id && <Label>You</Label>}
40+ </>
41+ }
42+ meta={
43+ <MuLabel variant="engraved">
44+ {m.name && (
45+ <>
46+ <b>{m.name}</b> ·{" "}
47+ </>
48+ )}
49+ Joined <b>{timeAgo(m.joinedAt)}</b>
50+ </MuLabel>
51+ }
52+ >
53+ <RoleSelect action={setMemberRoleAction.bind(null, org.username, m.id)} role={m.role} label={`Role of ${m.username}`} />
54+ <ActionButton
55+ action={removeMemberAction.bind(null, org.username, m.id)}
56+ confirm={{
57+ title: m.id === viewer.id ? `Leave ${org.username}?` : `Remove ${m.username} from ${org.username}?`,
58+ description:
59+ m.id === viewer.id
60+ ? "You lose access to its private repositories and its settings."
61+ : "They lose access to its private repositories. Their commits, issues and comments stay.",
62+ label: m.id === viewer.id ? "Leave" : "Remove",
63+ destructive: true,
64+ }}
65+ >
66+ {m.id === viewer.id ? "Leave" : "Remove"}
67+ </ActionButton>
68+ </ListRow>
69+ ))}
70+ </SettingsSection>
71+
72+ <SettingsSection title={`Pending invitations · ${invitations.length}`}>
73+ {invitations.map((inv) => (
74+ <ListRow
75+ key={inv.id}
76+ lead={<Avatar name={inv.user.name || inv.user.username} size={32} />}
77+ title={
78+ <>
79+ <Link href={`/${inv.user.username}`} className="truncate hover:underline">
80+ {inv.user.username}
81+ </Link>
82+ <Label tone="attention">{ROLE_INFO[inv.role].title}</Label>
83+ </>
84+ }
85+ meta={
86+ <MuLabel variant="engraved">
87+ Invited <b>{timeAgo(inv.createdAt)}</b>
88+ {inv.invitedBy && (
89+ <>
90+ {" "}
91+ by <b>{inv.invitedBy}</b>
92+ </>
93+ )}
94+ </MuLabel>
95+ }
96+ >
97+ <ConfirmButton
98+ action={cancelInvitationAction.bind(null, org.username, inv.id)}
99+ message={`Cancel the invitation for ${inv.user.username}? They won’t be able to join unless you invite them again.`}
100+ variant="default"
101+ confirmLabel="Cancel Invitation"
102+ >
103+ Cancel
104+ </ConfirmButton>
105+ </ListRow>
106+ ))}
107+ {invitations.length === 0 && <Blankslate icon={<MailIcon />} title="No pending invitations" />}
108+ </SettingsSection>
109+
110+ <PlateSection title="Invite a member">
111+ <InviteForm action={inviteMemberAction.bind(null, org.username)} />
112+ </PlateSection>
113+ </div>
114+ </div>
115+ );
116+ }
+25−0app/organizations/[org]/settings/page.tsxaddedView File
@@ -0,0 +1,25 @@
1+ import type { Metadata } from "next";
2+ import { deleteOrgAction, updateOrgAction } from "@/app/actions/orgs";
3+ import { PlateSection } from "@/components/settings-plate";
4+ import { PageTitle } from "@/components/ui";
5+ import { DeleteOrgZone, OrgProfileForm } from "./forms";
6+ import { getOwnedOrg } from "./load";
7+
8+ export const metadata: Metadata = { title: "Organization settings" };
9+
10+ export default async function OrgSettingsPage({ params }: PageProps<"/organizations/[org]/settings">) {
11+ const { org } = await getOwnedOrg((await params).org);
12+ return (
13+ <div>
14+ <PageTitle title="General" />
15+ <div className="flex flex-col gap-7">
16+ <PlateSection title="Profile">
17+ <OrgProfileForm action={updateOrgAction.bind(null, org.username)} org={{ username: org.username, name: org.name, bio: org.bio }} />
18+ </PlateSection>
19+ <PlateSection title="Danger zone">
20+ <DeleteOrgZone action={deleteOrgAction.bind(null, org.username)} name={org.username} />
21+ </PlateSection>
22+ </div>
23+ </div>
24+ );
25+ }
+39−0app/organizations/new/form.tsxaddedView File
@@ -0,0 +1,39 @@
1+ "use client";
2+
3+ import { useActionState } from "react";
4+ import { createOrgAction } from "@/app/actions/orgs";
5+ import { PlusIcon } from "@/components/icons";
6+ import { TextareaField, TextField } from "@/components/settings-fields";
7+ import { PlateBody, PlateFooter } from "@/components/settings-plate";
8+ import { Alert, Box, Button } from "@/components/ui";
9+
10+ export function NewOrgForm() {
11+ const [state, action, pending] = useActionState(createOrgAction, undefined);
12+ return (
13+ <form action={action}>
14+ <Box>
15+ <PlateBody>
16+ <TextField
17+ label="Organization name"
18+ description="Its address and the first part of its repositories’ names, e.g. /acme/website. It can’t be changed."
19+ name="name"
20+ required
21+ autoFocus
22+ maxLength={39}
23+ pattern="[A-Za-z0-9][A-Za-z0-9\-]*"
24+ placeholder="acme"
25+ />
26+ <TextField label="Display name" optional name="displayName" maxLength={100} placeholder="Acme Inc." />
27+ <TextareaField label="Description" optional name="description" minRows={2} maxLength={300} placeholder="What does this organization work on?" />
28+ <p className="type-body text-ink2">You’ll be its owner. Next, invite people and choose what each of them may do.</p>
29+ {state?.error && <Alert>{state.error}</Alert>}
30+ </PlateBody>
31+ <PlateFooter>
32+ <Button variant="primary" icon={<PlusIcon />} disabled={pending}>
33+ {pending ? "Creating…" : "Create Organization"}
34+ </Button>
35+ </PlateFooter>
36+ </Box>
37+ </form>
38+ );
39+ }
+24−0app/organizations/new/page.tsxaddedView File
@@ -0,0 +1,24 @@
1+ import type { Metadata } from "next";
2+ import { PeopleIcon } from "@/components/icons";
3+ import { requireViewer } from "@/lib/auth";
4+ import { NewOrgForm } from "./form";
5+
6+ export const metadata: Metadata = { title: "New organization" };
7+
8+ export default async function NewOrgPage() {
9+ await requireViewer("/organizations/new");
10+ return (
11+ <main className="mx-auto w-full max-w-2xl px-4 py-12">
12+ <div className="mb-8 flex items-center gap-4">
13+ <span className="icon-tile size-12 shrink-0 rounded-plate [&_svg]:size-[22px]">
14+ <PeopleIcon />
15+ </span>
16+ <div>
17+ <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Create an organization</h1>
18+ <p className="mt-1 type-lead text-ink2">Repositories that belong to a team, with people you invite and the access you give them.</p>
19+ </div>
20+ </div>
21+ <NewOrgForm />
22+ </main>
23+ );
24+ }
+36−1app/page.tsxView File
@@ -2,6 +2,7 @@ import Link from "next/link";
22 import { Card, Surface } from "@unlocalhosted/metalui";
33 import { BRAND } from "@/components/brand";
44 import { HomeTerminal } from "@/components/home-terminal";
5+ import { OrgInvitations } from "@/components/org-invitations";
56 import {
67 ArrowRightIcon,
78 BranchIcon,
@@ -10,6 +11,7 @@ import {
1011 CompassIcon,
1112 IssueOpenedIcon,
1213 KeyIcon,
14+ OrganizationIcon,
1315 PencilIcon,
1416 PeopleIcon,
1517 PlusIcon,
@@ -22,6 +24,7 @@ import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvata
2224 import { getViewer } from "@/lib/auth";
2325 import { shortSha, timeAgo } from "@/lib/format";
2426 import { recentActivity, withActivity } from "@/lib/listing";
27+ import { listInvitationsFor, listOrgsOf } from "@/lib/orgs";
2528 import { listReposForUser, searchRepos } from "@/lib/repos";
2629 import { serverHost } from "@/lib/request-origin";
2730 import { SIGNUPS_ENABLED } from "@/lib/signups";
@@ -124,7 +127,12 @@ function greeting() {
124127 const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush";
125128
126129 async function Dashboard({ viewer }: { viewer: User }) {
127- const [mine, recent] = await Promise.all([listReposForUser(viewer.id), searchRepos("", viewer.id, 30)]);
130+ const [mine, recent, orgs, invitations] = await Promise.all([
131+ listReposForUser(viewer.id),
132+ searchRepos("", viewer.id, 30),
133+ listOrgsOf(viewer.id),
134+ listInvitationsFor(viewer.id),
135+ ]);
128136 const [activity, explore] = await Promise.all([
129137 recentActivity(mine),
130138 withActivity(recent.filter((r) => r.ownerId !== viewer.id && r.visibility === "public").slice(0, 5)),
@@ -144,6 +152,27 @@ async function Dashboard({ viewer }: { viewer: User }) {
144152 </div>
145153 <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion }))} />
146154 </Box>
155+ <Box className="p-4">
156+ <div className="mb-2 flex items-center justify-between gap-2">
157+ <h2 className="flex items-center gap-2 type-label engraved">
158+ Your organizations <Counter>{orgs.length}</Counter>
159+ </h2>
160+ <ButtonLink href="/organizations/new" variant="invisible" icon={<PlusIcon />}>
161+ New
162+ </ButtonLink>
163+ </div>
164+ {orgs.length === 0 && <p className="type-body text-ink3">Share repositories with a team, and choose what each person may do.</p>}
165+ <ul className="-mx-2 flex flex-col gap-0.5">
166+ {orgs.map(({ org }) => (
167+ <li key={org.id}>
168+ <Link href={`/${org.username}`} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}>
169+ <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />
170+ <span className="truncate type-ui text-ink">{org.username}</span>
171+ </Link>
172+ </li>
173+ ))}
174+ </ul>
175+ </Box>
147176 </aside>
148177
149178 <main className="min-w-0">
@@ -157,6 +186,12 @@ async function Dashboard({ viewer }: { viewer: User }) {
157186 </div>
158187 </div>
159188
189+ {invitations.length > 0 && (
190+ <div className="mb-6">
191+ <OrgInvitations invitations={invitations} />
192+ </div>
193+ )}
194+
160195 {activity.length === 0 ? (
161196 <Box>
162197 <Blankslate
+2−1app/settings/layout.tsxView File
@@ -1,5 +1,5 @@
11 import { SettingsNav } from "@/components/settings-nav";
2- import { ActivityIcon, KeyIcon, PersonIcon, ServerIcon, ShieldLockIcon, SparkleIcon } from "@/components/icons";
2+ import { ActivityIcon, KeyIcon, OrganizationIcon, PersonIcon, ServerIcon, ShieldLockIcon, SparkleIcon } from "@/components/icons";
33 import { Avatar } from "@/components/ui";
44 import { requireViewer } from "@/lib/auth";
55
@@ -19,6 +19,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting
1919 title="Account"
2020 items={[
2121 { href: "/settings/profile", label: "Public profile", icon: <PersonIcon /> },
22+ { href: "/settings/organizations", label: "Organizations", icon: <OrganizationIcon /> },
2223 { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> },
2324 { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> },
2425 { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> },
+85−0app/settings/organizations/page.tsxaddedView File
@@ -0,0 +1,85 @@
1+ import type { Metadata } from "next";
2+ import Link from "next/link";
3+ import { Label as MuLabel } from "@unlocalhosted/metalui";
4+ import { leaveOrgAction } from "@/app/actions/orgs";
5+ import { ActionButton } from "@/components/action-button";
6+ import { GearIcon, OrganizationIcon, PlusIcon } from "@/components/icons";
7+ import { SettingsSection } from "@/components/metal";
8+ import { OrgInvitations } from "@/components/org-invitations";
9+ import { ListRow } from "@/components/settings-plate";
10+ import { Avatar, Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui";
11+ import { requireViewer } from "@/lib/auth";
12+ import { timeAgo } from "@/lib/format";
13+ import { listInvitationsFor, listOrgsOf, ROLE_INFO } from "@/lib/orgs";
14+
15+ export const metadata: Metadata = { title: "Organizations" };
16+
17+ export default async function OrganizationsSettingsPage() {
18+ const viewer = await requireViewer("/settings/organizations");
19+ const [memberships, invitations] = await Promise.all([listOrgsOf(viewer.id), listInvitationsFor(viewer.id)]);
20+ return (
21+ <div>
22+ <PageTitle
23+ title="Organizations"
24+ description="Organizations own repositories for a team. Their owners invite people and choose what each of them may do."
25+ actions={
26+ <ButtonLink href="/organizations/new" variant="primary" icon={<PlusIcon />}>
27+ New Organization
28+ </ButtonLink>
29+ }
30+ />
31+ <div className="flex flex-col gap-7">
32+ {invitations.length > 0 && (
33+ <section className="flex flex-col gap-2">
34+ <h2 className="px-1.5 type-label engraved">Invitations · {invitations.length}</h2>
35+ <OrgInvitations invitations={invitations} />
36+ </section>
37+ )}
38+ <SettingsSection title={`Your organizations · ${memberships.length}`}>
39+ {memberships.map(({ org, role, joinedAt }) => (
40+ <ListRow
41+ key={org.id}
42+ lead={<Avatar name={org.name || org.username} size={32} />}
43+ title={
44+ <>
45+ <Link href={`/${org.username}`} className="truncate hover:underline">
46+ {org.username}
47+ </Link>
48+ <Label tone={role === "owner" ? "accent" : "default"}>{ROLE_INFO[role].title}</Label>
49+ </>
50+ }
51+ meta={
52+ <MuLabel variant="engraved">
53+ {org.name && (
54+ <>
55+ <b>{org.name}</b> ·{" "}
56+ </>
57+ )}
58+ Joined <b>{timeAgo(joinedAt)}</b>
59+ </MuLabel>
60+ }
61+ >
62+ {role === "owner" && (
63+ <ButtonLink href={`/organizations/${org.username}/settings`} icon={<GearIcon />}>
64+ Settings
65+ </ButtonLink>
66+ )}
67+ <ActionButton
68+ action={leaveOrgAction.bind(null, org.username)}
69+ confirm={{
70+ title: `Leave ${org.username}?`,
71+ description: "You lose access to its private repositories. To come back, an owner has to invite you again.",
72+ label: "Leave",
73+ destructive: true,
74+ }}
75+ >
76+ Leave
77+ </ActionButton>
78+ </ListRow>
79+ ))}
80+ {memberships.length === 0 && <Blankslate icon={<OrganizationIcon />} title="You’re not a member of any organization" />}
81+ </SettingsSection>
82+ </div>
83+ </div>
84+ );
85+ }
+75−0components/delete-zone.tsxaddedView File
@@ -0,0 +1,75 @@
1+ "use client";
2+
3+ import { useActionState, useState, type ReactNode } from "react";
4+ import type { FormState } from "@/app/actions/types";
5+ import { AlertIcon, TrashIcon } from "./icons";
6+ import { TextField } from "./settings-fields";
7+ import { PlateBody, PlateFooter } from "./settings-plate";
8+ import { Alert, Button } from "./ui";
9+
10+ /** Deleting something for good: one red cap opens the type-to-confirm form, whose red cap does it. */
11+ export function DeleteZone({
12+ action,
13+ confirmText,
14+ title,
15+ description,
16+ consequence,
17+ openLabel,
18+ confirmLabel,
19+ }: {
20+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
21+ /** What has to be typed to confirm; posted as `confirm`. */
22+ confirmText: string;
23+ title: string;
24+ description: string;
25+ /** What goes with it, said in red once the form is open. */
26+ consequence: ReactNode;
27+ openLabel: string;
28+ confirmLabel: string;
29+ }) {
30+ const [state, formAction, pending] = useActionState(action, undefined);
31+ const [confirm, setConfirm] = useState("");
32+ const [open, setOpen] = useState(false);
33+ return (
34+ <>
35+ <div className="flex min-h-[52px] flex-wrap items-center gap-4 px-6 py-4">
36+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
37+ <p className="type-title text-ink">{title}</p>
38+ <p className="type-body text-ink2">{description}</p>
39+ </div>
40+ {!open && (
41+ <Button type="button" variant="danger" icon={<TrashIcon />} onClick={() => setOpen(true)}>
42+ {openLabel}
43+ </Button>
44+ )}
45+ </div>
46+ {open && (
47+ <form action={formAction}>
48+ <PlateBody className="pt-0">
49+ <p className="flex items-start gap-2 type-body text-red">
50+ <AlertIcon className="mt-px shrink-0" />
51+ <span>{consequence}</span>
52+ </p>
53+ <TextField
54+ className="max-w-xl"
55+ label={`To confirm, type “${confirmText}” below:`}
56+ name="confirm"
57+ value={confirm}
58+ onChange={(e) => setConfirm(e.target.value)}
59+ autoFocus
60+ />
61+ {state?.error && <Alert>{state.error}</Alert>}
62+ </PlateBody>
63+ <PlateFooter>
64+ <Button type="button" onClick={() => setOpen(false)}>
65+ Cancel
66+ </Button>
67+ <Button variant="danger" icon={<TrashIcon />} disabled={confirm !== confirmText || pending}>
68+ {pending ? "Deleting…" : confirmLabel}
69+ </Button>
70+ </PlateFooter>
71+ </form>
72+ )}
73+ </>
74+ );
75+ }
+12−0components/icons.tsxView File
@@ -198,6 +198,18 @@ export const PeopleIcon = icon(
198198 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
199199 </>,
200200 );
201+ export const MailIcon = icon(
202+ <>
203+ <rect x="2" y="4" width="20" height="16" rx="2" />
204+ <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
205+ </>,
206+ );
207+ export const OrganizationIcon = icon(
208+ <>
209+ <path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z" />
210+ <path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2M10 6h4M10 10h4M10 14h4M10 18h4" />
211+ </>,
212+ );
201213 export const SignOutIcon = icon(
202214 <>
203215 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
+45−0components/org-invitations.tsxaddedView File
@@ -0,0 +1,45 @@
1+ import Link from "next/link";
2+ import { answerInvitationAction } from "@/app/actions/orgs";
3+ import { timeAgo } from "@/lib/format";
4+ import { ROLE_INFO } from "@/lib/org-roles";
5+ import type { OrgInvitation } from "@/lib/orgs";
6+ import { ActionButton } from "./action-button";
7+ import { CheckIcon } from "./icons";
8+ import { ListRow } from "./settings-plate";
9+ import { Avatar, Box, Label } from "./ui";
10+
11+ /** Invitations to join organizations, for the person invited: accept (and land on the organization) or decline. */
12+ export function OrgInvitations({ invitations }: { invitations: OrgInvitation[] }) {
13+ if (!invitations.length) return null;
14+ return (
15+ <Box>
16+ {invitations.map((inv) => (
17+ <ListRow
18+ key={inv.id}
19+ lead={<Avatar name={inv.org.name || inv.org.username} size={32} />}
20+ title={
21+ <>
22+ <span>
23+ {inv.invitedBy ? `@${inv.invitedBy} invited you to join` : "You’re invited to join"}{" "}
24+ <Link href={`/${inv.org.username}`} className="hover:underline">
25+ {inv.org.username}
26+ </Link>
27+ </span>
28+ <Label tone="accent">{ROLE_INFO[inv.role].title}</Label>
29+ </>
30+ }
31+ meta={
32+ <span className="type-meta text-ink3">
33+ {ROLE_INFO[inv.role].description} Sent {timeAgo(inv.createdAt)}.
34+ </span>
35+ }
36+ >
37+ <ActionButton action={answerInvitationAction.bind(null, inv.id, false)}>Decline</ActionButton>
38+ <ActionButton action={answerInvitationAction.bind(null, inv.id, true)} variant="primary" icon={<CheckIcon />}>
39+ Join
40+ </ActionButton>
41+ </ListRow>
42+ ))}
43+ </Box>
44+ );
45+ }
+4−1components/sidebar-nav.tsxView File
@@ -7,7 +7,7 @@ import { Avatar as MuAvatar, Menu, MenuItem, MenuSeparator, Sidebar } from "@unl
77 import { logoutAction } from "@/app/actions/auth";
88 import type { Theme } from "@/lib/theme";
99 import { BRAND, LogoMark } from "./brand";
10- import { ChevronsUpDownIcon, CompassIcon, GearIcon, HomeIcon, KeyIcon, MenuIcon, PersonIcon, PlusIcon, RepoIcon, ShieldLockIcon, SignOutIcon } from "./icons";
10+ import { ChevronsUpDownIcon, CompassIcon, GearIcon, HomeIcon, KeyIcon, MenuIcon, OrganizationIcon, PersonIcon, PlusIcon, RepoIcon, ShieldLockIcon, SignOutIcon } from "./icons";
1111 import { ThemeToggle } from "./theme-toggle";
1212 import { RepoAvatar } from "./ui";
1313
@@ -33,6 +33,9 @@ function AccountMenuItems({ viewer }: { viewer: SidebarViewer }) {
3333 <MenuItem icon={<PersonIcon />} onSelect={() => router.push(`/${viewer.username}`)}>
3434 Your Profile
3535 </MenuItem>
36+ <MenuItem icon={<OrganizationIcon />} onSelect={() => router.push("/settings/organizations")}>
37+ Your Organizations
38+ </MenuItem>
3639 <MenuItem icon={<GearIcon />} onSelect={() => router.push("/settings/profile")}>
3740 Settings
3841 </MenuItem>
+1−0docs/features.mdView File
@@ -3,6 +3,7 @@
33 - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
44 - **Repositories**: public or private, rename, delete, optional README on creation
55 - **Collaborators**: give other users push access to a repository
6+ - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
67 - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private
78 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
89 - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens
+1−0docs/layout.mdView File
@@ -3,6 +3,7 @@
33 - `lib/db.ts`: starting and connecting to PocketBase; `pb_migrations/`: the schema
44 - `lib/users.ts`, `lib/auth.ts`: accounts and sessions (PocketBase auth records and tokens)
55 - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators
6+ - `lib/orgs.ts`, `lib/org-roles.ts`: organizations (accounts in `users` with kind "org"), their members' roles and invitations; UI in `app/organizations/` and `app/settings/organizations/`
67 - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/`
78 - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens
89 - `lib/oauth.ts`, `lib/oauth-rules.ts`: the OAuth 2.1 authorization server for agents (`app/oauth/*`, `app/.well-known/*`); `lib/mcp/*`: the MCP endpoint (`app/mcp`) and its tools
+6−3lib/agents.tsView File
@@ -1,5 +1,6 @@
11 import { appAgentKey, parseAppAgentKey, type AgentRef } from "./actor";
22 import { db, filter, findById, findFirst } from "./db";
3+ import { orgPushersFilter } from "./orgs";
34
45 // The agents people approved (connected apps), as something issues can be assigned to and work
56 // can be traced back to. See lib/actor.ts for how agents are named.
@@ -7,13 +8,15 @@ import { db, filter, findById, findFirst } from "./db";
78 export type AssignableAgent = AgentRef & { userId: string; username: string };
89
910 /**
10- * Agents that can take on work in a repository: connected apps of its owner and collaborators that
11- * may write to it. An issue assigned to one shows up in that agent's get_my_tasks.
11+ * Agents that can take on work in a repository: connected apps of its owner, collaborators and
12+ * organization members that may write to it. An issue assigned to one shows up in that agent's
13+ * get_my_tasks.
1214 */
1315 export async function listAssignableAgents(repo: { id: string; ownerId: string }): Promise<AssignableAgent[]> {
16+ const orgPushers = await orgPushersFilter("user", repo.ownerId);
1417 const rows = await (await db()).collection("oauth_grants").getFullList({
1518 filter: filter(
16- "permission = 'write' && refresh_expires_at > {:now} && (all_repos = true || repos ?= {:repoId}) && (user = {:ownerId} || user.collaborators_via_user.repo ?= {:repoId})",
19+ `permission = 'write' && refresh_expires_at > {:now} && (all_repos = true || repos ?= {:repoId}) && (user = {:ownerId} || user.collaborators_via_user.repo ?= {:repoId}${orgPushers ? ` || ${orgPushers}` : ""})`,
1720 { now: Date.now(), repoId: repo.id, ownerId: repo.ownerId },
1821 ),
1922 expand: "client,user",
+1−1lib/git.tsView File
@@ -11,7 +11,7 @@ import { DATA_ROOT, REPOS_ROOT, WIKIS_ROOT } from "./paths";
1111 const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
1212 const RESERVED = new Set([
1313 "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2",
14- "mcp", "oauth",
14+ "mcp", "oauth", "organizations",
1515 ]);
1616
1717 export function isValidName(name: string) {
+8−3lib/issues.tsView File
@@ -14,6 +14,7 @@ import {
1414 type Member,
1515 type NewIssueInput,
1616 } from "./issue-meta";
17+ import { orgPushersFilter } from "./orgs";
1718 import type { RepoRecord } from "./repos";
1819 import { getUserById } from "./users";
1920
@@ -92,7 +93,7 @@ export async function validateIssuePatch(repo: { id: string; ownerId: string },
9293 }
9394 if (patch.assigneeId !== undefined) {
9495 if (patch.assigneeId !== null && !(await listMembers(repo.id, repo.ownerId)).some((m) => m.id === patch.assigneeId)) {
95- throw new IssueError("Only the owner, collaborators and their connected agents can be assigned.");
96+ throw new IssueError("Only people with push access and their connected agents can be assigned.");
9697 }
9798 out.assigneeId = patch.assigneeId;
9899 }
@@ -162,11 +163,15 @@ export async function listEvents(issueId: string): Promise<IssueEvent[]> {
162163 }));
163164 }
164165
165- /** Who can be assigned: the owner, the collaborators, and their agents that may write to the repository. */
166+ /**
167+ * Who can be assigned: the owner (unless it's an organization), the collaborators, the members of
168+ * the organization who can push, and their agents that may write to the repository.
169+ */
166170 export async function listMembers(repoId: string, ownerId: string): Promise<Member[]> {
171+ const orgPushers = await orgPushersFilter("id", ownerId);
167172 const [rows, agents] = await Promise.all([
168173 (await db()).collection("users").getFullList({
169- filter: filter("id = {:ownerId} || collaborators_via_user.repo ?= {:repoId}", { ownerId, repoId }),
174+ filter: filter(`(id = {:ownerId} && kind != 'org') || collaborators_via_user.repo ?= {:repoId}${orgPushers ? ` || ${orgPushers}` : ""}`, { ownerId, repoId }),
170175 sort: "username",
171176 fields: "id,username,name",
172177 }),
+1−1lib/markdown-views.tsView File
@@ -139,7 +139,7 @@ async function userPage(viewer: Viewer, origin: string, username: string) {
139139 const user = await getUserByUsername(username);
140140 if (!user) return null;
141141 const repos = (await listReposByOwner(user.id, viewer.userId)).filter((r) => !viewer.scope || tokenCovers(viewer.scope, r.id, false));
142- return `# ${user.name || user.username} (@${user.username})\n\n${user.bio || ""}\n\n## Repositories\n\n${
142+ return `# ${user.name || user.username} (@${user.username})${user.isOrg ? "\n\nAn organization." : ""}\n\n${user.bio || ""}\n\n## Repositories\n\n${
143143 repos.map((r) => `- [${r.owner}/${r.name}](${origin}/${r.owner}/${r.name})${r.visibility === "private" ? " (private)" : ""}${r.description ? `: ${r.description}` : ""}`).join("\n") || "_None._"
144144 }\n`;
145145 }
+12−4lib/mcp/tools.tsView File
@@ -43,6 +43,7 @@ import {
4343 import { cloneUrls } from "../repo";
4444 import { canWrite, countForks, createRepo, listReposByOwner, listReposForUser, markPushed, repoAccess, searchRepos, updateRepo, type RepoRecord } from "../repos";
4545 import { cancelRun, getRun, isFinished, listJobs, listRuns, listSteps, readJobLogs, type Run } from "../runs";
46+ import { getOrg, listOrgsOf, mayCreateRepos } from "../orgs";
4647 import { getUserByUsername } from "../users";
4748 import { contentPages, findPage, listPages, pageSlug, readPage, savePage, wikiHead } from "../wiki";
4849 import { pageHref } from "../wiki-links";
@@ -107,6 +108,7 @@ const accountTools = [
107108 name: ctx.user.name || null,
108109 email: ctx.user.email,
109110 profile_url: `${ctx.origin}/${ctx.user.username}`,
111+ organizations: (await listOrgsOf(ctx.user.id)).map((m) => ({ name: m.org.username, role: m.role })),
110112 // What you change is recorded as this agent's, done for the user above.
111113 agent: ctx.agent.name,
112114 connection: {
@@ -122,7 +124,7 @@ const accountTools = [
122124 description:
123125 "Repositories you own or collaborate on, most recently active first. With owner, that user's repositories that you can see instead.",
124126 access: "read",
125- params: { owner: string("Only this user's repositories.", { max: 39 }).optional() },
127+ params: { owner: string("Only this user's or organization's repositories.", { max: 39 }).optional() },
126128 run: async ({ owner }, ctx) => {
127129 let repos: RepoRecord[];
128130 if (owner) {
@@ -201,17 +203,23 @@ const accountTools = [
201203 tool({
202204 name: "create_repository",
203205 title: "Create a repository",
204- description: "Creates a repository owned by you. With readme (the default), it starts with a README.md commit on main.",
206+ description:
207+ "Creates a repository owned by you, or by an organization you're an admin or owner of. With readme (the default), it starts with a README.md commit on main.",
205208 access: "write",
206209 params: {
207210 name: string("Letters, digits, '.', '-' and '_'.", { min: 1, max: 100 }),
211+ owner: string("An organization to create it in, instead of your own account.", { max: 39 }).optional(),
208212 description: string("A short description.", { max: 350 }).default(""),
209213 visibility: oneOf("Who can see it.", ["public", "private"] as const).default("private"),
210214 readme: boolean("Start with a README.md.").default(true),
211215 },
212- run: async ({ name, description, visibility, readme }, ctx) => {
216+ run: async ({ name, owner, description, visibility, readme }, ctx) => {
213217 if (!ctx.scope.allRepos) throw new ToolError("This connection is limited to selected repositories, so it can't create new ones.");
214- const created = await createRepo(ctx.user, { name, description, visibility, readme }, agentCommitter(ctx.agent));
218+ const account = owner && owner.toLowerCase() !== ctx.user.username.toLowerCase() ? await getOrg(owner) : ctx.user;
219+ if (!account || !(await mayCreateRepos(account, ctx.user))) {
220+ throw new ToolError(`You can't create repositories in ${owner}: only its admins and owners can.`);
221+ }
222+ const created = await createRepo(account, { name, description, visibility, readme }, { author: ctx.user, committer: agentCommitter(ctx.agent) });
215223 return { ...repoSummary(ctx, created), clone_urls: await cloneUrls(created) };
216224 },
217225 }),
+18−0lib/org-roles.tsaddedView File
@@ -0,0 +1,18 @@
1+ // What organization members may do (see lib/orgs.ts), shared with the pages that change it.
2+
3+ /** From least to most. */
4+ export const ORG_ROLES = ["read", "write", "admin", "owner"] as const;
5+ export type OrgRole = (typeof ORG_ROLES)[number];
6+
7+ export const ROLE_INFO: Record<OrgRole, { title: string; description: string }> = {
8+ read: { title: "Read", description: "Sees all of the organization’s repositories, private ones too, and can open issues and comment." },
9+ write: { title: "Write", description: "Also pushes to every repository and manages issues and pull requests." },
10+ admin: { title: "Admin", description: "Also creates repositories, changes their settings and deletes them." },
11+ owner: { title: "Owner", description: "Full control: also manages the organization, its members and invitations." },
12+ };
13+
14+ export const isOrgRole = (value: unknown): value is OrgRole => ORG_ROLES.includes(value as OrgRole);
15+ export const roleAtLeast = (role: OrgRole | null, need: OrgRole) => !!role && ORG_ROLES.indexOf(role) >= ORG_ROLES.indexOf(need);
16+
17+ /** What a role gives its member in each of the organization's repositories. */
18+ export const repoAccessOfRole = (role: OrgRole) => (role === "owner" || role === "admin" ? "admin" : role);
+220−0lib/orgs.tsaddedView File
@@ -0,0 +1,220 @@
1+ import { randomBytes } from "node:crypto";
2+ import { count, db, filter, findById, findFirst, isUniqueViolation, withLock, type RecordModel } from "./db";
3+ import { isValidOwner } from "./git";
4+ import { ORG_ROLES, roleAtLeast, type OrgRole } from "./org-roles";
5+ import { getUserByUsername, toUser, type User } from "./users";
6+
7+ export * from "./org-roles";
8+
9+ // Organizations are accounts that own repositories for a group of people (see
10+ // pb_migrations/1791300000_orgs.js). Their members' roles are their access to every repository the
11+ // organization owns; people get one by accepting an invitation from an owner.
12+
13+ export type OrgMember = { id: string; username: string; name: string; role: OrgRole; joinedAt: number };
14+ export type OrgInvitation = { id: string; org: User; user: User; role: OrgRole; invitedBy: string | null; createdAt: number };
15+
16+ export class OrgError extends Error {}
17+
18+ const members = async () => (await db()).collection("org_members");
19+ const invitations = async () => (await db()).collection("org_invitations");
20+
21+ /** Membership changes are read-modify-write (an organization always keeps an owner). */
22+ const lockOrg = <T>(orgId: string, fn: () => Promise<T>) => withLock(`org:${orgId}`, fn);
23+
24+ export async function getOrg(name: string) {
25+ const user = await getUserByUsername(name);
26+ return user?.isOrg ? user : null;
27+ }
28+
29+ export async function createOrg(creator: User, input: { name: string; displayName: string; description: string }) {
30+ const name = input.name.trim();
31+ if (!isValidOwner(name)) {
32+ throw new OrgError("Organization names may only contain letters, numbers and hyphens, and can't be a reserved word.");
33+ }
34+ if (await getUserByUsername(name)) throw new OrgError(`The name ${name} is already taken.`);
35+ // Nobody signs in as an organization; PocketBase still wants a password for the record.
36+ const password = randomBytes(36).toString("base64url");
37+ let record: RecordModel;
38+ try {
39+ record = await (await db()).collection("users").create({
40+ username: name,
41+ kind: "org",
42+ name: input.displayName.trim().slice(0, 100),
43+ bio: input.description.trim().slice(0, 300),
44+ password,
45+ passwordConfirm: password,
46+ });
47+ } catch (e) {
48+ if (isUniqueViolation(e)) throw new OrgError(`The name ${name} was just taken.`);
49+ throw e;
50+ }
51+ await (await members()).create({ org: record.id, user: creator.id, role: "owner", created_at: Date.now() });
52+ return toUser(record);
53+ }
54+
55+ export async function updateOrgProfile(orgId: string, data: { displayName: string; description: string }) {
56+ await (await db()).collection("users").update(orgId, { name: data.displayName.trim().slice(0, 100), bio: data.description.trim().slice(0, 300) });
57+ }
58+
59+ /** Deletes an organization without repositories, and its memberships and invitations with it. */
60+ export async function deleteOrg(org: User) {
61+ const repos = await count("repos", filter("owner = {:id}", { id: org.id }));
62+ if (repos) throw new OrgError(`${org.username} still owns ${repos === 1 ? "a repository. Delete it" : `${repos} repositories. Delete them`} first.`);
63+ await (await db()).collection("users").delete(org.id);
64+ }
65+
66+ // ---------------------------------------------------------------------------
67+ // Members
68+
69+ /** The person's role in the organization, or null (also when `orgId` is a person's). */
70+ export async function orgRole(orgId: string, userId: string | null | undefined): Promise<OrgRole | null> {
71+ if (!userId || orgId === userId) return null;
72+ const row = await findFirst("org_members", filter("org = {:orgId} && user = {:userId}", { orgId, userId }), { fields: "role" });
73+ return (row?.role as OrgRole) ?? null;
74+ }
75+
76+ export async function listOrgMembers(orgId: string): Promise<OrgMember[]> {
77+ const rows = await (await members()).getFullList({ filter: filter("org = {:orgId}", { orgId }), expand: "user" });
78+ return rows
79+ .map((r) => {
80+ const user = r.expand?.user as RecordModel;
81+ return { id: user.id, username: user.username as string, name: user.name as string, role: r.role as OrgRole, joinedAt: r.created_at as number };
82+ })
83+ .sort((a, b) => ORG_ROLES.indexOf(b.role) - ORG_ROLES.indexOf(a.role) || a.username.localeCompare(b.username));
84+ }
85+
86+ /** The ids of members with at least `role`. */
87+ export async function orgMemberIds(orgId: string, role: OrgRole) {
88+ const roles = ORG_ROLES.slice(ORG_ROLES.indexOf(role));
89+ const rows = await (await members()).getFullList({
90+ filter: `${filter("org = {:orgId}", { orgId })} && (${roles.map((r) => filter("role = {:r}", { r })).join(" || ")})`,
91+ fields: "user",
92+ });
93+ return rows.map((r) => r.user as string);
94+ }
95+
96+ /**
97+ * Matches `field` (a user id) against the organization's members who can push to its repositories,
98+ * for filters; "" if there are none (or `orgId` is a person's).
99+ */
100+ export async function orgPushersFilter(field: string, orgId: string) {
101+ return (await orgMemberIds(orgId, "write")).map((id) => filter(`${field} = {:id}`, { id })).join(" || ");
102+ }
103+
104+ /** The organizations a person belongs to, by name. */
105+ export async function listOrgsOf(userId: string) {
106+ const rows = await (await members()).getFullList({ filter: filter("user = {:userId}", { userId }), expand: "org" });
107+ return rows
108+ .map((r) => ({ org: toUser(r.expand?.org as RecordModel), role: r.role as OrgRole, joinedAt: r.created_at as number }))
109+ .sort((a, b) => a.org.username.localeCompare(b.org.username));
110+ }
111+
112+ /** Where a person may create repositories: their own account, and organizations they're an admin or owner of. */
113+ export async function repoOwnersFor(user: User) {
114+ const orgs = (await listOrgsOf(user.id)).filter((m) => roleAtLeast(m.role, "admin")).map((m) => m.org);
115+ return [user, ...orgs];
116+ }
117+
118+ export async function mayCreateRepos(owner: User, user: User) {
119+ return owner.id === user.id || (owner.isOrg && roleAtLeast(await orgRole(owner.id, user.id), "admin"));
120+ }
121+
122+ async function membership(orgId: string, userId: string) {
123+ return findFirst("org_members", filter("org = {:orgId} && user = {:userId}", { orgId, userId }));
124+ }
125+
126+ /** Refuses to leave the organization without an owner. */
127+ async function keepAnOwner(orgId: string, userId: string) {
128+ const owners = await orgMemberIds(orgId, "owner");
129+ if (owners.length === 1 && owners[0] === userId) {
130+ throw new OrgError("An organization needs at least one owner. Make someone else an owner first.");
131+ }
132+ }
133+
134+ export async function setMemberRole(orgId: string, userId: string, role: OrgRole) {
135+ return lockOrg(orgId, async () => {
136+ const row = await membership(orgId, userId);
137+ if (!row) throw new OrgError("They're not a member of this organization.");
138+ if (row.role === role) return;
139+ if (row.role === "owner") await keepAnOwner(orgId, userId);
140+ await (await members()).update(row.id, { role });
141+ });
142+ }
143+
144+ /** Removes a member, or lets one leave. */
145+ export async function removeMember(orgId: string, userId: string) {
146+ return lockOrg(orgId, async () => {
147+ const row = await membership(orgId, userId);
148+ if (!row) return;
149+ if (row.role === "owner") await keepAnOwner(orgId, userId);
150+ await (await members()).delete(row.id);
151+ });
152+ }
153+
154+ // ---------------------------------------------------------------------------
155+ // Invitations
156+
157+ function toInvitation(r: RecordModel): OrgInvitation {
158+ const invitedBy = r.expand?.invited_by as RecordModel | undefined;
159+ return {
160+ id: r.id,
161+ org: toUser(r.expand?.org as RecordModel),
162+ user: toUser(r.expand?.user as RecordModel),
163+ role: r.role as OrgRole,
164+ invitedBy: (invitedBy?.username as string) ?? null,
165+ createdAt: r.created_at,
166+ };
167+ }
168+
169+ const INVITATION_EXPAND = "org,user,invited_by";
170+
171+ export async function inviteMember(org: User, inviter: User, username: string, role: OrgRole) {
172+ const user = await getUserByUsername(username.trim());
173+ if (!user || user.isOrg) throw new OrgError(`No user named ${username.trim()}.`);
174+ return lockOrg(org.id, async () => {
175+ if (await membership(org.id, user.id)) throw new OrgError(`${user.username} is already a member.`);
176+ try {
177+ await (await invitations()).create({ org: org.id, user: user.id, role, invited_by: inviter.id, created_at: Date.now() });
178+ } catch (e) {
179+ if (isUniqueViolation(e)) throw new OrgError(`${user.username} has already been invited.`);
180+ throw e;
181+ }
182+ return user;
183+ });
184+ }
185+
186+ /** Invitations an organization sent that haven't been answered. */
187+ export async function listOrgInvitations(orgId: string) {
188+ const rows = await (await invitations()).getFullList({ filter: filter("org = {:orgId}", { orgId }), sort: "-created_at", expand: INVITATION_EXPAND });
189+ return rows.map(toInvitation);
190+ }
191+
192+ /** Invitations a person hasn't answered yet. */
193+ export async function listInvitationsFor(userId: string) {
194+ const rows = await (await invitations()).getFullList({ filter: filter("user = {:userId}", { userId }), sort: "-created_at", expand: INVITATION_EXPAND });
195+ return rows.map(toInvitation);
196+ }
197+
198+ export async function cancelInvitation(orgId: string, invitationId: string) {
199+ const row = await findById("org_invitations", invitationId);
200+ if (row?.org === orgId) await (await invitations()).delete(row.id);
201+ }
202+
203+ /** Accepts (the person joins with the role they were invited with) or declines an invitation addressed to `userId`. */
204+ export async function answerInvitation(userId: string, invitationId: string, accept: boolean) {
205+ const row = await findById("org_invitations", invitationId, { expand: INVITATION_EXPAND });
206+ if (!row || row.user !== userId) throw new OrgError("That invitation doesn't exist anymore.");
207+ const invitation = toInvitation(row);
208+ if (accept) {
209+ await lockOrg(row.org, async () => {
210+ try {
211+ await (await members()).create({ org: row.org, user: userId, role: row.role, created_at: Date.now() });
212+ } catch (e) {
213+ // Already a member.
214+ if (!isUniqueViolation(e)) throw e;
215+ }
216+ });
217+ }
218+ await (await invitations()).delete(row.id);
219+ return invitation;
220+ }
+44−18lib/repos.tsView File
@@ -17,7 +17,8 @@ import { commitFiles } from "./git-write";
1717 import { deleteRepoImages } from "./registry";
1818 import { deleteRepoReleaseFiles } from "./releases";
1919 import type { AgentPush } from "./branch-rules";
20- import { getUserByUsername, type User } from "./users";
20+ import { orgRole, repoAccessOfRole } from "./orgs";
21+ import { getPersonByUsername, type User } from "./users";
2122
2223 export type Visibility = "public" | "private";
2324 export type Access = "admin" | "write" | "read" | null;
@@ -29,6 +30,8 @@ export type RepoRecord = {
2930 id: string;
3031 ownerId: string;
3132 owner: string;
33+ /** Owned by an organization, whose members' roles apply to it (see lib/orgs.ts). */
34+ ownerIsOrg: boolean;
3235 name: string;
3336 description: string;
3437 visibility: Visibility;
@@ -49,12 +52,14 @@ export type RepoRecord = {
4952 const EXPAND = "owner,fork_of.owner";
5053
5154 function toRepo(r: RecordModel): RepoRecord {
52- const owner = (r.expand?.owner as RecordModel).username as string;
55+ const ownerRecord = r.expand?.owner as RecordModel;
56+ const owner = ownerRecord.username as string;
5357 const parent = r.expand?.fork_of as RecordModel | undefined;
5458 return {
5559 id: r.id,
5660 ownerId: r.owner,
5761 owner,
62+ ownerIsOrg: ownerRecord.kind === "org",
5863 name: r.name,
5964 description: r.description,
6065 visibility: r.visibility,
@@ -109,12 +114,22 @@ export async function ensureWikiRepo(repo: RepoRecord) {
109114 await initBareRepo(repo.wikiDir);
110115 }
111116
117+ const ACCESS_LEVELS: Access[] = [null, "read", "write", "admin"];
118+ const highest = (...levels: Access[]) => levels.reduce((a, b) => (ACCESS_LEVELS.indexOf(b) > ACCESS_LEVELS.indexOf(a) ? b : a), null);
119+
120+ /**
121+ * What a user may do in a repository: its owner administers it; collaborators push to it; members
122+ * of the organization that owns it get what their role gives them (see lib/orgs.ts).
123+ */
112124 export async function repoAccess(repo: Pick<RepoRecord, "id" | "ownerId" | "visibility">, userId: string | null | undefined): Promise<Access> {
113- if (userId && repo.ownerId === userId) return "admin";
114- if (userId && (await findFirst("collaborators", filter("repo = {:repo} && user = {:userId}", { repo: repo.id, userId }), { fields: "id" }))) {
115- return "write";
116- }
117- return repo.visibility === "public" ? "read" : null;
125+ const everyone: Access = repo.visibility === "public" ? "read" : null;
126+ if (!userId) return everyone;
127+ if (repo.ownerId === userId) return "admin";
128+ const [collaborator, role] = await Promise.all([
129+ findFirst("collaborators", filter("repo = {:repo} && user = {:userId}", { repo: repo.id, userId }), { fields: "id" }),
130+ orgRole(repo.ownerId, userId),
131+ ]);
132+ return highest(everyone, collaborator ? "write" : null, role && repoAccessOfRole(role));
118133 }
119134
120135 export const canWrite = (access: Access) => access === "write" || access === "admin";
@@ -124,10 +139,13 @@ export async function visibleParent(repo: RepoRecord, viewerId: string | null |
124139 return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null;
125140 }
126141
127- /** Repos a user may see: public ones, their own, and ones they collaborate on. */
142+ /** Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. */
128143 const visibleTo = (viewerId: string | null) =>
129144 viewerId
130- ? filter("(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer})", { viewer: viewerId })
145+ ? filter(
146+ "(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer} || owner.org_members_via_org.user ?= {:viewer})",
147+ { viewer: viewerId },
148+ )
131149 : "visibility = 'public'";
132150
133151 async function list(where: string, limit?: number) {
@@ -140,9 +158,9 @@ export async function listReposByOwner(ownerId: string, viewerId: string | null)
140158 return list(`${filter("owner = {:owner}", { owner: ownerId })} && ${visibleTo(viewerId)}`);
141159 }
142160
143- /** Repositories the user owns or collaborates on. */
161+ /** Repositories the user owns or collaborates on, and those of their organizations. */
144162 export async function listReposForUser(userId: string) {
145- return list(filter("owner = {:u} || collaborators_via_repo.user ?= {:u}", { u: userId }));
163+ return list(filter("owner = {:u} || collaborators_via_repo.user ?= {:u} || owner.org_members_via_org.user ?= {:u}", { u: userId }));
146164 }
147165
148166 export async function searchRepos(query: string, viewerId: string | null, limit = 50) {
@@ -152,6 +170,9 @@ export async function searchRepos(query: string, viewerId: string | null, limit
152170 // <name>.wiki.git is the clone URL of a repository's wiki.
153171 const isWikiName = (name: string) => name.toLowerCase().endsWith(".wiki");
154172
173+ const alreadyHas = (owner: Pick<User, "username" | "isOrg">, name: string) =>
174+ `${owner.isOrg ? `${owner.username} already has` : "You already have"} a repository named ${name}.`;
175+
155176 /** Checks a name for a new repository of `owner` and returns where it goes on disk. */
156177 async function newRepoDir(owner: User, rawName: string) {
157178 const name = rawName.trim();
@@ -159,7 +180,7 @@ async function newRepoDir(owner: User, rawName: string) {
159180 throw new RepoError("Repository names may only contain letters, digits, '.', '-' and '_', and may not end in .git.");
160181 }
161182 if (isWikiName(name)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from.");
162- if (await findRepo(owner.username, name)) throw new RepoError(`You already have a repository named ${name}.`);
183+ if (await findRepo(owner.username, name)) throw new RepoError(alreadyHas(owner, name));
163184 const dir = repoPath(owner.username, name);
164185 if (await pathExists(dir)) throw new RepoError(`A repository directory for ${name} already exists on disk.`);
165186 await mkdir(path.dirname(dir), { recursive: true });
@@ -173,7 +194,7 @@ async function createRecord(owner: User, dir: string, data: Record<string, unkno
173194 return await (await repos()).create({ owner: owner.id, created_at: now, activity_at: now, ...data });
174195 } catch (e) {
175196 await rm(dir, { recursive: true, force: true });
176- if (isUniqueViolation(e)) throw new RepoError(`You already have a repository named ${data.name}.`);
197+ if (isUniqueViolation(e)) throw new RepoError(alreadyHas(owner, data.name));
177198 throw e;
178199 }
179200 }
@@ -181,9 +202,14 @@ async function createRecord(owner: User, dir: string, data: Record<string, unkno
181202 export async function createRepo(
182203 owner: User,
183204 input: { name: string; description: string; visibility: Visibility; readme: boolean },
184- /** Who commits the README, if not the owner: an agent creating the repository for them. */
185- committer?: { name: string; email: string },
205+ by: {
206+ /** Who writes the README: the owner, or the person creating a repository for their organization. */
207+ author?: User;
208+ /** Who commits it, if not the author: an agent creating the repository for them. */
209+ committer?: { name: string; email: string };
210+ } = {},
186211 ) {
212+ const author = by.author ?? owner;
187213 const { name, dir } = await newRepoDir(owner, input.name);
188214 await initBareRepo(dir);
189215 const record = await createRecord(owner, dir, { name, description: input.description.trim().slice(0, 350), visibility: input.visibility });
@@ -195,8 +221,8 @@ export async function createRepo(
195221 parent: null,
196222 changes: [{ path: "README.md", content: Buffer.from(body) }],
197223 message: "Initial commit",
198- author: { name: owner.name || owner.username, email: owner.email },
199- committer,
224+ author: { name: author.name || author.username, email: author.email },
225+ committer: by.committer,
200226 });
201227 await markPushed(record.id);
202228 }
@@ -392,7 +418,7 @@ export async function listCollaborators(repoId: string) {
392418 }
393419
394420 export async function addCollaborator(repo: RepoRecord, username: string) {
395- const user = await getUserByUsername(username.trim());
421+ const user = await getPersonByUsername(username.trim());
396422 if (!user) throw new RepoError(`No user named ${username}.`);
397423 if (user.id === repo.ownerId) throw new RepoError("The owner already has full access.");
398424 try {
+11−3lib/users.tsView File
@@ -12,13 +12,15 @@ export type User = {
1212 email: string;
1313 bio: string;
1414 createdAt: number;
15+ /** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */
16+ isOrg: boolean;
1517 };
1618
1719 /** PocketBase dates read "2026-01-02 03:04:05.678Z". */
1820 export const parseDate = (value: string) => Date.parse(value.replace(" ", "T"));
1921
2022 export function toUser(r: RecordModel): User {
21- return { id: r.id, username: r.username, name: r.name, email: r.email, bio: r.bio, createdAt: parseDate(r.created) };
23+ return { id: r.id, username: r.username, name: r.name, email: r.email, bio: r.bio, createdAt: parseDate(r.created), isOrg: r.kind === "org" };
2224 }
2325
2426 export function validatePassword(password: string) {
@@ -66,7 +68,7 @@ export async function authenticateUser(login: string, password: string) {
6668 login = login.trim();
6769 if (!login || !password) return null;
6870 // PocketBase signs in by email; usernames are resolved here, case-insensitively.
69- const record = await findFirst("users", filter("username:lower = {:login} || email:lower = {:login}", { login: login.toLowerCase() }));
71+ const record = await findFirst("users", filter("kind != 'org' && (username:lower = {:login} || email:lower = {:login})", { login: login.toLowerCase() }));
7072 try {
7173 // Unknown logins are still checked, so usernames can't be probed by timing.
7274 const auth = await client().collection("users").authWithPassword(record?.email ?? login, password);
@@ -97,6 +99,12 @@ export async function getUserByUsername(username: string) {
9799 return record ? toUser(record) : null;
98100 }
99101
102+ /** A person (not an organization) by username. */
103+ export async function getPersonByUsername(username: string) {
104+ const user = await getUserByUsername(username);
105+ return user && !user.isOrg ? user : null;
106+ }
107+
100108 /** Returns whether the email changed; PocketBase then rejects the user's existing session tokens. */
101109 export async function updateProfile(userId: string, data: { name: string; email: string; bio: string }) {
102110 const email = data.email.trim();
@@ -115,6 +123,6 @@ export async function updateProfile(userId: string, data: { name: string; email:
115123 export async function searchUsers(query: string, limit = 8) {
116124 const { items } = await (await db())
117125 .collection("users")
118- .getList(1, limit, { filter: filter("username ~ {:q} || name ~ {:q}", { q: query }), sort: "username", skipTotal: true });
126+ .getList(1, limit, { filter: filter("kind != 'org' && (username ~ {:q} || name ~ {:q})", { q: query }), sort: "username", skipTotal: true });
119127 return items.map(toUser);
120128 }
+83−0pb_migrations/1791300000_orgs.jsaddedView File
@@ -0,0 +1,83 @@
1+ // Organizations, like on GitHub: accounts that own repositories for a group of people.
2+ //
3+ // An organization is a record in `users` with kind "org", so it shares the namespace of usernames
4+ // and repositories keep pointing at their owner the same way (/<org>/<repo>). Nobody signs in as
5+ // one: it has no email and a random password, and the auth rule refuses it anyway.
6+ //
7+ // `org_members` gives people a role in it, which is their access to all of its repositories:
8+ // read, write, admin (also creates, configures and deletes repositories) or owner (also manages
9+ // the organization and its members). People join by accepting an invitation.
10+
11+ const ROLES = ["owner", "admin", "write", "read"];
12+
13+ const number = (name, opts = {}) => ({ type: "number", name, onlyInt: true, ...opts });
14+ const time = (name, opts = {}) => number(name, { min: 0, ...opts });
15+ const select = (name, values, opts = {}) => ({ type: "select", name, values, maxSelect: 1, required: true, ...opts });
16+ const relation = (name, collection, opts = {}) => ({
17+ type: "relation",
18+ name,
19+ collectionId: collection.id,
20+ maxSelect: 1,
21+ cascadeDelete: false,
22+ ...opts,
23+ });
24+ const parent = (name, collection) => relation(name, collection, { required: true, cascadeDelete: true });
25+
26+ function create(app, name, fields, indexes = []) {
27+ const collection = new Collection({
28+ type: "base",
29+ name,
30+ listRule: null,
31+ viewRule: null,
32+ createRule: null,
33+ updateRule: null,
34+ deleteRule: null,
35+ fields,
36+ indexes,
37+ });
38+ app.save(collection);
39+ return collection;
40+ }
41+
42+ migrate(
43+ (app) => {
44+ const users = app.findCollectionByNameOrId("users");
45+ // "" for a person.
46+ users.fields.add(new SelectField({ name: "kind", values: ["org"], maxSelect: 1 }));
47+ // Organizations have none; the unique index on email skips empty ones.
48+ users.fields.getByName("email").required = false;
49+ users.authRule = "kind != 'org'";
50+ app.save(users);
51+
52+ create(
53+ app,
54+ "org_members",
55+ [parent("org", users), parent("user", users), select("role", ROLES), time("created_at", { required: true })],
56+ ["CREATE UNIQUE INDEX idx_org_members ON org_members (org, user)", "CREATE INDEX idx_org_members_user ON org_members (user)"],
57+ );
58+
59+ // Pending until the person invited accepts (and becomes a member) or declines.
60+ create(
61+ app,
62+ "org_invitations",
63+ [
64+ parent("org", users),
65+ parent("user", users),
66+ select("role", ROLES),
67+ relation("invited_by", users, { cascadeDelete: true }),
68+ time("created_at", { required: true }),
69+ ],
70+ ["CREATE UNIQUE INDEX idx_org_invitations ON org_invitations (org, user)", "CREATE INDEX idx_org_invitations_user ON org_invitations (user)"],
71+ );
72+ },
73+ (app) => {
74+ for (const name of ["org_invitations", "org_members"]) app.delete(app.findCollectionByNameOrId(name));
75+ // Organizations can't be people: they go, and their repositories with them.
76+ for (const org of app.findRecordsByFilter("users", "kind = 'org'")) app.delete(org);
77+ const users = app.findCollectionByNameOrId("users");
78+ users.fields.removeByName("kind");
79+ users.fields.getByName("email").required = true;
80+ users.authRule = "";
81+ app.save(users);
82+ },
83+ );
+4−1scripts/create-user.mjsView File
@@ -17,7 +17,10 @@ if (!username || !email) {
1717 }
1818
1919 // The same rules as createUser() in lib/users.ts and isValidOwner() in lib/git.ts.
20- const RESERVED = new Set(["new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin"]);
20+ const RESERVED = new Set([
21+ "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2",
22+ "mcp", "oauth", "organizations",
23+ ]);
2124 const fail = (message) => (console.error(message), process.exit(1));
2225 if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username) || RESERVED.has(username.toLowerCase())) {
2326 fail("Username may only contain letters, numbers and hyphens, and can't be a reserved word.");