356 lines15,701 bytesCodeBlame
1import { ArrowUpRight, Users } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, data, useNavigation, 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, Button, CopyLine, ErrorText, Field, Input, Pill, 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] = 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 ]);
46 return {
47 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,
53 };
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();
60 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 }
76 const result =
77 form.get("action") === "remove"
78 ? 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;
82}
83
84/** 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 <>
100 <ul className="divide-y divide-line rounded-xl border border-line">
101 {members.map((member) => (
102 <li key={member.username} className="flex items-center gap-3 px-4 py-3">
103 <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} />
118 <Button variant="quiet" type="submit">
119 Remove
120 </Button>
121 </Form>
122 )}
123 </span>
124 )}
125 </li>
126 ))}
127 </ul>
128 {owner && (
129 <Form method="post" className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start">
130 <div className="grow">
131 <Field
132 label="Add a member"
133 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."
134 >
135 <Input name="member" required maxLength={254} placeholder="username or name@example.com" />
136 </Field>
137 </div>
138 <div className="sm:pt-[1.625rem]">
139 <Button type="submit">Add</Button>
140 </div>
141 </Form>
142 )}
143 {actionData && "invited" in actionData && actionData.invited && (
144 <p className="text-sm text-muted" role="status">
145 Invite sent to <span className="text-fg">{actionData.invited}</span>.
146 </p>
147 )}
148 {actionData && "converted" in actionData && (
149 <p className="text-sm text-muted" role="status">
150 <span className="font-mono text-fg">{actionData.converted}</span> is now a member of {params.owner}.
151 </p>
152 )}
153 {actionData && !("base" in actionData) && !("converting" in actionData && actionData.converting) && (
154 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
155 )}
156 {actionData && "outOfInvites" in actionData && actionData.outOfInvites && (
157 <p className="mt-1 text-sm text-muted">
158 <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4">
159 Ask for more invites
160 </a>{" "}
161 for {params.owner}.
162 </p>
163 )}
164
165 {owner && pending.length > 0 && (
166 <section className="mt-8">
167 <h2 className="text-sm font-medium">Pending invites</h2>
168 <ul className="mt-3 divide-y divide-line rounded-xl border border-line">
169 {pending.map((invite) => (
170 <li key={invite.id} className="space-y-2 px-4 py-3">
171 <div className="flex flex-wrap items-center gap-3">
172 <span className="min-w-0 grow truncate text-sm">{invite.email}</span>
173 <Pill>{inviteState(invite).label}</Pill>
174 <Form method="post">
175 <input type="hidden" name="action" value="revoke-invite" />
176 <input type="hidden" name="id" value={invite.id} />
177 <Button variant="quiet" type="submit">
178 Revoke
179 </Button>
180 </Form>
181 </div>
182 <p className="text-xs text-faint">
183 By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} />
184 </p>
185 {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
186 </li>
187 ))}
188 </ul>
189 </section>
190 )}
191
192 <BasePermissionSection
193 base={base}
194 owner={owner}
195 slug={params.owner}
196 error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null}
197 saved={Boolean(actionData && "based" in actionData)}
198 />
199 </>
200 );
201 return (
202 <div className="max-w-2xl">
203 {owner ? (
204 <Tabs
205 value={tab}
206 onValueChange={(value) => {
207 const next = new URLSearchParams(search);
208 if (value === "outside") next.set("tab", "outside");
209 else next.delete("tab");
210 setSearch(next, { replace: true, preventScrollReset: true });
211 }}
212 >
213 <TabsList className="mb-3">
214 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
215 <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger>
216 </TabsList>
217 <TabsContent value="members">{membersTab}</TabsContent>
218 <TabsContent value="outside">
219 <OutsideCollaborators
220 people={outside}
221 slug={params.owner}
222 error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
223 />
224 </TabsContent>
225 </Tabs>
226 ) : (
227 membersTab
228 )}
229 </div>
230 );
231}
232
233/** What members get on every repository, which owners choose. */
234function BasePermissionSection({
235 base,
236 owner,
237 slug,
238 error,
239 saved,
240}: {
241 base: BasePermission;
242 owner: boolean;
243 slug: string;
244 error: string | null;
245 saved: boolean;
246}) {
247 const [chosen, setChosen] = useState<BasePermission>(base);
248 const saving = useNavigation().formData?.get("action") === "base-permission";
249 return (
250 <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8">
251 <h2 className="font-medium">Base permission</h2>
252 <p className="mt-1 text-sm text-muted">
253 The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to
254 someone on one repository adds to this; it never takes away.
255 </p>
256 {owner ? (
257 <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4">
258 <input type="hidden" name="action" value="base-permission" />
259 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
260 <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
261 <SelectTrigger aria-label="Base permission" className="sm:max-w-48">
262 <SelectValue />
263 </SelectTrigger>
264 <SelectContent>
265 {BASE_PERMISSIONS.map((value) => (
266 <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
267 {BASE_PERMISSION_LABELS[value]}
268 </SelectItem>
269 ))}
270 </SelectContent>
271 </Select>
272 <Button type="submit" variant="quiet" disabled={saving || chosen === base}>
273 {saving ? "Saving…" : "Save"}
274 </Button>
275 {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>}
276 </div>
277 <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p>
278 <ErrorText>{error}</ErrorText>
279 </Form>
280 ) : (
281 <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm">
282 <p>
283 <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "}
284 <span className="text-muted">{BASE_MEANS[base]}</span>
285 </p>
286 <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p>
287 </div>
288 )}
289 </section>
290 );
291}
292
293/** People with a role on some of the workspace's repositories who are not members of it. */
294function OutsideCollaborators({
295 people,
296 slug,
297 error,
298}: {
299 people: Route.ComponentProps["loaderData"]["outside"];
300 slug: string;
301 error: string | null | undefined;
302}) {
303 return (
304 <>
305 <p className="mb-4 text-sm text-muted">
306 People given a role on one or more of {slug}'s repositories without being members. They see only those
307 repositories. Change or take away a role on the repository's Access settings.
308 </p>
309 {people.length === 0 ? (
310 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
311 <Users size={18} className="mx-auto text-faint" />
312 <p className="mt-2 text-sm font-medium">No outside collaborators</p>
313 <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p>
314 </div>
315 ) : (
316 <ul className="divide-y divide-line rounded-xl border border-line">
317 {people.map((person) => (
318 <li key={person.username} className="px-4 py-3">
319 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
320 <Avatar name={person.username} image={person.avatar} size={28} />
321 <div className="min-w-0 grow basis-32">
322 <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
323 {person.username}
324 </Link>
325 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
326 </div>
327 <Form method="post">
328 <input type="hidden" name="action" value="convert" />
329 <input type="hidden" name="member" value={person.username} />
330 <Button variant="quiet" type="submit">
331 Convert to member
332 </Button>
333 </Form>
334 </div>
335 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
336 {person.repos.map((grant) => (
337 <li key={grant.repo}>
338 <Link
339 to={`/${grant.repo}/settings/access`}
340 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"
341 >
342 <span className="font-mono text-fg/90">{grant.repo}</span>
343 <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span>
344 <ArrowUpRight size={11} className="text-faint group-hover:text-fg" />
345 </Link>
346 </li>
347 ))}
348 </ul>
349 </li>
350 ))}
351 </ul>
352 )}
353 <ErrorText>{error}</ErrorText>
354 </>
355 );
356}