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.

546 linesCodeBlameRaw
1import { Box, Lock, RotateCcw, Trash2, User, UserPlus, Users } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, Link, useFetcher } from "react-router";
4
5import {
6 type ActionsAccess,
7 PACKAGE_ROLES,
8 type PackageAccess,
9 type PackageRole,
10 type PackageSettings,
11 type PackageVersion,
12 PACKAGE_RESTORE_DAYS,
13} from "@g1t/contracts";
14
15import { ConfirmDialog } from "./repo-lifecycle";
16import { SettingsSection as Section } from "./settings-section";
17import { ErrorText, SubmitButton, TimeAgo } from "./ui";
18import { Badge } from "./ui/badge";
19import { Button } from "./ui/button";
20import { Card } from "./ui/card";
21import { Hint } from "./ui/hint";
22import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
23import { Skeleton, SkeletonLine, SkeletonRows } from "./ui/skeleton";
24import { SwitchCard } from "./ui/switch";
25import { formatBytes, shortDigest } from "../lib/packages";
26
27/** A one-line text field the height of the small selects beside it. */
28const FIELD =
29 "h-8 w-full min-w-0 rounded-md border border-line bg-bg px-2.5 font-mono text-sm placeholder:font-sans placeholder:text-faint focus:border-line-strong focus:outline-none";
30
31/** What an action on the Settings tab answers. */
32export type SettingsOutcome = { error: string | null; message: string | null };
33
34export const PACKAGE_ROLE_LABEL: Record<PackageRole, string> = { read: "Read", write: "Write", admin: "Admin" };
35const PACKAGE_ROLE_SUMMARY: Record<PackageRole, string> = {
36 read: "Pull and install it.",
37 write: "Also publish new versions and tags.",
38 admin: "Also delete and restore it, and change these settings.",
39};
40const ACTIONS_ROLE_SUMMARY: Record<"read" | "write", string> = {
41 read: "Its workflows pull or install the package.",
42 write: "Its workflows publish it too.",
43};
44
45/** A role picker: read, write and admin, or read and write for a repository. */
46function RoleSelect({
47 value,
48 roles,
49 label,
50 name,
51 disabled,
52 onValueChange,
53 className = "w-28",
54}: {
55 value: string;
56 roles: readonly ("read" | "write" | "admin")[];
57 label: string;
58 name?: string;
59 disabled?: boolean;
60 onValueChange?: (role: string) => void;
61 className?: string;
62}) {
63 return (
64 <Select name={name} value={value} onValueChange={onValueChange} disabled={disabled}>
65 <SelectTrigger size="sm" aria-label={label} className={className}>
66 <SelectValue />
67 </SelectTrigger>
68 <SelectContent align="end" className="w-64">
69 {roles.map((role) => (
70 <SelectItem
71 key={role}
72 value={role}
73 description={role === "admin" ? PACKAGE_ROLE_SUMMARY.admin : roles.length === 2 ? ACTIONS_ROLE_SUMMARY[role] : PACKAGE_ROLE_SUMMARY[role]}
74 >
75 {PACKAGE_ROLE_LABEL[role]}
76 </SelectItem>
77 ))}
78 </SelectContent>
79 </Select>
80 );
81}
82
83/** The Settings tab of a package: who may use it, from where, and what happens to it. Admins only. */
84export function PackageSettingsTab({ settings, outcome }: { settings: PackageSettings; outcome: SettingsOutcome | undefined }) {
85 const { package: pkg, access, actions_access: actions, deleted_versions: deleted, permissions } = settings;
86 const repo = pkg.repo ? `${pkg.repo.namespace}/${pkg.repo.name}` : null;
87 const npm = pkg.ecosystem !== "container";
88 return (
89 <div className="max-w-4xl space-y-8">
90 {outcome?.error && <ErrorText>{outcome.error}</ErrorText>}
91 {outcome?.message && (
92 <p className="text-sm text-success" role="status">
93 {outcome.message}
94 </p>
95 )}
96
97 <Section
98 title="Manage access"
99 about={
100 repo
101 ? `People and teams with a role on the package itself. The workspace's owners administer every package.`
102 : `People and teams with a role on the package itself, beyond what members of ${pkg.workspace} get from its base permission. Owners administer every package.`
103 }
104 >
105 {repo && <InheritToggle on={pkg.inherit_access !== false} repo={repo} />}
106 {access.length === 0 ? (
107 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
108 <p>No one has a role on the package itself.</p>
109 </Card>
110 ) : (
111 <Card asChild tone="plain" divided>
112 <ul>
113 {access.map((entry) => (
114 <AccessRow key={`${entry.kind}:${entry.id}`} entry={entry} workspace={pkg.workspace} />
115 ))}
116 </ul>
117 </Card>
118 )}
119 <AddAccessForm />
120 </Section>
121
122 <Section
123 title="Manage Actions access"
124 about="Which repositories' workflows may use the package with their job token, G1T_TOKEN. A workflow in any other repository is refused."
125 >
126 <Card asChild tone="plain" divided>
127 <ul>
128 {actions.length === 0 && <li className="px-4 py-6 text-center text-sm text-muted">No repository's workflows may use it yet.</li>}
129 {actions.map((entry) => (
130 <ActionsRow key={entry.repo_id} entry={entry} />
131 ))}
132 </ul>
133 </Card>
134 <AddActionsForm workspace={pkg.workspace} />
135 </Section>
136
137 <Section title="Visibility" about="Who may pull it. Public packages need no sign-in to pull; pushing always needs the Write role.">
138 {repo ? (
139 <Card asChild className="p-4 text-sm text-muted">
140 <p>
141 <span className="inline-flex items-center gap-1.5 font-medium text-fg">
142 {pkg.visibility === "private" && <Lock size={13} />}
143 {pkg.visibility === "private" ? "Private" : "Public"}
144 </span>
145 , as{" "}
146 <Link to={`/${repo}`} className="text-fg-soft hover:text-fg">
147 {repo}
148 </Link>{" "}
149 is. A linked package has its repository's visibility: change the repository's, or unlink the package.
150 </p>
151 </Card>
152 ) : (
153 <Card asChild className="flex flex-wrap items-center gap-3 p-4">
154 <Form method="post">
155 <input type="hidden" name="intent" value="visibility" />
156 <Select name="visibility" defaultValue={pkg.visibility}>
157 <SelectTrigger size="sm" aria-label="Visibility" className="w-full sm:w-64">
158 <SelectValue />
159 </SelectTrigger>
160 <SelectContent align="start">
161 <SelectItem value="private" description={`Members of ${pkg.workspace}, by its base permission, and those given a role here.`}>
162 Private
163 </SelectItem>
164 <SelectItem value="public" description="Anyone can pull it, signed in or not.">
165 Public
166 </SelectItem>
167 </SelectContent>
168 </Select>
169 <SubmitButton variant="outline" match={{ intent: "visibility" }} pending="Saving…">
170 Save
171 </SubmitButton>
172 </Form>
173 </Card>
174 )}
175 </Section>
176
177 <Section
178 title="Repository"
179 about="A linked package shows on its repository, takes its visibility and, while inheriting access, its roles, and its repository's workflows may publish it."
180 >
181 <Card className="space-y-3 p-4">
182 {repo ? (
183 <div className="flex flex-wrap items-center justify-between gap-3">
184 <p className="flex min-w-0 items-center gap-2 text-sm">
185 <Box size={14} className="shrink-0 text-faint" />
186 <span className="text-muted">Linked to</span>
187 <Link to={`/${repo}`} className="truncate font-mono text-fg-soft hover:text-fg">
188 {repo}
189 </Link>
190 </p>
191 <Form method="post">
192 <input type="hidden" name="intent" value="unlink" />
193 <SubmitButton variant="outline" match={{ intent: "unlink" }} pending="Unlinking…">
194 Unlink
195 </SubmitButton>
196 </Form>
197 </div>
198 ) : (
199 <p className="text-sm text-muted">Not linked: it is the workspace's.</p>
200 )}
201 <Form method="post" className="flex flex-wrap items-center gap-2">
202 <input type="hidden" name="intent" value="link" />
203 <label className="sr-only" htmlFor="link-repo">
204 Repository to link
205 </label>
206 <input id="link-repo" name="repo" required placeholder="repository" className={`${FIELD} sm:w-56`} />
207 <SubmitButton variant="outline" match={{ intent: "link" }} pending="Linking…">
208 {repo ? "Link to another" : "Link"}
209 </SubmitButton>
210 </Form>
211 <p className="text-xs text-faint">Linking needs the Admin role on the repository too.</p>
212 </Card>
213 </Section>
214
215 {permissions.delete && (
216 <Section
217 title="Deleted versions"
218 about={`Versions deleted in the last ${PACKAGE_RESTORE_DAYS} days, which can still be restored. Their ${npm ? "versions" : "digests"} cannot be published again until they are purged.`}
219 >
220 {deleted.length === 0 ? (
221 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
222 <p>No deleted versions.</p>
223 </Card>
224 ) : (
225 <Card asChild tone="plain" divided>
226 <ul>
227 {deleted.map((version) => (
228 <DeletedVersionRow key={version.id} version={version} npm={npm} />
229 ))}
230 </ul>
231 </Card>
232 )}
233 </Section>
234 )}
235
236 {permissions.delete && (
237 <Section title="Delete this package" about={`Every version goes from the registry at once. An admin can restore it for ${PACKAGE_RESTORE_DAYS} days.`}>
238 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-danger/30 bg-surface p-4">
239 <p className="text-sm text-muted">Its name stays taken until it is purged, so nobody else can publish a package called {pkg.name} meanwhile.</p>
240 <ConfirmDialog
241 intent="delete-package"
242 title={`Delete ${pkg.name}?`}
243 description={`Anyone pulling it gets an error from then on. It can be restored from the workspace's deleted packages for ${PACKAGE_RESTORE_DAYS} days.`}
244 confirm={pkg.name}
245 submit="Delete package"
246 busy="Deleting…"
247 trigger={(open) => (
248 <Button type="button" variant="destructive" onClick={open}>
249 <Trash2 size={14} />
250 Delete package
251 </Button>
252 )}
253 >
254 <li>
255 {pkg.versions} {pkg.versions === 1 ? "version" : "versions"} and their tags.
256 </li>
257 <li>After {PACKAGE_RESTORE_DAYS} days it is purged, and files no other package uses are removed.</li>
258 </ConfirmDialog>
259 </div>
260 </Section>
261 )}
262 </div>
263 );
264}
265
266/** Whether a linked package takes its repository's roles: saved as soon as it is flipped. */
267function InheritToggle({ on, repo }: { on: boolean; repo: string }) {
268 const fetcher = useFetcher<SettingsOutcome>();
269 const shown = fetcher.formData ? fetcher.formData.get("inherit") === "on" : on;
270 return (
271 <>
272 <SwitchCard
273 title="Inherit access from the linked repository"
274 checked={shown}
275 disabled={fetcher.state !== "idle"}
276 onCheckedChange={(checked) => fetcher.submit({ intent: "inherit", inherit: checked ? "on" : "off" }, { method: "post" })}
277 >
278 {shown
279 ? `Everyone with a role on ${repo} has it on the package too, and the people and teams below add to it.`
280 : `Only the people and teams below, and the workspace's owners, have access. Roles on ${repo} no longer reach the package.`}
281 </SwitchCard>
282 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
283 </>
284 );
285}
286
287function AccessRow({ entry, workspace }: { entry: PackageAccess; workspace: string }) {
288 const fetcher = useFetcher<SettingsOutcome>();
289 const shown = fetcher.formData?.get("intent") === "access-role" ? String(fetcher.formData.get("role")) : entry.role;
290 const removing = fetcher.formData?.get("intent") === "access-remove";
291 const slug = entry.name.includes("/") ? entry.name.slice(entry.name.indexOf("/") + 1) : entry.name;
292 const who: Record<string, string> = entry.kind === "user" ? { user: entry.name } : { team: slug };
293 return (
294 <li className={`flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3 ${removing ? "opacity-50" : ""}`}>
295 <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">
296 {entry.kind === "user" ? <User size={15} /> : <Users size={15} />}
297 </span>
298 <div className="min-w-0 grow basis-40">
299 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
300 <Link to={entry.kind === "user" ? `/u/${entry.name}` : `/${workspace}/-/teams/${slug}`} className="truncate font-mono text-sm hover:text-accent">
301 {entry.name}
302 </Link>
303 <Badge>{entry.kind === "user" ? "Person" : "Team"}</Badge>
304 </p>
305 <p className="mt-0.5 text-xs text-faint">
306 Added <TimeAgo at={entry.created_at} />
307 </p>
308 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
309 </div>
310 <div className="flex shrink-0 items-center gap-2">
311 <RoleSelect
312 value={shown}
313 roles={PACKAGE_ROLES}
314 label={`Role of ${entry.name}`}
315 disabled={fetcher.state !== "idle"}
316 onValueChange={(role) => fetcher.submit({ intent: "access-role", ...who, role }, { method: "post" })}
317 />
318 <Hint label={`Take ${entry.name}'s role away`}>
319 <Button
320 type="button"
321 aria-label={`Remove ${entry.name}`}
322 disabled={fetcher.state !== "idle"}
323 onClick={() => fetcher.submit({ intent: "access-remove", ...who }, { method: "post" })}
324 variant="ghost"
325 size="inline"
326 className="p-1.5 text-faint hover:text-danger"
327 >
328 <Trash2 size={14} />
329 </Button>
330 </Hint>
331 </div>
332 </li>
333 );
334}
335
336/** A person by username, or a team by slug, and the role to give. */
337function AddAccessForm() {
338 const [kind, setKind] = useState<"user" | "team">("user");
339 const [role, setRole] = useState<PackageRole>("read");
340 const fetcher = useFetcher<SettingsOutcome>();
341 const [key, setKey] = useState(0);
342 // Cleared once someone was added, ready for the next.
343 useEffect(() => {
344 if (fetcher.data && !fetcher.data.error) setKey((k) => k + 1);
345 }, [fetcher.data]);
346 return (
347 <Card asChild className="space-y-3 p-4">
348 <fetcher.Form method="post">
349 <input type="hidden" name="intent" value="access-add" />
350 <input type="hidden" name="kind" value={kind} />
351 <input type="hidden" name="role" value={role} />
352 <div className="grid gap-3 sm:grid-cols-[8rem_minmax(0,1fr)_7rem_auto] sm:items-center">
353 <Select value={kind} onValueChange={(value) => setKind(value as "user" | "team")}>
354 <SelectTrigger size="sm" aria-label="Person or team">
355 <SelectValue />
356 </SelectTrigger>
357 <SelectContent align="start">
358 <SelectItem value="user">Person</SelectItem>
359 <SelectItem value="team">Team</SelectItem>
360 </SelectContent>
361 </Select>
362 <input
363 key={key}
364 name="who"
365 required
366 aria-label={kind === "user" ? "Username" : "Team"}
367 placeholder={kind === "user" ? "username" : "team slug"}
368 className={FIELD}
369 />
370 <RoleSelect value={role} roles={PACKAGE_ROLES} label="Role" onValueChange={(value) => setRole(value as PackageRole)} className="w-full" />
371 <SubmitButton variant="outline" fetcher={fetcher} match={{ intent: "access-add" }} pending="Adding…">
372 <UserPlus size={14} />
373 Add
374 </SubmitButton>
375 </div>
376 <p className="text-xs text-faint">
377 {PACKAGE_ROLE_LABEL[role]}: {PACKAGE_ROLE_SUMMARY[role].toLowerCase()}
378 </p>
379 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
380 {fetcher.data?.message && (
381 <p className="text-sm text-success" role="status">
382 {fetcher.data.message}
383 </p>
384 )}
385 </fetcher.Form>
386 </Card>
387 );
388}
389
390function ActionsRow({ entry }: { entry: ActionsAccess }) {
391 const fetcher = useFetcher<SettingsOutcome>();
392 const shown = fetcher.formData?.get("intent") === "actions-role" ? String(fetcher.formData.get("role")) : entry.role;
393 const removing = fetcher.formData?.get("intent") === "actions-remove";
394 return (
395 <li className={`flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3 ${removing ? "opacity-50" : ""}`}>
396 <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-raised text-muted">
397 <Box size={15} />
398 </span>
399 <div className="min-w-0 grow basis-40">
400 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
401 <Link to={`/${entry.repo}`} className="truncate font-mono text-sm hover:text-accent">
402 {entry.repo}
403 </Link>
404 {entry.linked && <Badge tone="accent">Linked repository</Badge>}
405 </p>
406 <p className="mt-0.5 text-xs text-faint">
407 {entry.linked ? "Its workflows always publish the package it is linked to." : <>Added {entry.created_at && <TimeAgo at={entry.created_at} />}</>}
408 </p>
409 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
410 </div>
411 <div className="flex shrink-0 items-center gap-2">
412 {entry.linked ? (
413 <span className="inline-flex h-8 w-28 items-center rounded-md border border-line px-2.5 text-[0.8125rem] text-muted">Write</span>
414 ) : (
415 <RoleSelect
416 value={shown}
417 roles={["read", "write"]}
418 label={`Role of ${entry.repo}'s workflows`}
419 disabled={fetcher.state !== "idle"}
420 onValueChange={(role) => fetcher.submit({ intent: "actions-role", repo: entry.repo, role }, { method: "post" })}
421 />
422 )}
423 <span className="flex w-8.5 justify-end">
424 {!entry.linked && (
425 <Hint label={`Stop ${entry.repo}'s workflows using it`}>
426 <Button
427 type="button"
428 aria-label={`Remove ${entry.repo}`}
429 disabled={fetcher.state !== "idle"}
430 onClick={() => fetcher.submit({ intent: "actions-remove", repo: entry.repo }, { method: "post" })}
431 variant="ghost"
432 size="inline"
433 className="p-1.5 text-faint hover:text-danger"
434 >
435 <Trash2 size={14} />
436 </Button>
437 </Hint>
438 )}
439 </span>
440 </div>
441 </li>
442 );
443}
444
445function AddActionsForm({ workspace }: { workspace: string }) {
446 const [role, setRole] = useState<"read" | "write">("read");
447 const fetcher = useFetcher<SettingsOutcome>();
448 const [key, setKey] = useState(0);
449 useEffect(() => {
450 if (fetcher.data && !fetcher.data.error) setKey((k) => k + 1);
451 }, [fetcher.data]);
452 return (
453 <Card asChild className="space-y-3 p-4">
454 <fetcher.Form method="post">
455 <input type="hidden" name="intent" value="actions-add" />
456 <input type="hidden" name="role" value={role} />
457 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem_auto] sm:items-center">
458 <div className="flex h-8 min-w-0 items-center rounded-md border border-line bg-bg pl-2.5 font-mono text-sm focus-within:border-line-strong">
459 <span className="shrink-0 text-faint">{workspace}/</span>
460 <input
461 key={key}
462 name="repo"
463 required
464 aria-label="Repository"
465 placeholder="repository"
466 className="h-full min-w-0 grow bg-transparent pr-2.5 placeholder:text-faint focus:outline-none"
467 />
468 </div>
469 <RoleSelect value={role} roles={["read", "write"]} label="Role" onValueChange={(value) => setRole(value as "read" | "write")} className="w-full" />
470 <SubmitButton variant="outline" fetcher={fetcher} match={{ intent: "actions-add" }} pending="Adding…">
471 Add repository
472 </SubmitButton>
473 </div>
474 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
475 {fetcher.data?.message && (
476 <p className="text-sm text-success" role="status">
477 {fetcher.data.message}
478 </p>
479 )}
480 </fetcher.Form>
481 </Card>
482 );
483}
484
485function DeletedVersionRow({ version, npm }: { version: PackageVersion; npm: boolean }) {
486 const fetcher = useFetcher<SettingsOutcome>();
487 return (
488 <li className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
489 <div className="min-w-0 grow space-y-1">
490 <p className="flex flex-wrap items-center gap-2">
491 {npm ? (
492 <span className="font-mono text-sm font-medium">{version.version}</span>
493 ) : (
494 <Hint label={version.digest}>
495 <code className="font-mono text-sm">{shortDigest(version.digest)}</code>
496 </Hint>
497 )}
498 {version.tags.map((tag) => (
499 <Badge key={tag} className="font-mono">
500 {tag}
501 </Badge>
502 ))}
503 </p>
504 <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums">
505 <span>{formatBytes(version.size)}</span>
506 {version.deleted_at && (
507 <span>
508 Deleted {version.deleted_by ? `by ${version.deleted_by} ` : ""}
509 <TimeAgo at={version.deleted_at} />
510 </span>
511 )}
512 {version.purge_at && <span>Purged {new Date(version.purge_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>}
513 </p>
514 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
515 </div>
516 <fetcher.Form method="post">
517 <input type="hidden" name="intent" value="restore-version" />
518 <input type="hidden" name="version" value={version.id} />
519 <SubmitButton variant="outline" fetcher={fetcher} match={{ intent: "restore-version", version: version.id }} pending="Restoring…">
520 <RotateCcw size={14} />
521 Restore
522 </SubmitButton>
523 </fetcher.Form>
524 </li>
525 );
526}
527
528/** The Settings tab's outline while it loads. */
529export function PackageSettingsSkeleton() {
530 return (
531 <div aria-hidden="true" className="max-w-4xl space-y-8">
532 {[3, 2, 1].map((rows, index) => (
533 <div key={index} className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]">
534 <div className="space-y-2">
535 <SkeletonLine className="w-32" />
536 <Skeleton className="h-3 w-48" />
537 <Skeleton className="h-3 w-40" />
538 </div>
539 <Card tone="plain">
540 <SkeletonRows rows={rows} rowClassName="h-14" />
541 </Card>
542 </div>
543 ))}
544 </div>
545 );
546}