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.

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