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.

160 linesCodeBlameRaw
1import { Users } from "lucide-react";
2import { Form, Link, data, useSearchParams } from "react-router";
3
4import type { TeamRole } from "@g1t/contracts";
5
6import type { Route } from "./+types/members";
7import { useTeam } from "../../../components/teams";
8import { ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
9import { Avatar } from "../../../components/ui/avatar";
10import { Badge } from "../../../components/ui/badge";
11import { Card } from "../../../components/ui/card";
12import { CheckboxOption } from "../../../components/ui/checkbox";
13import { Switch } from "../../../components/ui/switch";
14import { WithPresence } from "../../../components/presence";
15import { leads, personPath } from "../../../lib/people";
16import { teamPath } from "../../../lib/teams";
17import { identity } from "../../../lib/services.server";
18import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
19import { UserCard } from "../../../components/user-card";
20
21export async function loader({ params, context, request }: Route.LoaderArgs) {
22 const viewer = getViewer(context);
23 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24 const children = new URL(request.url).searchParams.get("children") === "1";
25 const members = unwrap(await identity.teamMembers(viewer, params.owner, params.team, children));
26 return { members, children, me: viewer?.username ?? null };
27}
28
29export async function action({ request, params, context }: Route.ActionArgs) {
30 assertSameOrigin(request);
31 const user = requireUser(context, request);
32 const form = await request.formData();
33 const intent = String(form.get("intent") ?? "");
34 const username = String(form.get("username") ?? "")
35 .trim()
36 .replace(/^@/, "");
37 if (!username) return { intent, username, error: "Enter a username." };
38 if (intent === "remove") {
39 const removed = await identity.removeTeamMember(user, params.owner, params.team, username);
40 return removed.ok ? { intent, username, error: null } : { intent, username, error: removed.error.message };
41 }
42 const role: TeamRole = form.get("role") === "maintainer" ? "maintainer" : "member";
43 const set = await identity.setTeamMember(user, params.owner, params.team, username, role);
44 return set.ok ? { intent, username, error: null } : { intent, username, error: set.error.message };
45}
46
47export default function TeamMembers({ loaderData, actionData }: Route.ComponentProps) {
48 const team = useTeam();
49 const { members, children, me } = loaderData;
50 const [, setParams] = useSearchParams();
51 const manage = team.can_manage;
52 const rowError = (username: string) => (actionData && actionData.intent !== "add" && actionData.username === username ? actionData.error : null);
53
54 return (
55 <div className="max-w-3xl space-y-10">
56 <section aria-labelledby="team-people" className="space-y-4">
57 <div className="flex flex-wrap items-center justify-between gap-3">
58 <h2 id="team-people" className="flex items-center gap-2 font-medium">
59 <Users size={15} className="text-faint" />
60 People <span className="text-sm font-normal text-faint tabular-nums">{team.members_count}</span>
61 </h2>
62 {team.child_teams_count > 0 && (
63 <label className="flex items-center gap-2.5 text-sm text-muted">
64 <Switch
65 size="sm"
66 checked={children}
67 onCheckedChange={(on) => setParams(on ? { children: "1" } : {}, { replace: true, preventScrollReset: true })}
68 />
69 Include the people of child teams
70 </label>
71 )}
72 </div>
73
74 {members.length === 0 ? (
75 <Card asChild tone="plain" className="border-dashed px-6 py-6 text-center text-sm text-muted">
76 <p>
77 {`No one is in ${team.name} yet.`}
78 {manage && " Add members of the workspace below."}
79 </p>
80 </Card>
81 ) : (
82 <Card asChild tone="plain" divided>
83 <ul>
84 {members.map((member) => {
85 const self = member.username === me;
86 return (
87 <li key={member.username} className="px-4 py-3">
88 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
89 <WithPresence person={{ username: member.username }} size={28}>
90 <Avatar name={member.username} image={member.avatar} size={28} />
91 </WithPresence>
92 <div className="min-w-0 grow basis-32 truncate">
93 <UserCard username={member.username}>
94 <Link to={personPath(team.workspace, member.username)} className="text-sm font-medium hover:text-accent">
95 {member.name || member.username}
96 </Link>
97 </UserCard>
98 {member.name && <span className="ml-2 hidden font-mono text-xs text-faint sm:inline">{member.username}</span>}
99 </div>
100 {leads(team.lead, { username: member.username }) && <Badge tone="accent">Lead</Badge>}
101 {member.via ? (
102 <Link to={teamPath(team.workspace, member.via)}>
103 <Badge>via {member.via}</Badge>
104 </Link>
105 ) : member.role === "maintainer" ? (
106 <Badge tone="accent">Maintainer</Badge>
107 ) : (
108 <Badge>Member</Badge>
109 )}
110 {!member.via && (manage || self) && (
111 <span className="flex items-center gap-1">
112 {manage && (
113 <Form method="post">
114 <input type="hidden" name="intent" value="role" />
115 <input type="hidden" name="username" value={member.username} />
116 <input type="hidden" name="role" value={member.role === "maintainer" ? "member" : "maintainer"} />
117 <SubmitButton variant="outline" match={{ intent: "role", username: member.username }} pending="Saving…">
118 {member.role === "maintainer" ? "Make member" : "Make maintainer"}
119 </SubmitButton>
120 </Form>
121 )}
122 <Form method="post">
123 <input type="hidden" name="intent" value="remove" />
124 <input type="hidden" name="username" value={member.username} />
125 <SubmitButton variant="outline" match={{ intent: "remove", username: member.username }} pending="Removing…">
126 {self ? "Leave" : "Remove"}
127 </SubmitButton>
128 </Form>
129 </span>
130 )}
131 </div>
132 <ErrorText>{rowError(member.username)}</ErrorText>
133 </li>
134 );
135 })}
136 </ul>
137 </Card>
138 )}
139
140 {manage && (
141 <Form method="post" key={members.length} className="flex flex-col gap-3 sm:flex-row sm:items-start">
142 <input type="hidden" name="intent" value="add" />
143 <div className="grow">
144 <Field label="Add a person" hint={`A member of ${team.workspace}, by username. Maintainers manage the team's people and settings.`}>
145 <Input name="username" required maxLength={39} placeholder="username" />
146 </Field>
147 </div>
148 <CheckboxOption name="role" value="maintainer" label="Maintainer" className="sm:pt-[2.1rem]" />
149 <div className="sm:pt-6.5">
150 <SubmitButton match={{ intent: "add" }} pending="Adding…">
151 Add
152 </SubmitButton>
153 </div>
154 </Form>
155 )}
156 {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>}
157 </section>
158 </div>
159 );
160}