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.

358 linesCodeBlameRaw
1import { Code2, MessagesSquare, UsersRound } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { Form, Link, data, useNavigation } from "react-router";
4
5import {
6 BASE_PERMISSIONS,
7 BASE_PERMISSION_LABELS,
8 type BasePermission,
9 type ChatAllowed,
10 DEFAULT_BASE_PERMISSION,
11 DEFAULT_MEMBER_PRIVILEGES,
12 type MemberPrivileges,
13 type TeamCreation,
14 type Workspace,
15} from "@g1t/contracts";
16
17import type { Route } from "./+types/permissions";
18import { page } from "../../lib/meta";
19import { ErrorText, SubmitButton } from "../../components/ui";
20import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card";
21import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
22import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
23import { SwitchCard } from "../../components/ui/switch";
24import { chat, identity } from "../../lib/services.server";
25import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
26
27export function meta({ params, ...args }: Route.MetaArgs) {
28 return page(args, { title: `Permissions · ${params.owner} · g1t` });
29}
30
31/**
32 * Permissions: who may do what in the workspace, in one place, a section
33 * for each part of g1t. What exists today saves through the service that
34 * owns it (identity for repositories and teams, chat for channels).
35 * Owners only.
36 */
37export async function loader({ params, context }: Route.LoaderArgs) {
38 const viewer = getViewer(context);
39 if (roleIn(viewer, params.owner) !== "owner" || !viewer) throw data(null, { status: 404 });
40 const slug = params.owner.toLowerCase();
41 const [workspace, chatSettings] = await Promise.all([identity.getWorkspace(params.owner), chat.chatSettings(slug, viewer).catch(() => null)]);
42 if (!workspace) throw data(null, { status: 404 });
43 return {
44 slug,
45 base: workspace.basePermission ?? DEFAULT_BASE_PERMISSION,
46 privileges: { ...DEFAULT_MEMBER_PRIVILEGES, ...pickPrivileges(workspace) },
47 teamCreation: workspace.teamCreation ?? "members",
48 // Null when chat did not answer: the section says so and takes no change.
49 channels: chatSettings?.ok ? { public: chatSettings.value.settings.public_channels, private: chatSettings.value.settings.private_channels } : null,
50 };
51}
52
53export async function action({ request, params, context }: Route.ActionArgs) {
54 assertSameOrigin(request);
55 const user = requireUser(context, request);
56 const form = await request.formData();
57 const intent = String(form.get("intent") ?? "");
58 const slug = params.owner.toLowerCase();
59 // Each section saves on its own; identity and chat check the owner again.
60 if (intent === "base-permission") {
61 const base = String(form.get("base") ?? "");
62 if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { intent, error: "Choose a base permission." };
63 const result = await identity.setBasePermission(user, params.owner, base as BasePermission);
64 return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
65 }
66 if (intent === "member-privileges") {
67 // A switch posts only when on.
68 const change = Object.fromEntries(PRIVILEGES.map(([key]) => [key, form.get(key) === "on"])) as MemberPrivileges;
69 const result = await identity.setMemberPrivileges(user, params.owner, change);
70 return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
71 }
72 if (intent === "team-creation") {
73 const wanted: TeamCreation = form.get("team_creation") === "owners" ? "owners" : "members";
74 const result = await identity.setTeamCreation(user, params.owner, wanted);
75 return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
76 }
77 if (intent === "chat-channels") {
78 const who = (key: string): ChatAllowed => (form.get(key) === "owners" ? "owners" : "members");
79 try {
80 const result = await chat.setChatSettings(slug, user, { public_channels: who("public_channels"), private_channels: who("private_channels") });
81 return result.ok ? { intent, saved: true } : { intent, error: result.error.message };
82 } catch {
83 return { intent, error: "Chat didn't answer. Try again in a moment." };
84 }
85 }
86 return { intent, error: "Unknown request." };
87}
88
89/** What the base permission means for members, in a sentence. */
90const BASE_MEANS: Record<BasePermission, string> = {
91 none: "Members see only public repositories and the ones they are given a role on.",
92 read: "Members can see and clone every repository, open work items and pull requests, and comment.",
93 write: "Members can see every repository, manage its work items and pull requests, push and merge.",
94 admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.",
95};
96
97/** The member privileges, in the order the Code section shows them, with what each lets members do. */
98const PRIVILEGES: [keyof MemberPrivileges, string, string][] = [
99 ["members_can_create_public_repositories", "Members can create public repositories", "Owners always can. Off: members ask an owner."],
100 ["members_can_create_private_repositories", "Members can create private repositories", "Owners always can. Whoever creates a repository is its Admin."],
101 [
102 "members_can_change_repo_visibility",
103 "Repository admins can change visibility",
104 "Members with the Admin role on a repository can make it public or private. Off: owners only.",
105 ],
106 [
107 "members_can_delete_repositories",
108 "Repository admins can delete and transfer repositories",
109 "Members with the Admin role on a repository can delete it, or transfer it to a workspace where they can create one. Off: owners only.",
110 ],
111 [
112 "members_can_invite_outside_collaborators",
113 "Repository admins can add outside collaborators",
114 "Members with the Admin role on a repository can give a role on it to someone outside the workspace. Off: owners only.",
115 ],
116];
117
118/** The member privileges a workspace carries, by name. */
119function pickPrivileges(workspace: Workspace): Partial<MemberPrivileges> {
120 const picked: Partial<MemberPrivileges> = {};
121 for (const [key] of PRIVILEGES) {
122 const value = workspace[key];
123 if (typeof value === "boolean") picked[key] = value;
124 }
125 return picked;
126}
127
128type Answer = Route.ComponentProps["actionData"];
129
130/** What the last save of `intent` came to: saved, or why not. */
131function outcome(answer: Answer, intent: string): { saved: boolean; error: string | null } {
132 if (!answer || answer.intent !== intent) return { saved: false, error: null };
133 return { saved: "saved" in answer && answer.saved === true, error: "error" in answer ? (answer.error ?? null) : null };
134}
135
136/** Whether a save of `intent` is on its way. */
137function useSaving(intent: string): boolean {
138 const navigation = useNavigation();
139 return navigation.state !== "idle" && navigation.formData?.get("intent") === intent;
140}
141
142/** One part of g1t: its sections, under its name and what the part covers. */
143function Part({ icon, title, about, children }: { icon: ReactNode; title: string; about: string; children: ReactNode }) {
144 return (
145 <Card asChild>
146 <section aria-labelledby={`part-${title.toLowerCase().replace(/\W+/g, "-")}`}>
147 <CardHeader className="border-b border-line">
148 <CardTitle id={`part-${title.toLowerCase().replace(/\W+/g, "-")}`} className="flex items-center gap-2 text-base">
149 <span className="text-faint [&>svg]:size-4">{icon}</span>
150 {title}
151 </CardTitle>
152 <CardDescription>{about}</CardDescription>
153 </CardHeader>
154 <CardContent className="space-y-8 p-5">{children}</CardContent>
155 </section>
156 </Card>
157 );
158}
159
160/** A setting's heading inside a part, and a line on what it decides. */
161function Setting({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) {
162 return (
163 <div>
164 <h3 className="text-sm font-medium">{title}</h3>
165 <p className="mt-1 text-xs leading-relaxed text-faint">{about}</p>
166 <div className="mt-3">{children}</div>
167 </div>
168 );
169}
170
171/** Saved, or why not, beside a Save button. */
172function SaveRow({ intent, changed, answer }: { intent: string; changed: boolean; answer: Answer }) {
173 const saving = useSaving(intent);
174 const { saved, error } = outcome(answer, intent);
175 return (
176 <div className="flex flex-wrap items-center gap-3">
177 <SubmitButton variant="outline" size="sm" match={{ intent }} pending="Saving…" disabled={saving || !changed}>
178 Save
179 </SubmitButton>
180 {saved && !changed && !error && (
181 <p role="status" className="text-xs text-muted">
182 Saved.
183 </p>
184 )}
185 <ErrorText>{error}</ErrorText>
186 </div>
187 );
188}
189
190export default function WorkspacePermissions({ loaderData, actionData, params }: Route.ComponentProps) {
191 const { slug, base, privileges, teamCreation, channels } = loaderData;
192 return (
193 <div className="max-w-3xl space-y-6">
194 <Part
195 icon={<Code2 />}
196 title="Code"
197 about="What members may do with the workspace's repositories, beyond the role each repository gives them. Owners always can do all of it."
198 >
199 <BasePermissionSetting key={base} base={base} slug={params.owner} answer={actionData} />
200 <PrivilegesSetting key={JSON.stringify(privileges)} privileges={privileges} answer={actionData} />
201 </Part>
202
203 <Part icon={<UsersRound />} title="People and teams" about="Who may shape the workspace's teams.">
204 <TeamCreationSetting key={teamCreation} setting={teamCreation} answer={actionData} />
205 </Part>
206
207 <Part
208 icon={<MessagesSquare />}
209 title="Chat"
210 about="Who may open channels. What a channel's own members may do, its default channels and emoji are on Chat settings."
211 >
212 {channels ? (
213 <ChannelsSetting key={`${channels.public}:${channels.private}`} channels={channels} slug={slug} answer={actionData} />
214 ) : (
215 <Setting title="Creating channels" about="Who can create public and private channels.">
216 <p className="text-sm text-muted">Chat didn't answer. Reload to try again.</p>
217 </Setting>
218 )}
219 </Part>
220
221 <p className="text-xs text-faint">
222 Each change is recorded in the{" "}
223 <Link to={`/${slug}/-/audit`} className="underline underline-offset-4 hover:text-fg">
224 audit log
225 </Link>
226 . Every other setting of the workspace is in this sidebar.
227 </p>
228 </div>
229 );
230}
231
232/** What members get on every repository, which owners choose. */
233function BasePermissionSetting({ base, slug, answer }: { base: BasePermission; slug: string; answer: Answer }) {
234 const [chosen, setChosen] = useState<BasePermission>(base);
235 return (
236 <Setting
237 title="Base permission"
238 about={`The role every member has on each of ${slug}'s repositories. Owners are Admins on all of them, and a role given to someone on one repository adds to this; it never takes away.`}
239 >
240 <Form method="post" className="space-y-3">
241 <input type="hidden" name="intent" value="base-permission" />
242 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
243 <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
244 <SelectTrigger aria-label="Base permission" className="sm:max-w-56">
245 <SelectValue />
246 </SelectTrigger>
247 <SelectContent>
248 {BASE_PERMISSIONS.map((value) => (
249 <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
250 {BASE_PERMISSION_LABELS[value]}
251 </SelectItem>
252 ))}
253 </SelectContent>
254 </Select>
255 <p className="text-sm text-muted">{BASE_MEANS[chosen]}</p>
256 </div>
257 <SaveRow intent="base-permission" changed={chosen !== base} answer={answer} />
258 </Form>
259 </Setting>
260 );
261}
262
263/** What members may do: create repositories, and what repository admins may do. */
264function PrivilegesSetting({ privileges, answer }: { privileges: MemberPrivileges; answer: Answer }) {
265 const [chosen, setChosen] = useState<MemberPrivileges>(privileges);
266 const changed = PRIVILEGES.some(([key]) => chosen[key] !== privileges[key]);
267 return (
268 <Setting title="Repositories" about="Creating repositories, and what a member with Admin on one may do with it.">
269 <Form method="post" className="space-y-3">
270 <input type="hidden" name="intent" value="member-privileges" />
271 {PRIVILEGES.map(([key, title, about]) => (
272 <SwitchCard key={key} name={key} title={title} checked={chosen[key]} onCheckedChange={(on) => setChosen((current) => ({ ...current, [key]: on }))}>
273 {about}
274 </SwitchCard>
275 ))}
276 <SaveRow intent="member-privileges" changed={changed} answer={answer} />
277 </Form>
278 </Setting>
279 );
280}
281
282/** Who may create the workspace's teams. Teams already made stay as they are, whoever made them. */
283function TeamCreationSetting({ setting, answer }: { setting: TeamCreation; answer: Answer }) {
284 const [choice, setChoice] = useState<TeamCreation>(setting);
285 return (
286 <Setting title="Who can create teams" about="Whoever creates a team becomes its first maintainer. Teams that already exist stay as they are.">
287 <Form method="post" className="space-y-3">
288 <input type="hidden" name="intent" value="team-creation" />
289 <RadioGroup
290 name="team_creation"
291 value={choice}
292 onValueChange={(value) => setChoice(value as TeamCreation)}
293 aria-label="Who can create teams"
294 className="grid gap-2 sm:grid-cols-2"
295 >
296 <RadioOption value="members" label="Any member" description="Every member with a confirmed email address. The default." />
297 <RadioOption value="owners" label="Owners only" description="Members ask an owner to create a team; maintainers still manage their own." />
298 </RadioGroup>
299 <SaveRow intent="team-creation" changed={choice !== setting} answer={answer} />
300 </Form>
301 </Setting>
302 );
303}
304
305/** Who may create public and private channels, saved by the chat service. */
306function ChannelsSetting({ channels, slug, answer }: { channels: { public: ChatAllowed; private: ChatAllowed }; slug: string; answer: Answer }) {
307 const [chosen, setChosen] = useState(channels);
308 const changed = chosen.public !== channels.public || chosen.private !== channels.private;
309 return (
310 <Setting
311 title="Creating channels"
312 about={
313 <>
314 Whoever creates a channel becomes its first owner. Renaming, archiving, emoji and default channels are on{" "}
315 <Link to={`/${slug}/-/settings/chat`} className="underline underline-offset-4 hover:text-fg">
316 Chat settings
317 </Link>
318 .
319 </>
320 }
321 >
322 <Form method="post" className="space-y-3">
323 <input type="hidden" name="intent" value="chat-channels" />
324 <div className="grid gap-4 sm:grid-cols-2">
325 <div>
326 <h4 className="mb-2 text-xs font-medium text-fg-soft">Workspace channels</h4>
327 <RadioGroup
328 name="public_channels"
329 value={chosen.public}
330 onValueChange={(value) => setChosen((now) => ({ ...now, public: value as ChatAllowed }))}
331 aria-label="Who can create workspace channels"
332 >
333 <RadioOption value="members" label="Any member" description="The default." />
334 <RadioOption
335 value="owners"
336 label="Owners only"
337 description="Members join and read workspace channels, but ask an owner for a new one. Public channels, open to anyone on g1t, are always for owners to make."
338 />
339 </RadioGroup>
340 </div>
341 <div>
342 <h4 className="mb-2 text-xs font-medium text-fg-soft">Private channels</h4>
343 <RadioGroup
344 name="private_channels"
345 value={chosen.private}
346 onValueChange={(value) => setChosen((now) => ({ ...now, private: value as ChatAllowed }))}
347 aria-label="Who can create private channels"
348 >
349 <RadioOption value="members" label="Any member" description="The default." />
350 <RadioOption value="owners" label="Owners only" description="Only owners can make a channel that is kept to the people invited." />
351 </RadioGroup>
352 </div>
353 </div>
354 <SaveRow intent="chat-channels" changed={changed} answer={answer} />
355 </Form>
356 </Setting>
357 );
358}