Skip to content
628 linesCodeBlameRaw
1import { ArrowUpRight, Check, Ellipsis, ShieldAlert, ShieldCheck, Users } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router";
4
5import {
6 BASE_PERMISSIONS,
7 BASE_PERMISSION_LABELS,
8 type BasePermission,
9 DEFAULT_BASE_PERMISSION,
10 type Member,
11 ORG_ROLES,
12 ORG_ROLE_LABELS,
13 ORG_ROLE_SUMMARIES,
14 type OrgRole,
15 REPO_ROLE_LABELS,
16} from "@g1t/contracts";
17
18import type { Route } from "./+types/people";
19import { page } from "../../lib/meta";
20import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui";
21import { Badge } from "../../components/ui/badge";
22import {
23 AlertDialog,
24 AlertDialogCancel,
25 AlertDialogContent,
26 AlertDialogDescription,
27 AlertDialogFooter,
28 AlertDialogHeader,
29 AlertDialogTitle,
30} from "../../components/ui/alert-dialog";
31import {
32 DropdownMenu,
33 DropdownMenuContent,
34 DropdownMenuItem,
35 DropdownMenuLabel,
36 DropdownMenuSeparator,
37 DropdownMenuTrigger,
38} from "../../components/ui/dropdown-menu";
39import { Hint } from "../../components/ui/hint";
40import { forgetWorkspace } from "../../lib/workspace-choice";
41import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
42import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
43import { inviteLink, inviteState, moreInvitesMailto } from "../../lib/invites";
44import { billing, identity } from "../../lib/services.server";
45import { StartPlanToInvite } from "../../components/start-plan";
46import {
47 assertSameOrigin,
48 getViewer,
49 requireUser,
50 roleIn,
51 unwrap,
52} from "../../lib/session.server";
53import { UserCard } from "../../components/user-card";
54
55export function meta({ params, ...args }: Route.MetaArgs) {
56 return page(args, { title: `People · ${params.owner} · g1t` });
57}
58
59export async function loader({ params, context, request }: Route.LoaderArgs) {
60 const viewer = getViewer(context);
61 const role = roleIn(viewer, params.owner);
62 // Who is in a workspace is its members' business; anyone else, including
63 // someone a repository is shared with, gets nothing here.
64 if (!role) throw data(null, { status: 404 });
65 const owner = role === "owner";
66 const [members, invites, workspace, outside, teams, free] = await Promise.all([
67 identity.listMembers(params.owner, viewer),
68 owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
69 identity.getWorkspace(params.owner),
70 owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null,
71 // Each member's teams, as the viewer may see them.
72 identity.teamMemberships(viewer, params.owner).catch(() => null),
73 // A free workspace adds no one until it starts the plan; identity
74 // refuses it either way, so a failure here only hides the note.
75 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
76 ]);
77 return {
78 role,
79 free: free.includes(params.owner.toLowerCase()),
80 members: unwrap(members),
81 invites: invites?.ok ? invites.value : [],
82 base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION,
83 outside: outside?.ok ? outside.value : [],
84 teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])),
85 origin: new URL(request.url).origin,
86 me: viewer?.username ?? null,
87 };
88}
89
90export async function action({ request, params, context }: Route.ActionArgs) {
91 assertSameOrigin(request);
92 const user = requireUser(context, request);
93 const form = await request.formData();
94 if (form.get("action") === "revoke-invite") {
95 const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? ""));
96 return result.ok ? null : { error: result.error.message };
97 }
98 const target = String(form.get("member") ?? "").trim();
99 // Owner or member, and the roles that add to a member.
100 if (form.get("action") === "role") {
101 const role = form.get("role") === "owner" ? "owner" : "member";
102 const result = await identity.updateMember(user, params.owner, target, { role });
103 return result.ok ? { changed: target } : { error: result.error.message, row: target };
104 }
105 if (form.get("action") === "org-roles") {
106 const roles = String(form.get("org_roles") ?? "")
107 .split(",")
108 .filter((role): role is OrgRole => (ORG_ROLES as readonly string[]).includes(role));
109 const result = await identity.updateMember(user, params.owner, target, { org_roles: roles });
110 return result.ok ? { changed: target } : { error: result.error.message, row: target };
111 }
112 if (form.get("action") === "transfer") {
113 const result = await identity.transferOwnership(user, params.owner, target);
114 return result.ok ? { transferred: target } : { error: result.error.message, row: target };
115 }
116 if (form.get("action") === "leave") {
117 const result = await identity.leaveWorkspace(user, params.owner);
118 if (!result.ok) return { error: result.error.message, leaving: true };
119 const secure = new URL(request.url).protocol === "https:";
120 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
121 }
122 if (form.get("action") === "base-permission") {
123 const base = String(form.get("base") ?? "");
124 if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." };
125 const result = await identity.setBasePermission(user, params.owner, base as BasePermission);
126 return result.ok ? { based: result.value } : { error: result.error.message, base: true };
127 }
128 const member = String(form.get("member") ?? "").trim();
129 // An address is invited by email; a username is added at once.
130 if (form.get("action") !== "remove" && member.includes("@")) {
131 const result = await identity.inviteMember(user, params.owner, member);
132 return result.ok ? { invited: result.value.email, outOfInvites: false } : { error: result.error.message, outOfInvites: result.error.code === "limit" };
133 }
134 const result =
135 form.get("action") === "remove"
136 ? await identity.removeMember(user, params.owner, member)
137 : await identity.addMember(user, params.owner, member);
138 if (!result.ok) return { error: result.error.message, converting: form.get("action") === "convert" };
139 return form.get("action") === "convert" ? { converted: member } : null;
140}
141
142/** What the base permission means for members, in a sentence. */
143const BASE_MEANS: Record<BasePermission, string> = {
144 none: "Members see only public repositories and the ones they are given a role on.",
145 read: "Members can see and clone every repository, open issues and pull requests, and comment.",
146 write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.",
147 admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.",
148};
149
150/** What a member's roles say about them, as badges: owner or member, then the roles on top. */
151function RoleBadges({ member }: { member: Member }) {
152 return (
153 <span className="flex flex-wrap justify-end gap-1">
154 {member.role === "owner" ? <Badge tone="accent">Owner</Badge> : <Badge>Member</Badge>}
155 {(member.org_roles ?? []).map((role) => (
156 <Hint key={role} label={ORG_ROLE_SUMMARIES[role]}>
157 <Badge tone="info" tabIndex={0}>
158 {ORG_ROLE_LABELS[role]}
159 </Badge>
160 </Hint>
161 ))}
162 </span>
163 );
164}
165
166/** Whether a member has two-factor authentication on, for owners. */
167function TwoFactorMark({ on }: { on: boolean | null | undefined }) {
168 if (on == null) return null;
169 return on ? (
170 <Hint label="Two-factor authentication is on">
171 <span tabIndex={0} className="inline-flex text-success" aria-label="Two-factor authentication on">
172 <ShieldCheck size={15} />
173 </span>
174 </Hint>
175 ) : (
176 <Hint label="Two-factor authentication is off">
177 <span tabIndex={0} className="inline-flex text-warn" aria-label="Two-factor authentication off">
178 <ShieldAlert size={15} />
179 </span>
180 </Hint>
181 );
182}
183
184/** An owner's menu for one member: their role, the roles on top, handing over and removing. */
185function MemberMenu({ member, self, owners, slug }: { member: Member; self: boolean; owners: number; slug: string }) {
186 const submit = useSubmit();
187 const [confirm, setConfirm] = useState<"transfer" | "remove" | null>(null);
188 const roles = member.org_roles ?? [];
189 const post = (fields: Record<string, string>) =>
190 submit({ ...fields, member: member.username }, { method: "post", preventScrollReset: true });
191 const lastOwner = member.role === "owner" && owners <= 1;
192 return (
193 <>
194 <DropdownMenu>
195 <DropdownMenuTrigger asChild>
196 <button
197 type="button"
198 aria-label={`Manage ${member.username}`}
199 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
200 >
201 <Ellipsis size={16} />
202 </button>
203 </DropdownMenuTrigger>
204 <DropdownMenuContent align="end" className="w-64">
205 <DropdownMenuLabel>Role in {slug}</DropdownMenuLabel>
206 {member.role === "member" ? (
207 <DropdownMenuItem onSelect={() => post({ action: "role", role: "owner" })}>Make owner</DropdownMenuItem>
208 ) : (
209 <DropdownMenuItem disabled={lastOwner} onSelect={() => post({ action: "role", role: "member" })}>
210 {lastOwner ? "The only owner" : "Make member"}
211 </DropdownMenuItem>
212 )}
213 <DropdownMenuSeparator />
214 <DropdownMenuLabel>Also</DropdownMenuLabel>
215 {ORG_ROLES.map((role) => {
216 const has = roles.includes(role);
217 const next = has ? roles.filter((other) => other !== role) : [...roles, role];
218 return (
219 <DropdownMenuItem key={role} onSelect={() => post({ action: "org-roles", org_roles: next.join(",") })}>
220 <span className="flex size-4 items-center justify-center">{has && <Check />}</span>
221 {ORG_ROLE_LABELS[role]}
222 </DropdownMenuItem>
223 );
224 })}
225 {!self && (
226 <>
227 <DropdownMenuSeparator />
228 {member.role === "member" && (
229 <DropdownMenuItem onSelect={() => setConfirm("transfer")}>Transfer ownership…</DropdownMenuItem>
230 )}
231 <DropdownMenuItem disabled={lastOwner} onSelect={() => setConfirm("remove")} className="text-danger">
232 Remove from {slug}…
233 </DropdownMenuItem>
234 </>
235 )}
236 </DropdownMenuContent>
237 </DropdownMenu>
238 <AlertDialog open={confirm !== null} onOpenChange={(open) => !open && setConfirm(null)}>
239 <AlertDialogContent>
240 <Form method="post" className="grid gap-4" onSubmit={() => setConfirm(null)}>
241 <input type="hidden" name="action" value={confirm === "transfer" ? "transfer" : "remove"} />
242 <input type="hidden" name="member" value={member.username} />
243 <AlertDialogHeader>
244 <AlertDialogTitle>
245 {confirm === "transfer" ? `Hand ${slug} to ${member.username}?` : `Remove ${member.username} from ${slug}?`}
246 </AlertDialogTitle>
247 <AlertDialogDescription>
248 {confirm === "transfer"
249 ? `${member.username} becomes an owner and you a member. Only an owner can make you an owner again.`
250 : `Their roles on ${slug}'s repositories and their place in its teams go too. To keep them on a repository, add them back to it as an outside collaborator.`}
251 </AlertDialogDescription>
252 </AlertDialogHeader>
253 <AlertDialogFooter>
254 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
255 <SubmitButton variant="danger" match={{ action: confirm ?? "", member: member.username }} pending="Working…">
256 {confirm === "transfer" ? "Transfer ownership" : "Remove"}
257 </SubmitButton>
258 </AlertDialogFooter>
259 </Form>
260 </AlertDialogContent>
261 </AlertDialog>
262 </>
263 );
264}
265
266/** Leaving the workspace, for anyone in it. */
267function LeaveSection({ slug, soleOwner, error }: { slug: string; soleOwner: boolean; error: string | null }) {
268 const [open, setOpen] = useState(false);
269 return (
270 <section id="leave" className="mt-10 scroll-mt-20 border-t border-line pt-8">
271 <h2 className="font-medium">Leave {slug}</h2>
272 <p className="mt-1 text-sm text-muted">
273 {soleOwner
274 ? `You are ${slug}'s only owner. Make another member an owner first, or delete the workspace in its settings.`
275 : "Your roles on its repositories and your place in its teams go too. An owner can add you again."}
276 </p>
277 <div className="mt-4">
278 <Button variant="danger" type="button" disabled={soleOwner} onClick={() => setOpen(true)}>
279 Leave {slug}
280 </Button>
281 </div>
282 <ErrorText>{error}</ErrorText>
283 <AlertDialog open={open} onOpenChange={setOpen}>
284 <AlertDialogContent>
285 <Form method="post" className="grid gap-4">
286 <input type="hidden" name="action" value="leave" />
287 <AlertDialogHeader>
288 <AlertDialogTitle>Leave {slug}?</AlertDialogTitle>
289 <AlertDialogDescription>You lose access to its private repositories at once.</AlertDialogDescription>
290 </AlertDialogHeader>
291 <AlertDialogFooter>
292 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
293 <SubmitButton variant="danger" match={{ action: "leave" }} pending="Leaving…">
294 Leave
295 </SubmitButton>
296 </AlertDialogFooter>
297 </Form>
298 </AlertDialogContent>
299 </AlertDialog>
300 </section>
301 );
302}
303
304export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) {
305 const { role, members, invites, origin, base, outside, teams, free, me } = loaderData;
306 const owner = role === "owner";
307 const owners = members.filter((member) => member.role === "owner").length;
308 const rowError = (username: string) =>
309 actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null;
310 // Waiting to be used, or used by someone still confirming their email:
311 // either can be revoked.
312 const pending = invites.filter((invite) => invite.status === "pending" || invite.status === "awaiting_confirmation");
313 const [search, setSearch] = useSearchParams();
314 const tab = search.get("tab") === "outside" && owner ? "outside" : "members";
315 const membersTab = (
316 <>
317 <ul className="divide-y divide-line rounded-xl border border-line">
318 {members.map((member) => (
319 <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3">
320 <Avatar name={member.username} image={member.avatar} size={28} />
321 <div className="min-w-0 grow truncate">
322 <UserCard username={member.username}>
323 <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
324 {member.username}
325 </Link>
326 </UserCard>
327 {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
328 {(teams[member.username] ?? []).length > 0 && (
329 <div className="mt-1 flex flex-wrap gap-1">
330 {(teams[member.username] ?? []).slice(0, 3).map((team) => (
331 <Link
332 key={team.slug}
333 to={`/${params.owner}/-/teams/${team.slug}`}
334 className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
335 >
336 {team.name}
337 </Link>
338 ))}
339 {(teams[member.username] ?? []).length > 3 && (
340 <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span>
341 )}
342 </div>
343 )}
344 </div>
345 {/* Together, so on a narrow screen they move under the name as one. */}
346 <div className="ml-auto flex items-center gap-2">
347 {owner && <TwoFactorMark on={member.two_factor} />}
348 <RoleBadges member={member} />
349 {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />}
350 </div>
351 {rowError(member.username) && (
352 <p className="basis-full text-sm text-danger" role="alert">
353 {rowError(member.username)}
354 </p>
355 )}
356 </li>
357 ))}
358 </ul>
359 {owner && free && (
360 <div className="mt-6">
361 <StartPlanToInvite workspace={params.owner} owner={owner} />
362 </div>
363 )}
364 {owner && !free && (
365 // Empty again once the person is on the list; kept as typed when it failed.
366 <Form
367 method="post"
368 key={`${members.length}:${pending.length}`}
369 className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start"
370 >
371 <input type="hidden" name="action" value="add" />
372 <div className="grow">
373 <Field
374 label="Add a member"
375 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."
376 >
377 <Input name="member" required maxLength={254} placeholder="username or name@example.com" />
378 </Field>
379 </div>
380 <div className="sm:pt-[1.625rem]">
381 <SubmitButton match={{ action: "add" }} pending="Adding…">
382 Add
383 </SubmitButton>
384 </div>
385 </Form>
386 )}
387 {actionData && "invited" in actionData && actionData.invited && (
388 <p className="text-sm text-muted" role="status">
389 Invite sent to <span className="text-fg">{actionData.invited}</span>.
390 </p>
391 )}
392 {actionData && "transferred" in actionData && (
393 <p className="text-sm text-muted" role="status">
394 <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member.
395 </p>
396 )}
397 {actionData && "converted" in actionData && (
398 <p className="text-sm text-muted" role="status">
399 <span className="font-mono text-fg">{actionData.converted}</span> is now a member of {params.owner}.
400 </p>
401 )}
402 {actionData &&
403 !("base" in actionData) &&
404 !("row" in actionData) &&
405 !("leaving" in actionData) &&
406 !("converting" in actionData && actionData.converting) && (
407 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
408 )}
409 {actionData && "outOfInvites" in actionData && actionData.outOfInvites && (
410 <p className="mt-1 text-sm text-muted">
411 <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4">
412 Ask for more invites
413 </a>{" "}
414 for {params.owner}.
415 </p>
416 )}
417
418 {owner && pending.length > 0 && (
419 <section className="mt-8">
420 <h2 className="text-sm font-medium">Pending invites</h2>
421 <ul className="mt-3 divide-y divide-line rounded-xl border border-line">
422 {pending.map((invite) => (
423 <li key={invite.id} className="space-y-2 px-4 py-3">
424 <div className="flex flex-wrap items-center gap-3">
425 <span className="min-w-0 grow truncate text-sm">{invite.email}</span>
426 <Pill>{inviteState(invite).label}</Pill>
427 <Form method="post">
428 <input type="hidden" name="action" value="revoke-invite" />
429 <input type="hidden" name="id" value={invite.id} />
430 <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…">
431 Revoke
432 </SubmitButton>
433 </Form>
434 </div>
435 <p className="text-xs text-faint">
436 By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} />
437 </p>
438 {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
439 </li>
440 ))}
441 </ul>
442 </section>
443 )}
444
445 <BasePermissionSection
446 // Starts from the saved choice whenever it changes.
447 key={base}
448 base={base}
449 owner={owner}
450 slug={params.owner}
451 error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null}
452 saved={Boolean(actionData && "based" in actionData)}
453 />
454
455 <LeaveSection
456 slug={params.owner}
457 soleOwner={owner && owners <= 1}
458 error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null}
459 />
460 </>
461 );
462 return (
463 <div className="max-w-2xl">
464 {owner ? (
465 <Tabs
466 value={tab}
467 onValueChange={(value) => {
468 const next = new URLSearchParams(search);
469 if (value === "outside") next.set("tab", "outside");
470 else next.delete("tab");
471 setSearch(next, { replace: true, preventScrollReset: true });
472 }}
473 >
474 <TabsList className="mb-3">
475 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
476 <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger>
477 </TabsList>
478 <TabsContent value="members">{membersTab}</TabsContent>
479 <TabsContent value="outside">
480 <OutsideCollaborators
481 people={outside}
482 slug={params.owner}
483 free={free}
484 error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
485 />
486 </TabsContent>
487 </Tabs>
488 ) : (
489 membersTab
490 )}
491 </div>
492 );
493}
494
495/** What members get on every repository, which owners choose. */
496function BasePermissionSection({
497 base,
498 owner,
499 slug,
500 error,
501 saved,
502}: {
503 base: BasePermission;
504 owner: boolean;
505 slug: string;
506 error: string | null;
507 saved: boolean;
508}) {
509 const [chosen, setChosen] = useState<BasePermission>(base);
510 return (
511 <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8">
512 <h2 className="font-medium">Base permission</h2>
513 <p className="mt-1 text-sm text-muted">
514 The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to
515 someone on one repository adds to this; it never takes away.
516 </p>
517 {owner ? (
518 <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4">
519 <input type="hidden" name="action" value="base-permission" />
520 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
521 <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
522 <SelectTrigger aria-label="Base permission" className="sm:max-w-48">
523 <SelectValue />
524 </SelectTrigger>
525 <SelectContent>
526 {BASE_PERMISSIONS.map((value) => (
527 <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
528 {BASE_PERMISSION_LABELS[value]}
529 </SelectItem>
530 ))}
531 </SelectContent>
532 </Select>
533 <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}>
534 Save
535 </SubmitButton>
536 {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>}
537 </div>
538 <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p>
539 <ErrorText>{error}</ErrorText>
540 </Form>
541 ) : (
542 <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm">
543 <p>
544 <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "}
545 <span className="text-muted">{BASE_MEANS[base]}</span>
546 </p>
547 <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p>
548 </div>
549 )}
550 </section>
551 );
552}
553
554/** People with a role on some of the workspace's repositories who are not members of it. */
555function OutsideCollaborators({
556 people,
557 slug,
558 free,
559 error,
560}: {
561 people: Route.ComponentProps["loaderData"]["outside"];
562 slug: string;
563 free: boolean;
564 error: string | null | undefined;
565}) {
566 return (
567 <>
568 <p className="mb-4 text-sm text-muted">
569 People given a role on one or more of {slug}'s repositories without being members. They see only those
570 repositories. Change or take away a role on the repository's Access settings.
571 </p>
572 {free && (
573 <div className="mb-4">
574 <StartPlanToInvite workspace={slug} owner />
575 </div>
576 )}
577 {people.length === 0 ? (
578 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
579 <Users size={18} className="mx-auto text-faint" />
580 <p className="mt-2 text-sm font-medium">No outside collaborators</p>
581 <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p>
582 </div>
583 ) : (
584 <ul className="divide-y divide-line rounded-xl border border-line">
585 {people.map((person) => (
586 <li key={person.username} className="px-4 py-3">
587 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
588 <Avatar name={person.username} image={person.avatar} size={28} />
589 <div className="min-w-0 grow basis-32">
590 <UserCard username={person.username}>
591 <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
592 {person.username}
593 </Link>
594 </UserCard>
595 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
596 </div>
597 {!free && (
598 <Form method="post">
599 <input type="hidden" name="action" value="convert" />
600 <input type="hidden" name="member" value={person.username} />
601 <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…">
602 Convert to member
603 </SubmitButton>
604 </Form>
605 )}
606 </div>
607 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
608 {person.repos.map((grant) => (
609 <li key={grant.repo}>
610 <Link
611 to={`/${grant.repo}/settings/access`}
612 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"
613 >
614 <span className="font-mono text-fg/90">{grant.repo}</span>
615 <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span>
616 <ArrowUpRight size={11} className="text-faint group-hover:text-fg" />
617 </Link>
618 </li>
619 ))}
620 </ul>
621 </li>
622 ))}
623 </ul>
624 )}
625 <ErrorText>{error}</ErrorText>
626 </>
627 );
628}