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