Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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
syntaqxcommitted Parent4f60940Browse files
11 files+418−3310/11 viewed
+7−6
121121 | **Pushes** | How many branches and tags one push may update. See [pushes](#pushes). |
122122 | **Archives** | Whether downloads hold Git LFS objects. Coming soon. |
123123 | **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**. |
125125
126126 While a repository is [archived](#archive-a-repository), the settings that
127127 change it are turned off until it is unarchived.
429429 Archiving makes a repository read-only. Use it for work that is finished
430430 but should stay readable.
431431
432−1. Open the repository's **Settings → Repository**.
432+1. Open the project's **Settings → General**.
433433 2. Under **Danger zone**, choose **Archive**.
434434 3. Read what changes and choose **Archive** again.
435435
471471 Deleting takes a repository away at once, but an owner can restore it for
472472 30 days. After that it is purged: removed for good, its git data with it.
473473
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**.
478479
479480 | | While it is deleted | When it is purged |
480481 | --- | --- | --- |
+3−3
223223
224224 | Tab | What it holds |
225225 | --- | --- |
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. |
227227 | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). |
228228 | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). |
229229 | **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/). |
231231 | **Access** | Who has a [role](/guides/access-and-roles/) on the repository, and invitations. |
232232 | **Branches and merging** | Auto-merge, the CODEOWNERS file's errors, and what the rules hold for the default branch. |
233233 | **Rules** | [Rulesets](/guides/rules/): branch and tag protection, [required status checks](/guides/pull-requests/#required-status-checks), approvals and the merge queue, with Insights. |
248248 | **Rules**, **Guardrails** | Admin |
249249 | **Access**: seeing who has a role; changing it | Write; Admin |
250250 | **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 |
252252 | Transfer and delete | An owner of the workspace |
253253
254254 Someone without the role does not see the tab.
+3−2
2525
2626 ## Transfer a repository
2727
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.
3031 3. Pick the workspace to move it to. Only workspaces where you can create
3132 a repository like it are listed.
3233 4. Read what changes, type the repository's full name (`<old>/<repo>`) to
+39−0
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+}
+2−15
33
44 import { ACCOUNT_RESTORE_DAYS, type AccountDeletion } from "@g1t/contracts";
55
6+import { ConfirmName } from "./confirm-name";
67 import { DangerAction } from "./danger-zone";
78 import { ErrorText } from "./ui";
89 import {
139140 The username <span className="font-mono text-fg">{username}</span> is never given to anyone else.
140141 </li>
141142 </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)} />
157144 {hasPassword ? (
158145 <FormField>
159146 <FieldLabel htmlFor="confirm-password">Your password</FieldLabel>
+239−0
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+}
+3−18
1212 AlertDialogHeader,
1313 AlertDialogTitle,
1414 } 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";
1716 import { confirmsName } from "../lib/repo-lifecycle";
1817
1918 /**
9493 {children && <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">{children}</ul>}
9594 {extra}
9695 {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)} />
11297 )}
11398 <ErrorText>{error}</ErrorText>
11499 <AlertDialogFooter>
148133 </p>
149134 {owner && (
150135 <Link
151− to={`${base}/settings/repository#danger-zone`}
136+ to={`${base}/settings#danger-zone`}
152137 className="shrink-0 pl-6.5 font-medium text-warn hover:underline sm:pl-0"
153138 >
154139 Unarchive
+2−2
22
33 /** Each of a project's settings pages: its name and what it is for. */
44 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." },
66 deployments: { title: "Deployments", about: "How the project builds, where it runs, and its previews." },
77 domains: { title: "Domains", about: "Addresses of your own for the production deployment." },
88 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." },
1010 access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." },
1111 keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." },
1212 branches: { title: "Branches and merging", about: "How pull requests merge, and who owns which files." },
+32−249
22 import { useState } from "react";
33 import { Form, Link, redirect, useNavigation } from "react-router";
44
5−import { DEFAULT_MEMBER_PRIVILEGES, RESTORE_DAYS, type Result, needs } from "@g1t/contracts";
5+import { type Result, needs } from "@g1t/contracts";
66
77 import { DangerAction, DangerZone } from "../../components/danger-zone";
88 import { ConfirmDialog } from "../../components/repo-lifecycle";
1414 import { confirmsName, tidyName } from "../../lib/repo-lifecycle";
1515 import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
1616 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";
2617 import { Button } from "../../components/ui/button";
2718 import { Badge } from "../../components/ui/badge";
2819 import { Card } from "../../components/ui/card";
5041 actions.artifactRetention(path, viewer),
5142 work.getSettings(path, viewer).catch(() => null),
5243 ]);
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.
5647 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− : [];
6848 return {
6949 repo,
7050 // An empty repository has no branches yet; the page still works.
7151 branches: branches.ok ? branches.value.map((b) => b.name) : [],
7252 owner,
73− remove: access.can.delete,
7453 visibility: access.can.change_visibility,
75− destinations,
7654 // How long workflow runs' artifacts are kept.
7755 retention: retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 },
7856 // Whether issues can be opened in it; null when that could not be read.
10583 const text = (name: string) => String(form.get(name) ?? "").trim();
10684 const typed = text("confirm");
10785 // 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.
10987 const capability =
11088 intent === "details" || intent === "artifacts" || intent === "features" || intent === "commits"
11189 ? "manage_settings"
11290 : intent === "rename-branch"
11391 ? "push"
114− : intent === "transfer" || intent === "delete"
115− ? "delete"
116− : "administer";
92+ : "administer";
11793 const refused = await refusal(context, params, capability);
11894 if (refused) return { intent, saved: false, error: refused };
11995
132108 if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` };
133109 return outcome(intent, await repos.setVisibility(user, path, text("visibility") === "private", typed));
134110 }
135− case "archive":
136− return outcome(intent, await repos.archive(user, path, text("archived") === "true"));
137111 case "features": {
138112 // The other settings are sent back as they are: only issues change here.
139113 const current = await work.getSettings(path, user);
174148 return outcome(intent, await repos.detachFork(user, path));
175149 case "artifacts":
176150 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− }
192151 default:
193152 return outcome(
194153 "details",
208167 }
209168
210169 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;
212171 const navigation = useNavigation();
213172 const posting = (intent: string) => navigation.state !== "idle" && navigation.formData?.get("intent") === intent;
214173 const base = `/${repo.namespace}/${repo.name}`;
433392 </Section>
434393 )}
435394
436− {owner ? (
395+ {owner && (visibility || repo.parentId) ? (
437396 <div id="danger-zone" className="scroll-mt-20 border-t border-line pt-8">
438397 <DangerZone>
439398 {visibility && <VisibilityAction full={full} isPrivate={repo.isPrivate} fork={Boolean(repo.parentId)} error={errorFor("visibility")} />}
440399 {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")} />}
444400 </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.
450411 </p>
451412 )}
413+ <LifecycleNote base={base} />
452414 </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>
458415 )}
459416 </div>
460417 </>
461418 );
462419 }
463420
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+
464434 /** The most branches and tags one push may update: off (0), or a number. */
465435 function PushLimitForm({
466436 limit,
807777 <li>It stops showing among {from}'s forks, and its page no longer says where it came from.</li>
808778 <li>Its visibility can then be changed on its own.</li>
809779 <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− )}
855780 </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>
998781 );
999782 }
+79−8
11 import { Box, Code2, GitBranch, Image as ImageIcon, Link2, Rocket } from "lucide-react";
22 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";
46
57 import type { Route } from "./+types/settings";
8+import { LIFECYCLE_INTENTS, type LifecycleIntent, ProjectDangerZone } from "../../components/project-lifecycle";
9+import { confirmsName } from "../../lib/repo-lifecycle";
610 import { page } from "../../lib/meta";
711 import { useAddresses } from "../../lib/addresses";
812 import { productionImageUrl, readProductionImageUpload } from "../../lib/production-image";
1216 import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
1317 import { Card } from "../../components/ui/card";
1418 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";
1620 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";
1822
1923 export function meta({ params, ...args }: Route.MetaArgs) {
2024 return page(args, { title: `Settings · ${params.owner}/${params.repo} · g1t` });
2327 export async function loader({ params, context }: Route.LoaderArgs) {
2428 const viewer = getViewer(context);
2529 // 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");
2731 const [project, deploys] = await Promise.all([
2832 projects.get(params.owner, params.repo, viewer),
2933 // Whether Deployments are on: needs Admin, so unknown (null) below it.
3741 deploymentsOn: deploys?.ok ? deploys.value.enabled : null,
3842 mine: viewer?.id === found.createdBy,
3943 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,
4049 };
4150 }
4251
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+
43101 export async function action({ request, params, context }: Route.ActionArgs) {
44102 assertSameOrigin(request);
45103 const user = requireUser(context, request);
46− await requireCapability(context, params, "manage_settings");
47104 const form = await request.formData();
48− // Production's picture on the overview: whoever may change its address may change it.
49105 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.
50111 if (intent === "production-image" || intent === "remove-production-image" || intent === "prefer-production-image") {
51112 let change: { image?: string | null; preferred?: boolean };
52113 if (intent === "production-image") {
77138 }
78139
79140 export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) {
80− const { project, deploymentsOn, mine, creator } = loaderData;
141+ const { project, deploymentsOn, mine, creator, lifecycle } = loaderData;
81142 const [choice, setChoice] = useState<KindChoice | null>(choiceOf(project.setting));
82143 // Something that never deploys, while Deployments are on, is refused: they are turned off first.
83144 const stops = choice === "library" || choice === "tool" || choice === "other";
198259 <TimeAgo at={project.createdAt} />
199260 </span>
200261 </div>
201− <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
262+ <ErrorText>{actionData && "error" in actionData && !("lifecycle" in actionData) ? actionData.error : null}</ErrorText>
202263 </Form>
203264
204265 {/* Its own forms, sent as soon as an image is picked: outside the settings form. */}
218279 screenshots={project.runs === "g1t" || (project.runs === "elsewhere" && !!project.productionUrl)}
219280 />
220281 </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+ )}
221292 </div>
222293 );
223294 }
+9−28
1616 import { useAddresses } from "../../lib/addresses";
1717 import { AvatarField } from "../../components/avatar-field";
1818 import { GettingStartedButton } from "../../components/onboarding";
19+import { ConfirmName } from "../../components/confirm-name";
1920 import { DangerAction, DangerZone } from "../../components/danger-zone";
2021 import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
2122 import {
467468 </li>
468469 <li>Members keep their own accounts.</li>
469470 </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)} />
485472 <ErrorText>{error}</ErrorText>
486473 <AlertDialogFooter>
487474 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
625612 <li>After {SLUG_HOLD_DAYS} days, anyone can take the name {workspace.slug}.</li>
626613 <li>You can change the address again after {RENAME_COOLDOWN_HOURS} hours.</li>
627614 </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+ />
641622 <ErrorText>{error}</ErrorText>
642623 <AlertDialogFooter>
643624 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>

Comments

Sign in to comment.