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.

619 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>) => submit({ ...fields, member: member.username }, { method: "post", preventScrollReset: true });
173 const lastOwner = member.role === "owner" && owners <= 1;
174 return (
175 <>
176 <DropdownMenu>
177 <DropdownMenuTrigger asChild>
178 <Button type="button" aria-label={`Manage ${member.username}`} variant="ghost" size="inline" className="p-1.5 text-faint max-sm:p-2.5">
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" && <DropdownMenuItem onSelect={() => setConfirm("transfer")}>Transfer ownership…</DropdownMenuItem>}
207 <DropdownMenuItem disabled={lastOwner} onSelect={() => setConfirm("remove")} className="text-danger">
208 Remove from {slug}…
209 </DropdownMenuItem>
210 </>
211 )}
212 </DropdownMenuContent>
213 </DropdownMenu>
214 <AlertDialog open={confirm !== null} onOpenChange={(open) => !open && setConfirm(null)}>
215 <AlertDialogContent>
216 <Form method="post" className="grid gap-4" onSubmit={() => setConfirm(null)}>
217 <input type="hidden" name="action" value={confirm === "transfer" ? "transfer" : "remove"} />
218 <input type="hidden" name="member" value={member.username} />
219 <AlertDialogHeader>
220 <AlertDialogTitle>{confirm === "transfer" ? `Hand ${slug} to ${member.username}?` : `Remove ${member.username} from ${slug}?`}</AlertDialogTitle>
221 <AlertDialogDescription>
222 {confirm === "transfer"
223 ? `${member.username} becomes an owner and you a member. Only an owner can make you an owner again.`
224 : `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.`}
225 </AlertDialogDescription>
226 </AlertDialogHeader>
227 <AlertDialogFooter>
228 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
229 <SubmitButton variant="destructive" match={{ action: confirm ?? "", member: member.username }} pending="Working…">
230 {confirm === "transfer" ? "Transfer ownership" : "Remove"}
231 </SubmitButton>
232 </AlertDialogFooter>
233 </Form>
234 </AlertDialogContent>
235 </AlertDialog>
236 </>
237 );
238}
239
240/** Leaving the workspace, for anyone in it. */
241function LeaveSection({ slug, soleOwner, error }: { slug: string; soleOwner: boolean; error: string | null }) {
242 const [open, setOpen] = useState(false);
243 return (
244 <section id="leave" className="mt-10 scroll-mt-20 border-t border-line pt-8">
245 <h2 className="font-medium">Leave {slug}</h2>
246 <p className="mt-1 text-sm text-muted">
247 {soleOwner
248 ? `You are ${slug}'s only owner. Make another member an owner first, or delete the workspace in its settings.`
249 : "Your roles on its repositories and your place in its teams go too. An owner can add you again."}
250 </p>
251 <div className="mt-4">
252 <Button variant="destructive" type="button" disabled={soleOwner} onClick={() => setOpen(true)}>
253 Leave {slug}
254 </Button>
255 </div>
256 <ErrorText>{error}</ErrorText>
257 <AlertDialog open={open} onOpenChange={setOpen}>
258 <AlertDialogContent>
259 <Form method="post" className="grid gap-4">
260 <input type="hidden" name="action" value="leave" />
261 <AlertDialogHeader>
262 <AlertDialogTitle>Leave {slug}?</AlertDialogTitle>
263 <AlertDialogDescription>You lose access to its private repositories at once.</AlertDialogDescription>
264 </AlertDialogHeader>
265 <AlertDialogFooter>
266 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
267 <SubmitButton variant="destructive" match={{ action: "leave" }} pending="Leaving…">
268 Leave
269 </SubmitButton>
270 </AlertDialogFooter>
271 </Form>
272 </AlertDialogContent>
273 </AlertDialog>
274 </section>
275 );
276}
277
278/**
279 * The row above the list: words to search by, the role, two-factor (for
280 * owners) and a team, each kept in the address so a filtered list can be
281 * shared; and, for owners, Invite.
282 */
283function MembersToolbar({
284 owner,
285 teams,
286 invite,
287}: {
288 owner: boolean;
289 /** The teams any listed member is on, to filter by. */
290 teams: { slug: string; name: string }[];
291 invite: ReactNode;
292}) {
293 const [search, setSearch] = useSearchParams();
294 const filters = memberFilters(search);
295 const [words, setWords] = useState(filters.query);
296 // The box is typed into freely; the address follows a moment later.
297 useEffect(() => {
298 if (words === filters.query) return;
299 const timer = setTimeout(() => {
300 const next = new URLSearchParams(search);
301 if (words.trim()) next.set("q", words.trim());
302 else next.delete("q");
303 setSearch(next, { replace: true, preventScrollReset: true });
304 }, 200);
305 return () => clearTimeout(timer);
306 }, [words, filters.query, search, setSearch]);
307 const set = (key: string, value: string) => {
308 const next = new URLSearchParams(search);
309 if (value && value !== "all") next.set(key, value);
310 else next.delete(key);
311 setSearch(next, { replace: true, preventScrollReset: true });
312 };
313 return (
314 <div className="mb-4 flex flex-wrap items-center gap-2">
315 <div className="relative min-w-0 grow basis-56">
316 <Search size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
317 <Input
318 type="search"
319 aria-label="Search members"
320 placeholder="Search by username or name"
321 value={words}
322 onChange={(event) => setWords(event.currentTarget.value)}
323 className="pl-9"
324 />
325 </div>
326 <SelectField
327 aria-label="Role"
328 value={filters.role}
329 onValueChange={(value) => set("role", value)}
330 className="w-auto min-w-28"
331 options={[
332 { value: "all", label: "Any role" },
333 { value: "owner", label: "Owners" },
334 { value: "member", label: "Members" },
335 ]}
336 />
337 {owner && (
338 <SelectField
339 aria-label="Two-factor authentication"
340 value={filters.twoFactor}
341 onValueChange={(value) => set("2fa", value)}
342 className="w-auto min-w-28"
343 options={[
344 { value: "all", label: "Two-factor: any" },
345 { value: "on", label: "Two-factor on" },
346 { value: "off", label: "Two-factor off" },
347 ]}
348 />
349 )}
350 {teams.length > 0 && (
351 <SelectField
352 aria-label="Team"
353 value={filters.team ?? "all"}
354 onValueChange={(value) => set("team", value)}
355 className="w-auto min-w-28"
356 options={[{ value: "all", label: "Any team" }, ...teams.map((team) => ({ value: team.slug.toLowerCase(), label: team.name }))]}
357 />
358 )}
359 {invite && <div className="ml-auto">{invite}</div>}
360 </div>
361 );
362}
363
364export default function WorkspaceMembers({ loaderData, actionData, params }: Route.ComponentProps) {
365 const { role, members, outside, teams, teamsFailed, free, me, name, pendingInvitations } = loaderData;
366 const owner = role === "owner";
367 const owners = members.filter((member) => member.role === "owner").length;
368 const rowError = (username: string) => (actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null);
369 const [search, setSearch] = useSearchParams();
370 const tab = search.get("tab") === "outside" && owner ? "outside" : "members";
371 const filters = memberFilters(search);
372 const shown = filterMembers(members, filters, teams);
373 const filtered = filters.role !== "all" || filters.twoFactor !== "all" || filters.team !== null || filters.query !== "";
374 // Every team a listed member is on, once each, to filter by.
375 const teamChoices = [
376 ...new Map(
377 Object.values(teams)
378 .flat()
379 .map((team) => [team.slug.toLowerCase(), team]),
380 ).values(),
381 ].sort((a, b) => a.name.localeCompare(b.name));
382 // Who was just invited from the dialog, said under the list.
383 const [invited, setInvited] = useState<string | null>(null);
384 const invite = owner && !free ? <InviteDialog name={name} action={`/${params.owner}/-/members`} onInvited={setInvited} /> : null;
385 const membersTab = (
386 <>
387 <MembersToolbar owner={owner} teams={teamChoices} invite={invite} />
388 {teamsFailed && (
389 <div className="mb-4">
390 <Unavailable what={teamsFailed.what} compact />
391 </div>
392 )}
393 {owner && free && (
394 <div className="mb-4">
395 <StartPlanToInvite workspace={params.owner} owner={owner} />
396 </div>
397 )}
398 {shown.length === 0 ? (
399 <EmptyState title={filtered ? "No members match" : "No members yet"}>
400 {filtered ? "Clear a filter, or search for something else." : "Invite someone and they are listed here once they accept."}
401 </EmptyState>
402 ) : (
403 <Card asChild tone="plain" divided>
404 <ul>
405 {shown.map((member) => (
406 <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3">
407 <WithPresence person={{ username: member.username }} size={28}>
408 <Avatar name={member.username} image={member.avatar} size={28} />
409 </WithPresence>
410 <div className="min-w-0 grow truncate">
411 <UserCard username={member.username}>
412 <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
413 {member.username}
414 </Link>
415 </UserCard>
416 <PersonStatusEmoji person={{ username: member.username }} size={13} className="ml-1.5 align-[-2px]" />
417 {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
418 {(teams[member.username] ?? []).length > 0 && (
419 <div className="mt-1 flex flex-wrap gap-1">
420 {(teams[member.username] ?? []).slice(0, 3).map((team) => (
421 <Link
422 key={team.slug}
423 to={`/${params.owner}/-/teams/${team.slug}`}
424 className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
425 >
426 {team.name}
427 </Link>
428 ))}
429 {(teams[member.username] ?? []).length > 3 && (
430 <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span>
431 )}
432 </div>
433 )}
434 </div>
435 {/* Together, so on a narrow screen they move under the name as one. */}
436 <div className="ml-auto flex items-center gap-2">
437 {owner && <TwoFactorMark on={member.two_factor} />}
438 <RoleBadges member={member} />
439 {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />}
440 </div>
441 {rowError(member.username) && (
442 <p className="basis-full text-sm text-danger" role="alert">
443 {rowError(member.username)}
444 </p>
445 )}
446 </li>
447 ))}
448 </ul>
449 </Card>
450 )}
451 <p className="mt-2 text-xs text-faint">
452 {filtered ? `${shown.length} of ${members.length}` : members.length} {members.length === 1 ? "member" : "members"}
453 {owner && pendingInvitations === null && (
454 <>
455 {" · "}
456 <Link
457 to={`/${params.owner}/-/members/invitations?state=pending`}
458 className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline"
459 >
460 <Mail size={12} aria-hidden="true" />
461 Invitations
462 </Link>
463 </>
464 )}
465 {owner && pendingInvitations != null && pendingInvitations > 0 && (
466 <>
467 {" · "}
468 <Link
469 to={`/${params.owner}/-/members/invitations?state=pending`}
470 className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline"
471 >
472 <Mail size={12} aria-hidden="true" />
473 {pendingInvitations} {pendingInvitations === 1 ? "invitation" : "invitations"} waiting
474 </Link>
475 </>
476 )}
477 </p>
478 {invited && (
479 <p className="mt-3 text-sm text-muted" role="status">
480 Invitation to join {name} sent to <span className="text-fg">{invited}</span>. They join once they accept; until then it is listed under{" "}
481 <Link to={`/${params.owner}/-/members/invitations`} className="text-accent underline-offset-4 hover:underline">
482 Invitations
483 </Link>
484 .
485 </p>
486 )}
487 {actionData && "transferred" in actionData && (
488 <p className="mt-3 text-sm text-muted" role="status">
489 <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member.
490 </p>
491 )}
492 {actionData && "converted" in actionData && (
493 <p className="mt-3 text-sm text-muted" role="status">
494 <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept.
495 </p>
496 )}
497 {actionData &&
498 "error" in actionData &&
499 !("row" in actionData) &&
500 !("leaving" in actionData) &&
501 !("converting" in actionData && actionData.converting) && <ErrorText>{actionData.error}</ErrorText>}
502
503 <LeaveSection slug={params.owner} soleOwner={owner && owners <= 1} error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null} />
504 </>
505 );
506 return (
507 <div className="max-w-3xl">
508 {owner ? (
509 <Tabs
510 value={tab}
511 onValueChange={(value) => {
512 const next = new URLSearchParams(search);
513 if (value === "outside") next.set("tab", "outside");
514 else next.delete("tab");
515 setSearch(next, { replace: true, preventScrollReset: true });
516 }}
517 >
518 <TabsList className="mb-4">
519 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
520 <TabsTrigger value="outside">{isFailed(outside) ? "Outside collaborators" : `Outside collaborators · ${outside.length}`}</TabsTrigger>
521 </TabsList>
522 <TabsContent value="members">{membersTab}</TabsContent>
523 <TabsContent value="outside">
524 {isFailed(outside) ? (
525 <Unavailable what={outside.what} />
526 ) : (
527 <OutsideCollaborators
528 people={outside}
529 slug={params.owner}
530 free={free}
531 error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
532 />
533 )}
534 </TabsContent>
535 </Tabs>
536 ) : (
537 membersTab
538 )}
539 </div>
540 );
541}
542
543/** People with a role on some of the workspace's repositories who are not members of it. */
544function OutsideCollaborators({
545 people,
546 slug,
547 free,
548 error,
549}: {
550 people: Exclude<Route.ComponentProps["loaderData"]["outside"], Failed>;
551 slug: string;
552 free: boolean;
553 error: string | null | undefined;
554}) {
555 return (
556 <>
557 <p className="mb-4 text-sm text-muted">
558 People given a role on one or more of {slug}'s repositories without being members. They see only those repositories. Change or take away a role on the
559 repository's Access settings.
560 </p>
561 {free && (
562 <div className="mb-4">
563 <StartPlanToInvite workspace={slug} owner />
564 </div>
565 )}
566 {people.length === 0 ? (
567 <Card tone="plain" className="border-dashed px-6 py-10 text-center">
568 <Users size={18} className="mx-auto text-faint" />
569 <p className="mt-2 text-sm font-medium">No outside collaborators</p>
570 <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p>
571 </Card>
572 ) : (
573 <Card asChild tone="plain" divided>
574 <ul>
575 {people.map((person) => (
576 <li key={person.username} className="px-4 py-3">
577 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
578 <Avatar name={person.username} image={person.avatar} size={28} />
579 <div className="min-w-0 grow basis-32">
580 <UserCard username={person.username}>
581 <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
582 {person.username}
583 </Link>
584 </UserCard>
585 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
586 </div>
587 {!free && (
588 <Form method="post">
589 <input type="hidden" name="action" value="convert" />
590 <input type="hidden" name="member" value={person.username} />
591 <SubmitButton variant="outline" match={{ action: "convert", member: person.username }} pending="Inviting…">
592 Invite as a member
593 </SubmitButton>
594 </Form>
595 )}
596 </div>
597 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
598 {person.repos.map((grant) => (
599 <li key={grant.repo}>
600 <Link
601 to={`/${grant.repo}/settings/access`}
602 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"
603 >
604 <span className="font-mono text-fg/90">{grant.repo}</span>
605 <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span>
606 <ArrowUpRight size={11} className="text-faint group-hover:text-fg" />
607 </Link>
608 </li>
609 ))}
610 </ul>
611 </li>
612 ))}
613 </ul>
614 </Card>
615 )}
616 <ErrorText>{error}</ErrorText>
617 </>
618 );
619}