g1t/apps/web/app/components/package-settings.tsx
| 1 | import { Box, Lock, RotateCcw, Trash2, User, UserPlus, Users } from "lucide-react"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { Form, Link, useFetcher } from "react-router"; |
| 4 | |
| 5 | import { |
| 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 | |
| 15 | import { ConfirmDialog } from "./repo-lifecycle"; |
| 16 | import { SettingsSection as Section } from "./settings-section"; |
| 17 | import { Button, ErrorText, SubmitButton, TimeAgo } from "./ui"; |
| 18 | import { Badge } from "./ui/badge"; |
| 19 | import { Hint } from "./ui/hint"; |
| 20 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| 21 | import { Skeleton, SkeletonLine, SkeletonRows } from "./ui/skeleton"; |
| 22 | import { SwitchCard } from "./ui/switch"; |
| 23 | import { formatBytes, shortDigest } from "../lib/packages"; |
| 24 | |
| 25 | /** A one-line text field the height of the small selects beside it. */ |
| 26 | const 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. */ |
| 30 | export type SettingsOutcome = { error: string | null; message: string | null }; |
| 31 | |
| 32 | export const PACKAGE_ROLE_LABEL: Record<PackageRole, string> = { read: "Read", write: "Write", admin: "Admin" }; |
| 33 | const 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 | }; |
| 38 | const 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. */ |
| 44 | function 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. */ |
| 82 | export 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. */ |
| 260 | function 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 | |
| 280 | function 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. */ |
| 331 | function 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 | |
| 382 | function 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 | |
| 435 | function 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 | |
| 473 | function 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. */ |
| 517 | export 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 | } |