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.

687 linesCodeBlameRaw
1import { Mail, UserPlus, Users } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, Link, useFetcher } from "react-router";
4
5import {
6 BASE_PERMISSION_LABELS,
7 REPO_ROLES,
8 REPO_ROLE_LABELS,
9 type Collaborator,
10 type RepoInvitation,
11 type RepoRole,
12 type RepoTeam,
13 type Team,
14 baseRole,
15} from "@g1t/contracts";
16
17import type { Route } from "./+types/settings-access";
18import { RoleSelect, RolesTable } from "../../components/access";
19import { RepoSettingsHeading } from "../../components/repo-settings-heading";
20import { StartPlanToInvite } from "../../components/start-plan";
21import { SettingsSection as Section } from "../../components/settings-section";
22import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
23import { Badge } from "../../components/ui/badge";
24import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
25import {
26 AlertDialog,
27 AlertDialogCancel,
28 AlertDialogContent,
29 AlertDialogDescription,
30 AlertDialogFooter,
31 AlertDialogHeader,
32 AlertDialogTitle,
33} from "../../components/ui/alert-dialog";
34import { Avatar } from "../../components/ui/avatar";
35import { Button } from "../../components/ui/button";
36import { Card } from "../../components/ui/card";
37import { page } from "../../lib/meta";
38import { refusal, requireInsider } from "../../lib/access.server";
39import { billing, identity } from "../../lib/services.server";
40import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server";
41import { UserCard } from "../../components/user-card";
42import { Unavailable } from "../../components/boundary";
43import { isFailed, part } from "../../lib/partial";
44
45export function meta({ params, ...args }: Route.MetaArgs) {
46 return page(args, { title: `Access · ${params.owner}/${params.repo} · g1t` });
47}
48
49export async function loader({ params, context }: Route.LoaderArgs) {
50 // Write and up see who has access; Admins change it. To anyone without a
51 // role here the page does not exist.
52 const { viewer, access } = await requireInsider(context, params, "push");
53 const manage = access.can.manage_access;
54 // The workspace's teams, for Add team: only Admins add one, and a
55 // failure there says so in Add team's place, not as "no other team".
56 const [found, teams, free] = await Promise.all([
57 identity.repoAccess(params.owner, params.repo, viewer),
58 manage ? part(identity.listTeams(viewer, params.owner), "Teams to add") : Promise.resolve(null),
59 // A free workspace invites no one from outside; identity refuses it
60 // either way, so a failure here only hides the note.
61 manage ? billing.freeWorkspaces([params.owner]).catch(() => [] as string[]) : Promise.resolve([] as string[]),
62 ]);
63 return {
64 access: unwrap(found),
65 teams: isFailed(teams) ? teams : teams?.ok ? teams.value : ([] as Team[]),
66 manage,
67 free: free.includes(params.owner.toLowerCase()),
68 // Owners change the base permission, on the workspace's People page.
69 owner: roleIn(viewer, params.owner) === "owner",
70 };
71}
72
73/** What a form on this page came back with. */
74type Outcome = { intent: string; ok: boolean; message: string | null; error: string | null };
75
76const isRole = (value: string): value is RepoRole => (REPO_ROLES as readonly string[]).includes(value);
77
78export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome> {
79 assertSameOrigin(request);
80 const user = requireUser(context, request);
81 const form = await request.formData();
82 const intent = String(form.get("intent") ?? "");
83 const text = (name: string) => String(form.get(name) ?? "").trim();
84 const failed = (error: string): Outcome => ({ intent, ok: false, message: null, error });
85 const refused = await refusal(context, params, "manage_access");
86 if (refused) return failed(refused);
87 const role = text("role");
88
89 switch (intent) {
90 case "add": {
91 const invitee = text("invitee");
92 if (!invitee) return failed("Enter a username or an email address.");
93 if (!isRole(role)) return failed("Choose a role.");
94 const added = await identity.addCollaborator(user, params.owner, params.repo, invitee, role);
95 if (!added.ok) return failed(added.error.message);
96 const label = REPO_ROLE_LABELS[role];
97 if (added.value.result === "granted") {
98 return { intent, ok: true, error: null, message: `${added.value.collaborator.username} now has the ${label} role.` };
99 }
100 const invitation = added.value.invitation;
101 return {
102 intent,
103 ok: true,
104 error: null,
105 message: invitation.invitee
106 ? `We sent ${invitation.invitee} an invitation. They get the ${label} role when they accept it.`
107 : `We sent an invite to ${invitation.email ?? invitee}. They get the ${label} role once they make an account and join.`,
108 };
109 }
110 case "role": {
111 if (!isRole(role)) return failed("Choose a role.");
112 const changed = await identity.setCollaboratorRole(user, params.owner, params.repo, text("username"), role);
113 return changed.ok
114 ? { intent, ok: true, error: null, message: `${changed.value.username} has the ${REPO_ROLE_LABELS[changed.value.role]} role.` }
115 : failed(changed.error.message);
116 }
117 case "remove": {
118 const removed = await identity.removeCollaborator(user, params.owner, params.repo, text("username"));
119 return removed.ok
120 ? { intent, ok: true, error: null, message: `${text("username")} no longer has a role given here.` }
121 : failed(removed.error.message);
122 }
123 case "team-add":
124 case "team-role": {
125 const team = text("team");
126 if (!team) return failed("Choose a team.");
127 if (!isRole(role)) return failed("Choose a role.");
128 const set = await identity.setTeamRepo(user, params.owner, team, params.owner, params.repo, role);
129 return set.ok
130 ? { intent, ok: true, error: null, message: `${team} has the ${REPO_ROLE_LABELS[set.value.role]} role.` }
131 : failed(set.error.message);
132 }
133 case "team-remove": {
134 const team = text("team");
135 const removed = await identity.removeTeamRepo(user, params.owner, team, params.owner, params.repo);
136 return removed.ok
137 ? { intent, ok: true, error: null, message: `${team} no longer has a role here.` }
138 : failed(removed.error.message);
139 }
140 case "revoke": {
141 const revoked = await identity.revokeRepoInvitation(user, params.owner, params.repo, text("id"));
142 return revoked.ok ? { intent, ok: true, error: null, message: null } : failed(revoked.error.message);
143 }
144 default:
145 return failed("Unknown request.");
146 }
147}
148
149const SOURCE: Record<Collaborator["source"], string> = {
150 owner: "Owner of the workspace",
151 base: "Member, by the base permission",
152 direct: "Given a role here",
153 team: "Through a team",
154};
155
156export default function RepoAccessSettings({ loaderData, actionData, params }: Route.ComponentProps) {
157 const { access, manage, owner, teams, free } = loaderData;
158 const base = `/${params.owner}/${params.repo}`;
159 const full = `${params.owner}/${params.repo}`;
160 const pending = access.invitations.filter((invitation) => invitation.status === "pending");
161 const people = [...access.people].sort(
162 (a, b) => REPO_ROLES.indexOf(b.role) - REPO_ROLES.indexOf(a.role) || a.username.localeCompare(b.username),
163 );
164 const result = actionData?.intent === "add" ? actionData : undefined;
165 const teamResult = actionData?.intent === "team-add" ? actionData : undefined;
166 const repoTeams = [...(access.teams ?? [])].sort(
167 (a, b) => REPO_ROLES.indexOf(b.role) - REPO_ROLES.indexOf(a.role) || a.name.localeCompare(b.name),
168 );
169 const listed = new Set(repoTeams.map((team) => team.slug));
170 const addable = isFailed(teams) ? null : teams.filter((team) => !listed.has(team.slug));
171 // Roles are changed and people removed from their own rows, which say
172 // what went wrong; what went right is said once, under the list.
173 const [notice, setNotice] = useState<string | null>(null);
174 return (
175 <>
176 <RepoSettingsHeading base={base} />
177 <div className="max-w-4xl space-y-8">
178 <Section
179 title="Base permission"
180 about={`What every member of ${params.owner} gets on its repositories.`}
181 >
182 <Card className="p-4 text-sm">
183 <p>
184 Members have{" "}
185 <span className="font-medium text-fg">
186 {access.base_permission === "none" ? "no permission" : `the ${BASE_PERMISSION_LABELS[access.base_permission]} role`}
187 </span>{" "}
188 on {full}
189 {access.base_permission === "none" ? " unless they are given a role here." : ", or the higher role they are given here."}{" "}
190 Owners of the workspace are Admins on every repository.
191 </p>
192 <p className="mt-2 text-muted">
193 {owner ? (
194 <Link to={`/${params.owner}/-/members#base-permission`} className="text-accent hover:underline">
195 Change the base permission
196 </Link>
197 ) : (
198 "Owners of the workspace choose it."
199 )}
200 </p>
201 </Card>
202 </Section>
203
204 {manage && (
205 <Section
206 title="Add people"
207 about="A member of the workspace gets the role at once. Anyone else gets an invitation, by email, and has the role once they accept it."
208 >
209 {free && <StartPlanToInvite workspace={params.owner} owner={owner} outside />}
210 <AddForm result={result} />
211 </Section>
212 )}
213
214 <Section
215 title="People with access"
216 about={
217 manage
218 ? "Who can see the repository, and what each can do. Outside collaborators are not members of the workspace; they see only the repositories shared with them."
219 : "Who can see the repository, and what each can do. Changing who has access needs the Admin role."
220 }
221 >
222 <Card asChild tone="plain" divided>
223 <ul>
224 {people.map((person) => (
225 <PersonRow
226 key={person.username}
227 person={person}
228 manage={manage}
229 base={access.base_permission}
230 full={full}
231 workspace={params.owner}
232 onDone={setNotice}
233 />
234 ))}
235 </ul>
236 </Card>
237 {notice && (
238 <p className="text-sm text-success" role="status">
239 {notice}
240 </p>
241 )}
242 </Section>
243
244 <Section
245 title="Teams with access"
246 about={
247 manage
248 ? `Teams of ${params.owner} with a role here. Everyone on a team gets its role, and the team can be asked to review.`
249 : `Teams of ${params.owner} with a role here. Everyone on a team gets its role. Changing them needs the Admin role.`
250 }
251 >
252 {repoTeams.length === 0 ? (
253 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
254 <p>
255 No team has a role here.
256 </p>
257 </Card>
258 ) : (
259 <Card asChild tone="plain" divided>
260 <ul>
261 {repoTeams.map((team) => (
262 <TeamRow key={team.slug} team={team} workspace={params.owner} full={full} manage={manage} onDone={setNotice} />
263 ))}
264 </ul>
265 </Card>
266 )}
267 {manage && addable && <AddTeamForm teams={addable} result={teamResult} workspace={params.owner} />}
268 {manage && isFailed(teams) && <Unavailable what={teams.what} compact />}
269 </Section>
270
271 {manage && (
272 <Section title="Pending invitations" about="Invitations not yet accepted. Each expires after 7 days.">
273 {pending.length === 0 ? (
274 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
275 <p>
276 No invitations are waiting.
277 </p>
278 </Card>
279 ) : (
280 <Card asChild tone="plain" divided>
281 <ul>
282 {pending.map((invitation) => (
283 <InvitationRow key={invitation.id} invitation={invitation} />
284 ))}
285 </ul>
286 </Card>
287 )}
288 </Section>
289 )}
290
291 <Section
292 title="Roles"
293 about="What each role can do. Each role can do everything the one before it can. Transferring and deleting also need an owner of the workspace."
294 >
295 <RolesTable />
296 </Section>
297 </div>
298 </>
299 );
300}
301
302/** A username or an address, and the role to give. */
303function AddForm({ result }: { result: Outcome | undefined }) {
304 const [role, setRole] = useState<RepoRole>("write");
305 const [key, setKey] = useState(0);
306 // Cleared once someone was added, ready for the next.
307 useEffect(() => {
308 if (result?.ok) setKey((k) => k + 1);
309 }, [result]);
310 return (
311 <Card asChild className="space-y-3 p-4">
312 <Form method="post">
313 <input type="hidden" name="intent" value="add" />
314 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_11rem_auto] sm:items-end">
315 <Field label="Username or email address">
316 <Input key={key} name="invitee" required maxLength={254} placeholder="username or name@example.com" />
317 </Field>
318 <div>
319 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
320 <RoleSelect name="role" label="Role" value={role} onValueChange={(value) => setRole(value as RepoRole)} />
321 </div>
322 <SubmitButton match={{ intent: "add" }} pending="Adding…">
323 <UserPlus size={15} />
324 Add
325 </SubmitButton>
326 </div>
327 <p className="text-xs text-faint">{REPO_ROLE_LABELS[role]}: {roleLine(role)}</p>
328 {result &&
329 (result.ok ? (
330 <p className="flex items-start gap-2 text-sm text-fg" role="status">
331 <Mail size={14} className="mt-0.5 shrink-0 text-accent" />
332 {result.message}
333 </p>
334 ) : (
335 <ErrorText>{result.error}</ErrorText>
336 ))}
337 </Form>
338 </Card>
339 );
340}
341
342function roleLine(role: RepoRole): string {
343 return {
344 read: "they can see and clone it, open work items and pull requests, and comment.",
345 triage: "they can also apply labels and milestones, and assign, close and reopen work items and pull requests.",
346 write: "they can also push, merge, manage labels and milestones, see security alerts and run workflows.",
347 maintain: "they can also change its settings and topics.",
348 admin: "they can do everything, including branch protection, webhooks, secrets, deployments and who has access.",
349 }[role];
350}
351
352/** One person: who they are, how they have access, and their role. */
353function PersonRow({
354 person,
355 manage,
356 base,
357 full,
358 workspace,
359 onDone,
360}: {
361 person: Collaborator;
362 manage: boolean;
363 base: Parameters<typeof baseRole>[0];
364 full: string;
365 workspace: string;
366 onDone: (notice: string | null) => void;
367}) {
368 const fetcher = useFetcher<Outcome>();
369 useEffect(() => {
370 if (fetcher.data) onDone(fetcher.data.ok ? fetcher.data.message : null);
371 }, [fetcher.data, onDone]);
372 const outside = person.workspace_role == null;
373 // A member keeps the base permission whatever role they are given here,
374 // so only higher roles mean anything for them.
375 const floor = outside ? null : baseRole(base);
376 const roles = floor ? REPO_ROLES.filter((role) => REPO_ROLES.indexOf(role) >= REPO_ROLES.indexOf(floor)) : REPO_ROLES;
377 const shown = fetcher.formData?.get("intent") === "role" ? String(fetcher.formData.get("role")) : person.role;
378 const editable = manage && person.source !== "owner";
379 return (
380 <li className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
381 <Avatar name={person.username} image={person.avatar} size={32} />
382 <div className="min-w-0 grow basis-40">
383 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
384 <UserCard username={person.username}>
385 <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
386 {person.username}
387 </Link>
388 </UserCard>
389 {person.name && <span className="truncate text-sm text-muted">{person.name}</span>}
390 {person.source === "owner" && <Badge tone="accent">Owner</Badge>}
391 {outside && <Badge tone="info">Outside collaborator</Badge>}
392 </p>
393 <p className="mt-0.5 text-xs text-faint">
394 {person.source === "team" && person.team ? (
395 <>
396 Through team{" "}
397 <Link to={`/${workspace}/-/teams/${person.team}`} className="text-muted hover:text-accent hover:underline">
398 {person.team}
399 </Link>
400 </>
401 ) : (
402 SOURCE[person.source]
403 )}
404 {!outside && person.direct != null && person.source !== "direct" ? ` · given ${REPO_ROLE_LABELS[person.direct]} here` : ""}
405 </p>
406 {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
407 </div>
408 <div className="flex shrink-0 items-center gap-2">
409 {editable ? (
410 <RoleSelect
411 size="sm"
412 label={`Role of ${person.username}`}
413 roles={roles}
414 value={shown}
415 className="w-32"
416 disabled={fetcher.state !== "idle"}
417 onValueChange={(role) =>
418 fetcher.submit({ intent: "role", username: person.username, role }, { method: "post" })
419 }
420 />
421 ) : (
422 <span className="inline-flex h-8 w-32 items-center rounded-md border border-line px-2.5 text-[0.8125rem] text-muted">
423 {REPO_ROLE_LABELS[person.role]}
424 </span>
425 )}
426 {/* The same room for Remove on every row, so the roles line up. */}
427 {manage && (
428 <span className="flex w-22 justify-end">
429 {editable && person.direct != null && (
430 <RemoveButton person={person} full={full} outside={outside} onDone={onDone} />
431 )}
432 </span>
433 )}
434 </div>
435 </li>
436 );
437}
438
439/** Taking someone's role here away, after saying what that means. */
440function RemoveButton({
441 person,
442 full,
443 outside,
444 onDone,
445}: {
446 person: Collaborator;
447 full: string;
448 outside: boolean;
449 onDone: (notice: string | null) => void;
450}) {
451 const fetcher = useFetcher<Outcome>();
452 const [open, setOpen] = useState(false);
453 // Said as soon as the answer comes, before the row leaves the list.
454 useEffect(() => {
455 if (fetcher.data) onDone(fetcher.data.ok ? fetcher.data.message : null);
456 }, [fetcher.data, onDone]);
457 useEffect(() => {
458 if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
459 }, [fetcher.state, fetcher.data]);
460 return (
461 <AlertDialog open={open} onOpenChange={setOpen}>
462 <Button type="button" variant="outline" onClick={() => setOpen(true)}>
463 Remove
464 </Button>
465 <AlertDialogContent>
466 <fetcher.Form method="post" className="grid gap-4">
467 <input type="hidden" name="intent" value="remove" />
468 <input type="hidden" name="username" value={person.username} />
469 <AlertDialogHeader>
470 <AlertDialogTitle>
471 Remove {person.username} from {full}?
472 </AlertDialogTitle>
473 <AlertDialogDescription>
474 {outside
475 ? "They lose access to the repository at once, and stop seeing it in Shared with you. Their work items, pull requests and comments stay."
476 : "They lose the role given to them here, and keep what the workspace's base permission gives every member."}
477 </AlertDialogDescription>
478 </AlertDialogHeader>
479 {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
480 <AlertDialogFooter>
481 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
482 <SubmitButton variant="destructive" fetcher={fetcher} pending="Removing…">
483 Remove
484 </SubmitButton>
485 </AlertDialogFooter>
486 </fetcher.Form>
487 </AlertDialogContent>
488 </AlertDialog>
489 );
490}
491
492function day(at: string): string {
493 return new Date(at).toISOString().slice(0, 10);
494}
495
496/** A pending invitation: to whom, with which role, until when. */
497function InvitationRow({ invitation }: { invitation: RepoInvitation }) {
498 const fetcher = useFetcher<Outcome>();
499 const who = invitation.invitee ?? invitation.email ?? "Someone";
500 return (
501 <li className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
502 {invitation.invitee ? (
503 <Avatar name={invitation.invitee} size={32} />
504 ) : (
505 <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted ring-1 ring-line">
506 <Mail size={14} />
507 </span>
508 )}
509 <div className="min-w-0 grow basis-40">
510 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
511 <span className={`min-w-0 truncate text-sm ${invitation.invitee ? "font-mono" : ""}`}>{who}</span>
512 <Badge>{REPO_ROLE_LABELS[invitation.role]}</Badge>
513 </p>
514 <p className="mt-0.5 text-xs text-faint">
515 Invited by {invitation.invited_by ?? "g1t"} on {day(invitation.created_at)} · expires {day(invitation.expires_at)}
516 </p>
517 {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
518 </div>
519 <fetcher.Form method="post" className="shrink-0">
520 <input type="hidden" name="intent" value="revoke" />
521 <input type="hidden" name="id" value={invitation.id} />
522 <SubmitButton variant="outline" fetcher={fetcher} pending="Revoking…">
523 Revoke
524 </SubmitButton>
525 </fetcher.Form>
526 </li>
527 );
528}
529
530/** A team with a role here: its name, handle, size, and the role it gives. */
531function TeamRow({
532 team,
533 workspace,
534 full,
535 manage,
536 onDone,
537}: {
538 team: RepoTeam;
539 workspace: string;
540 full: string;
541 manage: boolean;
542 onDone: (notice: string | null) => void;
543}) {
544 const fetcher = useFetcher<Outcome>();
545 const [open, setOpen] = useState(false);
546 useEffect(() => {
547 if (fetcher.data) onDone(fetcher.data.ok ? fetcher.data.message : null);
548 }, [fetcher.data, onDone]);
549 useEffect(() => {
550 if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
551 }, [fetcher.state, fetcher.data]);
552 const shown = fetcher.formData?.get("intent") === "team-role" ? String(fetcher.formData.get("role")) : team.role;
553 const href = `/${workspace}/-/teams/${team.slug}`;
554 return (
555 <li className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
556 <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
557 <Users size={15} />
558 </span>
559 <div className="min-w-0 grow basis-40">
560 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
561 <Link to={href} className="text-sm font-medium hover:text-accent">
562 {team.name}
563 </Link>
564 <span className="truncate font-mono text-xs text-muted">
565 @{workspace}/{team.slug}
566 </span>
567 {team.visibility === "secret" && <Badge tone="warn">Secret</Badge>}
568 </p>
569 <p className="mt-0.5 text-xs text-faint">
570 {team.members_count === 1 ? "1 member" : `${team.members_count} members`}
571 </p>
572 {fetcher.data && !fetcher.data.ok && !open && <ErrorText>{fetcher.data.error}</ErrorText>}
573 </div>
574 <div className="flex shrink-0 items-center gap-2">
575 {manage ? (
576 <RoleSelect
577 size="sm"
578 label={`Role of team ${team.name}`}
579 value={shown}
580 className="w-32"
581 disabled={fetcher.state !== "idle"}
582 onValueChange={(role) => fetcher.submit({ intent: "team-role", team: team.slug, role }, { method: "post" })}
583 />
584 ) : (
585 <span className="inline-flex h-8 w-32 items-center rounded-md border border-line px-2.5 text-[0.8125rem] text-muted">
586 {REPO_ROLE_LABELS[team.role]}
587 </span>
588 )}
589 {manage && (
590 <span className="flex w-22 justify-end">
591 <AlertDialog open={open} onOpenChange={setOpen}>
592 <Button type="button" variant="outline" onClick={() => setOpen(true)}>
593 Remove
594 </Button>
595 <AlertDialogContent>
596 <fetcher.Form method="post" className="grid gap-4">
597 <input type="hidden" name="intent" value="team-remove" />
598 <input type="hidden" name="team" value={team.slug} />
599 <AlertDialogHeader>
600 <AlertDialogTitle>
601 Remove {team.name} from {full}?
602 </AlertDialogTitle>
603 <AlertDialogDescription>
604 Its people lose the role it gave them here, and keep any role they have otherwise. The team itself stays.
605 </AlertDialogDescription>
606 </AlertDialogHeader>
607 {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
608 <AlertDialogFooter>
609 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
610 <SubmitButton variant="destructive" fetcher={fetcher} pending="Removing…">
611 Remove
612 </SubmitButton>
613 </AlertDialogFooter>
614 </fetcher.Form>
615 </AlertDialogContent>
616 </AlertDialog>
617 </span>
618 )}
619 </div>
620 </li>
621 );
622}
623
624/** One of the workspace's teams without a role here yet, and the role to give it. */
625function AddTeamForm({ teams, result, workspace }: { teams: Team[]; result: Outcome | undefined; workspace: string }) {
626 const [team, setTeam] = useState("");
627 const [role, setRole] = useState<RepoRole>("write");
628 // Cleared once the team was added, ready for the next.
629 useEffect(() => {
630 if (result?.ok) setTeam("");
631 }, [result]);
632 if (teams.length === 0) {
633 return (
634 <p className="text-sm text-muted">
635 No other team of {workspace} to add.{" "}
636 <Link to={`/${workspace}/-/teams`} className="text-accent hover:underline">
637 See its teams
638 </Link>
639 </p>
640 );
641 }
642 return (
643 <Card asChild className="space-y-3 p-4">
644 <Form method="post">
645 <input type="hidden" name="intent" value="team-add" />
646 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_11rem_auto] sm:items-end">
647 <div>
648 <span className="mb-1.5 block text-sm font-medium text-muted">Team</span>
649 <Select name="team" value={team} onValueChange={setTeam} required>
650 <SelectTrigger aria-label="Team">
651 <SelectValue placeholder="Choose a team" />
652 </SelectTrigger>
653 <SelectContent align="start">
654 {teams.map((option) => (
655 <SelectItem
656 key={option.slug}
657 value={option.slug}
658 description={`@${option.workspace}/${option.slug} · ${option.members_count === 1 ? "1 member" : `${option.members_count} members`}`}
659 >
660 {option.name}
661 </SelectItem>
662 ))}
663 </SelectContent>
664 </Select>
665 </div>
666 <div>
667 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
668 <RoleSelect name="role" label="Role" value={role} onValueChange={(value) => setRole(value as RepoRole)} />
669 </div>
670 <SubmitButton match={{ intent: "team-add" }} pending="Adding…" disabled={!team}>
671 <Users size={15} />
672 Add team
673 </SubmitButton>
674 </div>
675 <p className="text-xs text-faint">{REPO_ROLE_LABELS[role]}: everyone on the team {roleLine(role).replace(/^they /, "")}</p>
676 {result &&
677 (result.ok ? (
678 <p className="text-sm text-success" role="status">
679 {result.message}
680 </p>
681 ) : (
682 <ErrorText>{result.error}</ErrorText>
683 ))}
684 </Form>
685 </Card>
686 );
687}