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