g1t/apps/web/app/routes/workspace/people.tsx

365 lines16,234 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { ArrowUpRight, Users } from "lucide-react";
2import { useState } from "react";
Merge branch 'worktree-agent-a985c01f43538af01'3import { Form, Link, data, useSearchParams } from "react-router";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look4
5import {
6 BASE_PERMISSIONS,
7 BASE_PERMISSION_LABELS,
8 type BasePermission,
9 DEFAULT_BASE_PERMISSION,
10 REPO_ROLE_LABELS,
11} from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell12
13import type { Route } from "./+types/people";
Social cards for every page: og.g1t.sh14import { page } from "../../lib/meta";
Merge branch 'worktree-agent-a985c01f43538af01'15import { Avatar, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16import { Badge } from "../../components/ui/badge";
17import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
18import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
19import { inviteLink, inviteState, moreInvitesMailto } from "../../lib/invites";
Agents as a team: lifecycle, merge queue, billing and a new shell20import { identity } from "../../lib/services.server";
21import {
22 assertSameOrigin,
23 getViewer,
24 requireUser,
25 roleIn,
26 unwrap,
27} from "../../lib/session.server";
28
Social cards for every page: og.g1t.sh29export function meta({ params, ...args }: Route.MetaArgs) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member30 return page(args, { title: `Members · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell31}
32
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33export async function loader({ params, context, request }: Route.LoaderArgs) {
Agents as a team: lifecycle, merge queue, billing and a new shell34 const viewer = getViewer(context);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look35 const role = roleIn(viewer, params.owner);
36 // Who is in a workspace is its members' business; anyone else, including
37 // someone a repository is shared with, gets nothing here.
38 if (!role) throw data(null, { status: 404 });
39 const owner = role === "owner";
40 const [members, invites, workspace, outside] = await Promise.all([
41 identity.listMembers(params.owner, viewer),
42 owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
43 identity.getWorkspace(params.owner),
44 owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null,
45 ]);
Agents as a team: lifecycle, merge queue, billing and a new shell46 return {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look47 role,
48 members: unwrap(members),
49 invites: invites?.ok ? invites.value : [],
50 base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION,
51 outside: outside?.ok ? outside.value : [],
52 origin: new URL(request.url).origin,
Agents as a team: lifecycle, merge queue, billing and a new shell53 };
54}
55
56export async function action({ request, params, context }: Route.ActionArgs) {
57 assertSameOrigin(request);
58 const user = requireUser(context, request);
59 const form = await request.formData();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look60 if (form.get("action") === "revoke-invite") {
61 const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? ""));
62 return result.ok ? null : { error: result.error.message };
63 }
64 if (form.get("action") === "base-permission") {
65 const base = String(form.get("base") ?? "");
66 if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." };
67 const result = await identity.setBasePermission(user, params.owner, base as BasePermission);
68 return result.ok ? { based: result.value } : { error: result.error.message, base: true };
69 }
70 const member = String(form.get("member") ?? "").trim();
71 // An address is invited by email; a username is added at once.
72 if (form.get("action") !== "remove" && member.includes("@")) {
73 const result = await identity.inviteMember(user, params.owner, member);
74 return result.ok ? { invited: result.value.email, outOfInvites: false } : { error: result.error.message, outOfInvites: result.error.code === "limit" };
75 }
Agents as a team: lifecycle, merge queue, billing and a new shell76 const result =
77 form.get("action") === "remove"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look78 ? await identity.removeMember(user, params.owner, member)
79 : await identity.addMember(user, params.owner, member);
80 if (!result.ok) return { error: result.error.message, converting: form.get("action") === "convert" };
81 return form.get("action") === "convert" ? { converted: member } : null;
Agents as a team: lifecycle, merge queue, billing and a new shell82}
83
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look84/** What the base permission means for members, in a sentence. */
85const BASE_MEANS: Record<BasePermission, string> = {
86 none: "Members see only public repositories and the ones they are given a role on.",
87 read: "Members can see and clone every repository, open issues and pull requests, and comment.",
88 write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.",
89 admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.",
90};
91
92export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) {
93 const { role, members, invites, origin, base, outside } = loaderData;
94 const owner = role === "owner";
95 const pending = invites.filter((invite) => invite.status === "pending");
96 const [search, setSearch] = useSearchParams();
97 const tab = search.get("tab") === "outside" && owner ? "outside" : "members";
98 const membersTab = (
99 <>
A catalogue of model providers, and settings that feel like settings100 <ul className="divide-y divide-line rounded-xl border border-line">
Agents as a team: lifecycle, merge queue, billing and a new shell101 {members.map((member) => (
102 <li key={member.username} className="flex items-center gap-3 px-4 py-3">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look103 <Avatar name={member.username} image={member.avatar} size={28} />
104 <div className="min-w-0 grow truncate">
105 <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
106 {member.username}
107 </Link>
108 {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
109 </div>
110 {member.role === "owner" ? <Badge tone="accent">Owner</Badge> : <Badge>Member</Badge>}
111 {owner && (
112 // The same room on every row, so the badges line up.
113 <span className="flex w-[5.5rem] justify-end">
114 {member.role !== "owner" && (
115 <Form method="post">
116 <input type="hidden" name="action" value="remove" />
117 <input type="hidden" name="member" value={member.username} />
Merge branch 'worktree-agent-a985c01f43538af01'118 <SubmitButton variant="quiet" match={{ action: "remove", member: member.username }} pending="Removing…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look119 Remove
Merge branch 'worktree-agent-a985c01f43538af01'120 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look121 </Form>
122 )}
123 </span>
Agents as a team: lifecycle, merge queue, billing and a new shell124 )}
125 </li>
126 ))}
127 </ul>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look128 {owner && (
Merge branch 'worktree-agent-a985c01f43538af01'129 // Empty again once the person is on the list; kept as typed when it failed.
130 <Form
131 method="post"
132 key={`${members.length}:${pending.length}`}
133 className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start"
134 >
135 <input type="hidden" name="action" value="add" />
Agents as a team: lifecycle, merge queue, billing and a new shell136 <div className="grow">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look137 <Field
138 label="Add a member"
139 hint="A g1t username joins at once, as a member. An email address gets an invite: without a g1t account, it makes one and joins in one step, using one of your invites."
140 >
141 <Input name="member" required maxLength={254} placeholder="username or name@example.com" />
Agents as a team: lifecycle, merge queue, billing and a new shell142 </Field>
143 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look144 <div className="sm:pt-[1.625rem]">
Merge branch 'worktree-agent-a985c01f43538af01'145 <SubmitButton match={{ action: "add" }} pending="Adding…">
146 Add
147 </SubmitButton>
Agents as a team: lifecycle, merge queue, billing and a new shell148 </div>
149 </Form>
150 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look151 {actionData && "invited" in actionData && actionData.invited && (
152 <p className="text-sm text-muted" role="status">
153 Invite sent to <span className="text-fg">{actionData.invited}</span>.
154 </p>
155 )}
156 {actionData && "converted" in actionData && (
157 <p className="text-sm text-muted" role="status">
158 <span className="font-mono text-fg">{actionData.converted}</span> is now a member of {params.owner}.
159 </p>
160 )}
161 {actionData && !("base" in actionData) && !("converting" in actionData && actionData.converting) && (
162 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
163 )}
164 {actionData && "outOfInvites" in actionData && actionData.outOfInvites && (
165 <p className="mt-1 text-sm text-muted">
166 <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4">
167 Ask for more invites
168 </a>{" "}
169 for {params.owner}.
170 </p>
171 )}
172
173 {owner && pending.length > 0 && (
174 <section className="mt-8">
175 <h2 className="text-sm font-medium">Pending invites</h2>
176 <ul className="mt-3 divide-y divide-line rounded-xl border border-line">
177 {pending.map((invite) => (
178 <li key={invite.id} className="space-y-2 px-4 py-3">
179 <div className="flex flex-wrap items-center gap-3">
180 <span className="min-w-0 grow truncate text-sm">{invite.email}</span>
181 <Pill>{inviteState(invite).label}</Pill>
182 <Form method="post">
183 <input type="hidden" name="action" value="revoke-invite" />
184 <input type="hidden" name="id" value={invite.id} />
Merge branch 'worktree-agent-a985c01f43538af01'185 <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look186 Revoke
Merge branch 'worktree-agent-a985c01f43538af01'187 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look188 </Form>
189 </div>
190 <p className="text-xs text-faint">
191 By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} />
192 </p>
193 {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
194 </li>
195 ))}
196 </ul>
197 </section>
198 )}
199
200 <BasePermissionSection
Merge branch 'worktree-agent-a985c01f43538af01'201 // Starts from the saved choice whenever it changes.
202 key={base}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look203 base={base}
204 owner={owner}
205 slug={params.owner}
206 error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null}
207 saved={Boolean(actionData && "based" in actionData)}
208 />
209 </>
210 );
211 return (
212 <div className="max-w-2xl">
213 {owner ? (
214 <Tabs
215 value={tab}
216 onValueChange={(value) => {
217 const next = new URLSearchParams(search);
218 if (value === "outside") next.set("tab", "outside");
219 else next.delete("tab");
220 setSearch(next, { replace: true, preventScrollReset: true });
221 }}
222 >
223 <TabsList className="mb-3">
224 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
225 <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger>
226 </TabsList>
227 <TabsContent value="members">{membersTab}</TabsContent>
228 <TabsContent value="outside">
229 <OutsideCollaborators
230 people={outside}
231 slug={params.owner}
232 error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
233 />
234 </TabsContent>
235 </Tabs>
236 ) : (
237 membersTab
238 )}
Agents as a team: lifecycle, merge queue, billing and a new shell239 </div>
240 );
241}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look242
243/** What members get on every repository, which owners choose. */
244function BasePermissionSection({
245 base,
246 owner,
247 slug,
248 error,
249 saved,
250}: {
251 base: BasePermission;
252 owner: boolean;
253 slug: string;
254 error: string | null;
255 saved: boolean;
256}) {
257 const [chosen, setChosen] = useState<BasePermission>(base);
258 return (
259 <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8">
260 <h2 className="font-medium">Base permission</h2>
261 <p className="mt-1 text-sm text-muted">
262 The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to
263 someone on one repository adds to this; it never takes away.
264 </p>
265 {owner ? (
266 <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4">
267 <input type="hidden" name="action" value="base-permission" />
268 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
269 <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
270 <SelectTrigger aria-label="Base permission" className="sm:max-w-48">
271 <SelectValue />
272 </SelectTrigger>
273 <SelectContent>
274 {BASE_PERMISSIONS.map((value) => (
275 <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
276 {BASE_PERMISSION_LABELS[value]}
277 </SelectItem>
278 ))}
279 </SelectContent>
280 </Select>
Merge branch 'worktree-agent-a985c01f43538af01'281 <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}>
282 Save
283 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look284 {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>}
285 </div>
286 <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p>
287 <ErrorText>{error}</ErrorText>
288 </Form>
289 ) : (
290 <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm">
291 <p>
292 <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "}
293 <span className="text-muted">{BASE_MEANS[base]}</span>
294 </p>
295 <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p>
296 </div>
297 )}
298 </section>
299 );
300}
301
302/** People with a role on some of the workspace's repositories who are not members of it. */
303function OutsideCollaborators({
304 people,
305 slug,
306 error,
307}: {
308 people: Route.ComponentProps["loaderData"]["outside"];
309 slug: string;
310 error: string | null | undefined;
311}) {
312 return (
313 <>
314 <p className="mb-4 text-sm text-muted">
315 People given a role on one or more of {slug}'s repositories without being members. They see only those
316 repositories. Change or take away a role on the repository's Access settings.
317 </p>
318 {people.length === 0 ? (
319 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
320 <Users size={18} className="mx-auto text-faint" />
321 <p className="mt-2 text-sm font-medium">No outside collaborators</p>
322 <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p>
323 </div>
324 ) : (
325 <ul className="divide-y divide-line rounded-xl border border-line">
326 {people.map((person) => (
327 <li key={person.username} className="px-4 py-3">
328 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
329 <Avatar name={person.username} image={person.avatar} size={28} />
330 <div className="min-w-0 grow basis-32">
331 <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
332 {person.username}
333 </Link>
334 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
335 </div>
336 <Form method="post">
337 <input type="hidden" name="action" value="convert" />
338 <input type="hidden" name="member" value={person.username} />
Merge branch 'worktree-agent-a985c01f43538af01'339 <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look340 Convert to member
Merge branch 'worktree-agent-a985c01f43538af01'341 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look342 </Form>
343 </div>
344 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
345 {person.repos.map((grant) => (
346 <li key={grant.repo}>
347 <Link
348 to={`/${grant.repo}/settings/access`}
349 className="group inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-xs transition-colors hover:border-line-strong hover:bg-surface"
350 >
351 <span className="font-mono text-fg/90">{grant.repo}</span>
352 <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span>
353 <ArrowUpRight size={11} className="text-faint group-hover:text-fg" />
354 </Link>
355 </li>
356 ))}
357 </ul>
358 </li>
359 ))}
360 </ul>
361 )}
362 <ErrorText>{error}</ErrorText>
363 </>
364 );
365}

This file's history is long; its oldest lines are credited to the oldest commit read.