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.

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