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.

330 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 issues and pull requests, and comment.",
93 write: "Members can see every repository, manage its issues 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 icon={<Code2 />} title="Code" about="What members may do with the workspace's repositories, beyond the role each repository gives them. Owners always can do all of it.">
195 <BasePermissionSetting key={base} base={base} slug={params.owner} answer={actionData} />
196 <PrivilegesSetting key={JSON.stringify(privileges)} privileges={privileges} answer={actionData} />
197 </Part>
198
199 <Part icon={<UsersRound />} title="People and teams" about="Who may shape the workspace's teams.">
200 <TeamCreationSetting key={teamCreation} setting={teamCreation} answer={actionData} />
201 </Part>
202
203 <Part icon={<MessagesSquare />} title="Chat" about="Who may open channels. What a channel's own members may do, its default channels and emoji are on Chat settings.">
204 {channels ? (
205 <ChannelsSetting key={`${channels.public}:${channels.private}`} channels={channels} slug={slug} answer={actionData} />
206 ) : (
207 <Setting title="Creating channels" about="Who can create public and private channels.">
208 <p className="text-sm text-muted">Chat didn't answer. Reload to try again.</p>
209 </Setting>
210 )}
211 </Part>
212
213 <p className="text-xs text-faint">
214 Each change is recorded in the{" "}
215 <Link to={`/${slug}/-/audit`} className="underline underline-offset-4 hover:text-fg">
216 audit log
217 </Link>
218 . Every other setting of the workspace is in this sidebar.
219 </p>
220 </div>
221 );
222}
223
224/** What members get on every repository, which owners choose. */
225function BasePermissionSetting({ base, slug, answer }: { base: BasePermission; slug: string; answer: Answer }) {
226 const [chosen, setChosen] = useState<BasePermission>(base);
227 return (
228 <Setting
229 title="Base permission"
230 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.`}
231 >
232 <Form method="post" className="space-y-3">
233 <input type="hidden" name="intent" value="base-permission" />
234 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
235 <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
236 <SelectTrigger aria-label="Base permission" className="sm:max-w-56">
237 <SelectValue />
238 </SelectTrigger>
239 <SelectContent>
240 {BASE_PERMISSIONS.map((value) => (
241 <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
242 {BASE_PERMISSION_LABELS[value]}
243 </SelectItem>
244 ))}
245 </SelectContent>
246 </Select>
247 <p className="text-sm text-muted">{BASE_MEANS[chosen]}</p>
248 </div>
249 <SaveRow intent="base-permission" changed={chosen !== base} answer={answer} />
250 </Form>
251 </Setting>
252 );
253}
254
255/** What members may do: create repositories, and what repository admins may do. */
256function PrivilegesSetting({ privileges, answer }: { privileges: MemberPrivileges; answer: Answer }) {
257 const [chosen, setChosen] = useState<MemberPrivileges>(privileges);
258 const changed = PRIVILEGES.some(([key]) => chosen[key] !== privileges[key]);
259 return (
260 <Setting title="Repositories" about="Creating repositories, and what a member with Admin on one may do with it.">
261 <Form method="post" className="space-y-3">
262 <input type="hidden" name="intent" value="member-privileges" />
263 {PRIVILEGES.map(([key, title, about]) => (
264 <SwitchCard key={key} name={key} title={title} checked={chosen[key]} onCheckedChange={(on) => setChosen((current) => ({ ...current, [key]: on }))}>
265 {about}
266 </SwitchCard>
267 ))}
268 <SaveRow intent="member-privileges" changed={changed} answer={answer} />
269 </Form>
270 </Setting>
271 );
272}
273
274/** Who may create the workspace's teams. Teams already made stay as they are, whoever made them. */
275function TeamCreationSetting({ setting, answer }: { setting: TeamCreation; answer: Answer }) {
276 const [choice, setChoice] = useState<TeamCreation>(setting);
277 return (
278 <Setting title="Who can create teams" about="Whoever creates a team becomes its first maintainer. Teams that already exist stay as they are.">
279 <Form method="post" className="space-y-3">
280 <input type="hidden" name="intent" value="team-creation" />
281 <RadioGroup name="team_creation" value={choice} onValueChange={(value) => setChoice(value as TeamCreation)} aria-label="Who can create teams" className="grid gap-2 sm:grid-cols-2">
282 <RadioOption value="members" label="Any member" description="Every member with a confirmed email address. The default." />
283 <RadioOption value="owners" label="Owners only" description="Members ask an owner to create a team; maintainers still manage their own." />
284 </RadioGroup>
285 <SaveRow intent="team-creation" changed={choice !== setting} answer={answer} />
286 </Form>
287 </Setting>
288 );
289}
290
291/** Who may create public and private channels, saved by the chat service. */
292function ChannelsSetting({ channels, slug, answer }: { channels: { public: ChatAllowed; private: ChatAllowed }; slug: string; answer: Answer }) {
293 const [chosen, setChosen] = useState(channels);
294 const changed = chosen.public !== channels.public || chosen.private !== channels.private;
295 return (
296 <Setting
297 title="Creating channels"
298 about={
299 <>
300 Whoever creates a channel becomes its first owner. Renaming, archiving, emoji and default channels are on{" "}
301 <Link to={`/${slug}/-/settings/chat`} className="underline underline-offset-4 hover:text-fg">
302 Chat settings
303 </Link>
304 .
305 </>
306 }
307 >
308 <Form method="post" className="space-y-3">
309 <input type="hidden" name="intent" value="chat-channels" />
310 <div className="grid gap-4 sm:grid-cols-2">
311 <div>
312 <h4 className="mb-2 text-xs font-medium text-fg-soft">Public channels</h4>
313 <RadioGroup name="public_channels" value={chosen.public} onValueChange={(value) => setChosen((now) => ({ ...now, public: value as ChatAllowed }))} aria-label="Who can create public channels">
314 <RadioOption value="members" label="Any member" description="The default." />
315 <RadioOption value="owners" label="Owners only" description="Members join and read public channels, but ask an owner for a new one." />
316 </RadioGroup>
317 </div>
318 <div>
319 <h4 className="mb-2 text-xs font-medium text-fg-soft">Private channels</h4>
320 <RadioGroup name="private_channels" value={chosen.private} onValueChange={(value) => setChosen((now) => ({ ...now, private: value as ChatAllowed }))} aria-label="Who can create private channels">
321 <RadioOption value="members" label="Any member" description="The default." />
322 <RadioOption value="owners" label="Owners only" description="Only owners can make a channel that is kept to the people invited." />
323 </RadioGroup>
324 </div>
325 </div>
326 <SaveRow intent="chat-channels" changed={changed} answer={answer} />
327 </Form>
328 </Setting>
329 );
330}