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