Skip to content

g1t/apps/web/app/components/package-settings.tsx

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