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>
Showing 36 changed files with 1391 additions and 101 deletions.
| @@ -8,6 +8,7 @@ import { SettingsSection } from "@/components/metal"; | ||
| 8 | 8 | import { ListRow, PlateSection } from "@/components/settings-plate"; |
| 9 | 9 | import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui"; |
| 10 | 10 | import { timeAgo } from "@/lib/format"; |
| 11 | + import { orgRole } from "@/lib/orgs"; | |
| 11 | 12 | import { getRepo } from "@/lib/repo"; |
| 12 | 13 | import { listCollaborators } from "@/lib/repos"; |
| 13 | 14 | import { AddCollaboratorForm } from "./form"; |
| @@ -17,11 +18,15 @@ export const metadata: Metadata = { title: "Collaborators" }; | ||
| 17 | 18 | export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/settings/access">) { |
| 18 | 19 | const { owner, repo: repoParam } = await params; |
| 19 | 20 | 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"; | |
| 21 | 23 | |
| 22 | 24 | return ( |
| 23 | 25 | <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 | + /> | |
| 25 | 30 | <div className="flex flex-col gap-7"> |
| 26 | 31 | <SettingsSection title={`People with access · ${collaborators.length + 1}`}> |
| 27 | 32 | <ListRow |
| @@ -31,9 +36,21 @@ export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/ | ||
| 31 | 36 | <Link href={`/${repo.owner}`} className="truncate hover:underline"> |
| 32 | 37 | {repo.owner} |
| 33 | 38 | </Link> |
| 34 | - <Label tone="accent">Owner</Label> | |
| 39 | + <Label tone="accent">{repo.ownerIsOrg ? "Organization" : "Owner"}</Label> | |
| 35 | 40 | </> |
| 36 | 41 | } |
| 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 | + } | |
| 37 | 54 | /> |
| 38 | 55 | {collaborators.map((c) => ( |
| 39 | 56 | <ListRow |
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | import { useActionState, useEffect, useState } from "react"; |
| 4 | 4 | import { Checkbox } from "@unlocalhosted/metalui"; |
| 5 | 5 | 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"; | |
| 7 | 8 | import { ChoiceCards, TextField } from "@/components/settings-fields"; |
| 8 | 9 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 9 | 10 | import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui"; |
| @@ -164,53 +165,21 @@ export function FeaturesForm({ action, wiki }: { action: Action; wiki: boolean } | ||
| 164 | 165 | ); |
| 165 | 166 | } |
| 166 | 167 | |
| 167 | - /** Deleting the repository: one red cap opens the type-to-confirm form, whose red cap does it. */ | |
| 168 | + /** Deleting the repository. */ | |
| 168 | 169 | 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); | |
| 172 | 170 | 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 | + /> | |
| 215 | 184 | ); |
| 216 | 185 | } |
| @@ -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 | + } | |
| @@ -9,6 +9,7 @@ import { formatDate } from "@/lib/format"; | ||
| 9 | 9 | import { withActivity } from "@/lib/listing"; |
| 10 | 10 | import { listReposByOwner } from "@/lib/repos"; |
| 11 | 11 | import { getUserByUsername } from "@/lib/users"; |
| 12 | + import { OrgProfile } from "./org-profile"; | |
| 12 | 13 | |
| 13 | 14 | export async function generateMetadata({ params }: PageProps<"/[owner]">) { |
| 14 | 15 | const user = await getUserByUsername((await params).owner); |
| @@ -22,6 +23,7 @@ export default async function ProfilePage({ params, searchParams }: PageProps<"/ | ||
| 22 | 23 | if (!user) notFound(); |
| 23 | 24 | const q = String((await searchParams).q ?? "").trim().toLowerCase(); |
| 24 | 25 | const viewer = await getViewer(); |
| 26 | + if (user.isOrg) return <OrgProfile org={user} viewer={viewer} q={q} />; | |
| 25 | 27 | const all = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id); |
| 26 | 28 | const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all; |
| 27 | 29 | const isSelf = viewer?.id === user.id; |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { getViewer } from "@/lib/auth"; |
| 3 | + import { getOrg, orgRole, roleAtLeast, type OrgRole } from "@/lib/orgs"; | |
| 3 | 4 | import { canWrite, findRepo, findRepoById, repoAccess, type Access } from "@/lib/repos"; |
| 4 | 5 | |
| 5 | 6 | export class ActionError extends Error {} |
| @@ -21,6 +22,16 @@ export async function repoForAction(owner: string, name: string, need: "read" | | ||
| 21 | 22 | return { repo, viewer, access }; |
| 22 | 23 | } |
| 23 | 24 | |
| 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 | + | |
| 24 | 35 | /** Of the repositories picked in a form, the ones the viewer may hand to a token or app with `permission`. */ |
| 25 | 36 | export async function grantableRepoIds(viewerId: string, ids: string[], permission: "read" | "write") { |
| 26 | 37 | const out: string[] = []; |
| @@ -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 | + } | |
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | updateRepo, |
| 16 | 16 | type Visibility, |
| 17 | 17 | } from "@/lib/repos"; |
| 18 | + import { getOrg, mayCreateRepos } from "@/lib/orgs"; | |
| 18 | 19 | import { ActionError, repoForAction, viewerOrThrow } from "./guard"; |
| 19 | 20 | import type { FormState } from "./types"; |
| 20 | 21 | |
| @@ -33,12 +34,19 @@ export async function createRepoAction(_prev: FormState, formData: FormData): Pr | ||
| 33 | 34 | let path = ""; |
| 34 | 35 | const result = await attempt(async () => { |
| 35 | 36 | 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 | + ); | |
| 42 | 50 | path = `/${repo.owner}/${repo.name}`; |
| 43 | 51 | }); |
| 44 | 52 | if (result?.error) return result; |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useActionState } from "react"; |
| 4 | - import { Checkbox, Rule } from "@unlocalhosted/metalui"; | |
| 4 | + import { Checkbox, Rule, Select } from "@unlocalhosted/metalui"; | |
| 5 | 5 | import { createRepoAction } from "@/app/actions/repo"; |
| 6 | 6 | import { BookIcon, GlobeIcon, LockIcon, PlusIcon } from "@/components/icons"; |
| 7 | 7 | import { ChoiceCards, TextField } from "@/components/settings-fields"; |
| 8 | 8 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 9 | 9 | import { Alert, Avatar, Box, Button } from "@/components/ui"; |
| 10 | 10 | |
| 11 | - export function NewRepoForm({ owner }: { owner: string }) { | |
| 11 | + export function NewRepoForm({ owners, owner }: { owners: string[]; owner: string }) { | |
| 12 | 12 | const [state, action, pending] = useActionState(createRepoAction, undefined); |
| 13 | 13 | return ( |
| 14 | 14 | <form action={action}> |
| @@ -17,10 +17,16 @@ export function NewRepoForm({ owner }: { owner: string }) { | ||
| 17 | 17 | <div className="flex flex-wrap items-start gap-2"> |
| 18 | 18 | <div className="flex flex-col gap-1.5"> |
| 19 | 19 | <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 | + )} | |
| 24 | 30 | </div> |
| 25 | 31 | <span aria-hidden className="mt-[22px] flex h-8 items-center type-content text-ink3"> |
| 26 | 32 | / |
| @@ -1,12 +1,15 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import { RepoIcon } from "@/components/icons"; |
| 3 | 3 | import { requireViewer } from "@/lib/auth"; |
| 4 | + import { repoOwnersFor } from "@/lib/orgs"; | |
| 4 | 5 | import { NewRepoForm } from "./form"; |
| 5 | 6 | |
| 6 | 7 | export const metadata: Metadata = { title: "New repository" }; |
| 7 | 8 | |
| 8 | - export default async function NewRepoPage() { | |
| 9 | + export default async function NewRepoPage({ searchParams }: PageProps<"/new">) { | |
| 9 | 10 | const viewer = await requireViewer("/new"); |
| 11 | + const owners = (await repoOwnersFor(viewer)).map((o) => o.username); | |
| 12 | + const wanted = String((await searchParams).owner ?? "").toLowerCase(); | |
| 10 | 13 | return ( |
| 11 | 14 | <main className="mx-auto w-full max-w-2xl px-4 py-12"> |
| 12 | 15 | <div className="mb-8 flex items-center gap-4"> |
| @@ -18,7 +21,7 @@ export default async function NewRepoPage() { | ||
| 18 | 21 | <p className="mt-1 type-lead text-ink2">A repository holds all your project’s files and their revision history.</p> |
| 19 | 22 | </div> |
| 20 | 23 | </div> |
| 21 | - <NewRepoForm owner={viewer.username} /> | |
| 24 | + <NewRepoForm owners={owners} owner={owners.find((o) => o.toLowerCase() === wanted) ?? viewer.username} /> | |
| 22 | 25 | </main> |
| 23 | 26 | ); |
| 24 | 27 | } |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + }); | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -2,6 +2,7 @@ import Link from "next/link"; | ||
| 2 | 2 | import { Card, Surface } from "@unlocalhosted/metalui"; |
| 3 | 3 | import { BRAND } from "@/components/brand"; |
| 4 | 4 | import { HomeTerminal } from "@/components/home-terminal"; |
| 5 | + import { OrgInvitations } from "@/components/org-invitations"; | |
| 5 | 6 | import { |
| 6 | 7 | ArrowRightIcon, |
| 7 | 8 | BranchIcon, |
| @@ -10,6 +11,7 @@ import { | ||
| 10 | 11 | CompassIcon, |
| 11 | 12 | IssueOpenedIcon, |
| 12 | 13 | KeyIcon, |
| 14 | + OrganizationIcon, | |
| 13 | 15 | PencilIcon, |
| 14 | 16 | PeopleIcon, |
| 15 | 17 | PlusIcon, |
| @@ -22,6 +24,7 @@ import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvata | ||
| 22 | 24 | import { getViewer } from "@/lib/auth"; |
| 23 | 25 | import { shortSha, timeAgo } from "@/lib/format"; |
| 24 | 26 | import { recentActivity, withActivity } from "@/lib/listing"; |
| 27 | + import { listInvitationsFor, listOrgsOf } from "@/lib/orgs"; | |
| 25 | 28 | import { listReposForUser, searchRepos } from "@/lib/repos"; |
| 26 | 29 | import { serverHost } from "@/lib/request-origin"; |
| 27 | 30 | import { SIGNUPS_ENABLED } from "@/lib/signups"; |
| @@ -124,7 +127,12 @@ function greeting() { | ||
| 124 | 127 | const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush"; |
| 125 | 128 | |
| 126 | 129 | 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 | + ]); | |
| 128 | 136 | const [activity, explore] = await Promise.all([ |
| 129 | 137 | recentActivity(mine), |
| 130 | 138 | withActivity(recent.filter((r) => r.ownerId !== viewer.id && r.visibility === "public").slice(0, 5)), |
| @@ -144,6 +152,27 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 144 | 152 | </div> |
| 145 | 153 | <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion }))} /> |
| 146 | 154 | </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> | |
| 147 | 176 | </aside> |
| 148 | 177 | |
| 149 | 178 | <main className="min-w-0"> |
| @@ -157,6 +186,12 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 157 | 186 | </div> |
| 158 | 187 | </div> |
| 159 | 188 | |
| 189 | + {invitations.length > 0 && ( | |
| 190 | + <div className="mb-6"> | |
| 191 | + <OrgInvitations invitations={invitations} /> | |
| 192 | + </div> | |
| 193 | + )} | |
| 194 | + | |
| 160 | 195 | {activity.length === 0 ? ( |
| 161 | 196 | <Box> |
| 162 | 197 | <Blankslate |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { Avatar } from "@/components/ui"; |
| 4 | 4 | import { requireViewer } from "@/lib/auth"; |
| 5 | 5 | |
| @@ -19,6 +19,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting | ||
| 19 | 19 | title="Account" |
| 20 | 20 | items={[ |
| 21 | 21 | { href: "/settings/profile", label: "Public profile", icon: <PersonIcon /> }, |
| 22 | + { href: "/settings/organizations", label: "Organizations", icon: <OrganizationIcon /> }, | |
| 22 | 23 | { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> }, |
| 23 | 24 | { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> }, |
| 24 | 25 | { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> }, |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -198,6 +198,18 @@ export const PeopleIcon = icon( | ||
| 198 | 198 | <path d="M16 3.13a4 4 0 0 1 0 7.75" /> |
| 199 | 199 | </>, |
| 200 | 200 | ); |
| 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 | + ); | |
| 201 | 213 | export const SignOutIcon = icon( |
| 202 | 214 | <> |
| 203 | 215 | <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> |
| @@ -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 | + } | |
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions) |
| 4 | 4 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 5 | 5 | - **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`) | |
| 6 | 7 | - **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 |
| 7 | 8 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| 8 | 9 | - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens |
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | - `lib/db.ts`: starting and connecting to PocketBase; `pb_migrations/`: the schema |
| 4 | 4 | - `lib/users.ts`, `lib/auth.ts`: accounts and sessions (PocketBase auth records and tokens) |
| 5 | 5 | - `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/` | |
| 6 | 7 | - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/` |
| 7 | 8 | - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens |
| 8 | 9 | - `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 |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { appAgentKey, parseAppAgentKey, type AgentRef } from "./actor"; |
| 2 | 2 | import { db, filter, findById, findFirst } from "./db"; |
| 3 | + import { orgPushersFilter } from "./orgs"; | |
| 3 | 4 | |
| 4 | 5 | // The agents people approved (connected apps), as something issues can be assigned to and work |
| 5 | 6 | // 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"; | ||
| 7 | 8 | export type AssignableAgent = AgentRef & { userId: string; username: string }; |
| 8 | 9 | |
| 9 | 10 | /** |
| 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. | |
| 12 | 14 | */ |
| 13 | 15 | export async function listAssignableAgents(repo: { id: string; ownerId: string }): Promise<AssignableAgent[]> { |
| 16 | + const orgPushers = await orgPushersFilter("user", repo.ownerId); | |
| 14 | 17 | const rows = await (await db()).collection("oauth_grants").getFullList({ |
| 15 | 18 | 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}` : ""})`, | |
| 17 | 20 | { now: Date.now(), repoId: repo.id, ownerId: repo.ownerId }, |
| 18 | 21 | ), |
| 19 | 22 | expand: "client,user", |
| @@ -11,7 +11,7 @@ import { DATA_ROOT, REPOS_ROOT, WIKIS_ROOT } from "./paths"; | ||
| 11 | 11 | const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; |
| 12 | 12 | const RESERVED = new Set([ |
| 13 | 13 | "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2", |
| 14 | - "mcp", "oauth", | |
| 14 | + "mcp", "oauth", "organizations", | |
| 15 | 15 | ]); |
| 16 | 16 | |
| 17 | 17 | export function isValidName(name: string) { |
| @@ -14,6 +14,7 @@ import { | ||
| 14 | 14 | type Member, |
| 15 | 15 | type NewIssueInput, |
| 16 | 16 | } from "./issue-meta"; |
| 17 | + import { orgPushersFilter } from "./orgs"; | |
| 17 | 18 | import type { RepoRecord } from "./repos"; |
| 18 | 19 | import { getUserById } from "./users"; |
| 19 | 20 | |
| @@ -92,7 +93,7 @@ export async function validateIssuePatch(repo: { id: string; ownerId: string }, | ||
| 92 | 93 | } |
| 93 | 94 | if (patch.assigneeId !== undefined) { |
| 94 | 95 | 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."); | |
| 96 | 97 | } |
| 97 | 98 | out.assigneeId = patch.assigneeId; |
| 98 | 99 | } |
| @@ -162,11 +163,15 @@ export async function listEvents(issueId: string): Promise<IssueEvent[]> { | ||
| 162 | 163 | })); |
| 163 | 164 | } |
| 164 | 165 | |
| 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 | + */ | |
| 166 | 170 | export async function listMembers(repoId: string, ownerId: string): Promise<Member[]> { |
| 171 | + const orgPushers = await orgPushersFilter("id", ownerId); | |
| 167 | 172 | const [rows, agents] = await Promise.all([ |
| 168 | 173 | (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 }), | |
| 170 | 175 | sort: "username", |
| 171 | 176 | fields: "id,username,name", |
| 172 | 177 | }), |
| @@ -139,7 +139,7 @@ async function userPage(viewer: Viewer, origin: string, username: string) { | ||
| 139 | 139 | const user = await getUserByUsername(username); |
| 140 | 140 | if (!user) return null; |
| 141 | 141 | 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${ | |
| 143 | 143 | repos.map((r) => `- [${r.owner}/${r.name}](${origin}/${r.owner}/${r.name})${r.visibility === "private" ? " (private)" : ""}${r.description ? `: ${r.description}` : ""}`).join("\n") || "_None._" |
| 144 | 144 | }\n`; |
| 145 | 145 | } |
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | import { cloneUrls } from "../repo"; |
| 44 | 44 | import { canWrite, countForks, createRepo, listReposByOwner, listReposForUser, markPushed, repoAccess, searchRepos, updateRepo, type RepoRecord } from "../repos"; |
| 45 | 45 | import { cancelRun, getRun, isFinished, listJobs, listRuns, listSteps, readJobLogs, type Run } from "../runs"; |
| 46 | + import { getOrg, listOrgsOf, mayCreateRepos } from "../orgs"; | |
| 46 | 47 | import { getUserByUsername } from "../users"; |
| 47 | 48 | import { contentPages, findPage, listPages, pageSlug, readPage, savePage, wikiHead } from "../wiki"; |
| 48 | 49 | import { pageHref } from "../wiki-links"; |
| @@ -107,6 +108,7 @@ const accountTools = [ | ||
| 107 | 108 | name: ctx.user.name || null, |
| 108 | 109 | email: ctx.user.email, |
| 109 | 110 | profile_url: `${ctx.origin}/${ctx.user.username}`, |
| 111 | + organizations: (await listOrgsOf(ctx.user.id)).map((m) => ({ name: m.org.username, role: m.role })), | |
| 110 | 112 | // What you change is recorded as this agent's, done for the user above. |
| 111 | 113 | agent: ctx.agent.name, |
| 112 | 114 | connection: { |
| @@ -122,7 +124,7 @@ const accountTools = [ | ||
| 122 | 124 | description: |
| 123 | 125 | "Repositories you own or collaborate on, most recently active first. With owner, that user's repositories that you can see instead.", |
| 124 | 126 | 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() }, | |
| 126 | 128 | run: async ({ owner }, ctx) => { |
| 127 | 129 | let repos: RepoRecord[]; |
| 128 | 130 | if (owner) { |
| @@ -201,17 +203,23 @@ const accountTools = [ | ||
| 201 | 203 | tool({ |
| 202 | 204 | name: "create_repository", |
| 203 | 205 | 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.", | |
| 205 | 208 | access: "write", |
| 206 | 209 | params: { |
| 207 | 210 | 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(), | |
| 208 | 212 | description: string("A short description.", { max: 350 }).default(""), |
| 209 | 213 | visibility: oneOf("Who can see it.", ["public", "private"] as const).default("private"), |
| 210 | 214 | readme: boolean("Start with a README.md.").default(true), |
| 211 | 215 | }, |
| 212 | - run: async ({ name, description, visibility, readme }, ctx) => { | |
| 216 | + run: async ({ name, owner, description, visibility, readme }, ctx) => { | |
| 213 | 217 | 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) }); | |
| 215 | 223 | return { ...repoSummary(ctx, created), clone_urls: await cloneUrls(created) }; |
| 216 | 224 | }, |
| 217 | 225 | }), |
| @@ -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); | |
| @@ -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 | + } | |
| @@ -17,7 +17,8 @@ import { commitFiles } from "./git-write"; | ||
| 17 | 17 | import { deleteRepoImages } from "./registry"; |
| 18 | 18 | import { deleteRepoReleaseFiles } from "./releases"; |
| 19 | 19 | 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"; | |
| 21 | 22 | |
| 22 | 23 | export type Visibility = "public" | "private"; |
| 23 | 24 | export type Access = "admin" | "write" | "read" | null; |
| @@ -29,6 +30,8 @@ export type RepoRecord = { | ||
| 29 | 30 | id: string; |
| 30 | 31 | ownerId: string; |
| 31 | 32 | owner: string; |
| 33 | + /** Owned by an organization, whose members' roles apply to it (see lib/orgs.ts). */ | |
| 34 | + ownerIsOrg: boolean; | |
| 32 | 35 | name: string; |
| 33 | 36 | description: string; |
| 34 | 37 | visibility: Visibility; |
| @@ -49,12 +52,14 @@ export type RepoRecord = { | ||
| 49 | 52 | const EXPAND = "owner,fork_of.owner"; |
| 50 | 53 | |
| 51 | 54 | 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; | |
| 53 | 57 | const parent = r.expand?.fork_of as RecordModel | undefined; |
| 54 | 58 | return { |
| 55 | 59 | id: r.id, |
| 56 | 60 | ownerId: r.owner, |
| 57 | 61 | owner, |
| 62 | + ownerIsOrg: ownerRecord.kind === "org", | |
| 58 | 63 | name: r.name, |
| 59 | 64 | description: r.description, |
| 60 | 65 | visibility: r.visibility, |
| @@ -109,12 +114,22 @@ export async function ensureWikiRepo(repo: RepoRecord) { | ||
| 109 | 114 | await initBareRepo(repo.wikiDir); |
| 110 | 115 | } |
| 111 | 116 | |
| 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 | + */ | |
| 112 | 124 | 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)); | |
| 118 | 133 | } |
| 119 | 134 | |
| 120 | 135 | export const canWrite = (access: Access) => access === "write" || access === "admin"; |
| @@ -124,10 +139,13 @@ export async function visibleParent(repo: RepoRecord, viewerId: string | null | | ||
| 124 | 139 | return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null; |
| 125 | 140 | } |
| 126 | 141 | |
| 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'. */ | |
| 128 | 143 | const visibleTo = (viewerId: string | null) => |
| 129 | 144 | 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 | + ) | |
| 131 | 149 | : "visibility = 'public'"; |
| 132 | 150 | |
| 133 | 151 | async function list(where: string, limit?: number) { |
| @@ -140,9 +158,9 @@ export async function listReposByOwner(ownerId: string, viewerId: string | null) | ||
| 140 | 158 | return list(`${filter("owner = {:owner}", { owner: ownerId })} && ${visibleTo(viewerId)}`); |
| 141 | 159 | } |
| 142 | 160 | |
| 143 | - /** Repositories the user owns or collaborates on. */ | |
| 161 | + /** Repositories the user owns or collaborates on, and those of their organizations. */ | |
| 144 | 162 | 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 })); | |
| 146 | 164 | } |
| 147 | 165 | |
| 148 | 166 | 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 | ||
| 152 | 170 | // <name>.wiki.git is the clone URL of a repository's wiki. |
| 153 | 171 | const isWikiName = (name: string) => name.toLowerCase().endsWith(".wiki"); |
| 154 | 172 | |
| 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 | + | |
| 155 | 176 | /** Checks a name for a new repository of `owner` and returns where it goes on disk. */ |
| 156 | 177 | async function newRepoDir(owner: User, rawName: string) { |
| 157 | 178 | const name = rawName.trim(); |
| @@ -159,7 +180,7 @@ async function newRepoDir(owner: User, rawName: string) { | ||
| 159 | 180 | throw new RepoError("Repository names may only contain letters, digits, '.', '-' and '_', and may not end in .git."); |
| 160 | 181 | } |
| 161 | 182 | 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)); | |
| 163 | 184 | const dir = repoPath(owner.username, name); |
| 164 | 185 | if (await pathExists(dir)) throw new RepoError(`A repository directory for ${name} already exists on disk.`); |
| 165 | 186 | await mkdir(path.dirname(dir), { recursive: true }); |
| @@ -173,7 +194,7 @@ async function createRecord(owner: User, dir: string, data: Record<string, unkno | ||
| 173 | 194 | return await (await repos()).create({ owner: owner.id, created_at: now, activity_at: now, ...data }); |
| 174 | 195 | } catch (e) { |
| 175 | 196 | 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)); | |
| 177 | 198 | throw e; |
| 178 | 199 | } |
| 179 | 200 | } |
| @@ -181,9 +202,14 @@ async function createRecord(owner: User, dir: string, data: Record<string, unkno | ||
| 181 | 202 | export async function createRepo( |
| 182 | 203 | owner: User, |
| 183 | 204 | 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 | + } = {}, | |
| 186 | 211 | ) { |
| 212 | + const author = by.author ?? owner; | |
| 187 | 213 | const { name, dir } = await newRepoDir(owner, input.name); |
| 188 | 214 | await initBareRepo(dir); |
| 189 | 215 | 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( | ||
| 195 | 221 | parent: null, |
| 196 | 222 | changes: [{ path: "README.md", content: Buffer.from(body) }], |
| 197 | 223 | 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, | |
| 200 | 226 | }); |
| 201 | 227 | await markPushed(record.id); |
| 202 | 228 | } |
| @@ -392,7 +418,7 @@ export async function listCollaborators(repoId: string) { | ||
| 392 | 418 | } |
| 393 | 419 | |
| 394 | 420 | export async function addCollaborator(repo: RepoRecord, username: string) { |
| 395 | - const user = await getUserByUsername(username.trim()); | |
| 421 | + const user = await getPersonByUsername(username.trim()); | |
| 396 | 422 | if (!user) throw new RepoError(`No user named ${username}.`); |
| 397 | 423 | if (user.id === repo.ownerId) throw new RepoError("The owner already has full access."); |
| 398 | 424 | try { |
| @@ -12,13 +12,15 @@ export type User = { | ||
| 12 | 12 | email: string; |
| 13 | 13 | bio: string; |
| 14 | 14 | createdAt: number; |
| 15 | + /** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */ | |
| 16 | + isOrg: boolean; | |
| 15 | 17 | }; |
| 16 | 18 | |
| 17 | 19 | /** PocketBase dates read "2026-01-02 03:04:05.678Z". */ |
| 18 | 20 | export const parseDate = (value: string) => Date.parse(value.replace(" ", "T")); |
| 19 | 21 | |
| 20 | 22 | 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" }; | |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | export function validatePassword(password: string) { |
| @@ -66,7 +68,7 @@ export async function authenticateUser(login: string, password: string) { | ||
| 66 | 68 | login = login.trim(); |
| 67 | 69 | if (!login || !password) return null; |
| 68 | 70 | // 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() })); | |
| 70 | 72 | try { |
| 71 | 73 | // Unknown logins are still checked, so usernames can't be probed by timing. |
| 72 | 74 | const auth = await client().collection("users").authWithPassword(record?.email ?? login, password); |
| @@ -97,6 +99,12 @@ export async function getUserByUsername(username: string) { | ||
| 97 | 99 | return record ? toUser(record) : null; |
| 98 | 100 | } |
| 99 | 101 | |
| 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 | + | |
| 100 | 108 | /** Returns whether the email changed; PocketBase then rejects the user's existing session tokens. */ |
| 101 | 109 | export async function updateProfile(userId: string, data: { name: string; email: string; bio: string }) { |
| 102 | 110 | const email = data.email.trim(); |
| @@ -115,6 +123,6 @@ export async function updateProfile(userId: string, data: { name: string; email: | ||
| 115 | 123 | export async function searchUsers(query: string, limit = 8) { |
| 116 | 124 | const { items } = await (await db()) |
| 117 | 125 | .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 }); | |
| 119 | 127 | return items.map(toUser); |
| 120 | 128 | } |
| @@ -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 | + ); | |
| @@ -17,7 +17,10 @@ if (!username || !email) { | ||
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | // 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 | + ]); | |
| 21 | 24 | const fail = (message) => (console.error(message), process.exit(1)); |
| 22 | 25 | if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username) || RESERVED.has(username.toLowerCase())) { |
| 23 | 26 | fail("Username may only contain letters, numbers and hyphens, and can't be a reserved word."); |