Skip to content

g1t/apps/web/app/routes/workspace/team/members.tsx

141 lines6,914 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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