From 3c478c99c3b6fd71be6ee1ccc29b1a6f2c6b2329 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 10:51:23 +0200 Subject: [PATCH 1/6] feat(dashboard): show active maintenance windows in a banner The dashboard never read /v1/maintenance/status, so senders only learned about a window from 503s on quote and ramp calls. The e2e mock serves an inactive window by default so every spec's unmatched-request check keeps passing. --- apps/dashboard/e2e/support/mockBackend.ts | 20 +++++++++++++ .../components/layout/MaintenanceBanner.tsx | 28 +++++++++++++++++++ .../src/hooks/useActiveMaintenance.ts | 14 ++++++++++ apps/dashboard/src/routes/_app.tsx | 2 ++ .../src/services/api/maintenance.service.ts | 18 ++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 apps/dashboard/src/components/layout/MaintenanceBanner.tsx create mode 100644 apps/dashboard/src/hooks/useActiveMaintenance.ts create mode 100644 apps/dashboard/src/services/api/maintenance.service.ts diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 21280da49..6484cb6aa 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -256,6 +256,8 @@ interface MockBackendOptions { apiCredentials?: Array>; approvedCorridors?: Array<"AR" | "BR" | "CO" | "MX" | "US">; limits?: Array>; + // Serve an active window on GET /v1/maintenance/status (default: none). Specs can flip `maintenance.active` later. + maintenanceActive?: boolean; onboardingState?: OnboardingState; companyMode?: boolean; selectionRequired?: boolean; @@ -304,6 +306,14 @@ interface MockBackendOptions { tokenBalances?: TokenBalances | null | ((requestIndex: number, network: BalanceNetwork) => TokenBalances | null); } +export const MAINTENANCE_DETAILS = { + end_datetime: "2026-10-05T18:00:00.000Z", + estimated_time_remaining_seconds: 3600, + message: "Ramps are paused while we upgrade.", + start_datetime: "2026-10-05T08:00:00.000Z", + title: "Scheduled maintenance" +}; + // AlfredPayStatus values the machine branches on (packages/shared AlfredPayStatus). const ALFREDPAY_SUCCESS = "SUCCESS"; const ALFREDPAY_VERIFYING = "VERIFYING"; @@ -435,6 +445,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) startRequests: [] as Array> }; const auth = { refreshes: 0 }; + const maintenance = { active: options.maintenanceActive ?? false }; let selectedCompany = options.companyMode ?? false; let hasActiveEntity = options.selectionRequired !== true; const fiatAccounts = [...(options.fiatAccounts ?? buildFiatAccounts())]; @@ -595,6 +606,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } + if (path === "/v1/maintenance/status" && method === "GET") { + await fulfillJson({ + is_maintenance_active: maintenance.active, + maintenance_details: maintenance.active ? MAINTENANCE_DETAILS : null + }); + return; + } + if (path === "/v1/limits" && method === "POST") { const body = request.postDataJSON() as { corridors?: Array<"AR" | "BR" | "CO" | "MX" | "US"> }; limitsRequests.push(body); @@ -1173,6 +1192,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) kyc, kycFormSubmissions, limitsRequests, + maintenance, monerium, quoteRequests, registerRequests, diff --git a/apps/dashboard/src/components/layout/MaintenanceBanner.tsx b/apps/dashboard/src/components/layout/MaintenanceBanner.tsx new file mode 100644 index 000000000..5ca95bc22 --- /dev/null +++ b/apps/dashboard/src/components/layout/MaintenanceBanner.tsx @@ -0,0 +1,28 @@ +import { TriangleAlert } from "lucide-react"; +import { useActiveMaintenance } from "@/hooks/useActiveMaintenance"; + +export function MaintenanceBanner() { + const maintenance = useActiveMaintenance(); + + if (!maintenance) return null; + + const endsAt = new Date(maintenance.end_datetime).toLocaleString(undefined, { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "short", + timeZoneName: "short" + }); + + return ( +
+ +
+

+ {maintenance.title} · {maintenance.message} +

+

New transfers and payment confirmations are paused until {endsAt}.

