From 45a4f968d62262a588a43054697d203f75129d01 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 10:23:17 +0000 Subject: [PATCH] feat: Let a customer rename a cloud instance from HQ --- .../app/(customer)/instances/[id]/page.tsx | 31 +++++ adminsite/components/RenamePanel.tsx | 110 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 adminsite/components/RenamePanel.tsx diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx index 1b42b76..e110eec 100644 --- a/adminsite/app/(customer)/instances/[id]/page.tsx +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -9,6 +9,7 @@ import { NotConnectedPanel } from "@/components/NotConnected"; import { LicenceDelivery } from "@/components/LicenceDelivery"; import { MembersPanel } from "@/components/MembersPanel"; import { RelinkPanel } from "@/components/RelinkPanel"; +import { RenamePanel } from "@/components/RenamePanel"; import { StatePill } from "@/components/StatePill"; import { TermBar } from "@/components/TermBar"; import { EmptyState, Note, Panel } from "@/components/Panel"; @@ -17,6 +18,7 @@ import { PageHeader } from "@/components/PageHeader"; import { LinkButton } from "@/components/Button"; import { formatDate, licenceState, limitLabel } from "@/lib/format"; import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features"; +import { useSession } from "@/lib/session"; /** One key/value row. The key is the same keyed idiom as everywhere else. */ function Row({ label, value }: { label: string; value: React.ReactNode }) { @@ -65,6 +67,10 @@ export default function InstancePage() { const qc = useQueryClient(); const [relinkError, setRelinkError] = useState(); + // useSession is the app's one way to ask who the caller is — it shares the + // ["me"] query, so this adds no request. + const { session } = useSession(); + const account = useQuery({ queryKey: ["account"], queryFn: api.account }); const licence = useQuery({ queryKey: ["license", id], @@ -95,6 +101,7 @@ export default function InstancePage() { const lic = licence.data; const state = licenceState(lic?.expires_at, Boolean(lic)); const cloud = instance.deployment === "cloud"; + const mayRename = session?.account_role === "owner" || session?.account_role === "admin"; const maxRelinks = account.data?.max_relinks ?? 3; const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null; @@ -194,6 +201,30 @@ export default function InstancePage() { {cloud && } + {/* + * Address rather than "Rename": the panel is about where this + * instance lives, and the rename is how you change it. Cloud + * only — a self-hosted install has no tenant subdomain for us to + * move. + */} + {cloud && mayRename && ( + +

+ The instance name is where its address comes from. Renaming moves it to a new address and releases the old + one, so saved links and bookmarks to it stop working. +

+ { + const res = await api.renameInstance(instance.instance_id, name); + qc.invalidateQueries({ queryKey: ["account"] }); + return res; + }} + /> +
+ )} + {/* * "Moves" rather than "Relinks": the count is rationed, so the * headline is how many are left, and the panel explains what diff --git a/adminsite/components/RenamePanel.tsx b/adminsite/components/RenamePanel.tsx new file mode 100644 index 0000000..bf406b6 --- /dev/null +++ b/adminsite/components/RenamePanel.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "./Button"; +import { Field } from "./Field"; +import { Note } from "./Panel"; +import { ApiError, type RenameResult } from "@/lib/api"; +import { baseSlug, hostFor, slugError } from "@/lib/slug"; + +/* + * The rename control, and only the control — the same shape as RelinkPanel: an + * input that expands in place rather than a modal, because this app has no modal + * and one action with one field does not need one. + * + * The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can + * disagree with the server; the 409 that comes back is the answer that counts. + */ +export function RenamePanel({ + currentName, + currentSlug, + onRename, +}: { + currentName: string; + currentSlug: string; + onRename: (name: string) => Promise; +}) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(currentName); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(); + + const name = value.trim(); + const derived = baseSlug(name); + const invalid = slugError(name); + // A cosmetic edit that lands on the same slug is still a rename worth doing — + // the name is what the customer reads. Only an empty or unchanged name is + // nothing to submit. + const unchanged = name === currentName.trim(); + + async function submit() { + setError(undefined); + setBusy(true); + try { + const res = await onRename(name); + setDone(res); + setOpen(false); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Rename failed. Try again."); + } finally { + setBusy(false); + } + } + + if (done) { + const host = done.login_url || `https://${hostFor(done.slug)}`; + return ( + + + + This instance is now {done.name}, at{" "} + {hostFor(done.slug)}. The old address has stopped working, and your + sign-in does not follow it — you will need to sign in again there. + + + Open {hostFor(done.slug)} → + + + + ); + } + + return ( +
+ {open && ( + setValue(e.target.value)} + error={error ?? (name ? invalid : undefined)} + hint={ + name && !invalid ? ( + <> + Moves to {hostFor(derived)} + {derived === currentSlug && " — the address does not change"} + + ) : ( + "Letters and digits; everything else becomes a hyphen." + ) + } + /> + )} +
+ + {open && ( + + Anyone signed in will need to sign in again at the new address, and links to the old one stop working. + + )} +
+
+ ); +}