diff --git a/apps/web/src/features/hosting-signup/hosting-api.ts b/apps/web/src/features/hosting-signup/hosting-api.ts index 25b95c7df8..00b4ee116d 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,54 @@ 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. `published` is the server's authoritative word on whether the + * change is live or only stored. */ + updateTenant: (username: string, token: string, config: HostingConfigInput) => + patch<{ message?: string; published?: boolean }>( + `/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..0193181ad0 --- /dev/null +++ b/apps/web/src/features/hosting-signup/tenant-settings.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { Alert } from "@ui/alert"; +import { Button } from "@ui/button"; +import { FormControl } from "@ui/input"; +import i18next from "i18next"; +import React, { 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); + // The server's word on whether the saved change is live or only stored; + // the listed subscription status can be stale by the time a save lands. + const [publishedResult, setPublishedResult] = useState(null); + + // What the instance currently stores, so only actual edits are sent. + const initialRef = useRef({ + title: "", + description: "", + 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; + let cancelled = false; + hostingApi + .tenantConfig(tenant.username) + .then((config) => { + if (cancelled || saveStartedRef.current) 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; + // 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. + .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 () => { + saveStartedRef.current = true; + setBusy(true); + setError(""); + setSaved(false); + try { + const token = await obtainHostingToken(owner); + const result = await hostingApi.updateTenant(tenant.username, token, changes); + setPublishedResult( + result.published ?? tenant.subscriptionStatus === "active" + ); + 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}} + {/* Persisting is not publishing: the message follows the PATCH + response's authoritative published flag, since the listed status can + have gone stale between fetching the panel and saving. */} + {saved && !hasChanges && ( + + {i18next.t( + publishedResult + ? "hosting.settings-saved" + : "hosting.settings-saved-pending" + )} + + )} + +
+ ); +} diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index 1db6025bcd..a1197b1df4 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -91,6 +91,17 @@ "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-saved-pending": "Saved. Your changes publish when the site activates.", + "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..2b57301a2f --- /dev/null +++ b/apps/web/src/specs/features/hosting-signup/tenant-settings.spec.tsx @@ -0,0 +1,186 @@ +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", + published: true + }); + 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 }; + mocks.updateTenant.mockResolvedValue({ + message: "Configuration saved. It goes live once the subscription is active.", + published: false + }); + 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" + }) + ); + // Persisting is not publishing: before activation the message must not + // promise a live site. + await screen.findByText("hosting.settings-saved-pending"); + }); + + it("trusts the PATCH response over a stale listed status", async () => { + // The tenant activated after the manage list was fetched: the server's + // published flag is authoritative, so the save reports live, not pending. + const staleInactive = { ...ACTIVE_TENANT, subscriptionStatus: "inactive" as const }; + mocks.updateTenant.mockResolvedValue({ + message: "Configuration updated", + published: true + }); + renderWithQueryClient(); + + fireEvent.change(screen.getByLabelText("hosting.settings-theme-label"), { + target: { value: "dark" } + }); + fireEvent.click(screen.getByRole("button", { name: "hosting.settings-save" })); + + await screen.findByText("hosting.settings-saved"); + expect(screen.queryByText("hosting.settings-saved-pending")).toBeNull(); + }); + + it("never lets a slow prefill eat keystrokes", async () => { + let resolveConfig!: (v: unknown) => void; + mocks.tenantConfig.mockReturnValue( + new Promise((resolve) => { + resolveConfig = resolve; + }) + ); + renderWithQueryClient(); + + // The owner starts typing while the config request is still in flight... + const inputs = screen.getAllByPlaceholderText("hosting.settings-keep"); + fireEvent.change(inputs[0], { target: { value: "Typed first" } }); + + // ...and the prefill that lands afterwards must not replace it. + resolveConfig({ + configuration: { + general: { theme: "light", styles: { accent: "#0066cc" } }, + instanceConfiguration: { + meta: { title: "Alice writes", description: "Notes" } + } + } + }); + await waitFor(() => + expect(screen.getByDisplayValue("Notes")).toBeTruthy() + ); + expect(screen.getByDisplayValue("Typed first")).toBeTruthy(); + + // The diff still runs against the fetched snapshot, so the edit travels. + fireEvent.click(screen.getByRole("button", { name: "hosting.settings-save" })); + await waitFor(() => + expect(mocks.updateTenant).toHaveBeenCalledWith("alice", "hosting-jwt", { + title: "Typed first" + }) + ); + }); + + 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(); + }); +});