Skip to content

g1t/apps/web/app/routes/repo/settings-access.tsx

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