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