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.

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