From 865ced502f8e12774ab1239f4dcf46ec4d9d7ff7 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 12 Aug 2026 16:42:01 +0000 Subject: [PATCH 1/3] hosting: make the manage panel a remote settings surface The manage panel edits a hosted instance's title, description, theme and accent without a visit to the instance. Authorization is a hosting token obtained in place: every ecency.com login method holds a Hivesigner-compatible session token that /v1/auth/hivesigner exchanges, with a Keychain posting-key challenge as the fallback rail, cached per account for its lifetime. The editor prefills from the served config when the tenant is active, sends only the fields that actually changed and a blank field always keeps the current value. Works for a tenant that is still activating too: the PATCH persists and publishes on activation. --- .../features/hosting-signup/hosting-api.ts | 56 ++++- .../hosting-signup/hosting-manage.tsx | 20 ++ .../features/hosting-signup/hosting-token.ts | 66 ++++++ .../hosting-signup/tenant-settings.tsx | 194 ++++++++++++++++++ apps/web/src/features/i18n/locales/en-US.json | 10 + .../hosting-signup/hosting-token.spec.ts | 95 +++++++++ .../hosting-signup/tenant-settings.spec.tsx | 121 +++++++++++ 7 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/features/hosting-signup/hosting-token.ts create mode 100644 apps/web/src/features/hosting-signup/tenant-settings.tsx create mode 100644 apps/web/src/specs/features/hosting-signup/hosting-token.spec.ts create mode 100644 apps/web/src/specs/features/hosting-signup/tenant-settings.spec.tsx diff --git a/apps/web/src/features/hosting-signup/hosting-api.ts b/apps/web/src/features/hosting-signup/hosting-api.ts index 25b95c7df8..32cf5509a0 100644 --- a/apps/web/src/features/hosting-signup/hosting-api.ts +++ b/apps/web/src/features/hosting-signup/hosting-api.ts @@ -100,6 +100,20 @@ async function post(path: string, body: unknown): Promise { return r.json() as Promise; } +async function patch(path: string, token: string, body: unknown): Promise { + const r = await fetch(`${HOSTING_API}${path}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(body) + }); + if (!r.ok) throw new Error(await parseError(r)); + return r.json() as Promise; +} + export const hostingApi = { /** The signup page only renders when the service URL is configured. */ isConfigured: () => HOSTING_API.length > 0, @@ -135,9 +149,49 @@ export const hostingApi = { /** Prorated cost to add a custom domain (upgrade an existing active standard tenant to Pro) for * the months remaining on its current term. `eligible: false` when not active / already Pro. */ upgradeQuote: (username: string) => - get(`/v1/payments/upgrade-quote/${encodeURIComponent(username)}`) + get(`/v1/payments/upgrade-quote/${encodeURIComponent(username)}`), + + /** The tenant's stored config document (public for ACTIVE tenants; 402 otherwise). The manage + * panel's settings editor prefills from it. */ + tenantConfig: (username: string) => + get(`/v1/tenants/${encodeURIComponent(username)}/config`), + + /** Exchange an ecency.com session's Hivesigner-compatible access token for a hosting token. + * Every login method on ecency.com holds one, so this is the universal rail. */ + authHivesigner: (accessToken: string) => + post("/v1/auth/hivesigner", { accessToken }), + + /** Keychain rail: fetch a challenge to sign with the posting key... */ + authChallenge: (username: string) => + post<{ username: string; challenge: string; expiresAt: string }>("/v1/auth/challenge", { + username + }), + + /** ...and trade the signature for the hosting token. */ + authVerify: (username: string, signature: string, challenge: string) => + post("/v1/auth/verify", { username, signature, challenge }), + + /** Update a tenant's config remotely with flat keys (title, description, theme, accent...). + * Requires a hosting token for the tenant's OWNER; persists for inactive tenants too and + * publishes on activation. */ + updateTenant: (username: string, token: string, config: HostingConfigInput) => + patch<{ message?: string }>(`/v1/tenants/${encodeURIComponent(username)}`, token, { config }) }; +export interface HostingAuthResult { + token: string; + username: string; + expiresAt?: string; +} + +/** The slice of the stored config document the settings editor reads. */ +export interface StoredTenantConfig { + configuration?: { + general?: { theme?: string; styles?: { accent?: string } }; + instanceConfiguration?: { meta?: { title?: string; description?: string } }; + }; +} + export type UpgradeQuote = | { eligible: false; reason: string } | { diff --git a/apps/web/src/features/hosting-signup/hosting-manage.tsx b/apps/web/src/features/hosting-signup/hosting-manage.tsx index 5a1dbbea46..72afed3b0c 100644 --- a/apps/web/src/features/hosting-signup/hosting-manage.tsx +++ b/apps/web/src/features/hosting-signup/hosting-manage.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import { CustomDomainManager } from "./custom-domain-manager"; import { CustomDomainUpgrade } from "./custom-domain-upgrade"; import { hostingApi, type OwnedTenant } from "./hosting-api"; +import { TenantSettings } from "./tenant-settings"; /** * Compact "your hosted sites" panel for the /hosting page. Signup used to be the only surface, @@ -19,6 +20,7 @@ export function HostingManage() { const username = activeUser?.username ?? ""; const [domainOpenFor, setDomainOpenFor] = useState(null); const [upgradeOpenFor, setUpgradeOpenFor] = useState(null); + const [settingsOpenFor, setSettingsOpenFor] = useState(null); // Keyed by owner so switching accounts can never render the previous account's tenants. const { data, refetch } = useQuery({ @@ -41,6 +43,7 @@ export function HostingManage() { useEffect(() => { setDomainOpenFor(null); setUpgradeOpenFor(null); + setSettingsOpenFor(null); }, [username]); if (!activeUser || tenants.length === 0) { @@ -93,6 +96,23 @@ export function HostingManage() { {statusLabel(t)} + {/* Remote settings: title, look and theme PATCH straight to the + hosting API with a token obtained in place, no instance visit. + Offered for every status, since the PATCH persists for a tenant + that is still activating and publishes on activation. */} +
+ {settingsOpenFor === t.username ? ( + + ) : ( + + )} +
+ {t.subscriptionPlan === "pro" && t.subscriptionStatus === "active" && (
{t.customDomain && t.customDomainVerified ? ( diff --git a/apps/web/src/features/hosting-signup/hosting-token.ts b/apps/web/src/features/hosting-signup/hosting-token.ts new file mode 100644 index 0000000000..a17e07b960 --- /dev/null +++ b/apps/web/src/features/hosting-signup/hosting-token.ts @@ -0,0 +1,66 @@ +import { getLoginType, ensureValidToken } from "@/utils/user-token"; +import { signBuffer } from "@/utils/keychain"; +import { hostingApi, type HostingAuthResult } from "./hosting-api"; + +/** + * A hosting-API token for the signed-in account, obtained in place so the + * manage panel can call the tenant PATCH endpoint without a visit to the + * instance. + * + * Two rails, tried in order: + * - Every ecency.com login method holds a Hivesigner-compatible access token, + * so exchanging it (/v1/auth/hivesigner) is the universal path. + * - A Keychain login can also sign the challenge (/v1/auth/challenge + + * /v1/auth/verify) when the exchange is unavailable. + * + * Tokens are cached per account for their lifetime (the API issues 24h), so + * a session edits many settings on one authorization. + */ + +const cache = new Map(); + +/** Test seam: module memory otherwise leaks between cases. */ +export function resetHostingTokenCache(): void { + cache.clear(); +} + +function remember(result: HostingAuthResult): string { + const expiresAt = result.expiresAt + ? Date.parse(result.expiresAt) + : Date.now() + 23 * 60 * 60 * 1000; + cache.set(result.username, { token: result.token, expiresAt }); + return result.token; +} + +export async function obtainHostingToken(username: string): Promise { + const hit = cache.get(username); + // A minute of slack: a token that expires mid-request helps nobody. + if (hit && hit.expiresAt - 60_000 > Date.now()) { + return hit.token; + } + cache.delete(username); + + // The universal rail first. ensureValidToken refreshes a stale stored + // token before it is exchanged, so a long-lived login works too. + let exchangeError: Error | null = null; + try { + const accessToken = await ensureValidToken(username); + if (accessToken) { + return remember(await hostingApi.authHivesigner(accessToken)); + } + } catch (e) { + exchangeError = e as Error; + } + + // Keychain can prove the account by signing the challenge directly. + if (getLoginType(username) === "keychain") { + const { challenge } = await hostingApi.authChallenge(username); + const signed = await signBuffer(username, challenge, "Posting"); + if (!signed.success || !signed.result) { + throw new Error(signed.message || "Signature refused"); + } + return remember(await hostingApi.authVerify(username, signed.result, challenge)); + } + + throw exchangeError ?? new Error("No session token available"); +} diff --git a/apps/web/src/features/hosting-signup/tenant-settings.tsx b/apps/web/src/features/hosting-signup/tenant-settings.tsx new file mode 100644 index 0000000000..0cfde00ec1 --- /dev/null +++ b/apps/web/src/features/hosting-signup/tenant-settings.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { Alert } from "@ui/alert"; +import { Button } from "@ui/button"; +import { FormControl } from "@ui/input"; +import i18next from "i18next"; +import { useEffect, useRef, useState } from "react"; +import { AccentPicker } from "./accent-picker"; +import { + ACCENT_HEX_PATTERN, + hostingApi, + type HostingConfigInput, + type OwnedTenant +} from "./hosting-api"; +import { obtainHostingToken } from "./hosting-token"; + +interface Props { + tenant: OwnedTenant; + /** The signed-in controlling account; the PATCH authorizes against it. */ + owner: string; +} + +type ThemeChoice = "" | "system" | "light" | "dark"; +const THEME_CHOICES: readonly ThemeChoice[] = ["system", "light", "dark"]; + +/** + * Remote settings for a hosted instance, right in the manage panel: title, + * description, theme and accent PATCH directly to the hosting API with a + * Hive-signed hosting token obtained in place, no visit to the instance + * needed. Works while a tenant is still activating too, since the PATCH + * persists for inactive tenants and publishes on activation. + * + * Prefilled from the served config when the tenant is active (the config + * endpoint answers 402 before activation); otherwise fields start blank and + * a blank field always means "keep the current value" (the flat PATCH + * vocabulary cannot unset). + */ +export function TenantSettings({ tenant, owner }: Props) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [theme, setTheme] = useState(""); + const [accent, setAccent] = useState(null); + const [accentInput, setAccentInput] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [saved, setSaved] = useState(false); + + // What the instance currently stores, so only actual edits are sent. + const initialRef = useRef({ + title: "", + description: "", + theme: "" as ThemeChoice, + accent: "" + }); + + useEffect(() => { + if (tenant.subscriptionStatus !== "active") return; + let cancelled = false; + hostingApi + .tenantConfig(tenant.username) + .then((config) => { + if (cancelled) return; + const meta = config.configuration?.instanceConfiguration?.meta; + const general = config.configuration?.general; + const storedTheme = general?.theme ?? ""; + const next = { + title: meta?.title ?? "", + description: meta?.description ?? "", + theme: (THEME_CHOICES as readonly string[]).includes(storedTheme) + ? (storedTheme as ThemeChoice) + : ("" as ThemeChoice), + accent: general?.styles?.accent ?? "" + }; + initialRef.current = next; + setTitle(next.title); + setDescription(next.description); + setTheme(next.theme); + setAccent(next.accent || null); + setAccentInput(next.accent); + }) + // Prefill is a convenience; without it the editor still works with + // blank-keeps-current semantics. + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [tenant.username, tenant.subscriptionStatus]); + + const accentPending = + accentInput.trim().length > 0 && !ACCENT_HEX_PATTERN.test(accentInput.trim()); + + // Only what actually changed travels: the flat PATCH merges what it is + // sent, and resending an unchanged value would still be a write. + const initial = initialRef.current; + const changes: HostingConfigInput = {}; + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (trimmedTitle && trimmedTitle !== initial.title) changes.title = trimmedTitle; + if (trimmedDescription && trimmedDescription !== initial.description) { + changes.description = trimmedDescription; + } + if (theme && theme !== initial.theme) changes.theme = theme; + if (accent && accent !== initial.accent) changes.accent = accent; + const hasChanges = Object.keys(changes).length > 0; + + const save = async () => { + setBusy(true); + setError(""); + setSaved(false); + try { + const token = await obtainHostingToken(owner); + await hostingApi.updateTenant(tenant.username, token, changes); + initialRef.current = { + title: changes.title ?? initial.title, + description: changes.description ?? initial.description, + theme: (changes.theme as ThemeChoice | undefined) ?? initial.theme, + accent: changes.accent ?? initial.accent + }; + setSaved(true); + } catch (e) { + setError((e as Error).message || i18next.t("hosting.settings-failed")); + } finally { + setBusy(false); + } + }; + + return ( +
+

{i18next.t("hosting.settings-hint")}

+ + + setTitle(e.target.value)} + placeholder={i18next.t("hosting.settings-keep")} + /> + + + setDescription(e.target.value)} + placeholder={i18next.t("hosting.settings-keep")} + /> + + + + + + { + setAccentInput(raw); + const trimmed = raw.trim(); + if (!trimmed) setAccent(null); + else if (ACCENT_HEX_PATTERN.test(trimmed)) setAccent(trimmed); + }} + onPick={(hex) => { + setAccent(hex); + setAccentInput(hex ?? ""); + }} + /> + + {error && {error}} + {saved && !hasChanges && ( + {i18next.t("hosting.settings-saved")} + )} + +
+ ); +} diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index 1db6025bcd..d1525cdf55 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -91,6 +91,16 @@ "manage-status-suspended": "Suspended", "manage-plan-pro": "Custom domain plan", "manage-domain": "Set up custom domain", + "manage-settings": "Edit settings", + "settings-hint": "Changes apply to your site directly. Blank fields keep the current value.", + "settings-keep": "Keep current", + "settings-theme-label": "Theme", + "theme-system": "System", + "theme-light": "Light", + "theme-dark": "Dark", + "settings-save": "Save settings", + "settings-saved": "Saved. Changes reach your site within a minute.", + "settings-failed": "Could not save. Please try again.", "manage-domain-active": "Custom domain: {{domain}}", "perk-card-title": "Your own blog", "perk-card-description": "Host your Hive blog on your own space at yourname.blogs.ecency.com, or your own domain.", diff --git a/apps/web/src/specs/features/hosting-signup/hosting-token.spec.ts b/apps/web/src/specs/features/hosting-signup/hosting-token.spec.ts new file mode 100644 index 0000000000..0c6550c03f --- /dev/null +++ b/apps/web/src/specs/features/hosting-signup/hosting-token.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// The manage panel's remote settings authorize through a hosting token +// obtained in place. These specs pin the two rails and the cache: the +// universal session-token exchange, the Keychain challenge fallback, and one +// authorization serving many edits. + +const mocks = vi.hoisted(() => ({ + ensureValidToken: vi.fn(), + getLoginType: vi.fn(), + signBuffer: vi.fn(), + authHivesigner: vi.fn(), + authChallenge: vi.fn(), + authVerify: vi.fn() +})); + +vi.mock("@/utils/user-token", () => ({ + ensureValidToken: mocks.ensureValidToken, + getLoginType: mocks.getLoginType +})); + +vi.mock("@/utils/keychain", () => ({ + signBuffer: mocks.signBuffer +})); + +vi.mock("@/features/hosting-signup/hosting-api", async () => { + const actual = await vi.importActual("@/features/hosting-signup/hosting-api"); + return { + ...actual, + hostingApi: { + ...actual.hostingApi, + authHivesigner: mocks.authHivesigner, + authChallenge: mocks.authChallenge, + authVerify: mocks.authVerify + } + }; +}); + +import { + obtainHostingToken, + resetHostingTokenCache +} from "@/features/hosting-signup/hosting-token"; + +describe("obtainHostingToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetHostingTokenCache(); + mocks.getLoginType.mockReturnValue("hivesigner"); + mocks.ensureValidToken.mockResolvedValue("hs-token"); + mocks.authHivesigner.mockResolvedValue({ + token: "hosting-jwt", + username: "alice", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + }); + }); + + it("exchanges the session token once and serves later calls from the cache", async () => { + expect(await obtainHostingToken("alice")).toBe("hosting-jwt"); + expect(await obtainHostingToken("alice")).toBe("hosting-jwt"); + expect(mocks.authHivesigner).toHaveBeenCalledTimes(1); + expect(mocks.authHivesigner).toHaveBeenCalledWith("hs-token"); + expect(mocks.signBuffer).not.toHaveBeenCalled(); + }); + + it("falls back to the Keychain challenge when the exchange is unavailable", async () => { + mocks.ensureValidToken.mockResolvedValue(undefined); + mocks.getLoginType.mockReturnValue("keychain"); + mocks.authChallenge.mockResolvedValue({ + username: "alice", + challenge: "ecency-hosting-login:alice:123:nonce", + expiresAt: new Date().toISOString() + }); + mocks.signBuffer.mockResolvedValue({ success: true, result: "sig" }); + mocks.authVerify.mockResolvedValue({ token: "kc-jwt", username: "alice" }); + + expect(await obtainHostingToken("alice")).toBe("kc-jwt"); + expect(mocks.signBuffer).toHaveBeenCalledWith( + "alice", + "ecency-hosting-login:alice:123:nonce", + "Posting" + ); + expect(mocks.authVerify).toHaveBeenCalledWith( + "alice", + "sig", + "ecency-hosting-login:alice:123:nonce" + ); + }); + + it("surfaces the exchange failure when no other rail exists", async () => { + mocks.authHivesigner.mockRejectedValue(new Error("expired token")); + mocks.getLoginType.mockReturnValue("hivesigner"); + await expect(obtainHostingToken("alice")).rejects.toThrow("expired token"); + expect(mocks.authChallenge).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/specs/features/hosting-signup/tenant-settings.spec.tsx b/apps/web/src/specs/features/hosting-signup/tenant-settings.spec.tsx new file mode 100644 index 0000000000..8a72ae8c04 --- /dev/null +++ b/apps/web/src/specs/features/hosting-signup/tenant-settings.spec.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { renderWithQueryClient } from "@/specs/test-utils"; + +// Remote settings from the manage panel: prefill from the served config, +// send ONLY what changed, authorize with the in-place hosting token. A field +// resent unchanged would still be a write, and a blank field must always +// mean "keep the current value". + +const mocks = vi.hoisted(() => ({ + tenantConfig: vi.fn(), + updateTenant: vi.fn(), + obtainHostingToken: vi.fn() +})); + +vi.mock("@/features/hosting-signup/hosting-api", async () => { + const actual = await vi.importActual("@/features/hosting-signup/hosting-api"); + return { + ...actual, + hostingApi: { + ...actual.hostingApi, + tenantConfig: mocks.tenantConfig, + updateTenant: mocks.updateTenant + } + }; +}); + +vi.mock("@/features/hosting-signup/hosting-token", () => ({ + obtainHostingToken: mocks.obtainHostingToken +})); + +import { TenantSettings } from "@/features/hosting-signup/tenant-settings"; +import type { OwnedTenant } from "@/features/hosting-signup/hosting-api"; + +const ACTIVE_TENANT: OwnedTenant = { + username: "alice", + owner: "alice", + type: "blog", + subscriptionStatus: "active", + subscriptionPlan: "standard", + blogUrl: "https://alice.blogs.ecency.com" +}; + +describe("TenantSettings remote editor", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.obtainHostingToken.mockResolvedValue("hosting-jwt"); + mocks.updateTenant.mockResolvedValue({ message: "Configuration updated" }); + mocks.tenantConfig.mockResolvedValue({ + configuration: { + general: { theme: "light", styles: { accent: "#0066cc" } }, + instanceConfiguration: { + meta: { title: "Alice writes", description: "Notes" } + } + } + }); + }); + + it("prefills from the served config and sends only what changed", async () => { + renderWithQueryClient(); + + // Prefill landed and, with no edits, there is nothing to save. + const saveBtn = (await screen.findByRole("button", { + name: "hosting.settings-save" + })) as HTMLButtonElement; + await waitFor(() => + expect(screen.getByDisplayValue("Alice writes")).toBeTruthy() + ); + expect(saveBtn.disabled).toBe(true); + + fireEvent.change(screen.getByDisplayValue("Alice writes"), { + target: { value: "Alice sails" } + }); + await waitFor(() => expect(saveBtn.disabled).toBe(false)); + fireEvent.click(saveBtn); + + await waitFor(() => expect(mocks.updateTenant).toHaveBeenCalled()); + // Only the edited field travels; theme, accent and description are + // untouched and must not be resent. + expect(mocks.updateTenant).toHaveBeenCalledWith("alice", "hosting-jwt", { + title: "Alice sails" + }); + await screen.findByText("hosting.settings-saved"); + }); + + it("edits an activating tenant blind: no prefill, entered fields sent as-is", async () => { + const inactive = { ...ACTIVE_TENANT, subscriptionStatus: "inactive" as const }; + renderWithQueryClient(); + + expect(mocks.tenantConfig).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("hosting.settings-theme-label"), { + target: { value: "dark" } + }); + const saveBtn = screen.getByRole("button", { + name: "hosting.settings-save" + }) as HTMLButtonElement; + await waitFor(() => expect(saveBtn.disabled).toBe(false)); + fireEvent.click(saveBtn); + + await waitFor(() => + expect(mocks.updateTenant).toHaveBeenCalledWith("alice", "hosting-jwt", { + theme: "dark" + }) + ); + }); + + it("surfaces a failed save instead of pretending", async () => { + mocks.updateTenant.mockRejectedValue(new Error("Unauthorized")); + renderWithQueryClient(); + await waitFor(() => + expect(screen.getByDisplayValue("Alice writes")).toBeTruthy() + ); + fireEvent.change(screen.getByDisplayValue("Alice writes"), { + target: { value: "Alice sails" } + }); + fireEvent.click(screen.getByRole("button", { name: "hosting.settings-save" })); + + await screen.findByText("Unauthorized"); + expect(screen.queryByText("hosting.settings-saved")).toBeNull(); + }); +}); From 210121917d90bd5dfbaf5abf7b9be7c29b31efc9 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 12 Aug 2026 16:52:26 +0000 Subject: [PATCH 2/3] hosting: prefill never eats keystrokes and inactive saves stay honest Fetched values now seed only fields the owner has not started editing and a prefill landing after a save is dropped entirely, so neither race can discard edits or re-flag saved fields. Saves for a tenant that is not yet active say the changes publish on activation instead of promising a live site, and the new input handlers carry real event types. --- .../hosting-signup/tenant-settings.tsx | 36 ++++++++++++----- apps/web/src/features/i18n/locales/en-US.json | 1 + .../hosting-signup/tenant-settings.spec.tsx | 39 +++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/web/src/features/hosting-signup/tenant-settings.tsx b/apps/web/src/features/hosting-signup/tenant-settings.tsx index 0cfde00ec1..c8ed2644c6 100644 --- a/apps/web/src/features/hosting-signup/tenant-settings.tsx +++ b/apps/web/src/features/hosting-signup/tenant-settings.tsx @@ -4,7 +4,7 @@ import { Alert } from "@ui/alert"; import { Button } from "@ui/button"; import { FormControl } from "@ui/input"; import i18next from "i18next"; -import { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { AccentPicker } from "./accent-picker"; import { ACCENT_HEX_PATTERN, @@ -52,6 +52,9 @@ export function TenantSettings({ tenant, owner }: Props) { theme: "" as ThemeChoice, accent: "" }); + // A save makes the fetched snapshot stale: a prefill landing after it must + // not overwrite the post-save baseline or re-flag saved fields as edits. + const saveStartedRef = useRef(false); useEffect(() => { if (tenant.subscriptionStatus !== "active") return; @@ -59,7 +62,7 @@ export function TenantSettings({ tenant, owner }: Props) { hostingApi .tenantConfig(tenant.username) .then((config) => { - if (cancelled) return; + if (cancelled || saveStartedRef.current) return; const meta = config.configuration?.instanceConfiguration?.meta; const general = config.configuration?.general; const storedTheme = general?.theme ?? ""; @@ -72,11 +75,15 @@ export function TenantSettings({ tenant, owner }: Props) { accent: general?.styles?.accent ?? "" }; initialRef.current = next; - setTitle(next.title); - setDescription(next.description); - setTheme(next.theme); - setAccent(next.accent || null); - setAccentInput(next.accent); + // The form is editable while this request runs, so each fetched value + // lands only in a field the owner has not already started editing: + // prefill is a convenience and must never eat keystrokes. The change + // diff still compares against the fetched snapshot either way. + setTitle((prev) => prev || next.title); + setDescription((prev) => prev || next.description); + setTheme((prev) => prev || next.theme); + setAccent((prev) => prev ?? (next.accent || null)); + setAccentInput((prev) => prev || next.accent); }) // Prefill is a convenience; without it the editor still works with // blank-keeps-current semantics. @@ -104,6 +111,7 @@ export function TenantSettings({ tenant, owner }: Props) { const hasChanges = Object.keys(changes).length > 0; const save = async () => { + saveStartedRef.current = true; setBusy(true); setError(""); setSaved(false); @@ -133,7 +141,7 @@ export function TenantSettings({ tenant, owner }: Props) { type="text" value={title} maxLength={100} - onChange={(e: any) => setTitle(e.target.value)} + onChange={(e: React.ChangeEvent) => setTitle(e.target.value)} placeholder={i18next.t("hosting.settings-keep")} /> @@ -142,7 +150,7 @@ export function TenantSettings({ tenant, owner }: Props) { type="text" value={description} maxLength={500} - onChange={(e: any) => setDescription(e.target.value)} + onChange={(e: React.ChangeEvent) => setDescription(e.target.value)} placeholder={i18next.t("hosting.settings-keep")} /> @@ -178,8 +186,16 @@ export function TenantSettings({ tenant, owner }: Props) { /> {error && {error}} + {/* Persisting is not publishing: before activation the PATCH only + stores the config, so the message must not promise a live site. */} {saved && !hasChanges && ( - {i18next.t("hosting.settings-saved")} + + {i18next.t( + tenant.subscriptionStatus === "active" + ? "hosting.settings-saved" + : "hosting.settings-saved-pending" + )} + )}