Skip to content

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

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