+
+
+ ); +} diff --git a/apps/dashboard/src/hooks/useActiveMaintenance.ts b/apps/dashboard/src/hooks/useActiveMaintenance.ts new file mode 100644 index 000000000..2032dade6 --- /dev/null +++ b/apps/dashboard/src/hooks/useActiveMaintenance.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { type MaintenanceDetails, MaintenanceService } from "@/services/api/maintenance.service"; + +/** The active maintenance window, or null. The API rejects quotes and ramp mutations while one is active. */ +export function useActiveMaintenance(): MaintenanceDetails | null { + const { data } = useQuery({ + queryFn: MaintenanceService.getStatus, + queryKey: ["maintenance-status"], + refetchInterval: 5 * 60 * 1000, + refetchOnWindowFocus: true + }); + + return data?.is_maintenance_active ? data.maintenance_details : null; +} diff --git a/apps/dashboard/src/routes/_app.tsx b/apps/dashboard/src/routes/_app.tsx index ee5ab0ba3..478064698 100644 --- a/apps/dashboard/src/routes/_app.tsx +++ b/apps/dashboard/src/routes/_app.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Navigate, Outlet, useRouterState } from "@tanstack/rea import { motion } from "motion/react"; import { AppSidebar } from "@/components/layout/AppSidebar"; import { ImpersonationBanner } from "@/components/layout/ImpersonationBanner"; +import { MaintenanceBanner } from "@/components/layout/MaintenanceBanner"; import { ManagedProfileBanner } from "@/components/layout/ManagedProfileBanner"; import { Topbar } from "@/components/layout/Topbar"; import { isChildModePathForbidden } from "@/components/managed-profiles/managed-profile-ui"; @@ -59,6 +60,7 @@ function AppLayout() {
+ diff --git a/apps/dashboard/src/services/api/maintenance.service.ts b/apps/dashboard/src/services/api/maintenance.service.ts new file mode 100644 index 000000000..b6c7f207a --- /dev/null +++ b/apps/dashboard/src/services/api/maintenance.service.ts @@ -0,0 +1,18 @@ +import { apiClient } from "./api-client"; + +// Mirrors GET /v1/maintenance/status (apps/api/src/api/services/maintenance.service.ts). +export interface MaintenanceDetails { + title: string; + message: string; + start_datetime: string; + end_datetime: string; +} + +interface MaintenanceStatusResponse { + is_maintenance_active: boolean; + maintenance_details: MaintenanceDetails | null; +} + +export const MaintenanceService = { + getStatus: () => apiClient.get("/maintenance/status") +}; From 54a527aba44f30e1255985f581fd000876f7ee00 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 10:51:24 +0200 Subject: [PATCH 2/6] feat(dashboard): pause transfer actions during maintenance The API rejects quote creation and ramp register/update/start during a window, so starting a transfer or confirming an onramp payment then can only fail. Disable those buttons while the banner is up, matching the widget. --- apps/dashboard/e2e/maintenance.spec.ts | 66 +++++++++++++++++++ .../src/components/transfer/OnrampForm.tsx | 4 +- .../transfer/OnrampPaymentInstructions.tsx | 4 +- .../src/components/transfer/TransferForm.tsx | 4 +- docs/operations-testing.md | 6 ++ docs/product-dashboard.md | 6 ++ 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/e2e/maintenance.spec.ts diff --git a/apps/dashboard/e2e/maintenance.spec.ts b/apps/dashboard/e2e/maintenance.spec.ts new file mode 100644 index 000000000..87fd9b530 --- /dev/null +++ b/apps/dashboard/e2e/maintenance.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; +import { MAINTENANCE_DETAILS, mockBackend } from "./support/mockBackend"; +import { injectMockWallet } from "./support/mockWallet"; +import { seedSession } from "./support/session"; + +const DESTINATION = "0x1111111111111111111111111111111111111111"; + +test("An active maintenance window shows the banner and blocks starting an onramp", async ({ page }) => { + const backend = await mockBackend(page, { fiatAccounts: [], maintenanceActive: true, onrampCurrency: "MXN" }); + await seedSession(page); + await page.goto("/transfer?mode=onramp"); + + await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible({ timeout: 20_000 }); + await page.getByLabel("Destination wallet address").fill(DESTINATION); + await page.getByLabel("You pay (MXN)").fill("100"); + await expect(page.getByText("You receive", { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("button", { name: "Continue to payment" })).toBeDisabled(); + + expect(backend.registerRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("A maintenance window that starts during payment setup blocks the payment confirmation", async ({ page }) => { + const backend = await mockBackend(page, { fiatAccounts: [], onrampCurrency: "MXN" }); + await seedSession(page); + await page.goto("/transfer?mode=onramp"); + + await page.getByLabel("Destination wallet address").fill(DESTINATION); + await page.getByLabel("You pay (MXN)").fill("100"); + const continueButton = page.getByRole("button", { name: "Continue to payment" }); + await expect(continueButton).toBeEnabled({ timeout: 20_000 }); + await continueButton.click(); + const confirmButton = page.getByRole("button", { name: "I have made the payment" }); + await expect(confirmButton).toBeEnabled({ timeout: 20_000 }); + + backend.maintenance.active = true; + // React Query refetches on visibilitychange, as when the sender returns to the tab. Repeat it: the + // status request fired when the instructions mounted may still be in flight with the old answer. + await expect(async () => { + await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange", { bubbles: true }))); + await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible({ timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + await expect(confirmButton).toBeDisabled(); + expect(backend.startRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("An active maintenance window blocks sending an offramp", async ({ page }) => { + const backend = await mockBackend(page, { maintenanceActive: true }); + await injectMockWallet(page, { chainIdHex: "0x89" }); + await seedSession(page); + await page.goto("/transfer?network=polygon"); + + const amountInput = page.locator("#token-amount"); + await expect(amountInput).toBeVisible({ timeout: 20_000 }); + await amountInput.fill("54.054054"); + await expect(page.getByText("Available: 1,000 USDC on Polygon", { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible(); + await expect(page.getByRole("button", { name: /^Send/ })).toBeDisabled(); + + expect(backend.registerRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); diff --git a/apps/dashboard/src/components/transfer/OnrampForm.tsx b/apps/dashboard/src/components/transfer/OnrampForm.tsx index 793782e69..36b764ef2 100644 --- a/apps/dashboard/src/components/transfer/OnrampForm.tsx +++ b/apps/dashboard/src/components/transfer/OnrampForm.tsx @@ -17,6 +17,7 @@ import { CORRIDORS } from "@/domain/corridors"; import { eurOnrampBlocker, getNetworkOptions, getRampTokenOptions, ONRAMP_CORRIDORS } from "@/domain/onramp"; import { shortenAddress } from "@/domain/transfer"; import type { CorridorId, SenderAccount } from "@/domain/types"; +import { useActiveMaintenance } from "@/hooks/useActiveMaintenance"; import { useApprovedCorridors } from "@/hooks/useApprovedCorridors"; import { formatCurrencyAmount } from "@/lib/amount"; import { transferActor } from "@/machines/transferActor"; @@ -110,6 +111,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi } : null; const { data: quote, error, isFetching } = useQuote(quoteParams); + const maintenance = useActiveMaintenance(); const eurRamp = account.onboardings.EU?.ramp ?? null; const eurBlocker = corridorId === "EU" ? eurOnrampBlocker(eurRamp, address) : null; const transferState = useSelector(transferActor, snapshot => snapshot); @@ -342,7 +344,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi
- diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 94671b683..61ff90dc3 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -214,10 +214,12 @@ different set of endpoints than the widget. Covered so far: - **Maintenance windows** (`maintenance.spec.ts`): an active window from `/v1/maintenance/status` shows the shell banner and disables the offramp `Send`, onramp `Continue to payment`, and `I have made the payment` buttons, and quote errors on the onramp, - offramp, and quote-explorer pages name the maintenance pause. The mid-flow case flips the mock's - `maintenance.active` flag and relies on React Query's refetch when the tab regains - focus. The mock serves an inactive window by default, so the other specs' `unmatchedRequests` - checks still pass. + offramp, and quote-explorer pages name the maintenance pause. Two cases open a window while + payment instructions are showing, by flipping the mock's `maintenance` handle and relying on + React Query's refetch when the tab regains focus: one ending before the ramp expires (details + stay, confirming is paused) and one outlasting it (details hidden). The mock serves an inactive + window by default, so the other specs' `unmatchedRequests` checks still pass. Bun unit tests + cover the end-of-window refetch interval and the pre-signing status check. - **Managed profiles** (`managed-profiles.spec.ts`): ordinary-user route denial, manager child selection, persisted acting mode, route-scoped managed-profile headers, hidden manager-only navigation, stopping child mode, and long-identifier mobile layout. diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 2740d2f0f..c0cddcb94 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -146,8 +146,12 @@ lifecycle reconciliation and external-profile import remain deferred. **Maintenance windows.** While an operator-scheduled window is active, every app page shows a banner with its message and end time. Starting an offramp or onramp and confirming an onramp payment are disabled, and the API rejects quote creation and ramp register/update/start for the -window anyway. Quote errors on the transfer and quote pages then say quotes are paused. The status is refetched every five minutes and when the tab regains focus -(implemented). +window anyway. Quote errors on the transfer and quote pages then say quotes are paused. Open +payment instructions explain that confirming is paused, or are hidden when the window outlasts the +ramp's start deadline, so nobody pays into a ramp that can no longer start. Before the wallet signs +an offramp, the dashboard re-checks the status so a window that opened in the meantime stops the +transfer before funds move. The status is refetched every five minutes, just after an active +window's end, and when the tab regains focus (implemented). ### Transactions - As a sender, I see my started onramp and offramp history — destination, corridor, amounts in and out,