Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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