Archiving, transferring and deleting a project are on its General settings, and "Type … to confirm" reads as one sentence
Deleting sat at the bottom of Settings → Repository, though it takes the whole project (its repository, deployments and all) with it. Archive, Transfer and Delete now end Settings → General as "… this project", saying the repository goes with it (components/project-lifecycle.tsx); Repository keeps visibility and detaching a fork, and points to General for the rest. A transfer lands back on General; the archived banner's Unarchive links there. Docs follow (projects, managing-repositories, transferring-repositories). Every type-to-confirm field (repository and project dialogs, transfer, deleting an account or a workspace, changing a workspace's address) is one component, ConfirmName. The label is a flex row, so "Type", a long name such as syntaqx/automation-lab-fork-1014095710 and "to confirm" became three squeezed columns, and select-none kept the name from being copied. Now it is a sentence that wraps, the name in a code chip that one click selects.
- Checked:
- apps/web tsc -b; npm test (986).
- G1t-Session:
- ses_01m5013729enzs3bnmjpkyzm8p
| 121 | 121 | | **Pushes** | How many branches and tags one push may update. See [pushes](#pushes). | | |
| 122 | 122 | | **Archives** | Whether downloads hold Git LFS objects. Coming soon. | | |
| 123 | 123 | | **Forking** | For a private repository, whether people who can read it may [fork it](/guides/forking-repositories/#allow-forking-a-private-repository). Public repositories can always be forked. | | |
| 124 | − | | **Danger zone** | Change visibility, archive, [transfer](/guides/transferring-repositories/), [detach a fork](/guides/forking-repositories/#detach-a-fork) and delete. Shown to people with the Admin role. | | |
| 124 | + | | **Danger zone** | Change visibility and [detach a fork](/guides/forking-repositories/#detach-a-fork). Shown to people with the Admin role. Archiving, [transferring](/guides/transferring-repositories/) and deleting are the project's, in the danger zone of **Settings → General**. | | |
| 125 | 125 | ||
| 126 | 126 | While a repository is [archived](#archive-a-repository), the settings that | |
| 127 | 127 | change it are turned off until it is unarchived. | |
| ⋯ | |||
| 429 | 429 | Archiving makes a repository read-only. Use it for work that is finished | |
| 430 | 430 | but should stay readable. | |
| 431 | 431 | ||
| 432 | − | 1. Open the repository's **Settings → Repository**. | |
| 432 | + | 1. Open the project's **Settings → General**. | |
| 433 | 433 | 2. Under **Danger zone**, choose **Archive**. | |
| 434 | 434 | 3. Read what changes and choose **Archive** again. | |
| 435 | 435 | ||
| ⋯ | |||
| 471 | 471 | Deleting takes a repository away at once, but an owner can restore it for | |
| 472 | 472 | 30 days. After that it is purged: removed for good, its git data with it. | |
| 473 | 473 | ||
| 474 | − | 1. Open the repository's **Settings → Repository**. | |
| 475 | − | 2. Under **Danger zone**, choose **Delete**. | |
| 476 | − | 3. Read what happens, type the repository's full name | |
| 477 | − | (`<workspace>/<repo>`) to confirm, and choose **Delete repository**. | |
| 474 | + | 1. Open the project's **Settings → General**. | |
| 475 | + | 2. Under **Danger zone**, choose **Delete**. The project goes with its | |
| 476 | + | repository. | |
| 477 | + | 3. Read what happens, type the full name (`<workspace>/<repo>`) to | |
| 478 | + | confirm, and choose **Delete project**. | |
| 478 | 479 | ||
| 479 | 480 | | | While it is deleted | When it is purged | | |
| 480 | 481 | | --- | --- | --- | | |
| 223 | 223 | ||
| 224 | 224 | | Tab | What it holds | | |
| 225 | 225 | | --- | --- | | |
| 226 | − | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), its [links](#links), and its [production image](#uploading-an-image). Only projects have descriptions: a repository has none of its own, and search, link previews and lists of repositories show its project's. A fork's project starts with the description of the project it was forked from. | | |
| 226 | + | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), its [links](#links), and its [production image](#uploading-an-image), and its danger zone: archive, [transfer](/guides/transferring-repositories/) and delete, which take its repository with it. Only projects have descriptions: a repository has none of its own, and search, link previews and lists of repositories show its project's. A fork's project starts with the description of the project it was forked from. | | |
| 227 | 227 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 228 | 228 | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 229 | 229 | | **Guardrails** | How long g1t's checks, merge queue and merge checks may run here. See [guardrails](/guides/guardrails/). | | |
| 230 | − | | **Repository** | The repository's name, website, [topics](/guides/search/#what-is-indexed) and default branch, and its danger zone: visibility, archive, transfer and delete. See [Managing a repository](/guides/managing-repositories/). | | |
| 230 | + | | **Repository** | The repository's name, website, [topics](/guides/search/#what-is-indexed) and default branch, and its danger zone: visibility, and detaching a fork. See [Managing a repository](/guides/managing-repositories/). | | |
| 231 | 231 | | **Access** | Who has a [role](/guides/access-and-roles/) on the repository, and invitations. | | |
| 232 | 232 | | **Branches and merging** | Auto-merge, the CODEOWNERS file's errors, and what the rules hold for the default branch. | | |
| 233 | 233 | | **Rules** | [Rulesets](/guides/rules/): branch and tag protection, [required status checks](/guides/pull-requests/#required-status-checks), approvals and the merge queue, with Insights. | | |
| ⋯ | |||
| 248 | 248 | | **Rules**, **Guardrails** | Admin | | |
| 249 | 249 | | **Access**: seeing who has a role; changing it | Write; Admin | | |
| 250 | 250 | | **Deployments**, **Domains**, **Secrets and variables**, **Runners**, **Webhooks** | Admin | | |
| 251 | − | | On **Repository**: its name, default branch, and the danger zone (visibility, archive) | Admin | | |
| 251 | + | | On **Repository**: its name, default branch, and the danger zone (visibility); on **General**, archive | Admin | | |
| 252 | 252 | | Transfer and delete | An owner of the workspace | | |
| 253 | 253 | ||
| 254 | 254 | Someone without the role does not see the tab. | |
| 25 | 25 | ||
| 26 | 26 | ## Transfer a repository | |
| 27 | 27 | ||
| 28 | − | 1. Open the repository's **Settings → Repository**. | |
| 29 | − | 2. Under **Danger zone**, choose **Transfer**. | |
| 28 | + | 1. Open the project's **Settings → General**. | |
| 29 | + | 2. Under **Danger zone**, choose **Transfer**. The project moves with its | |
| 30 | + | repository. | |
| 30 | 31 | 3. Pick the workspace to move it to. Only workspaces where you can create | |
| 31 | 32 | a repository like it are listed. | |
| 32 | 33 | 4. Read what changes, type the repository's full name (`<old>/<repo>`) to |
| 1 | + | import { type ComponentProps, useId } from "react"; | |
| 2 | + | ||
| 3 | + | import { FieldLabel, Field as FormField } from "./ui/field"; | |
| 4 | + | import { Input as TextInput } from "./ui/input"; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * "Type acme/web to confirm", above the box it is typed in. The words run | |
| 8 | + | * as one sentence that wraps however long the name is (a label is a flex | |
| 9 | + | * row otherwise, which split them into columns), and one click selects | |
| 10 | + | * the name to copy it. | |
| 11 | + | */ | |
| 12 | + | export function ConfirmName({ | |
| 13 | + | confirm, | |
| 14 | + | id, | |
| 15 | + | ...input | |
| 16 | + | }: { confirm: string; id?: string } & ComponentProps<typeof TextInput>) { | |
| 17 | + | const fallback = useId(); | |
| 18 | + | const field = id ?? `${fallback}-confirm`; | |
| 19 | + | return ( | |
| 20 | + | <FormField> | |
| 21 | + | <FieldLabel htmlFor={field} className="block leading-relaxed select-text"> | |
| 22 | + | Type{" "} | |
| 23 | + | <span className="rounded bg-raised px-1 py-0.5 font-mono text-[0.8125rem] break-all text-fg select-all"> | |
| 24 | + | {confirm} | |
| 25 | + | </span>{" "} | |
| 26 | + | to confirm | |
| 27 | + | </FieldLabel> | |
| 28 | + | <TextInput | |
| 29 | + | id={field} | |
| 30 | + | name="confirm" | |
| 31 | + | spellCheck={false} | |
| 32 | + | autoCapitalize="off" | |
| 33 | + | autoComplete="off" | |
| 34 | + | className="font-mono" | |
| 35 | + | {...input} | |
| 36 | + | /> | |
| 37 | + | </FormField> | |
| 38 | + | ); | |
| 39 | + | } |
| 3 | 3 | ||
| 4 | 4 | import { ACCOUNT_RESTORE_DAYS, type AccountDeletion } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | + | import { ConfirmName } from "./confirm-name"; | |
| 6 | 7 | import { DangerAction } from "./danger-zone"; | |
| 7 | 8 | import { ErrorText } from "./ui"; | |
| 8 | 9 | import { | |
| ⋯ | |||
| 139 | 140 | The username <span className="font-mono text-fg">{username}</span> is never given to anyone else. | |
| 140 | 141 | </li> | |
| 141 | 142 | </ul> | |
| 142 | − | <FormField> | |
| 143 | − | <FieldLabel htmlFor="confirm-account"> | |
| 144 | − | Type <span className="font-mono text-fg">{username}</span> to confirm | |
| 145 | − | </FieldLabel> | |
| 146 | − | <TextInput | |
| 147 | − | id="confirm-account" | |
| 148 | − | name="confirm" | |
| 149 | − | value={confirm} | |
| 150 | − | spellCheck={false} | |
| 151 | − | autoCapitalize="off" | |
| 152 | − | autoComplete="off" | |
| 153 | − | onChange={(event) => setConfirm(event.target.value)} | |
| 154 | − | className="font-mono" | |
| 155 | − | /> | |
| 156 | − | </FormField> | |
| 143 | + | <ConfirmName confirm={username} id="confirm-account" value={confirm} onChange={(event) => setConfirm(event.target.value)} /> | |
| 157 | 144 | {hasPassword ? ( | |
| 158 | 145 | <FormField> | |
| 159 | 146 | <FieldLabel htmlFor="confirm-password">Your password</FieldLabel> | |
| 1 | + | import { useState } from "react"; | |
| 2 | + | import { Form, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { RESTORE_DAYS } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { ConfirmName } from "./confirm-name"; | |
| 7 | + | import { DangerAction, DangerZone } from "./danger-zone"; | |
| 8 | + | import { ConfirmDialog } from "./repo-lifecycle"; | |
| 9 | + | import { ErrorText } from "./ui"; | |
| 10 | + | import { | |
| 11 | + | AlertDialog, | |
| 12 | + | AlertDialogCancel, | |
| 13 | + | AlertDialogContent, | |
| 14 | + | AlertDialogDescription, | |
| 15 | + | AlertDialogFooter, | |
| 16 | + | AlertDialogHeader, | |
| 17 | + | AlertDialogTitle, | |
| 18 | + | } from "./ui/alert-dialog"; | |
| 19 | + | import { Button } from "./ui/button"; | |
| 20 | + | import { FieldLabel, Field as FormField } from "./ui/field"; | |
| 21 | + | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 22 | + | import { confirmsName } from "../lib/repo-lifecycle"; | |
| 23 | + | ||
| 24 | + | /** The intents General's action takes from these forms. */ | |
| 25 | + | export const LIFECYCLE_INTENTS = ["archive", "transfer", "delete"] as const; | |
| 26 | + | export type LifecycleIntent = (typeof LIFECYCLE_INTENTS)[number]; | |
| 27 | + | ||
| 28 | + | /** | |
| 29 | + | * The end of General: archiving, moving or deleting the project, its | |
| 30 | + | * repository with it. `remove` says whether this person may transfer and | |
| 31 | + | * delete (owners, unless member privileges let Admins). | |
| 32 | + | */ | |
| 33 | + | export function ProjectDangerZone({ | |
| 34 | + | full, | |
| 35 | + | archived, | |
| 36 | + | remove, | |
| 37 | + | destinations, | |
| 38 | + | error, | |
| 39 | + | }: { | |
| 40 | + | full: string; | |
| 41 | + | archived: boolean; | |
| 42 | + | remove: boolean; | |
| 43 | + | destinations: { slug: string; name: string }[]; | |
| 44 | + | error: (intent: LifecycleIntent) => string | null; | |
| 45 | + | }) { | |
| 46 | + | return ( | |
| 47 | + | <div id="danger-zone" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 48 | + | <DangerZone> | |
| 49 | + | <ArchiveAction full={full} archived={archived} error={error("archive")} /> | |
| 50 | + | {remove && <TransferAction repo={full} destinations={destinations} error={error("transfer")} />} | |
| 51 | + | {remove && <DeleteAction full={full} error={error("delete")} />} | |
| 52 | + | </DangerZone> | |
| 53 | + | {!remove && ( | |
| 54 | + | <p className="mt-4 text-sm text-muted"> | |
| 55 | + | Only an owner of the workspace can transfer or delete it: its member privileges keep that to owners. | |
| 56 | + | </p> | |
| 57 | + | )} | |
| 58 | + | </div> | |
| 59 | + | ); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** Read-only, or back to normal. */ | |
| 63 | + | function ArchiveAction({ full, archived, error }: { full: string; archived: boolean; error: string | null }) { | |
| 64 | + | return ( | |
| 65 | + | <ConfirmDialog | |
| 66 | + | intent="archive" | |
| 67 | + | fields={{ archived: archived ? "false" : "true" }} | |
| 68 | + | title={archived ? `Unarchive ${full}?` : `Archive ${full}?`} | |
| 69 | + | description={archived ? "It goes back to working as before." : "It becomes read-only. You can unarchive it at any time."} | |
| 70 | + | submit={archived ? "Unarchive" : "Archive"} | |
| 71 | + | busy={archived ? "Unarchiving…" : "Archiving…"} | |
| 72 | + | danger={!archived} | |
| 73 | + | error={error} | |
| 74 | + | trigger={(open) => ( | |
| 75 | + | <DangerAction | |
| 76 | + | title={archived ? "Unarchive this project" : "Archive this project"} | |
| 77 | + | action={ | |
| 78 | + | <Button type="button" variant="destructive" onClick={open}> | |
| 79 | + | {archived ? "Unarchive" : "Archive"} | |
| 80 | + | </Button> | |
| 81 | + | } | |
| 82 | + | > | |
| 83 | + | {archived | |
| 84 | + | ? "It is archived and its repository read-only. Unarchiving lets pushes, merges and workflows work again." | |
| 85 | + | : "Make its repository read-only: everything stays where it is and can be read, and nothing changes it."} | |
| 86 | + | </DangerAction> | |
| 87 | + | )} | |
| 88 | + | > | |
| 89 | + | {archived ? ( | |
| 90 | + | <> | |
| 91 | + | <li>Pushes and merges are accepted again.</li> | |
| 92 | + | <li>Issues and pull requests unlock.</li> | |
| 93 | + | <li>Workflows run again.</li> | |
| 94 | + | </> | |
| 95 | + | ) : ( | |
| 96 | + | <> | |
| 97 | + | <li>Pushes and merges are refused, for everyone.</li> | |
| 98 | + | <li>Issues and pull requests are locked. They stay readable.</li> | |
| 99 | + | <li>Workflows do not run.</li> | |
| 100 | + | <li>Its deployments keep serving, and anyone who could see it still can.</li> | |
| 101 | + | </> | |
| 102 | + | )} | |
| 103 | + | </ConfirmDialog> | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** Deleting: hidden at once, restorable for a while, then gone. */ | |
| 108 | + | function DeleteAction({ full, error }: { full: string; error: string | null }) { | |
| 109 | + | const namespace = full.split("/")[0]; | |
| 110 | + | return ( | |
| 111 | + | <ConfirmDialog | |
| 112 | + | intent="delete" | |
| 113 | + | title={`Delete ${full}?`} | |
| 114 | + | description={`You can restore it for ${RESTORE_DAYS} days.`} | |
| 115 | + | confirm={full} | |
| 116 | + | submit="Delete project" | |
| 117 | + | busy="Deleting…" | |
| 118 | + | error={error} | |
| 119 | + | trigger={(open) => ( | |
| 120 | + | <DangerAction | |
| 121 | + | title="Delete this project" | |
| 122 | + | action={ | |
| 123 | + | <Button type="button" variant="destructive" onClick={open}> | |
| 124 | + | Delete | |
| 125 | + | </Button> | |
| 126 | + | } | |
| 127 | + | > | |
| 128 | + | Its repository goes with it. It can be restored from the workspace's repositories for {RESTORE_DAYS} days, then | |
| 129 | + | it is removed for good. | |
| 130 | + | </DangerAction> | |
| 131 | + | )} | |
| 132 | + | > | |
| 133 | + | <li> | |
| 134 | + | It disappears at once with its repository: its pages, git remote and API stop answering, for everyone, and its | |
| 135 | + | apps on g1t.page stop serving. | |
| 136 | + | </li> | |
| 137 | + | <li> | |
| 138 | + | Owners can restore it, as it was, from <span className="font-mono text-fg">{namespace}</span>'s Recently deleted | |
| 139 | + | repositories for {RESTORE_DAYS} days, custom domains and settings included. After that it is removed for good, | |
| 140 | + | its git data with it. | |
| 141 | + | </li> | |
| 142 | + | <li>Its name stays taken until then, so nothing else can be made at its address.</li> | |
| 143 | + | </ConfirmDialog> | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /** | |
| 148 | + | * Moving the project, with its repository, to another workspace the person | |
| 149 | + | * may create one in. The dialog says what moves and what changes, and asks | |
| 150 | + | * for the full name. | |
| 151 | + | */ | |
| 152 | + | function TransferAction({ | |
| 153 | + | repo, | |
| 154 | + | destinations, | |
| 155 | + | error, | |
| 156 | + | }: { | |
| 157 | + | repo: string; | |
| 158 | + | destinations: { slug: string; name: string }[]; | |
| 159 | + | error: string | null; | |
| 160 | + | }) { | |
| 161 | + | const [open, setOpen] = useState(Boolean(error)); | |
| 162 | + | const [to, setTo] = useState(destinations[0]?.slug ?? ""); | |
| 163 | + | const [confirm, setConfirm] = useState(""); | |
| 164 | + | const navigation = useNavigation(); | |
| 165 | + | const moving = navigation.state !== "idle" && navigation.formData?.get("intent") === "transfer"; | |
| 166 | + | const name = repo.split("/")[1]; | |
| 167 | + | return ( | |
| 168 | + | <DangerAction | |
| 169 | + | title="Transfer this project" | |
| 170 | + | action={ | |
| 171 | + | <Button type="button" variant="destructive" disabled={destinations.length === 0} onClick={() => setOpen(true)}> | |
| 172 | + | Transfer | |
| 173 | + | </Button> | |
| 174 | + | } | |
| 175 | + | > | |
| 176 | + | {destinations.length === 0 ? ( | |
| 177 | + | <>Move it to another workspace you own. You own no other workspace yet.</> | |
| 178 | + | ) : ( | |
| 179 | + | <>Move it, with its repository, to another workspace you own. Its old address keeps redirecting.</> | |
| 180 | + | )} | |
| 181 | + | <AlertDialog | |
| 182 | + | open={open} | |
| 183 | + | onOpenChange={(next) => { | |
| 184 | + | setOpen(next); | |
| 185 | + | setConfirm(""); | |
| 186 | + | }} | |
| 187 | + | > | |
| 188 | + | <AlertDialogContent> | |
| 189 | + | <Form method="post" className="grid gap-4"> | |
| 190 | + | <input type="hidden" name="intent" value="transfer" /> | |
| 191 | + | <AlertDialogHeader> | |
| 192 | + | <AlertDialogTitle>Transfer {repo}</AlertDialogTitle> | |
| 193 | + | <AlertDialogDescription> | |
| 194 | + | It keeps its name and moves with everything in it: code, issues, pull requests, workflow runs, | |
| 195 | + | deployments, and its own secrets, variables and webhooks. | |
| 196 | + | </AlertDialogDescription> | |
| 197 | + | </AlertDialogHeader> | |
| 198 | + | <FormField> | |
| 199 | + | <FieldLabel htmlFor="transfer-to">New workspace</FieldLabel> | |
| 200 | + | <Select name="to" value={to} onValueChange={setTo}> | |
| 201 | + | <SelectTrigger id="transfer-to" aria-label="New workspace"> | |
| 202 | + | <SelectValue /> | |
| 203 | + | </SelectTrigger> | |
| 204 | + | <SelectContent> | |
| 205 | + | {destinations.map((d) => ( | |
| 206 | + | <SelectItem key={d.slug} value={d.slug}> | |
| 207 | + | {d.name === d.slug ? d.slug : `${d.name} (${d.slug})`} | |
| 208 | + | </SelectItem> | |
| 209 | + | ))} | |
| 210 | + | </SelectContent> | |
| 211 | + | </Select> | |
| 212 | + | </FormField> | |
| 213 | + | <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted"> | |
| 214 | + | <li> | |
| 215 | + | It moves to <span className="font-mono break-all text-fg">g1t.sh/{to}/{name}</span>. Links, git remotes | |
| 216 | + | and API calls to <span className="font-mono break-all">g1t.sh/{repo}</span> redirect there until a | |
| 217 | + | repository is made at the old address. | |
| 218 | + | </li> | |
| 219 | + | <li> | |
| 220 | + | Apps it deploys move to <span className="font-mono">*.g1t.page</span> names with the new workspace; the | |
| 221 | + | old ones redirect for 90 days. Custom domains follow. | |
| 222 | + | </li> | |
| 223 | + | <li>The old workspace's secrets, webhooks and integrations stop reaching it; the new one's start.</li> | |
| 224 | + | <li>Usage from now on is charged to {to}. What it used before stays on the old workspace's bill.</li> | |
| 225 | + | </ul> | |
| 226 | + | <ConfirmName confirm={repo} id="transfer-confirm" value={confirm} onChange={(event) => setConfirm(event.target.value)} /> | |
| 227 | + | <ErrorText>{error}</ErrorText> | |
| 228 | + | <AlertDialogFooter> | |
| 229 | + | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 230 | + | <Button type="submit" variant="destructive" disabled={!to || !confirmsName(confirm, repo) || moving}> | |
| 231 | + | {moving ? "Transferring…" : "Transfer"} | |
| 232 | + | </Button> | |
| 233 | + | </AlertDialogFooter> | |
| 234 | + | </Form> | |
| 235 | + | </AlertDialogContent> | |
| 236 | + | </AlertDialog> | |
| 237 | + | </DangerAction> | |
| 238 | + | ); | |
| 239 | + | } |
| 12 | 12 | AlertDialogHeader, | |
| 13 | 13 | AlertDialogTitle, | |
| 14 | 14 | } from "./ui/alert-dialog"; | |
| 15 | − | import { FieldLabel, Field as FormField } from "./ui/field"; | |
| 16 | − | import { Input as TextInput } from "./ui/input"; | |
| 15 | + | import { ConfirmName } from "./confirm-name"; | |
| 17 | 16 | import { confirmsName } from "../lib/repo-lifecycle"; | |
| 18 | 17 | ||
| 19 | 18 | /** | |
| ⋯ | |||
| 94 | 93 | {children && <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">{children}</ul>} | |
| 95 | 94 | {extra} | |
| 96 | 95 | {confirm != null && ( | |
| 97 | − | <FormField> | |
| 98 | − | <FieldLabel htmlFor={`${id}-confirm`}> | |
| 99 | − | Type <span className="font-mono break-all text-fg">{confirm}</span> to confirm | |
| 100 | − | </FieldLabel> | |
| 101 | − | <TextInput | |
| 102 | − | id={`${id}-confirm`} | |
| 103 | − | name="confirm" | |
| 104 | − | value={typed} | |
| 105 | − | spellCheck={false} | |
| 106 | − | autoCapitalize="off" | |
| 107 | − | autoComplete="off" | |
| 108 | − | onChange={(event) => setTyped(event.target.value)} | |
| 109 | − | className="font-mono" | |
| 110 | − | /> | |
| 111 | − | </FormField> | |
| 96 | + | <ConfirmName confirm={confirm} id={`${id}-confirm`} value={typed} onChange={(event) => setTyped(event.target.value)} /> | |
| 112 | 97 | )} | |
| 113 | 98 | <ErrorText>{error}</ErrorText> | |
| 114 | 99 | <AlertDialogFooter> | |
| ⋯ | |||
| 148 | 133 | </p> | |
| 149 | 134 | {owner && ( | |
| 150 | 135 | <Link | |
| 151 | − | to={`${base}/settings/repository#danger-zone`} | |
| 136 | + | to={`${base}/settings#danger-zone`} | |
| 152 | 137 | className="shrink-0 pl-6.5 font-medium text-warn hover:underline sm:pl-0" | |
| 153 | 138 | > | |
| 154 | 139 | Unarchive | |
| 2 | 2 | ||
| 3 | 3 | /** Each of a project's settings pages: its name and what it is for. */ | |
| 4 | 4 | const PAGES: Record<string, { title: string; about: string }> = { | |
| 5 | − | "": { title: "General", about: "The project's name, where its code lives, and what it is for." }, | |
| 5 | + | "": { title: "General", about: "The project's name, where its code lives, what it is for, and archiving, moving or deleting it." }, | |
| 6 | 6 | deployments: { title: "Deployments", about: "How the project builds, where it runs, and its previews." }, | |
| 7 | 7 | domains: { title: "Domains", about: "Addresses of your own for the production deployment." }, | |
| 8 | 8 | guardrails: { title: "Guardrails", about: "How long this project's runs may take." }, | |
| 9 | − | repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." }, | |
| 9 | + | repository: { title: "Repository", about: "Its name, details and default branch, and who can see it." }, | |
| 10 | 10 | access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." }, | |
| 11 | 11 | keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." }, | |
| 12 | 12 | branches: { title: "Branches and merging", about: "How pull requests merge, and who owns which files." }, |
| 2 | 2 | import { useState } from "react"; | |
| 3 | 3 | import { Form, Link, redirect, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import { DEFAULT_MEMBER_PRIVILEGES, RESTORE_DAYS, type Result, needs } from "@g1t/contracts"; | |
| 5 | + | import { type Result, needs } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { DangerAction, DangerZone } from "../../components/danger-zone"; | |
| 8 | 8 | import { ConfirmDialog } from "../../components/repo-lifecycle"; | |
| ⋯ | |||
| 14 | 14 | import { confirmsName, tidyName } from "../../lib/repo-lifecycle"; | |
| 15 | 15 | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 16 | 16 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 17 | − | import { | |
| 18 | − | AlertDialog, | |
| 19 | − | AlertDialogCancel, | |
| 20 | − | AlertDialogContent, | |
| 21 | − | AlertDialogDescription, | |
| 22 | − | AlertDialogFooter, | |
| 23 | − | AlertDialogHeader, | |
| 24 | − | AlertDialogTitle, | |
| 25 | − | } from "../../components/ui/alert-dialog"; | |
| 26 | 17 | import { Button } from "../../components/ui/button"; | |
| 27 | 18 | import { Badge } from "../../components/ui/badge"; | |
| 28 | 19 | import { Card } from "../../components/ui/card"; | |
| ⋯ | |||
| 50 | 41 | actions.artifactRetention(path, viewer), | |
| 51 | 42 | work.getSettings(path, viewer).catch(() => null), | |
| 52 | 43 | ]); | |
| 53 | − | // Renaming, archiving and the default branch are for Admins; visibility, | |
| 54 | − | // moving and deleting, for owners of the workspace unless its member | |
| 55 | − | // privileges let its members with Admin do them. | |
| 44 | + | // Renaming and the default branch are for Admins; visibility, for owners | |
| 45 | + | // of the workspace unless its member privileges let its members with | |
| 46 | + | // Admin. Archiving, moving and deleting are the project's, on General. | |
| 56 | 47 | const owner = access.can.administer; | |
| 57 | − | // Where it can go: a workspace where the person can create a repository like it. | |
| 58 | − | const destinations = access.can.delete | |
| 59 | − | ? (viewer?.workspaces ?? []) | |
| 60 | − | .filter((m) => m.slug !== params.owner.toLowerCase()) | |
| 61 | − | .filter((m) => { | |
| 62 | − | if (m.role === "owner") return true; | |
| 63 | − | const privileges = { ...DEFAULT_MEMBER_PRIVILEGES, ...(m.privileges ?? {}) }; | |
| 64 | − | return repo.isPrivate ? privileges.members_can_create_private_repositories : privileges.members_can_create_public_repositories; | |
| 65 | − | }) | |
| 66 | − | .map((m) => ({ slug: m.slug, name: m.name ?? m.slug })) | |
| 67 | − | : []; | |
| 68 | 48 | return { | |
| 69 | 49 | repo, | |
| 70 | 50 | // An empty repository has no branches yet; the page still works. | |
| 71 | 51 | branches: branches.ok ? branches.value.map((b) => b.name) : [], | |
| 72 | 52 | owner, | |
| 73 | − | remove: access.can.delete, | |
| 74 | 53 | visibility: access.can.change_visibility, | |
| 75 | − | destinations, | |
| 76 | 54 | // How long workflow runs' artifacts are kept. | |
| 77 | 55 | retention: retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 }, | |
| 78 | 56 | // Whether issues can be opened in it; null when that could not be read. | |
| ⋯ | |||
| 105 | 83 | const text = (name: string) => String(form.get(name) ?? "").trim(); | |
| 106 | 84 | const typed = text("confirm"); | |
| 107 | 85 | // What each form needs: the details are settings, renaming a branch other | |
| 108 | − | // than the default is pushing, transfer and delete are for owners. | |
| 86 | + | // than the default is pushing. | |
| 109 | 87 | const capability = | |
| 110 | 88 | intent === "details" || intent === "artifacts" || intent === "features" || intent === "commits" | |
| 111 | 89 | ? "manage_settings" | |
| 112 | 90 | : intent === "rename-branch" | |
| 113 | 91 | ? "push" | |
| 114 | − | : intent === "transfer" || intent === "delete" | |
| 115 | − | ? "delete" | |
| 116 | − | : "administer"; | |
| 92 | + | : "administer"; | |
| 117 | 93 | const refused = await refusal(context, params, capability); | |
| 118 | 94 | if (refused) return { intent, saved: false, error: refused }; | |
| 119 | 95 | ||
| ⋯ | |||
| 132 | 108 | if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` }; | |
| 133 | 109 | return outcome(intent, await repos.setVisibility(user, path, text("visibility") === "private", typed)); | |
| 134 | 110 | } | |
| 135 | − | case "archive": | |
| 136 | − | return outcome(intent, await repos.archive(user, path, text("archived") === "true")); | |
| 137 | 111 | case "features": { | |
| 138 | 112 | // The other settings are sent back as they are: only issues change here. | |
| 139 | 113 | const current = await work.getSettings(path, user); | |
| ⋯ | |||
| 174 | 148 | return outcome(intent, await repos.detachFork(user, path)); | |
| 175 | 149 | case "artifacts": | |
| 176 | 150 | return outcome(intent, await actions.artifactRetention(path, user, Number(text("days")))); | |
| 177 | − | case "transfer": { | |
| 178 | − | // repos checks both workspaces' owners, the name and storage, and | |
| 179 | − | // keeps the old address as a redirect. | |
| 180 | − | const to = text("to").toLowerCase(); | |
| 181 | − | if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` }; | |
| 182 | − | const moved = await repos.transfer(user, path, to); | |
| 183 | − | if (!moved.ok) return outcome(intent, moved); | |
| 184 | − | throw redirect(`/${moved.value.namespace}/${moved.value.name}/settings/repository`); | |
| 185 | − | } | |
| 186 | − | case "delete": { | |
| 187 | − | if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` }; | |
| 188 | − | const deleted = await repos.delete(user, path, typed); | |
| 189 | − | if (!deleted.ok) return outcome(intent, deleted); | |
| 190 | − | throw redirect(`/${params.owner}/-/repositories?deleted=${encodeURIComponent(deleted.value.name)}`); | |
| 191 | − | } | |
| 192 | 151 | default: | |
| 193 | 152 | return outcome( | |
| 194 | 153 | "details", | |
| ⋯ | |||
| 208 | 167 | } | |
| 209 | 168 | ||
| 210 | 169 | export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 211 | − | const { repo, branches, owner, remove, visibility, destinations, retention, issues, pullSettings } = loaderData; | |
| 170 | + | const { repo, branches, owner, visibility, retention, issues, pullSettings } = loaderData; | |
| 212 | 171 | const navigation = useNavigation(); | |
| 213 | 172 | const posting = (intent: string) => navigation.state !== "idle" && navigation.formData?.get("intent") === intent; | |
| 214 | 173 | const base = `/${repo.namespace}/${repo.name}`; | |
| ⋯ | |||
| 433 | 392 | </Section> | |
| 434 | 393 | )} | |
| 435 | 394 | ||
| 436 | − | {owner ? ( | |
| 395 | + | {owner && (visibility || repo.parentId) ? ( | |
| 437 | 396 | <div id="danger-zone" className="scroll-mt-20 border-t border-line pt-8"> | |
| 438 | 397 | <DangerZone> | |
| 439 | 398 | {visibility && <VisibilityAction full={full} isPrivate={repo.isPrivate} fork={Boolean(repo.parentId)} error={errorFor("visibility")} />} | |
| 440 | 399 | {repo.parentId && <DetachAction full={full} parent={repo.parent ?? null} error={errorFor("detach")} />} | |
| 441 | − | <ArchiveAction full={full} archived={archived} error={errorFor("archive")} /> | |
| 442 | − | {remove && <TransferAction repo={full} destinations={destinations} error={errorFor("transfer") ?? undefined} />} | |
| 443 | − | {remove && <DeleteAction full={full} error={errorFor("delete")} />} | |
| 444 | 400 | </DangerZone> | |
| 445 | − | {(!remove || !visibility) && ( | |
| 446 | − | <p className="mt-4 text-sm text-muted"> | |
| 447 | − | Only an owner of the workspace can{" "} | |
| 448 | − | {[!visibility && "change who can see it", !remove && "transfer or delete it"].filter(Boolean).join(", or ")}: | |
| 449 | − | its member privileges keep that to owners. | |
| 401 | + | <LifecycleNote base={base} /> | |
| 402 | + | </div> | |
| 403 | + | ) : ( | |
| 404 | + | <div className="border-t border-line pt-8 text-sm text-muted"> | |
| 405 | + | {owner ? ( | |
| 406 | + | <p>Only an owner of the workspace can change who can see it: its member privileges keep that to owners.</p> | |
| 407 | + | ) : ( | |
| 408 | + | <p> | |
| 409 | + | Renaming it needs the Admin role, and changing who can see it an owner of the workspace, unless its member | |
| 410 | + | privileges let repository admins. | |
| 450 | 411 | </p> | |
| 451 | 412 | )} | |
| 413 | + | <LifecycleNote base={base} /> | |
| 452 | 414 | </div> | |
| 453 | − | ) : ( | |
| 454 | − | <p className="border-t border-line pt-8 text-sm text-muted"> | |
| 455 | − | Renaming it, changing who can see it and archiving it need the Admin role. Transferring and deleting it need an | |
| 456 | − | owner of the workspace, unless its member privileges let repository admins. | |
| 457 | − | </p> | |
| 458 | 415 | )} | |
| 459 | 416 | </div> | |
| 460 | 417 | </> | |
| 461 | 418 | ); | |
| 462 | 419 | } | |
| 463 | 420 | ||
| 421 | + | /** Where archiving, transferring and deleting went: they are the project's. */ | |
| 422 | + | function LifecycleNote({ base }: { base: string }) { | |
| 423 | + | return ( | |
| 424 | + | <p className="mt-4 text-sm text-muted"> | |
| 425 | + | Archiving, transferring and deleting it are the project's, at the end of{" "} | |
| 426 | + | <Link to={`${base}/settings#danger-zone`} className="text-accent hover:underline"> | |
| 427 | + | General | |
| 428 | + | </Link> | |
| 429 | + | . | |
| 430 | + | </p> | |
| 431 | + | ); | |
| 432 | + | } | |
| 433 | + | ||
| 464 | 434 | /** The most branches and tags one push may update: off (0), or a number. */ | |
| 465 | 435 | function PushLimitForm({ | |
| 466 | 436 | limit, | |
| ⋯ | |||
| 807 | 777 | <li>It stops showing among {from}'s forks, and its page no longer says where it came from.</li> | |
| 808 | 778 | <li>Its visibility can then be changed on its own.</li> | |
| 809 | 779 | <li>It can't be attached again.</li> | |
| 810 | − | </ConfirmDialog> | |
| 811 | − | ); | |
| 812 | − | } | |
| 813 | − | ||
| 814 | − | /** Read-only, or back to normal. */ | |
| 815 | − | function ArchiveAction({ full, archived, error }: { full: string; archived: boolean; error: string | null }) { | |
| 816 | − | return ( | |
| 817 | − | <ConfirmDialog | |
| 818 | − | intent="archive" | |
| 819 | − | fields={{ archived: archived ? "false" : "true" }} | |
| 820 | − | title={archived ? `Unarchive ${full}?` : `Archive ${full}?`} | |
| 821 | − | description={archived ? "It goes back to working as before." : "It becomes read-only. You can unarchive it at any time."} | |
| 822 | − | submit={archived ? "Unarchive" : "Archive"} | |
| 823 | − | busy={archived ? "Unarchiving…" : "Archiving…"} | |
| 824 | − | danger={!archived} | |
| 825 | − | error={error} | |
| 826 | − | trigger={(open) => ( | |
| 827 | − | <DangerAction | |
| 828 | − | title={archived ? "Unarchive this repository" : "Archive this repository"} | |
| 829 | − | action={ | |
| 830 | − | <Button type="button" variant="destructive" onClick={open}> | |
| 831 | − | {archived ? "Unarchive" : "Archive"} | |
| 832 | − | </Button> | |
| 833 | − | } | |
| 834 | − | > | |
| 835 | − | {archived | |
| 836 | − | ? "It is archived and read-only. Unarchiving lets pushes, merges and workflows work again." | |
| 837 | − | : "Make it read-only: everything stays where it is and can be read, and nothing changes it."} | |
| 838 | − | </DangerAction> | |
| 839 | − | )} | |
| 840 | − | > | |
| 841 | − | {archived ? ( | |
| 842 | − | <> | |
| 843 | − | <li>Pushes and merges are accepted again.</li> | |
| 844 | − | <li>Issues and pull requests unlock.</li> | |
| 845 | − | <li>Workflows run again.</li> | |
| 846 | − | </> | |
| 847 | − | ) : ( | |
| 848 | − | <> | |
| 849 | − | <li>Pushes and merges are refused, for everyone.</li> | |
| 850 | − | <li>Issues and pull requests are locked. They stay readable.</li> | |
| 851 | − | <li>Workflows do not run.</li> | |
| 852 | − | <li>Its deployments keep serving, and anyone who could see it still can.</li> | |
| 853 | − | </> | |
| 854 | − | )} | |
| 855 | 780 | </ConfirmDialog> | |
| 856 | − | ); | |
| 857 | − | } | |
| 858 | − | ||
| 859 | − | /** Deleting: hidden at once, restorable for a while, then gone. */ | |
| 860 | − | function DeleteAction({ full, error }: { full: string; error: string | null }) { | |
| 861 | − | const namespace = full.split("/")[0]; | |
| 862 | − | return ( | |
| 863 | − | <ConfirmDialog | |
| 864 | − | intent="delete" | |
| 865 | − | title={`Delete ${full}?`} | |
| 866 | − | description={`You can restore it for ${RESTORE_DAYS} days.`} | |
| 867 | − | confirm={full} | |
| 868 | − | submit="Delete repository" | |
| 869 | − | busy="Deleting…" | |
| 870 | − | error={error} | |
| 871 | − | trigger={(open) => ( | |
| 872 | − | <DangerAction | |
| 873 | − | title="Delete this repository" | |
| 874 | − | action={ | |
| 875 | − | <Button type="button" variant="destructive" onClick={open}> | |
| 876 | − | Delete | |
| 877 | − | </Button> | |
| 878 | − | } | |
| 879 | − | > | |
| 880 | − | It can be restored from the workspace's repositories for {RESTORE_DAYS} days, then it is removed for good. | |
| 881 | − | </DangerAction> | |
| 882 | − | )} | |
| 883 | − | > | |
| 884 | − | <li>It disappears at once: its pages, git remote and API stop answering, for everyone.</li> | |
| 885 | − | <li> | |
| 886 | − | Owners can restore it, as it was, from <span className="font-mono text-fg">{namespace}</span>'s Recently deleted | |
| 887 | − | repositories for {RESTORE_DAYS} days. After that it is removed for good, its git data with it. | |
| 888 | − | </li> | |
| 889 | − | <li>Its name stays taken until then, so no other repository can be made at its address.</li> | |
| 890 | − | </ConfirmDialog> | |
| 891 | − | ); | |
| 892 | − | } | |
| 893 | − | ||
| 894 | − | /** | |
| 895 | − | * Moving the repository to another workspace the owner also owns. The | |
| 896 | − | * dialog says what moves and what changes, and asks for the full name. | |
| 897 | − | */ | |
| 898 | − | function TransferAction({ | |
| 899 | − | repo, | |
| 900 | − | destinations, | |
| 901 | − | error, | |
| 902 | − | }: { | |
| 903 | − | repo: string; | |
| 904 | − | destinations: { slug: string; name: string }[]; | |
| 905 | − | error?: string; | |
| 906 | − | }) { | |
| 907 | − | const [open, setOpen] = useState(Boolean(error)); | |
| 908 | − | const [to, setTo] = useState(destinations[0]?.slug ?? ""); | |
| 909 | − | const [confirm, setConfirm] = useState(""); | |
| 910 | − | const navigation = useNavigation(); | |
| 911 | − | const moving = navigation.state !== "idle" && navigation.formData?.get("intent") === "transfer"; | |
| 912 | − | const name = repo.split("/")[1]; | |
| 913 | − | return ( | |
| 914 | − | <DangerAction | |
| 915 | − | title="Transfer this repository" | |
| 916 | − | action={ | |
| 917 | − | <Button type="button" variant="destructive" disabled={destinations.length === 0} onClick={() => setOpen(true)}> | |
| 918 | − | Transfer | |
| 919 | − | </Button> | |
| 920 | − | } | |
| 921 | − | > | |
| 922 | − | {destinations.length === 0 ? ( | |
| 923 | − | <>Move it to another workspace you own. You own no other workspace yet.</> | |
| 924 | − | ) : ( | |
| 925 | − | <>Move it to another workspace you own. Its old address keeps redirecting.</> | |
| 926 | − | )} | |
| 927 | − | <AlertDialog | |
| 928 | − | open={open} | |
| 929 | − | onOpenChange={(next) => { | |
| 930 | − | setOpen(next); | |
| 931 | − | setConfirm(""); | |
| 932 | − | }} | |
| 933 | − | > | |
| 934 | − | <AlertDialogContent> | |
| 935 | − | <Form method="post" className="grid gap-4"> | |
| 936 | − | <input type="hidden" name="intent" value="transfer" /> | |
| 937 | − | <AlertDialogHeader> | |
| 938 | − | <AlertDialogTitle>Transfer {repo}</AlertDialogTitle> | |
| 939 | − | <AlertDialogDescription> | |
| 940 | − | It keeps its name and moves with everything in it: code, issues, pull requests, workflow runs, | |
| 941 | − | deployments, its project, and its own secrets, variables and webhooks. | |
| 942 | − | </AlertDialogDescription> | |
| 943 | − | </AlertDialogHeader> | |
| 944 | − | <FormField> | |
| 945 | − | <FieldLabel htmlFor="transfer-to">New workspace</FieldLabel> | |
| 946 | − | <Select name="to" value={to} onValueChange={setTo}> | |
| 947 | − | <SelectTrigger id="transfer-to" aria-label="New workspace"> | |
| 948 | − | <SelectValue /> | |
| 949 | − | </SelectTrigger> | |
| 950 | − | <SelectContent> | |
| 951 | − | {destinations.map((d) => ( | |
| 952 | − | <SelectItem key={d.slug} value={d.slug}> | |
| 953 | − | {d.name === d.slug ? d.slug : `${d.name} (${d.slug})`} | |
| 954 | − | </SelectItem> | |
| 955 | − | ))} | |
| 956 | − | </SelectContent> | |
| 957 | − | </Select> | |
| 958 | − | </FormField> | |
| 959 | − | <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted"> | |
| 960 | − | <li> | |
| 961 | − | It moves to <span className="font-mono text-fg">g1t.sh/{to}/{name}</span>. Links, git remotes and API | |
| 962 | − | calls to <span className="font-mono">g1t.sh/{repo}</span> redirect there until a repository is made at | |
| 963 | − | the old address. | |
| 964 | − | </li> | |
| 965 | − | <li> | |
| 966 | − | Apps it deploys move to <span className="font-mono">*.g1t.page</span> names with the new workspace; the | |
| 967 | − | old ones redirect for 90 days. Custom domains follow. | |
| 968 | − | </li> | |
| 969 | − | <li>The old workspace's secrets, webhooks and integrations stop reaching it; the new one's start.</li> | |
| 970 | − | <li>Usage from now on is charged to {to}. What it used before stays on the old workspace's bill.</li> | |
| 971 | − | </ul> | |
| 972 | − | <FormField> | |
| 973 | − | <FieldLabel htmlFor="transfer-confirm"> | |
| 974 | − | Type <span className="font-mono text-fg">{repo}</span> to confirm | |
| 975 | − | </FieldLabel> | |
| 976 | − | <TextInput | |
| 977 | − | id="transfer-confirm" | |
| 978 | − | name="confirm" | |
| 979 | − | value={confirm} | |
| 980 | − | spellCheck={false} | |
| 981 | − | autoCapitalize="off" | |
| 982 | − | autoComplete="off" | |
| 983 | − | onChange={(event) => setConfirm(event.target.value)} | |
| 984 | − | className="font-mono" | |
| 985 | − | /> | |
| 986 | − | </FormField> | |
| 987 | − | <ErrorText>{error}</ErrorText> | |
| 988 | − | <AlertDialogFooter> | |
| 989 | − | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 990 | − | <Button type="submit" variant="destructive" disabled={!to || !confirmsName(confirm, repo) || moving}> | |
| 991 | − | {moving ? "Transferring…" : "Transfer"} | |
| 992 | − | </Button> | |
| 993 | − | </AlertDialogFooter> | |
| 994 | − | </Form> | |
| 995 | − | </AlertDialogContent> | |
| 996 | − | </AlertDialog> | |
| 997 | − | </DangerAction> | |
| 998 | 781 | ); | |
| 999 | 782 | } | |
| 1 | 1 | import { Box, Code2, GitBranch, Image as ImageIcon, Link2, Rocket } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, Link } from "react-router"; | |
| 3 | + | import { Form, Link, redirect } from "react-router"; | |
| 4 | + | ||
| 5 | + | import { DEFAULT_MEMBER_PRIVILEGES } from "@g1t/contracts"; | |
| 4 | 6 | ||
| 5 | 7 | import type { Route } from "./+types/settings"; | |
| 8 | + | import { LIFECYCLE_INTENTS, type LifecycleIntent, ProjectDangerZone } from "../../components/project-lifecycle"; | |
| 9 | + | import { confirmsName } from "../../lib/repo-lifecycle"; | |
| 6 | 10 | import { page } from "../../lib/meta"; | |
| 7 | 11 | import { useAddresses } from "../../lib/addresses"; | |
| 8 | 12 | import { productionImageUrl, readProductionImageUpload } from "../../lib/production-image"; | |
| ⋯ | |||
| 12 | 16 | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 13 | 17 | import { Card } from "../../components/ui/card"; | |
| 14 | 18 | import { KIND_CHOICE_SETS, type KindChoice, choiceOf, linksFromForm, neverDeploys } from "../../lib/project-kind"; | |
| 15 | − | import { deployments, identity, projects } from "../../lib/services.server"; | |
| 19 | + | import { deployments, identity, projects, repos } from "../../lib/services.server"; | |
| 16 | 20 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 17 | − | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 21 | + | import { refusal, requireCapability, requireInsider } from "../../lib/access.server"; | |
| 18 | 22 | ||
| 19 | 23 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 20 | 24 | return page(args, { title: `Settings · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 23 | 27 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 24 | 28 | const viewer = getViewer(context); | |
| 25 | 29 | // Maintain and up; to anyone without a role here the page does not exist. | |
| 26 | − | await requireInsider(context, params, "manage_settings"); | |
| 30 | + | const { repo, access } = await requireInsider(context, params, "manage_settings"); | |
| 27 | 31 | const [project, deploys] = await Promise.all([ | |
| 28 | 32 | projects.get(params.owner, params.repo, viewer), | |
| 29 | 33 | // Whether Deployments are on: needs Admin, so unknown (null) below it. | |
| ⋯ | |||
| 37 | 41 | deploymentsOn: deploys?.ok ? deploys.value.enabled : null, | |
| 38 | 42 | mine: viewer?.id === found.createdBy, | |
| 39 | 43 | creator: names[found.createdBy] ?? null, | |
| 44 | + | // Archiving is for Admins; transferring and deleting, for owners of the | |
| 45 | + | // workspace unless its member privileges let its members with Admin. | |
| 46 | + | lifecycle: access.can.administer | |
| 47 | + | ? { archived: Boolean(repo.archivedAt), remove: access.can.delete, destinations: destinations(viewer, params.owner, repo.isPrivate, access.can.delete) } | |
| 48 | + | : null, | |
| 40 | 49 | }; | |
| 41 | 50 | } | |
| 42 | 51 | ||
| 52 | + | /** Where it can be transferred: a workspace where the person can create a repository like it. */ | |
| 53 | + | function destinations( | |
| 54 | + | viewer: ReturnType<typeof getViewer>, | |
| 55 | + | from: string, | |
| 56 | + | isPrivate: boolean, | |
| 57 | + | allowed: boolean, | |
| 58 | + | ): { slug: string; name: string }[] { | |
| 59 | + | if (!allowed) return []; | |
| 60 | + | return (viewer?.workspaces ?? []) | |
| 61 | + | .filter((m) => m.slug !== from.toLowerCase()) | |
| 62 | + | .filter((m) => { | |
| 63 | + | if (m.role === "owner") return true; | |
| 64 | + | const privileges = { ...DEFAULT_MEMBER_PRIVILEGES, ...(m.privileges ?? {}) }; | |
| 65 | + | return isPrivate ? privileges.members_can_create_private_repositories : privileges.members_can_create_public_repositories; | |
| 66 | + | }) | |
| 67 | + | .map((m) => ({ slug: m.slug, name: m.name ?? m.slug })); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /** Archiving, transferring and deleting: the repository goes with the project. */ | |
| 71 | + | async function lifecycle(intent: LifecycleIntent, form: FormData, request: Request, params: Route.ActionArgs["params"], context: Route.ActionArgs["context"]) { | |
| 72 | + | const user = requireUser(context, request); | |
| 73 | + | const refused = await refusal(context, params, intent === "archive" ? "administer" : "delete"); | |
| 74 | + | if (refused) return { lifecycle: intent, error: refused }; | |
| 75 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 76 | + | const full = `${params.owner}/${params.repo}`; | |
| 77 | + | const typed = String(form.get("confirm") ?? "").trim(); | |
| 78 | + | const failed = (result: { ok: false; error: { message: string } }) => ({ lifecycle: intent, error: result.error.message }); | |
| 79 | + | switch (intent) { | |
| 80 | + | case "archive": { | |
| 81 | + | const done = await repos.archive(user, path, form.get("archived") === "true"); | |
| 82 | + | return done.ok ? { lifecycle: intent, error: null } : failed(done); | |
| 83 | + | } | |
| 84 | + | case "transfer": { | |
| 85 | + | // repos checks both workspaces' owners, the name and storage, and | |
| 86 | + | // keeps the old address as a redirect. | |
| 87 | + | if (!confirmsName(typed, full)) return { lifecycle: intent, error: `Type ${full} to confirm.` }; | |
| 88 | + | const moved = await repos.transfer(user, path, String(form.get("to") ?? "").trim().toLowerCase()); | |
| 89 | + | if (!moved.ok) return failed(moved); | |
| 90 | + | throw redirect(`/${moved.value.namespace}/${moved.value.name}/settings`); | |
| 91 | + | } | |
| 92 | + | case "delete": { | |
| 93 | + | if (!confirmsName(typed, full)) return { lifecycle: intent, error: `Type ${full} to confirm.` }; | |
| 94 | + | const deleted = await repos.delete(user, path, typed); | |
| 95 | + | if (!deleted.ok) return failed(deleted); | |
| 96 | + | throw redirect(`/${params.owner}/-/repositories?deleted=${encodeURIComponent(deleted.value.name)}`); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 43 | 101 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 44 | 102 | assertSameOrigin(request); | |
| 45 | 103 | const user = requireUser(context, request); | |
| 46 | − | await requireCapability(context, params, "manage_settings"); | |
| 47 | 104 | const form = await request.formData(); | |
| 48 | − | // Production's picture on the overview: whoever may change its address may change it. | |
| 49 | 105 | const intent = form.get("intent"); | |
| 106 | + | if (LIFECYCLE_INTENTS.includes(intent as LifecycleIntent)) { | |
| 107 | + | return lifecycle(intent as LifecycleIntent, form, request, params, context); | |
| 108 | + | } | |
| 109 | + | await requireCapability(context, params, "manage_settings"); | |
| 110 | + | // Production's picture on the overview: whoever may change its address may change it. | |
| 50 | 111 | if (intent === "production-image" || intent === "remove-production-image" || intent === "prefer-production-image") { | |
| 51 | 112 | let change: { image?: string | null; preferred?: boolean }; | |
| 52 | 113 | if (intent === "production-image") { | |
| ⋯ | |||
| 77 | 138 | } | |
| 78 | 139 | ||
| 79 | 140 | export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 80 | − | const { project, deploymentsOn, mine, creator } = loaderData; | |
| 141 | + | const { project, deploymentsOn, mine, creator, lifecycle } = loaderData; | |
| 81 | 142 | const [choice, setChoice] = useState<KindChoice | null>(choiceOf(project.setting)); | |
| 82 | 143 | // Something that never deploys, while Deployments are on, is refused: they are turned off first. | |
| 83 | 144 | const stops = choice === "library" || choice === "tool" || choice === "other"; | |
| ⋯ | |||
| 198 | 259 | <TimeAgo at={project.createdAt} /> | |
| 199 | 260 | </span> | |
| 200 | 261 | </div> | |
| 201 | − | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 262 | + | <ErrorText>{actionData && "error" in actionData && !("lifecycle" in actionData) ? actionData.error : null}</ErrorText> | |
| 202 | 263 | </Form> | |
| 203 | 264 | ||
| 204 | 265 | {/* Its own forms, sent as soon as an image is picked: outside the settings form. */} | |
| ⋯ | |||
| 218 | 279 | screenshots={project.runs === "g1t" || (project.runs === "elsewhere" && !!project.productionUrl)} | |
| 219 | 280 | /> | |
| 220 | 281 | </section> | |
| 282 | + | ||
| 283 | + | {lifecycle && ( | |
| 284 | + | <ProjectDangerZone | |
| 285 | + | full={`${params.owner}/${params.repo}`} | |
| 286 | + | archived={lifecycle.archived} | |
| 287 | + | remove={lifecycle.remove} | |
| 288 | + | destinations={lifecycle.destinations} | |
| 289 | + | error={(intent) => (actionData && "lifecycle" in actionData && actionData.lifecycle === intent ? actionData.error : null)} | |
| 290 | + | /> | |
| 291 | + | )} | |
| 221 | 292 | </div> | |
| 222 | 293 | ); | |
| 223 | 294 | } | |
| 16 | 16 | import { useAddresses } from "../../lib/addresses"; | |
| 17 | 17 | import { AvatarField } from "../../components/avatar-field"; | |
| 18 | 18 | import { GettingStartedButton } from "../../components/onboarding"; | |
| 19 | + | import { ConfirmName } from "../../components/confirm-name"; | |
| 19 | 20 | import { DangerAction, DangerZone } from "../../components/danger-zone"; | |
| 20 | 21 | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 21 | 22 | import { | |
| ⋯ | |||
| 467 | 468 | </li> | |
| 468 | 469 | <li>Members keep their own accounts.</li> | |
| 469 | 470 | </ul> | |
| 470 | − | <FormField> | |
| 471 | − | <FieldLabel htmlFor="confirm-delete"> | |
| 472 | − | Type <span className="font-mono text-fg">{workspace.slug}</span> to confirm | |
| 473 | − | </FieldLabel> | |
| 474 | − | <TextInput | |
| 475 | − | id="confirm-delete" | |
| 476 | − | name="confirm" | |
| 477 | − | value={confirm} | |
| 478 | − | spellCheck={false} | |
| 479 | − | autoCapitalize="off" | |
| 480 | − | autoComplete="off" | |
| 481 | − | onChange={(event) => setConfirm(event.target.value)} | |
| 482 | − | className="font-mono" | |
| 483 | − | /> | |
| 484 | − | </FormField> | |
| 471 | + | <ConfirmName confirm={workspace.slug} id="confirm-delete" value={confirm} onChange={(event) => setConfirm(event.target.value)} /> | |
| 485 | 472 | <ErrorText>{error}</ErrorText> | |
| 486 | 473 | <AlertDialogFooter> | |
| 487 | 474 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| ⋯ | |||
| 625 | 612 | <li>After {SLUG_HOLD_DAYS} days, anyone can take the name {workspace.slug}.</li> | |
| 626 | 613 | <li>You can change the address again after {RENAME_COOLDOWN_HOURS} hours.</li> | |
| 627 | 614 | </ul> | |
| 628 | − | <FormField> | |
| 629 | − | <FieldLabel htmlFor="confirm-slug"> | |
| 630 | − | Type <span className="font-mono text-fg">{wanted}</span> to confirm | |
| 631 | − | </FieldLabel> | |
| 632 | − | <TextInput | |
| 633 | − | id="confirm-slug" | |
| 634 | − | value={confirm} | |
| 635 | − | spellCheck={false} | |
| 636 | − | autoCapitalize="off" | |
| 637 | − | onChange={(event) => setConfirm(event.target.value)} | |
| 638 | − | className="font-mono" | |
| 639 | − | /> | |
| 640 | − | </FormField> | |
| 615 | + | <ConfirmName | |
| 616 | + | confirm={wanted} | |
| 617 | + | id="confirm-slug" | |
| 618 | + | name={undefined} | |
| 619 | + | value={confirm} | |
| 620 | + | onChange={(event) => setConfirm(event.target.value)} | |
| 621 | + | /> | |
| 641 | 622 | <ErrorText>{error}</ErrorText> | |
| 642 | 623 | <AlertDialogFooter> | |
| 643 | 624 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
Comments
Sign in to comment.