| 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 | } |