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