From df09e42cf2abd52350b0cb4b6284bba6ff126949 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 10:17:39 +0000 Subject: [PATCH] feat: Add rename calls and slug preview to the HQ client --- adminsite/lib/api.ts | 13 ++++++++++ adminsite/lib/slug.ts | 56 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 adminsite/lib/slug.ts diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts index 0e1c323..c1d3909 100644 --- a/adminsite/lib/api.ts +++ b/adminsite/lib/api.ts @@ -129,6 +129,7 @@ export interface Instance { status: InstanceStatus; current_license?: string; relink_count: number; + renamed_at?: string; inject_failed_at?: string | null; notices_sent?: string[]; created_at: string; @@ -288,6 +289,14 @@ export interface StaffInstanceResponse { injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null }; } +export interface RenameResult { + instance_id: string; + name: string; + slug: string; + /** Empty when APP_LOGIN_URL is unset on the server. */ + login_url?: string; +} + // --- calls --------------------------------------------------------------- export const api = { @@ -306,6 +315,8 @@ export const api = { post("/api/instances/link", { instance_id, name }), createInstance: (name: string) => post("/api/instances", { name }), renewInstance: (id: string) => post(`/api/instances/${id}/renew`, {}), + renameInstance: (id: string, name: string) => + put(`/api/instances/${id}/name`, { name }), // Self-hosted Free: issue the licence on an already-linked instance. claimFree: (id: string) => post(`/api/instances/${id}/claim-free`, {}), relink: (id: string, instance_id: string) => @@ -367,6 +378,8 @@ export const api = { post(`/api/staff/instances/${id}/issue`, payload), relink: (id: string, instance_id: string) => post(`/api/staff/instances/${id}/relink`, { instance_id }), + renameInstance: (id: string, name: string) => + put(`/api/staff/instances/${id}/name`, { name }), licenses: (params?: Record) => req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`), plans: () => req("/api/staff/plans"), diff --git a/adminsite/lib/slug.ts b/adminsite/lib/slug.ts new file mode 100644 index 0000000..b60507e --- /dev/null +++ b/adminsite/lib/slug.ts @@ -0,0 +1,56 @@ +/* + * A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the + * host a rename would move an instance to while the customer types. + * + * It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it + * must change in the same commit as the Go one — the same hazard as + * web/lib/targets.ts. The preview is a courtesy; the server's 409 is the + * boundary, and the two are allowed to disagree without anything breaking. + */ + +/** Mirrors provision.MinSlugLength / MaxSlugLength. */ +export const MIN_SLUG_LENGTH = 3; +export const MAX_SLUG_LENGTH = 40; + +/** Mirrors provision.ReservedSlugs. */ +const RESERVED = new Set([ + "www", "api", "app", "admin", "auth", + "install", "static", "_next", "default", +]); + +/* + * The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the + * customer instance page; those predate this file and are left alone rather than + * refactored under a rename change. + */ +export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk"; + +/** Mirrors provision.Slugify. */ +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Mirrors provision.BaseSlug's truncation. */ +export function baseSlug(name: string): string { + return slugify(name).slice(0, MAX_SLUG_LENGTH); +} + +/** The reason a name cannot become a slug, or undefined when it can. */ +export function slugError(name: string): string | undefined { + const base = slugify(name); + if (base.length < MIN_SLUG_LENGTH) { + return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`; + } + if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) { + return "That name is reserved."; + } + return undefined; +} + +/** The host an instance on this slug is reached at. */ +export function hostFor(slug: string): string { + return `${slug}.${INSTANCE_DOMAIN}`; +}