A repository's General settings get a Transfer section: pick an account you may create repositories in (an organization you're an admin or owner of, or your own) and the repository moves there under the same name. Its issues, pull requests, wiki, releases, images, forks and collaborators come along, since they're kept by the repository's id; only its directory moves. The new owner stops being a collaborator. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 5 changed files with 93 additions and 4 deletions.
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useActionState, useEffect, useState } from "react"; |
| 4 | - import { Checkbox } from "@unlocalhosted/metalui"; | |
| 4 | + import Link from "next/link"; | |
| 5 | + import { Checkbox, Select } from "@unlocalhosted/metalui"; | |
| 5 | 6 | import type { FormState } from "@/app/actions/types"; |
| 6 | 7 | import { DeleteZone } from "@/components/delete-zone"; |
| 7 | - import { BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons"; | |
| 8 | + import { ArrowRightIcon, BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons"; | |
| 8 | 9 | import { ChoiceCards, TextField } from "@/components/settings-fields"; |
| 9 | 10 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 10 | 11 | import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui"; |
| @@ -94,6 +95,47 @@ export function RenameForm({ action, name }: { action: Action; name: string }) { | ||
| 94 | 95 | ); |
| 95 | 96 | } |
| 96 | 97 | |
| 98 | + /** Moving the repository to another account: one of `owners`, the accounts the viewer may create repositories in. */ | |
| 99 | + export function TransferForm({ action, owners, name }: { action: Action; owners: string[]; name: string }) { | |
| 100 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 101 | + if (owners.length === 0) { | |
| 102 | + return ( | |
| 103 | + <PlateBody> | |
| 104 | + <p className="type-body text-ink2"> | |
| 105 | + Move this repository to an organization you’re an admin or owner of. You don’t have one yet:{" "} | |
| 106 | + <Link href="/organizations/new" className="text-ink underline"> | |
| 107 | + create an organization | |
| 108 | + </Link>{" "} | |
| 109 | + first. | |
| 110 | + </p> | |
| 111 | + </PlateBody> | |
| 112 | + ); | |
| 113 | + } | |
| 114 | + return ( | |
| 115 | + <form action={formAction}> | |
| 116 | + <PlateBody> | |
| 117 | + <p className="type-body text-ink2"> | |
| 118 | + Its issues, pull requests, wiki, releases, packages and collaborators move with it, and the new owner’s access rules apply. Clone | |
| 119 | + URLs and links change to the new owner’s name. | |
| 120 | + </p> | |
| 121 | + <div className="flex max-w-xl flex-wrap items-end gap-2"> | |
| 122 | + <div className="flex flex-col gap-1.5"> | |
| 123 | + <span className="type-ui text-ink">New owner</span> | |
| 124 | + <Select name="owner" aria-label="New owner" defaultValue={owners[0]} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-36" /> | |
| 125 | + </div> | |
| 126 | + <span aria-hidden className="flex h-8 items-center type-content text-ink3"> | |
| 127 | + / {name} | |
| 128 | + </span> | |
| 129 | + <Button icon={<ArrowRightIcon />} disabled={pending}> | |
| 130 | + {pending ? "Transferring…" : "Transfer"} | |
| 131 | + </Button> | |
| 132 | + </div> | |
| 133 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 134 | + </PlateBody> | |
| 135 | + </form> | |
| 136 | + ); | |
| 137 | + } | |
| 138 | + | |
| 97 | 139 | export function GeneralForm({ |
| 98 | 140 | action, |
| 99 | 141 | description, |
| @@ -3,20 +3,24 @@ import { | ||
| 3 | 3 | deleteRepoAction, |
| 4 | 4 | removeRepoImageAction, |
| 5 | 5 | renameRepoAction, |
| 6 | + transferRepoAction, | |
| 6 | 7 | updateFeaturesAction, |
| 7 | 8 | updateRepoAction, |
| 8 | 9 | updateRepoImageAction, |
| 9 | 10 | } from "@/app/actions/repo"; |
| 10 | 11 | import { PlateSection } from "@/components/settings-plate"; |
| 11 | 12 | import { PageTitle } from "@/components/ui"; |
| 13 | + import { repoOwnersFor } from "@/lib/orgs"; | |
| 12 | 14 | import { getRepo } from "@/lib/repo"; |
| 13 | - import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm } from "./forms"; | |
| 15 | + import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm, TransferForm } from "./forms"; | |
| 14 | 16 | |
| 15 | 17 | export const metadata: Metadata = { title: "Settings" }; |
| 16 | 18 | |
| 17 | 19 | export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings">) { |
| 18 | 20 | const { owner, repo: repoParam } = await params; |
| 19 | 21 | const repo = await getRepo(owner, repoParam); |
| 22 | + // Where it can move: accounts the viewer may create repositories in, other than its own. | |
| 23 | + const owners = repo.viewer ? (await repoOwnersFor(repo.viewer)).map((o) => o.username).filter((o) => o !== repo.owner) : []; | |
| 20 | 24 | return ( |
| 21 | 25 | <div> |
| 22 | 26 | <PageTitle title="General" /> |
| @@ -41,6 +45,9 @@ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[ | ||
| 41 | 45 | <PlateSection title="Features"> |
| 42 | 46 | <FeaturesForm action={updateFeaturesAction.bind(null, repo.owner, repo.name)} wiki={repo.wikiEnabled} /> |
| 43 | 47 | </PlateSection> |
| 48 | + <PlateSection title="Transfer"> | |
| 49 | + <TransferForm action={transferRepoAction.bind(null, repo.owner, repo.name)} owners={owners} name={repo.name} /> | |
| 50 | + </PlateSection> | |
| 44 | 51 | <PlateSection title="Danger zone"> |
| 45 | 52 | <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} /> |
| 46 | 53 | </PlateSection> |
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | RepoError, |
| 13 | 13 | setRepoImage, |
| 14 | 14 | setWikiEnabled, |
| 15 | + transferRepo, | |
| 15 | 16 | updateRepo, |
| 16 | 17 | type Visibility, |
| 17 | 18 | } from "@/lib/repos"; |
| @@ -103,6 +104,22 @@ export async function renameRepoAction(owner: string, name: string, _prev: FormS | ||
| 103 | 104 | redirect(path); |
| 104 | 105 | } |
| 105 | 106 | |
| 107 | + /** Moves the repository to an account the viewer may create repositories in: theirs, or an organization they're an admin of. */ | |
| 108 | + export async function transferRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 109 | + let path = ""; | |
| 110 | + const result = await attempt(async () => { | |
| 111 | + const { repo, viewer } = await repoForAction(owner, name, "admin"); | |
| 112 | + const ownerName = String(formData.get("owner") ?? ""); | |
| 113 | + const newOwner = ownerName.toLowerCase() === viewer.username.toLowerCase() ? viewer : await getOrg(ownerName); | |
| 114 | + if (!newOwner || !(await mayCreateRepos(newOwner, viewer))) throw new ActionError(`You can't move repositories to ${ownerName}.`); | |
| 115 | + const moved = await transferRepo(repo, newOwner); | |
| 116 | + revalidatePath("/", "layout"); | |
| 117 | + path = `/${moved.owner}/${moved.name}/settings`; | |
| 118 | + }); | |
| 119 | + if (result?.error) return result; | |
| 120 | + redirect(path); | |
| 121 | + } | |
| 122 | + | |
| 106 | 123 | export async function deleteRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { |
| 107 | 124 | const result = await attempt(async () => { |
| 108 | 125 | const { repo } = await repoForAction(owner, name, "admin"); |
| @@ -342,6 +342,29 @@ export async function renameRepo(repo: RepoRecord, newName: string) { | ||
| 342 | 342 | return (await findRepoById(repo.id))!; |
| 343 | 343 | } |
| 344 | 344 | |
| 345 | + /** | |
| 346 | + * Moves a repository to another account (an organization, or a person's) under the same name. Its | |
| 347 | + * issues, pull requests, wiki, releases, images, forks and collaborators come along: all of them | |
| 348 | + * are kept by the repository's id. The new owner stops being a collaborator: they own it now. | |
| 349 | + */ | |
| 350 | + export async function transferRepo(repo: RepoRecord, newOwner: User) { | |
| 351 | + if (newOwner.id === repo.ownerId) throw new RepoError(`${repo.owner}/${repo.name} already belongs to ${newOwner.username}.`); | |
| 352 | + if (await findRepo(newOwner.username, repo.name)) throw new RepoError(alreadyHas(newOwner, repo.name)); | |
| 353 | + const newDir = repoPath(newOwner.username, repo.name); | |
| 354 | + if (await pathExists(newDir)) throw new RepoError(`A repository directory for ${newOwner.username}/${repo.name} already exists on disk.`); | |
| 355 | + await mkdir(path.dirname(newDir), { recursive: true }); | |
| 356 | + await rename(repo.dir, newDir); | |
| 357 | + try { | |
| 358 | + await (await repos()).update(repo.id, { owner: newOwner.id }); | |
| 359 | + } catch (e) { | |
| 360 | + await rename(newDir, repo.dir); | |
| 361 | + if (isUniqueViolation(e)) throw new RepoError(alreadyHas(newOwner, repo.name)); | |
| 362 | + throw e; | |
| 363 | + } | |
| 364 | + await removeCollaborator(repo.id, newOwner.id); | |
| 365 | + return (await findRepoById(repo.id))!; | |
| 366 | + } | |
| 367 | + | |
| 345 | 368 | /** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */ |
| 346 | 369 | export async function deleteRepo(repo: RepoRecord) { |
| 347 | 370 | // Pull requests from it to other repositories can't change anymore. Loaded when needed: lib/pulls.ts needs this module. |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions) |
| 4 | 4 | - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before |
| 5 | - - **Repositories**: public or private, rename, delete, optional README on creation | |
| 5 | + - **Repositories**: public or private, rename, transfer to an organization (or back to your account), delete, optional README on creation | |
| 6 | 6 | - **Collaborators**: give other users push access to a repository |
| 7 | 7 | - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`) |
| 8 | 8 | - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private |