diff --git a/apps/web/src/components/sections-editor/serve-save-error.test.ts b/apps/web/src/components/sections-editor/serve-save-error.test.ts index 75660fb391..d58cb308cd 100644 --- a/apps/web/src/components/sections-editor/serve-save-error.test.ts +++ b/apps/web/src/components/sections-editor/serve-save-error.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; import { toast } from "sonner"; import { en } from "@/i18n/en/index.ts"; @@ -77,11 +77,8 @@ describe("save messages for a local deco serve (a developer)", () => { }); describe("one toast per save failure", () => { - afterEach(() => { - toast.dismiss(); - }); - test("a burst of failing autosaves shows a single toast", () => { + const before = toast.getToasts().length; for (let i = 0; i < 3; i++) { toastSaveError(t, new Error(`HTTP 50${i}`), "github"); } @@ -89,16 +86,14 @@ describe("one toast per save failure", () => { .getToasts() .filter((item) => item.id === SITE_EDITOR_SAVE_TOAST); expect(shown).toHaveLength(1); + expect(toast.getToasts().length - before).toBeLessThanOrEqual(1); }); }); describe("one toast per publish", () => { - afterEach(() => { - toast.dismiss(); - }); - test("a failure, its retry and the success share one toast", () => { let retried = 0; + const before = toast.getToasts().length; toastPublishFailed(t, { headline: "Couldn't publish this version.", detail: "latest-update-failed", @@ -124,6 +119,7 @@ describe("one toast per publish", () => { expect((shown[0] as { title?: unknown }).title).toBe("Published"); // The success doesn't keep the failure's "Try again". expect((shown[0] as { action?: unknown }).action).toBeUndefined(); + expect(toast.getToasts().length - before).toBeLessThanOrEqual(1); expect(retried).toBe(0); }); diff --git a/apps/web/src/components/sections-editor/site-editor-toast.tsx b/apps/web/src/components/sections-editor/site-editor-toast.tsx index c2e1da6c67..04b89bc8d9 100644 --- a/apps/web/src/components/sections-editor/site-editor-toast.tsx +++ b/apps/web/src/components/sections-editor/site-editor-toast.tsx @@ -60,7 +60,7 @@ export function toastSaveError( * Sonner merges an update into the toast with the same id, so an outcome * clears what an earlier one set (a failure's "Try again", its stay-open). */ -const FRESH_TOAST = { action: undefined, duration: undefined } as const; +export const FRESH_TOAST = { action: undefined, duration: undefined } as const; /** Published: what the site shows now. `description` says what went live. */ export function toastPublished(t: TFunction, description: string) { diff --git a/apps/web/src/components/thread/repository/cms-header-actions.tsx b/apps/web/src/components/thread/repository/cms-header-actions.tsx index e2543243af..5f32282d9f 100644 --- a/apps/web/src/components/thread/repository/cms-header-actions.tsx +++ b/apps/web/src/components/thread/repository/cms-header-actions.tsx @@ -38,6 +38,7 @@ import { import { KEYS } from "@/lib/query-keys"; import { useProjectContext, useVirtualMCP } from "@/sdk"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; +import { useContentBackend } from "../../sections-editor/use-content-backend.ts"; import { useDecofileWriting } from "../../sections-editor/use-decofile-writing.ts"; import { useDraftPointer, @@ -250,9 +251,10 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { await deleteRelease(published); } }, - /** The dialog is already closed by now, so a toast is the only surface. */ + /** The publish already succeeded and showed its one toast; this clean-up + * is nothing the user can act on, so it is logged, not toasted. */ onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : String(err)); + console.error("site editor: settling after publish failed", err); }, }); @@ -311,7 +313,11 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { */ const publishing = publishCompletion.isPending; - const button = selectCmsHeaderButton({ + // A hosted v8 draft is not a branch: there is no pull request to submit + // for review, so its header offers Publish only. + const backend = useContentBackend(virtualMcpId, branch); + const hosted = backend.kind === "protocol" && backend.source === "github"; + const selected = selectCmsHeaderButton({ branch: branchMeta, pr, checks: checksQuery.data ?? [], @@ -330,6 +336,12 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { publishableChangeCount, t, }); + const button = hosted + ? { + ...selected, + menu: selected.menu.filter((item) => item.key !== "request-approval"), + } + : selected; /** Keyed by head so a failed sync isn't retried until the branch moves. */ const autoGetLatestKey = diff --git a/apps/web/src/components/thread/repository/hosted-publish-api.ts b/apps/web/src/components/thread/repository/hosted-publish-api.ts new file mode 100644 index 0000000000..518fe10861 --- /dev/null +++ b/apps/web/src/components/thread/repository/hosted-publish-api.ts @@ -0,0 +1,65 @@ +/** + * Publish for a hosted v8 site (Blocks v8 on GitHub): commits the project's + * CDN draft straight to main, writes that commit's companion release and + * makes it current on the CDN. `release` says how far the CDN steps got: + * `current`, `created` (making it current failed) or `none` (no release was + * written). Either way the changes are saved on main, and + * {@link publishMainHead} retries the CDN steps ("Try again"). + */ + +export type HostedPublishResult = + | { result: "merged"; sha: string; release: "current" | "created" | "none" } + | { result: "up-to-date" }; + +/** A refused publish, with the API's error code (`main-moved`). */ +export class HostedPublishError extends Error { + constructor( + message: string, + readonly code: string | null, + ) { + super(message); + this.name = "HostedPublishError"; + } +} + +async function post(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const json = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) { + throw new HostedPublishError( + json.error ?? `HTTP ${res.status}`, + json.error ?? null, + ); + } + return json as T; +} + +export function publishHostedDraft( + params: { orgSlug: string; virtualMcpId: string; branch: string }, + note: string, +): Promise { + return post( + `/api/${params.orgSlug}/decofile/${encodeURIComponent(params.virtualMcpId)}/${encodeURIComponent(params.branch)}/publish`, + { note }, + ); +} + +/** + * "Try again" after a Publish that saved the changes but didn't put them + * live: writes the companion release of main's head when it is missing and + * makes it current. Main's head holds every published change, this one's + * included, so it never rolls back a newer Publish. + */ +export function publishMainHead(params: { + orgSlug: string; + virtualMcpId: string; +}): Promise { + return post( + `/api/${params.orgSlug}/hosted/${encodeURIComponent(params.virtualMcpId)}/releases/current`, + { head: true }, + ); +} diff --git a/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts b/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts new file mode 100644 index 0000000000..6bd02ded59 --- /dev/null +++ b/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { toast } from "sonner"; +import { en } from "@/i18n/en/index.ts"; +import { interpolate } from "@/i18n/interpolate.ts"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { SITE_EDITOR_PUBLISH_TOAST } from "@/components/sections-editor/site-editor-toast.tsx"; +import { + HostedPublishError, + type HostedPublishResult, +} from "./hosted-publish-api.ts"; +import { + hostedPublishFailure, + notifyHostedPublish, + notifySavedNotPublished, +} from "./hosted-publish-feedback.ts"; + +const t: TFunction = (key, vars) => interpolate(en[key], vars); + +type Shown = { + id?: string | number; + title?: unknown; + type?: string; + action?: { label: string; onClick: (event: unknown) => void }; +}; +/** The publish toast(s) on screen. Sonner's state is global and outlives a + * test, so "one toast" is: one with the publish id, and at most one new. */ +const shown = () => + (toast.getToasts() as Shown[]).filter( + (item) => item.id === SITE_EDITOR_PUBLISH_TOAST, + ); +const total = () => toast.getToasts().length; + +const JARGON = [/\bCDN\b/i, /merged?/i, /\bcurrent\b/i, /\bmain\b/i, /commit/i]; + +describe("hosted Publish: one toast per Publish", () => { + const cases: [HostedPublishResult, string, boolean][] = [ + [ + { result: "merged", sha: "a".repeat(40), release: "current" }, + "Published", + false, + ], + [ + { result: "merged", sha: "a".repeat(40), release: "created" }, + "Saved, but not published yet", + true, + ], + [ + { result: "merged", sha: "a".repeat(40), release: "none" }, + "Saved, but not published yet", + true, + ], + [{ result: "up-to-date" }, "Nothing to publish", false], + ]; + + for (const [result, title, offersRetry] of cases) { + test(`${result.result}${"release" in result ? `/${result.release}` : ""} → "${title}"`, () => { + let retries = 0; + const before = total(); + notifyHostedPublish(t, result, () => { + retries++; + }); + expect(total() - before).toBeLessThanOrEqual(1); + const toasts = shown(); + expect(toasts).toHaveLength(1); + expect(toasts[0]!.id).toBe(SITE_EDITOR_PUBLISH_TOAST); + expect(toasts[0]!.title).toBe(title); + for (const word of JARGON) + expect(String(toasts[0]!.title)).not.toMatch(word); + if (offersRetry) { + expect(toasts[0]!.action?.label).toBe("Try again"); + toasts[0]!.action!.onClick({ preventDefault: () => {} }); + expect(retries).toBe(1); + // The same toast stays, as "Publishing…", until the outcome. + expect(shown()).toHaveLength(1); + expect(shown()[0]!.title).toBe("Publishing…"); + } else { + expect(toasts[0]!.action).toBeUndefined(); + } + }); + } + + test("a failed Try again replaces the toast, it doesn't stack", () => { + const retry = () => {}; + const before = total(); + notifyHostedPublish( + t, + { result: "merged", sha: "a".repeat(40), release: "created" }, + retry, + ); + notifySavedNotPublished(t, new HostedPublishError("HTTP 502", null), retry); + expect(total() - before).toBeLessThanOrEqual(1); + expect(shown()).toHaveLength(1); + expect(shown()[0]!.title).toBe("Saved, but not published yet"); + }); +}); + +describe("hosted Publish: the dialog's error", () => { + test("someone else published meanwhile", () => { + const failure = hostedPublishFailure( + t, + new HostedPublishError("main-moved", "main-moved"), + ); + expect(failure.message).toMatch(/Someone else published/); + expect(failure.detail).toBeNull(); + }); + + test("any other failure: plain words, the API's text only in Details", () => { + for (const raw of [ + "hosted delivery not configured", + "Project has no repository", + "HTTP 500", + ]) { + const failure = hostedPublishFailure(t, new HostedPublishError(raw, raw)); + expect(failure.message).toBe( + "Couldn't publish. Your changes are saved. Try again in a moment.", + ); + expect(failure.detail).toBe(raw); + } + }); +}); diff --git a/apps/web/src/components/thread/repository/hosted-publish-feedback.ts b/apps/web/src/components/thread/repository/hosted-publish-feedback.ts new file mode 100644 index 0000000000..c2c5b33526 --- /dev/null +++ b/apps/web/src/components/thread/repository/hosted-publish-feedback.ts @@ -0,0 +1,80 @@ +/** + * What a hosted Publish tells the user, in business words: one toast per + * Publish (sonner id `site-editor-publish`, shared with its "Try again" and + * with the Versions screen), and the dialog's inline error when nothing was + * published. "Published" means live; the API's codes and texts only ever + * reach the Details disclosure. + */ + +import { toast } from "sonner"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { + FRESH_TOAST, + SITE_EDITOR_PUBLISH_TOAST, + errorDetail, + toastPublishFailed, + toastPublished, +} from "@/components/sections-editor/site-editor-toast.tsx"; +import { + HostedPublishError, + type HostedPublishResult, +} from "./hosted-publish-api.ts"; + +/** + * The one toast for a finished Publish. `retry` puts saved-but-not-live + * changes live (see `publishMainHead`); it is offered whenever the changes + * reached main but the site doesn't show them yet. + */ +export function notifyHostedPublish( + t: TFunction, + result: HostedPublishResult, + retry: () => void, +) { + if (result.result === "up-to-date") { + // toast.message, not toast(): only the typed calls replace a toast with + // the same id; a bare toast() always adds one. + toast.message(t("siteEditor.publish.nothingToPublish"), { + ...FRESH_TOAST, + id: SITE_EDITOR_PUBLISH_TOAST, + description: t("siteEditor.publish.everythingLive"), + }); + return; + } + if (result.release === "current") { + toastPublished(t, t("siteEditor.publish.changesLive")); + return; + } + notifySavedNotPublished(t, null, retry); +} + +/** The changes are saved but not live yet: a warning with "Try again". */ +export function notifySavedNotPublished( + t: TFunction, + error: unknown, + retry: () => void, +) { + toastPublishFailed(t, { + tone: "warning", + headline: t("siteEditor.publish.savedNotPublished"), + body: t("siteEditor.publish.savedNotPublishedBody"), + detail: errorDetail(error), + retry, + }); +} + +/** A Publish that published nothing: the dialog's inline error. */ +export function hostedPublishFailure( + t: TFunction, + error: unknown, +): { message: string; detail: string | null } { + if (error instanceof HostedPublishError && error.code === "main-moved") { + return { + message: t("siteEditor.publish.publishedMeanwhile"), + detail: null, + }; + } + return { + message: t("siteEditor.publish.failed"), + detail: errorDetail(error), + }; +} diff --git a/apps/web/src/components/thread/repository/publish-dialog.tsx b/apps/web/src/components/thread/repository/publish-dialog.tsx index 0667b6e842..474a2501ec 100644 --- a/apps/web/src/components/thread/repository/publish-dialog.tsx +++ b/apps/web/src/components/thread/repository/publish-dialog.tsx @@ -38,7 +38,8 @@ import { ErrorBoundary } from "@/components/error-boundary.tsx"; import { useT, type TFunction } from "@/i18n/use-t.ts"; import { authClient } from "@/lib/auth-client.ts"; import { coAuthorFromSessionUser } from "@/lib/co-author-identity.ts"; -import { formatTimeAgo } from "@/lib/format-time.ts"; +import { usePreferences } from "@/hooks/use-preferences.ts"; +import { formatRelativeTime } from "@/lib/format-time.ts"; import { lastPreviewPageKey, readLastPreviewPage, @@ -73,6 +74,8 @@ import { import { useCmsPublishState } from "./use-cms-publish-state.ts"; import { useResolvedPublishGate } from "@/components/sandbox/hooks/use-publish-gate.ts"; import { useOptionalChatTask } from "@/components/chat/chat-context"; +import { useContentBackend } from "@/components/sections-editor/use-content-backend.ts"; +import { ErrorDetails } from "@/components/sections-editor/site-editor-toast.tsx"; export type { CmsPublishMode }; @@ -394,6 +397,7 @@ function CmsPublishContent({ publishLockRef: React.MutableRefObject; }) { const t = useT(); + const [{ language }] = usePreferences(); /** The session publishing — the git routes resolve their runtime from it. */ const threadId = useOptionalChatTask()?.taskId ?? null; const { data: session } = authClient.useSession(); @@ -460,7 +464,10 @@ function CmsPublishContent({ ] .filter((id) => changes.some((change) => changeId(change) === id)) .slice(0, MAX_LIVE_PANES); - const isReview = mode === "review"; + const backend = useContentBackend(virtualMcpId, branch); + const hosted = backend.kind === "protocol" && backend.source === "github"; + // A hosted v8 draft has no pull request: review mode doesn't apply to it. + const isReview = mode === "review" && !hosted; const surfaceState: PublishSurfaceState = cardsPending ? "loading" : bodiesPending @@ -504,11 +511,12 @@ function CmsPublishContent({ isPublishing, isDiscarding, publishError, + publishErrorDetail, submit, discardChange, discardAll, } = useCmsPublishActions({ - mode, + mode: isReview ? "review" : "publish", target, note, allPaths, @@ -518,13 +526,20 @@ function CmsPublishContent({ refresh, onPullRequestChanged, onPublished, + hosted, + authorName: session?.user?.name, }); const canDiscard = (paths: readonly string[]) => discardablePaths === null || paths.every((p) => discardablePaths.has(p)); + // Until the backend is known, Publish would take the git path on a hosted + // site (and speak git): wait for it. const canSubmit = - !isPublishing && summary.count > 0 && (isReview || gate.allowed); + backend.kind !== "pending" && + !isPublishing && + summary.count > 0 && + (isReview || gate.allowed); const headerTitle = isReview ? summary.count === 0 @@ -555,7 +570,7 @@ function CmsPublishContent({ } const pr = lastPublishedPr; if (!pr?.mergedAt) return null; - const when = formatTimeAgo(new Date(pr.mergedAt)); + const when = formatRelativeTime(new Date(pr.mergedAt), language); const name = lastPublishAttribution(pr); return name ? t("thread.publishPopover.lastPublishedBy", { when, name }) @@ -785,6 +800,7 @@ function CmsPublishContent({
{!isReview && + !hosted && summary.count > 0 && !gate.allowed && !gate.pending ? ( @@ -816,7 +832,15 @@ function CmsPublishContent({ )}
{publishError ? ( -

{publishError}

+
+

{publishError}

+ {publishErrorDetail ? ( + + ) : null} +
) : null} } diff --git a/apps/web/src/components/thread/repository/publish-flow.ts b/apps/web/src/components/thread/repository/publish-flow.ts index 64517665d8..f49ea2eb01 100644 --- a/apps/web/src/components/thread/repository/publish-flow.ts +++ b/apps/web/src/components/thread/repository/publish-flow.ts @@ -210,8 +210,8 @@ function openPullRequest( /** * push → sync → open (or update) the pull request → squash-merge. Every failure - * arrives as a {@link PublishStepError}; hand it to {@link reportPublishFailure} - * for the presentation both surfaces share. + * arrives as a {@link PublishStepError}; read it with + * {@link describePublishFailure}. */ export async function runPublishFlow( target: PublishTarget, @@ -283,7 +283,7 @@ export function describePublishFailure( ): PublishFailure { if (error instanceof PublishHeadMovedError) { return { - message: t("thread.publishPopover.branchMoved"), + message: t("siteEditor.publish.changedMeanwhile"), pullRequest: null, headMoved: true, }; @@ -308,31 +308,6 @@ export function describePublishFailure( }; } -/** - * {@link describePublishFailure} plus the toast both surfaces raise for it. - * `pullRequestOpened` refreshes PR state; `headMoved` re-reads the change list. - */ -export function reportPublishFailure( - error: unknown, - t: TFunction, -): { message: string; pullRequestOpened: boolean; headMoved: boolean } { - const failure = describePublishFailure(error, t); - const pr = failure.pullRequest; - if (pr) { - toast.error(failure.message, { - action: { - label: t("thread.publishDialog.viewPr"), - onClick: () => window.open(pr.htmlUrl, "_blank", "noopener,noreferrer"), - }, - }); - } - return { - message: failure.message, - pullRequestOpened: pr !== null, - headMoved: failure.headMoved, - }; -} - /** Both surfaces confirm a review submission the same way: PR number + link. */ export function notifySubmittedForReview( pr: CreatedPullRequest, diff --git a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts index 1defd99bfe..d7ce110798 100644 --- a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts +++ b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts @@ -6,19 +6,36 @@ */ import type { MutableRefObject } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; import { useT } from "@/i18n/use-t.ts"; +import { KEYS } from "@/lib/query-keys.ts"; import type { PublishChange } from "./publish-change-summary.ts"; import { + describePublishFailure, notifySubmittedForReview, publishNoteParts, - reportPublishFailure, runPublishFlow, runSubmitForReviewFlow, type PublishTarget, } from "./publish-flow.ts"; import { discardGitFiles } from "./sandbox-git-api.ts"; +import { + type HostedPublishResult, + publishHostedDraft, + publishMainHead, +} from "./hosted-publish-api.ts"; +import { + hostedPublishFailure, + notifyHostedPublish, + notifySavedNotPublished, +} from "./hosted-publish-feedback.ts"; +import { + errorDetail, + errorDetailsDescription, + toastPublished, +} from "@/components/sections-editor/site-editor-toast.tsx"; /** `publish` merges to production; `review` stops at the pull request. */ export type CmsPublishMode = "publish" | "review"; @@ -38,12 +55,22 @@ interface CmsPublishActionsArgs { refresh: () => Promise; onPullRequestChanged?: () => void | Promise; onPublished?: () => void | Promise; + /** + * A hosted v8 site: publish commits the CDN draft to main, creates its + * release and makes it current (no pull request). Saved once on main; when + * it isn't live yet, the toast's "Try again" makes main's head live. + */ + hosted?: boolean; + /** Who publishes: a hosted Publish's default version note names them. */ + authorName?: string | null; } interface CmsPublishActions { isPublishing: boolean; isDiscarding: boolean; publishError: string | undefined; + /** The developer detail behind `publishError`, for a Details disclosure. */ + publishErrorDetail: string | null; /** Publish or submit for review, per mode — the button never branches. */ submit: () => Promise; discardChange: (change: PublishChange) => Promise; @@ -64,39 +91,121 @@ export function useCmsPublishActions( refresh, onPullRequestChanged, onPublished, + hosted = false, + authorName, } = args; const t = useT(); + const queryClient = useQueryClient(); const [isPublishing, setIsPublishing] = useState(false); const [isDiscarding, setIsDiscarding] = useState(false); const [publishError, setPublishError] = useState(); + const [publishErrorDetail, setPublishErrorDetail] = useState( + null, + ); const noteParts = () => publishNoteParts( note, - t("thread.publishDialog.changesFrom", { branch: target.headBranch }), + // A hosted site's notes are the Versions screen's titles: never a + // branch name there. + hosted + ? authorName + ? t("siteEditor.publish.defaultNote", { name: authorName }) + : t("siteEditor.publish.defaultNoteAnonymous") + : t("thread.publishDialog.changesFrom", { branch: target.headBranch }), ); + /** A follow-up after a finished Publish: its failure isn't the Publish's. */ + const quietly = async (followUp?: () => void | Promise) => { + try { + await followUp?.(); + } catch { + // The Publish already succeeded (and said so); a stale list re-reads. + } + }; + + const invalidateVersions = () => + queryClient.invalidateQueries({ + queryKey: KEYS.hostedReleases(target.orgSlug, target.virtualMcpId), + }); + + /** "Try again": put the saved changes live (main's head), same toast. */ + const goLive = async () => { + try { + await publishMainHead(target); + toastPublished(t, t("siteEditor.publish.changesLive")); + } catch (error) { + notifySavedNotPublished(t, error, () => void goLive()); + } finally { + void invalidateVersions(); + } + }; + + /** Saved on main is done: the popover closes with one toast. */ + const settleHosted = async (result: HostedPublishResult) => { + notifyHostedPublish(t, result, () => void goLive()); + // A mounted Versions screen shows the new version and what is live. + void invalidateVersions(); + onOpenChange(false); + await quietly(onPublished); + }; + + const publishHosted = async () => { + publishLockRef.current = true; + setIsPublishing(true); + setPublishError(undefined); + setPublishErrorDetail(null); + try { + await settleHosted(await publishHostedDraft(target, noteParts().message)); + } catch (error) { + const failure = hostedPublishFailure(t, error); + setPublishError(failure.message); + setPublishErrorDetail(failure.detail); + await refresh(); + } finally { + publishLockRef.current = false; + setIsPublishing(false); + } + }; + const publish = async () => { publishLockRef.current = true; setIsPublishing(true); setPublishError(undefined); + setPublishErrorDetail(null); try { await runPublishFlow(target, noteParts(), t); - toast.success( + toastPublished( + t, destinationHost - ? t("thread.publishPopover.publishedTo", { host: destinationHost }) - : t("thread.publishDialog.publishedTo", { - baseBranch: target.baseBranch, - }), + ? t("siteEditor.publish.liveOn", { host: destinationHost }) + : t("siteEditor.publish.changesLive"), ); onOpenChange(false); // Together: awaiting the PR re-read first let the stale open PR render. - await Promise.all([onPullRequestChanged?.(), onPublished?.()]); + await Promise.all([quietly(onPullRequestChanged), quietly(onPublished)]); } catch (error) { - const failure = reportPublishFailure(error, t); - setPublishError(failure.message); - if (failure.pullRequestOpened) await onPullRequestChanged?.(); + // One message in the dialog, in plain words (no toast on top of it); + // the step's own error, and the pull request a failed merge left + // open, go behind Details. + const failure = describePublishFailure(error, t); + setPublishError( + failure.headMoved ? failure.message : t("siteEditor.publish.failed"), + ); + setPublishErrorDetail( + failure.headMoved + ? null + : [ + failure.pullRequest + ? `#${failure.pullRequest.number} ${failure.pullRequest.htmlUrl}` + : null, + errorDetail(error), + ] + .filter(Boolean) + .join("\n") || null, + ); + if (failure.pullRequest) await quietly(onPullRequestChanged); // Nothing was published — re-read so the list matches the new head. if (failure.headMoved) await refresh(); } finally { @@ -109,6 +218,7 @@ export function useCmsPublishActions( publishLockRef.current = true; setIsPublishing(true); setPublishError(undefined); + setPublishErrorDetail(null); try { const pr = await runSubmitForReviewFlow(target, noteParts()); @@ -116,7 +226,7 @@ export function useCmsPublishActions( onOpenChange(false); await onPullRequestChanged?.(); } catch (error) { - const failure = reportPublishFailure(error, t); + const failure = describePublishFailure(error, t); setPublishError( failure.message || t("thread.publishDialog.failedSubmitForReview"), ); @@ -143,11 +253,9 @@ export function useCmsPublishActions( toast.success(success); await refresh(); } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("thread.publishPopover.failedDiscard"), - ); + toast.error(t("siteEditor.discard.failed"), { + description: errorDetailsDescription(t, errorDetail(error)), + }); } finally { setIsDiscarding(false); } @@ -157,7 +265,10 @@ export function useCmsPublishActions( isPublishing, isDiscarding, publishError, - submit: mode === "review" ? submitForReview : publish, + publishErrorDetail, + // Hosted callers never pass review mode (no pull request to open). + submit: + mode === "review" ? submitForReview : hosted ? publishHosted : publish, discardChange: (change) => discardFiles( change.filepaths, diff --git a/apps/web/src/components/thread/repository/use-releases.ts b/apps/web/src/components/thread/repository/use-releases.ts index 12aa65a1c6..504cbaf9c1 100644 --- a/apps/web/src/components/thread/repository/use-releases.ts +++ b/apps/web/src/components/thread/repository/use-releases.ts @@ -51,7 +51,10 @@ type ItemData = { item: VirtualMCPEntity | null }; /** Curated branch-backed release list at `metadata.releases`; discard drops only the entry, leaving the remote branch. */ export function useReleases(virtualMcpId: string) { const vm = useVirtualMCP(virtualMcpId); - const actions = useVirtualMCPActions(); + // Silent: these writes follow a user action (Publish, a rename) that has + // its own one toast, or happen in the background (auto-naming a draft). + // Callers report a failure themselves when the user can act on it. + const actions = useVirtualMCPActions({ silent: true }); const { org } = useProjectContext(); const queryClient = useQueryClient(); const releases: Release[] = vm?.metadata?.releases ?? []; diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts index 79b2c0e833..54c354ec0d 100644 --- a/apps/web/src/i18n/en/index.ts +++ b/apps/web/src/i18n/en/index.ts @@ -31,6 +31,7 @@ import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; import { releases } from "./releases.ts"; +import { siteTokens } from "./site-tokens.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -68,6 +69,7 @@ export const en = { ...mainPanelTabs, ...experiments, ...releases, + ...siteTokens, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts index de9b567ba3..504b6562cb 100644 --- a/apps/web/src/i18n/en/site-editor.ts +++ b/apps/web/src/i18n/en/site-editor.ts @@ -8,6 +8,22 @@ export const siteEditor = { "siteEditor.details": "Details", "siteEditor.tryAgain": "Try again", "siteEditor.publish.published": "Published", + "siteEditor.publish.changesLive": "Your changes are live.", + "siteEditor.publish.liveOn": "Your changes are live on {host}.", + "siteEditor.publish.nothingToPublish": "Nothing to publish", + "siteEditor.publish.everythingLive": "Everything is already live.", + "siteEditor.publish.savedNotPublished": "Saved, but not published yet", + "siteEditor.publish.savedNotPublishedBody": + "Your changes are safe. Try again to put them live.", + "siteEditor.publish.publishedMeanwhile": + "Someone else published while you were publishing, so nothing changed. Review your changes and publish again.", + "siteEditor.publish.changedMeanwhile": + "Your changes were updated while you were reviewing them. Close and reopen to see what will be published.", + "siteEditor.publish.failed": + "Couldn't publish. Your changes are saved. Try again in a moment.", + "siteEditor.publish.defaultNote": "Changes by {name}", + "siteEditor.publish.defaultNoteAnonymous": "Site update", + "siteEditor.discard.failed": "Couldn't discard. Try again.", "siteEditor.save.failed": "Couldn't save your change. Try again.", "siteEditor.save.conflict": "Not saved: someone else changed this at the same time. We're loading their version, so make your change again.", diff --git a/apps/web/src/i18n/en/site-tokens.ts b/apps/web/src/i18n/en/site-tokens.ts new file mode 100644 index 0000000000..4510edf349 --- /dev/null +++ b/apps/web/src/i18n/en/site-tokens.ts @@ -0,0 +1,14 @@ +export const siteTokens = { + "siteTokens.title": "Site tokens", + "siteTokens.description": + "Your site passes its site ID and a token to createCMS({ site, token }) to send telemetry. Keep the token on the server.", + "siteTokens.siteId": "Site ID", + "siteTokens.issue": "Issue token", + "siteTokens.issuedAgo": "Issued {when}", + "siteTokens.createdTitle": "Copy your site token", + "siteTokens.createdDescription": + "This is the only time it's shown. Store it as a server secret of your site.", + "siteTokens.copy": "Copy token", + "siteTokens.copied": "Token copied", + "siteTokens.done": "Done", +}; diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 3b40af41e5..fb6a04acc9 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -146,14 +146,10 @@ export const thread = { "thread.mergeRefused.notFound": "It no longer exists.", "thread.mergeRefused.error": "Failed to merge.", "thread.publishDialog.openingComparison": "Opening the comparison…", - "thread.publishDialog.publishedTo": "Published to {baseBranch}", "thread.publishDialog.submittedForReview": "Submitted pull request #{prNumber} for review", "thread.publishDialog.viewOnProvider": "View on provider", - "thread.publishDialog.viewPr": "View PR", "thread.publishPopover.blocksGroup": "Blocks", - "thread.publishPopover.branchMoved": - "This branch changed since these changes were shown. Close and reopen to review what will be published.", "thread.publishPopover.detailsUnavailable": "Details for these changes could not be loaded.", "thread.publishPopover.loadFailed": "Couldn't load your changes", @@ -165,7 +161,6 @@ export const thread = { "thread.publishPopover.chipRemoved": "Removed", "thread.publishPopover.emptyHint": "Your latest changes are already live.", "thread.publishPopover.everythingLive": "Everything is published", - "thread.publishPopover.failedDiscard": "Failed to discard changes", "thread.publishPopover.globalSection": "Global section", "thread.publishPopover.lastPublished": "Last published {when}", "thread.publishPopover.lastPublishedBy": "Last published {when} by {name}", @@ -189,7 +184,6 @@ export const thread = { "thread.publishPopover.publishOneInProduction": "Publish 1 change in production", "thread.publishPopover.publishOne": "Publish 1 change", - "thread.publishPopover.publishedTo": "Published to {host}", "thread.publishPopover.publishing": "Publishing…", "thread.publishPopover.requestApproval": "Request approval", "thread.publishPopover.discarded": "Discarded {name}", diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts index 55b96cad22..d37682f8bd 100644 --- a/apps/web/src/i18n/pt-br/index.ts +++ b/apps/web/src/i18n/pt-br/index.ts @@ -31,6 +31,7 @@ import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; import { releases } from "./releases.ts"; +import { siteTokens } from "./site-tokens.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -66,6 +67,7 @@ export const ptBR = { ...mainPanelTabs, ...experiments, ...releases, + ...siteTokens, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts index c2be242ba8..eb0982d76e 100644 --- a/apps/web/src/i18n/pt-br/site-editor.ts +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -4,6 +4,22 @@ export const siteEditor = { "siteEditor.details": "Detalhes", "siteEditor.tryAgain": "Tentar de novo", "siteEditor.publish.published": "Publicado", + "siteEditor.publish.changesLive": "Suas alterações estão no ar.", + "siteEditor.publish.liveOn": "Suas alterações estão no ar em {host}.", + "siteEditor.publish.nothingToPublish": "Nada para publicar", + "siteEditor.publish.everythingLive": "Tudo já está no ar.", + "siteEditor.publish.savedNotPublished": "Salvo, mas ainda não publicado", + "siteEditor.publish.savedNotPublishedBody": + "Suas alterações estão salvas. Tente de novo para colocá-las no ar.", + "siteEditor.publish.publishedMeanwhile": + "Outra pessoa publicou enquanto você publicava, então nada mudou. Revise suas alterações e publique de novo.", + "siteEditor.publish.changedMeanwhile": + "Suas alterações mudaram enquanto você revisava. Feche e abra de novo para ver o que será publicado.", + "siteEditor.publish.failed": + "Não foi possível publicar. Suas alterações estão salvas. Tente de novo em instantes.", + "siteEditor.publish.defaultNote": "Alterações de {name}", + "siteEditor.publish.defaultNoteAnonymous": "Atualização do site", + "siteEditor.discard.failed": "Não foi possível descartar. Tente de novo.", "siteEditor.save.failed": "Não foi possível salvar sua alteração. Tente de novo.", "siteEditor.save.conflict": diff --git a/apps/web/src/i18n/pt-br/site-tokens.ts b/apps/web/src/i18n/pt-br/site-tokens.ts new file mode 100644 index 0000000000..b484c20abf --- /dev/null +++ b/apps/web/src/i18n/pt-br/site-tokens.ts @@ -0,0 +1,16 @@ +import type { siteTokens as enSiteTokens } from "../en/site-tokens.ts"; + +export const siteTokens = { + "siteTokens.title": "Tokens do site", + "siteTokens.description": + "O seu site passa o ID do site e um token para createCMS({ site, token }) para enviar telemetria. Mantenha o token no servidor.", + "siteTokens.siteId": "ID do site", + "siteTokens.issue": "Emitir token", + "siteTokens.issuedAgo": "Emitido {when}", + "siteTokens.createdTitle": "Copie o token do site", + "siteTokens.createdDescription": + "Ele só é mostrado agora. Guarde-o como um segredo de servidor do seu site.", + "siteTokens.copy": "Copiar token", + "siteTokens.copied": "Token copiado", + "siteTokens.done": "Concluir", +} satisfies Record; diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 2b18162720..7126907a18 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -153,19 +153,15 @@ export const thread = { "thread.mergeRefused.notFound": "Não existe mais.", "thread.mergeRefused.error": "Falha ao fazer merge.", "thread.publishDialog.openingComparison": "Abrindo a comparação…", - "thread.publishDialog.publishedTo": "Publicado em {baseBranch}", "thread.publishDialog.submittedForReview": "Pull request #{prNumber} enviado para revisão", "thread.publishDialog.viewOnProvider": "Ver no provedor", - "thread.publishDialog.viewPr": "Ver PR", "thread.publishPopover.blocksGroup": "Blocos", - "thread.publishPopover.branchMoved": - "Esta branch mudou depois que estas alterações foram exibidas. Feche e abra novamente para revisar o que será publicado.", "thread.publishPopover.detailsUnavailable": "Não foi possível carregar os detalhes destas alterações.", "thread.publishPopover.loadFailed": "Não foi possível carregar suas alterações", - "thread.publishPopover.retry": "Tentar novamente", + "thread.publishPopover.retry": "Tentar de novo", "thread.publishPopover.showingFirst": "Mostrando as primeiras {shown} de {total} alterações", "thread.publishPopover.chipEdited": "Editado", @@ -174,7 +170,6 @@ export const thread = { "thread.publishPopover.emptyHint": "Suas alterações mais recentes já estão no ar.", "thread.publishPopover.everythingLive": "Tudo publicado", - "thread.publishPopover.failedDiscard": "Falha ao descartar alterações", "thread.publishPopover.globalSection": "Seção global", "thread.publishPopover.lastPublished": "Última publicação {when}", "thread.publishPopover.lastPublishedBy": @@ -200,7 +195,6 @@ export const thread = { "thread.publishPopover.publishOneInProduction": "Publicar 1 alteração em produção", "thread.publishPopover.publishOne": "Publicar 1 alteração", - "thread.publishPopover.publishedTo": "Publicado em {host}", "thread.publishPopover.publishing": "Publicando…", "thread.publishPopover.requestApproval": "Pedir aprovação", "thread.publishPopover.discarded": "{name} descartado", diff --git a/apps/web/src/i18n/site-editor-copy.test.ts b/apps/web/src/i18n/site-editor-copy.test.ts index 279115eced..1ce1397daf 100644 --- a/apps/web/src/i18n/site-editor-copy.test.ts +++ b/apps/web/src/i18n/site-editor-copy.test.ts @@ -13,6 +13,7 @@ const SITE_EDITOR_PREFIXES = [ "releases.", "common.mainPanelTabs.releases", "projects.apps.releases", + "thread.publishPopover.", ]; const BANNED: RegExp[] = [ diff --git a/apps/web/src/sdk/hooks/use-collections.silent.test.tsx b/apps/web/src/sdk/hooks/use-collections.silent.test.tsx new file mode 100644 index 0000000000..3d676b730a --- /dev/null +++ b/apps/web/src/sdk/hooks/use-collections.silent.test.tsx @@ -0,0 +1,54 @@ +import { setupComponentTest } from "../../../test/setup"; +setupComponentTest(); +import { describe, expect, it } from "bun:test"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { ReactNode } from "react"; +import { toast } from "sonner"; +import { useCollectionActions } from "./use-collections"; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + return {children}; +} + +const fakeClient = (fail: boolean) => + ({ + callTool: async () => + fail + ? { isError: true, content: [{ type: "text", text: "boom" }] } + : { structuredContent: { item: { id: "vm" } } }, + }) as unknown as Client; + +async function runUpdate(silent: boolean, fail: boolean) { + const { result } = renderHook( + () => + useCollectionActions("org", "VIRTUAL_MCP", fakeClient(fail), { + silent, + }), + { wrapper }, + ); + await act(async () => { + await result.current.update + .mutateAsync({ id: "vm", data: {} }) + .catch(() => {}); + }); +} + +describe("useCollectionActions toasts", () => { + it("toasts an update by default", async () => { + const before = toast.getToasts().length; + await runUpdate(false, false); + expect(toast.getToasts().length - before).toBe(1); + }); + + it("silent: a background update says nothing, success or failure", async () => { + const before = toast.getToasts().length; + await runUpdate(true, false); + await runUpdate(true, true); + expect(toast.getToasts().length - before).toBe(0); + }); +}); diff --git a/apps/web/src/sdk/hooks/use-collections.ts b/apps/web/src/sdk/hooks/use-collections.ts index bab1333f83..cf35a6a7bd 100644 --- a/apps/web/src/sdk/hooks/use-collections.ts +++ b/apps/web/src/sdk/hooks/use-collections.ts @@ -486,13 +486,22 @@ export function buildCollectionQueryKey( * @param scopeKey - The scope key (connectionId for connection-scoped, virtualMcpId for virtual-mcp-scoped, etc.) * @param collectionName - The name of the collection (e.g., "CONNECTIONS", "AGENT") * @param client - The MCP client used to call collection tools + * @param options.silent - No success/failure toasts: the caller owns the + * feedback (a background write the user didn't ask for says nothing). * @returns Object with create, update, and delete mutation hooks */ export function useCollectionActions( scopeKey: string, collectionName: string, client: Client, + options: { silent?: boolean } = {}, ) { + const notify = options.silent + ? { success: () => {}, error: () => {} } + : { + success: (message: string) => toast.success(message), + error: (message: string) => toast.error(message), + }; const queryClient = useQueryClient(); const upperName = collectionName.toUpperCase(); const createToolName = `COLLECTION_${upperName}_CREATE`; @@ -533,11 +542,11 @@ export function useCollectionActions( }, onSuccess: () => { invalidateCollection(); - toast.success(getToastMessage("itemCreatedSuccessfully")); + notify.success(getToastMessage("itemCreatedSuccessfully")); }, onError: (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - toast.error(getToastMessage("createItemFailed", { error: message })); + notify.error(getToastMessage("createItemFailed", { error: message })); }, }); @@ -553,11 +562,11 @@ export function useCollectionActions( }, onSuccess: () => { invalidateCollection(); - toast.success(getToastMessage("itemUpdatedSuccessfully")); + notify.success(getToastMessage("itemUpdatedSuccessfully")); }, onError: (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - toast.error(getToastMessage("updateItemFailed", { error: message })); + notify.error(getToastMessage("updateItemFailed", { error: message })); }, }); @@ -573,11 +582,11 @@ export function useCollectionActions( }, onSuccess: () => { invalidateCollection(); - toast.success(getToastMessage("itemDeletedSuccessfully")); + notify.success(getToastMessage("itemDeletedSuccessfully")); }, onError: (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - toast.error(getToastMessage("deleteItemFailed", { error: message })); + notify.error(getToastMessage("deleteItemFailed", { error: message })); }, }); diff --git a/apps/web/src/sdk/hooks/use-virtual-mcp.ts b/apps/web/src/sdk/hooks/use-virtual-mcp.ts index d0daceb41c..05ee487fb6 100644 --- a/apps/web/src/sdk/hooks/use-virtual-mcp.ts +++ b/apps/web/src/sdk/hooks/use-virtual-mcp.ts @@ -269,9 +269,10 @@ export function useVirtualMCPsLastUsed(ids: string[]) { /** * Hook to get virtual MCP mutation actions (create, update, delete) * + * @param options.silent - No toasts: the caller owns the feedback. * @returns Object with create, update, and delete mutation hooks */ -export function useVirtualMCPActions() { +export function useVirtualMCPActions(options: { silent?: boolean } = {}) { const { org } = useProjectContext(); const client = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -279,5 +280,10 @@ export function useVirtualMCPActions() { orgSlug: org.slug, }); - return useCollectionActions(org.id, "VIRTUAL_MCP", client); + return useCollectionActions( + org.id, + "VIRTUAL_MCP", + client, + options, + ); } diff --git a/apps/web/src/views/virtual-mcp/index.tsx b/apps/web/src/views/virtual-mcp/index.tsx index 201f6a9a55..bbd6cd6cd9 100644 --- a/apps/web/src/views/virtual-mcp/index.tsx +++ b/apps/web/src/views/virtual-mcp/index.tsx @@ -81,6 +81,8 @@ import { SettingsSection, } from "@/components/settings/settings-section"; import { useProjectSettingsSection } from "./settings/use-settings-section"; +import { SiteTokenSection } from "./settings/site-token-section"; +import { useOrgFlagState } from "@/hooks/use-organization-settings"; import { ALL_ITEMS_SELECTED } from "./selection-utils"; import { VirtualMcpFormSchema, type VirtualMcpFormData } from "./types"; import { VirtualMCPShareModal } from "./virtual-mcp-share-modal"; @@ -882,6 +884,7 @@ function VirtualMcpDetailViewWithData({ const addedConnectionIds = new Set(connections.map((c) => c.connection_id)); const { section } = useProjectSettingsSection(); + const hostedCms = useOrgFlagState("site_editor_content_protocol") === true; const views = useProjectViews({ virtualMcpId: virtualMcp.id, form, @@ -1193,6 +1196,14 @@ function VirtualMcpDetailViewWithData({ )} + {/* Hosted Deco CMS (Blocks v8): the site's telemetry tokens. */} + {section === "site" && hostedCms && hasRepository && ( + + )} + {/* Sandbox — the repo it clones and what it runs there. */} {section === "site" && ( diff --git a/apps/web/src/views/virtual-mcp/settings/site-token-section.tsx b/apps/web/src/views/virtual-mcp/settings/site-token-section.tsx new file mode 100644 index 0000000000..01acb275fb --- /dev/null +++ b/apps/web/src/views/virtual-mcp/settings/site-token-section.tsx @@ -0,0 +1,160 @@ +/** + * Site tokens of a hosted v8 site: what the site passes as + * `createCMS({ site, token })` to send telemetry. A token is shown once; + * issuing always works, and the list shows every token issued. + */ + +import { useState } from "react"; +import { Copy01, Key01, Plus } from "@untitledui/icons"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@decocms/ui/components/dialog.tsx"; +import { Input } from "@decocms/ui/components/input.tsx"; +import { Spinner } from "@decocms/ui/components/spinner.tsx"; +import { + SettingsCard, + SettingsCardRow, + SettingsSection, +} from "@/components/settings/settings-section"; +import { useT } from "@/i18n/use-t.ts"; +import { formatTimeAgo } from "@/lib/format-time.ts"; +import { KEYS } from "@/lib/query-keys"; + +interface SiteTokenRecord { + kid: string; + iat: number; +} + +async function readJson(res: Response): Promise { + const json = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); + return json as T; +} + +export function SiteTokenSection({ + orgSlug, + virtualMcpId, +}: { + orgSlug: string; + virtualMcpId: string; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const base = `/api/${orgSlug}/hosted/${encodeURIComponent(virtualMcpId)}/site-tokens`; + const key = KEYS.hostedSiteTokens(orgSlug, virtualMcpId); + const [created, setCreated] = useState(null); + + const list = useQuery({ + queryKey: key, + queryFn: async () => + readJson<{ site: string; tokens: SiteTokenRecord[] }>(await fetch(base)), + }); + const issue = useMutation({ + mutationFn: async () => + readJson<{ token: string }>(await fetch(base, { method: "POST" })), + onSuccess: ({ token }) => setCreated(token), + onError: (error) => toast.error(error.message), + onSettled: () => queryClient.invalidateQueries({ queryKey: key }), + }); + const tokens = list.data?.tokens ?? []; + + return ( + + + +
+
+

{t("siteTokens.siteId")}

+

+ {list.data?.site ?? "—"} +

+
+ +
+
+ {list.isError ? ( + +

{list.error.message}

+
+ ) : null} + {tokens.map((token) => ( + +
+ +
+

{token.kid}

+

+ {t("siteTokens.issuedAgo", { + when: formatTimeAgo(new Date(token.iat * 1000)), + })} +

+
+
+
+ ))} +
+ + !o && setCreated(null)} + > + + + {t("siteTokens.createdTitle")} + + {t("siteTokens.createdDescription")} + + +
+ + +
+ + + +
+
+
+ ); +}