From 31d52d4c327810450f575889ffccdd74a1e49593 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:39:57 +0000 Subject: [PATCH] Connect flow for self-hosted account linking, and the triggers that drive it (#7415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare account-link login box with a guided Connect flow, and wires up the triggers that actually put it in front of someone. ## Top bar image ## The modal Three steps on the portal's own `FlowModal` + `StepModalHeader`, the shells procurement and prepay already wear: 1. **What you unlock** — six benefits as a plain list. image 2. **Sign in** — the existing `SupabaseLoginForm`, reseated. image 3. **Connected** — confirms, then deep links into Users, Pipelines and Policies. image Re-auth stays a single step with no pitch and no success screen. ## The triggers **`LinkGate` stops being dead code.** It was built as the drop-anywhere "link to unlock" wrapper and was imported by nothing. It is now a blocking empty state that replaces the feature it guards, wired into Pipelines, Policies, Users, Sources and Integrations. **Scoped to creating and editing, never viewing.** Existing pipelines, policies, sources and connections keep listing and running, so upgrading an unlinked instance cannot take away something that already works. The clicks that would open a builder or a create modal ask for the connection first, which is the moment an admin has already declared intent. ## Capability signal `accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two facts: whether the instance is linked (`LinkContext`) and whether it *could* be (this flag). The account-link endpoints 404 when the feature flag is off, which the client cannot distinguish from "not linked yet" — so gating on link state alone would lock all five views on every default install with no way out. `useConnectGate` holds that decision in one place and shares the app-config query key, so it costs no extra request. Read from the environment rather than `AccountLinkProperties` because `:core` cannot depend on `:proprietary`. --- .../controller/api/misc/ConfigController.java | 13 + .../public/locales/en-US/translation.toml | 72 ++-- frontend/editor/src/core/types/appConfig.ts | 6 + .../editor/src/portal/PortalProviders.tsx | 15 +- frontend/editor/src/portal/ViewRouter.tsx | 16 +- .../portal/components/ConnectAccountRail.css | 53 +++ .../components/ConnectAccountRail.stories.tsx | 46 +++ .../portal/components/ConnectAccountRail.tsx | 57 ++++ .../editor/src/portal/components/HomeHero.tsx | 2 + .../editor/src/portal/components/Sidebar.tsx | 5 + .../account-link/ConnectCallbackHost.tsx | 144 +++----- .../account-link/ConnectCallbackView.tsx | 66 ++-- .../account-link/ConnectGuardedRoute.tsx | 26 ++ .../account-link/LinkAccountModal.css | 26 -- .../account-link/LinkAccountModal.test.tsx | 186 +++++++++-- .../account-link/LinkAccountModal.tsx | 310 +++++++++++------- .../components/account-link/LinkGate.tsx | 52 --- .../account-link/connect/ConnectAskStep.tsx | 52 +++ .../connect/ConnectBenefitsSlide.stories.tsx | 12 + .../connect/ConnectBenefitsSlide.tsx | 55 ++++ .../connect/ConnectDoneSlide.test.tsx | 84 +++++ .../account-link/connect/ConnectDoneSlide.tsx | 157 +++++++++ .../connect/ConnectHandoffGhost.tsx | 28 ++ .../account-link/connect/connect.css | 112 +++++++ .../billing/LinkAccountPrompt.stories.tsx | 14 - .../components/billing/LinkAccountPrompt.tsx | 33 -- .../billing/PortalBillingGate.test.tsx | 77 ++++- .../components/billing/PortalBillingGate.tsx | 26 +- .../components/shared/StepModalHeader.css | 2 +- .../components/shared/StepModalHeader.tsx | 4 +- .../src/portal/components/sidebarGroups.tsx | 4 +- .../src/portal/contexts/LinkContext.tsx | 8 + .../editor/src/portal/contexts/UIContext.tsx | 22 ++ .../src/portal/hooks/useConnectGate.test.tsx | 78 +++++ .../editor/src/portal/hooks/useConnectGate.ts | 63 ++++ .../src/portal/hooks/useConnectHandoff.ts | 64 ++++ .../portal/hooks/useConnectPrompt.test.tsx | 74 +++++ .../src/portal/hooks/useConnectPrompt.ts | 37 +++ .../portal/hooks/useDevConnectBypass.test.tsx | 53 +++ .../src/portal/hooks/useDevConnectBypass.ts | 39 +++ .../src/portal/test/TestQueryProvider.tsx | 19 ++ .../src/portal/views/ConnectCallback.css | 31 +- .../src/portal/views/ConnectCallback.test.tsx | 88 +++-- .../src/portal/views/Integrations.test.tsx | 10 + .../editor/src/portal/views/Integrations.tsx | 24 +- .../src/portal/views/Pipelines.gated.test.tsx | 117 +++++++ .../src/portal/views/Pipelines.test.tsx | 10 + .../editor/src/portal/views/Pipelines.tsx | 19 +- frontend/editor/src/portal/views/Policies.tsx | 26 +- .../src/portal/views/Sources.gated.test.tsx | 111 +++++++ .../editor/src/portal/views/Sources.test.tsx | 10 + frontend/editor/src/portal/views/Sources.tsx | 23 +- .../src/portal/views/Users.caching.test.tsx | 10 + .../src/portal/views/Users.gated.test.tsx | 96 ++++++ .../src/portal/views/Users.saas.test.tsx | 10 + frontend/editor/src/portal/views/Users.tsx | 25 +- .../src/saas/routes/ConnectApproveView.tsx | 90 ++++- frontend/editor/src/saas/routes/connect.css | 9 +- 58 files changed, 2346 insertions(+), 575 deletions(-) create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.css create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.tsx create mode 100644 frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx delete mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.css delete mode 100644 frontend/editor/src/portal/components/account-link/LinkGate.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/connect.css delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectHandoff.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.ts create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.ts create mode 100644 frontend/editor/src/portal/views/Pipelines.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Sources.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Users.gated.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 36beb6610c..618d5d642d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -338,6 +338,19 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + // Whether this instance can link a Stirling (SaaS) account at all. The account-link + // beans live in :proprietary and are @ConditionalOnProperty on this same key, so when + // it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot + // tell that 404 apart from "not linked yet", so it needs this told to it explicitly + // before it can prompt anyone to link. Read from the environment rather than + // AccountLinkProperties because :core must not depend on :proprietary. + configData.put( + "accountLinkAvailable", + applicationContext + .getEnvironment() + .getProperty( + "stirling.billing.account-link.enabled", Boolean.class, false)); + // AI Engine settings ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine(); configData.put("aiEngineEnabled", aiEngineConfig.isEnabled()); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index baee16cd02..df996b23c0 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3641,6 +3641,7 @@ system = "System Configuration" [connect] loading = "Checking this request." redirecting = "Returning you to your server." +step = "Step {{current}} of {{total}}" [connect.confirm] acknowledge = "I recognise this address and want to connect it to my team" @@ -6804,10 +6805,22 @@ after = "to enable account linking against the hosted Stirling account. In dev y before = "Set" title = "SaaS login not configured" +[portal.accountLink.connect] +close = "Close" +notNow = "Not now" +start = "Connect Stirling account" +step = "Step {{current}} of {{total}}" + +[portal.accountLink.connect.benefits] +creditsDetail = "500 free per month" +creditsLabel = "Credits" +processorDetail = "Pipelines, policies, sources and audit" +processorLabel = "Processor" +teamsDetail = "Free for up to 5 users" +teamsLabel = "Teams" + [portal.accountLink.connect.callback] -continue = "Continue" linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in." -modalTitle = "Connecting this server" retry = "Try again" signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete." working = "Finishing the connection." @@ -6816,10 +6829,6 @@ working = "Finishing the connection." body = "Connection requests are short lived. Start another one." title = "Request expired" -[portal.accountLink.connect.callback.linked] -body = "This server is connected to your Stirling account." -title = "Server connected" - [portal.accountLink.connect.callback.malformed] body = "This page was opened without a valid connection response. Start the connection from settings." title = "Could not read the response" @@ -6832,11 +6841,23 @@ title = "Connection not completed" body = "Stirling did not confirm the connection. This is usually temporary." title = "Not finished yet" -[portal.accountLink.gate] -action = "Link account" -description = "Link this org's Stirling account to use billable features." -title = "Link to unlock" -titleFeature = "Link to unlock {{feature}}" +[portal.accountLink.connect.done] +accountLabel = "Account" +addPolicy = "Add a policy" +buildPipeline = "Set up a pipeline" +creditsBarLabel = "Free credits remaining" +creditsSuffix = "of {{allowance}} free credits left" +cta = "Done" +inviteTeam = "Invite your team" +lede = "This server now runs against your Stirling account." +pendingTitle = "Almost there" +switchOnProcessor = "Switch on the Processor" +title = "Connected" + +[portal.accountLink.connect.handoff] +going = "Taking you to stirling.com" +reauthLede = "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way." +title = "Connecting" [portal.accountLink.instances] active = "Active" @@ -6866,17 +6887,11 @@ never = "never" [portal.accountLink.modal] cancel = "Cancel" -continueLink = "Continue to Stirling" continueReauth = "Sign in again" -linkSubtitle = "Connect this server to the Stirling account it should bill against." linkTitle = "Connect your Stirling account" noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment." -reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way." reauthTitle = "Sign in again" startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again." -step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on." -step2 = "You check this server's address and approve it. A team owner has to do this the first time." -step3 = "Stirling brings you straight back here and finishes up." [portal.accountLink.modal.loginNotConfigured] after = "so this server can finish the connection when you come back." @@ -6895,6 +6910,12 @@ forbidden = "Only the team owner can view the org's linked instances." generic = "Couldn't load the team's linked instances. Try again in a moment." title = "Couldn't load linked instances" +[portal.accountLink.rail] +cta = "Connect" +later = "Not now" +sub = "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free." +title = "Connect your Stirling account" + [portal.accountLink.state] free = "Editor plan" subscribed = "Processor plan" @@ -7018,11 +7039,6 @@ title = "Invoice history" viewAriaLabel = "View invoice {{number}} in Stripe" viewLink = "View ↗" -[portal.billing.linkPrompt] -cta = "Link Stirling account" -description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use." -title = "Link your Stirling account" - [portal.billing.paymentMethod] billedMonthly = "Billed monthly" cardEnding = "{{brand}} ending {{last4}}" @@ -7178,8 +7194,8 @@ label = "Projected to exceed." [portal.billing.spendThisMonth] eyebrow = "Spend this month" -freeRemaining_one = "{{formatted}} free PDF remaining" -freeRemaining_other = "{{formatted}} free PDFs remaining" +freeRemaining_one = "{{formatted}} free credit remaining" +freeRemaining_other = "{{formatted}} free credits remaining" processed_one = "{{formattedCount}} PDF processed." processed_other = "{{formattedCount}} PDFs processed." processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." @@ -7203,10 +7219,10 @@ eyebrow = "Processor trial" statusLabel_one = "{{used}} used" statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." -title_one = "Process {{allowance}} PDFs free" -title_other = "Process {{allowance}} PDFs free" -titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF" -titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +title_one = "{{allowance}} free credit to start" +title_other = "{{allowance}} free credits to start" +titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF" +titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF" [portal.components.billingUnit] approval = "approval" diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 2dafb3d07a..ef31ed4f4e 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -22,6 +22,12 @@ export interface AppConfig { premiumEnabled?: boolean; premiumKey?: string; paygEnabled?: boolean; + /** + * Whether this instance can link a Stirling (SaaS) account. False means the account-link + * endpoints are absent (404), which is indistinguishable from "not linked" on the client, so + * anything that prompts to link must gate on this first. + */ + accountLinkAvailable?: boolean; termsAndConditions?: string; privacyPolicy?: string; cookiePolicy?: string; diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index a82f1c7405..5008dd8537 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -5,15 +5,24 @@ import { LinkAccountModal } from "@portal/components/account-link/LinkAccountMod import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; import { PortalChrome } from "@portal/components/PortalChrome"; +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; -/** The one and only account-link modal. */ +/** The one and only account-link modal, whichever step it is on. */ function LinkModalHost() { - const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { linkModalOpen, linkModalMode, closeLinkModal, connectOutcome } = + useUI(); + // Ask once a session while the instance is unlinked, rather than waiting to be found. + useConnectPrompt(); + + // Mounted only while open, so closing discards the flow. Kept mounted, an interrupted hand-off + // stays flagged and every later open resumes on the ghost step with no way forward. + if (!linkModalOpen) return null; return ( ); } diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 8e77c20830..c7b87f7388 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -11,6 +11,7 @@ import { Policies } from "@portal/views/Policies"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; // Lazy so the generated docs manifest (bundled JSON) lands in its own chunk. @@ -32,13 +33,24 @@ export function ViewRouter() { } /> } /> } /> + {/* Building and editing need a linked account. Gated at the route so every way in is + covered: the list, the Documents review queue, the Connect flow's next steps, and a + typed URL. */} } + element={ + + + + } /> } + element={ + + + + } /> } /> {/* Source create/edit is a modal on the list now; old deep links land there. */} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.css b/frontend/editor/src/portal/components/ConnectAccountRail.css new file mode 100644 index 0000000000..9f4ad4a77e --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.css @@ -0,0 +1,53 @@ +/* ──────────────────────────────────────────────────────────────────────── */ +/* Connect rail — the ambient prompt above Home's deployment card */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/* Flat and quiet: it sits above the hero every visit until the account is + connected, so it has to be legible without competing with the card below it. */ + +.portal-connect-rail { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem 1rem; + margin-bottom: 0.75rem; + border: 1px solid var(--c-border); + border-radius: 0.5rem; + background: var(--c-surface); +} + +.portal-connect-rail__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-connect-rail__title { + font-size: 0.875rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect-rail__sub { + font-size: 0.8125rem; + color: var(--c-text-muted); +} + +.portal-connect-rail__actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +@media (max-width: 40rem) { + .portal-connect-rail { + flex-direction: column; + align-items: flex-start; + } + + .portal-connect-rail__actions { + margin-left: 0; + } +} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx new file mode 100644 index 0000000000..c813db9b3c --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; + +/** + * Both halves of "can link but has not" come from outside the component, and the global mock answers + * app-config without the flag — so a story that wants the rail has to ask for it. + */ +const canLink = { + msw: { + handlers: [ + http.get("/api/v1/config/app-config", () => + HttpResponse.json({ accountLinkAvailable: true }), + ), + ], + }, +}; + +const withLinkState = (state: LinkState) => [ + (Story: () => React.JSX.Element) => ( + + + + ), +]; + +const meta: Meta = { + title: "Portal/AccountLink/ConnectAccountRail", + component: ConnectAccountRail, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** On Home: the ask, plus a way to defer it for this session. */ +export const Default: Story = { + parameters: canLink, + decorators: withLinkState("unlinked"), +}; + +/** Connected, so the rail removes itself. Renders nothing on purpose. */ +export const Hidden: Story = { + parameters: canLink, + decorators: withLinkState("linked-free"), +}; diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.tsx new file mode 100644 index 0000000000..9f1e09c0ce --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; +import "@portal/components/ConnectAccountRail.css"; + +const DISMISSED_KEY = "portal::connect-rail-dismissed"; + +function readDismissed(): boolean { + try { + return sessionStorage.getItem(DISMISSED_KEY) === "true"; + } catch { + return false; + } +} + +/** Session-scoped dismissal, so the ask comes back until it is answered rather than for good. */ +export function ConnectAccountRail() { + const { t } = useTranslation(); + const { gated, loading, connect } = useConnectGate(); + const [dismissed, setDismissed] = useState(readDismissed); + + if (loading || !gated || dismissed) return null; + + const dismiss = () => { + try { + sessionStorage.setItem(DISMISSED_KEY, "true"); + } catch { + // Storage refusing is no reason to leave the rail stuck on screen. + } + setDismissed(true); + }; + + return ( +
+
+ + {t("portal.accountLink.rail.title", "Connect your Stirling account")} + + + {t( + "portal.accountLink.rail.sub", + "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free.", + )} + +
+
+ + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 954eac9908..f2fdd36b51 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; @@ -28,6 +29,7 @@ export function HomeHero() { return ( <> + {procurement.loading ? ( // Hold the rail's shape rather than committing to a footer: branching before the snapshot // lands paints the no-deal rail first, flashing on every refresh of an active deal. diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 2895ad4ec4..6d4cad1945 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -43,6 +44,7 @@ export function Sidebar() { const { displayName, profilePictureUrl } = useAccountIdentity(); const credits = useFreeCreditsSummary(); const openPlan = useOpenPlan(); + const { gated, connect } = useConnectGate(); // Collapse is a desktop-only affordance: on mobile the sidebar is an // off-canvas drawer, so the icon-rail state never applies there. @@ -67,6 +69,9 @@ export function Sidebar() { closeMobileNav(); if (entry.externalUrl) { window.open(entry.externalUrl, "_blank", "noopener,noreferrer"); + } else if (entry.requiresLink && gated) { + // Ask here: navigating first strands them on a page with nothing on it. + connect(); } else { setActiveView(id as ViewId); } diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx index fab501da4a..2fcbfdea0f 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx @@ -1,21 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Modal } from "@app/ui"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -import { withBasePath } from "@app/constants/app"; -import { - completeConnect, - startConnect, - type ConnectPhase, -} from "@portal/api/link"; +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { completeConnect, type ConnectPhase } from "@portal/api/link"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext"; -import { - ConnectCallbackView, - type ConnectCallbackState, -} from "@portal/components/account-link/ConnectCallbackView"; -import "@portal/views/ConnectCallback.css"; +import { useUI } from "@portal/contexts/UIContext"; +import type { ConnectCallbackState } from "@portal/components/account-link/ConnectCallbackView"; /** What the callback route hands over, read from the URL fragment before stripping it. */ export interface AccountLinkReturn { @@ -30,41 +19,23 @@ interface LocationState { } /** - * Finishes the handshake and reports the outcome, over the portal the admin - * started from. - * - * Mounted alongside the other portal-wide modal rather than being its own route: - * the result is a step in a task, so the page behind it should still be there. + * Renders nothing: the result belongs on step 3 of the dialog the admin left, so this publishes the + * outcome and the single dialog host reopens there. Mounted app-wide because the callback route + * only reads the fragment and navigates, so it is gone by the time there is an outcome. */ export function ConnectCallbackHost() { const location = useLocation(); - const navigate = useNavigate(); - const { t } = useTranslation(); const { refresh } = useAccountLinkContext(); + const { publishConnectOutcome } = useUI(); const handover = (location.state as LocationState | null)?.accountLinkReturn; - const [state, setState] = useState(null); - const [sessionRestored, setSessionRestored] = useState(false); - const nonceRef = useRef(null); const startedRef = useRef(false); - const finish = useCallback( - async (nonce: string) => { - setState("working"); - try { - const outcome = toViewState((await completeConnect(nonce)).phase); - setState(outcome); - // The portal read its status on mount, before this existed. Without this - // the page behind the modal still says unlinked until a reload. - if (outcome === "linked") await refresh(); - } catch { - // Could not reach our own backend. The handshake is still open, so this - // is worth another attempt rather than a restart. - setState("retry"); - } - }, - [refresh], - ); + // Refs so the effect runs on the hand-over alone: it consumes a single-use nonce. + const publishRef = useRef(publishConnectOutcome); + publishRef.current = publishConnectOutcome; + const refreshRef = useRef(refresh); + refreshRef.current = refresh; useEffect(() => { if (!handover || startedRef.current) return; @@ -72,17 +43,16 @@ export function ConnectCallbackHost() { const { type, nonce, accessToken, refreshToken } = handover; if (type !== "link" || !nonce) { - setState("malformed"); + publishRef.current({ state: "malformed", sessionRestored: false }); return; } - nonceRef.current = nonce; void (async () => { + let sessionRestored = false; if (accessToken && refreshToken) { try { const supabase = ensureSaasSupabase(); - // Logged, not swallowed: silently this resurfaces later as "session - // expired" on the usage page, with nothing tying it back here. + // Logged, not swallowed: this resurfaces later as "session expired" otherwise. if (!supabase) { console.warn( "[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build", @@ -95,7 +65,7 @@ export function ConnectCallbackHost() { if (error) { console.warn("[account-link] setSession failed:", error.message); } else { - setSessionRestored(true); + sessionRestored = true; } } } catch (e) { @@ -106,68 +76,36 @@ export function ConnectCallbackHost() { "[account-link] callback carried no tokens; the approval page had no session to pass", ); } - await finish(nonce); + + await claim(nonce, sessionRestored); })(); - }, [handover, finish]); - /** - * Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving. - */ - const onRetry = useCallback(() => { - if (state === "retry" && nonceRef.current) { - void finish(nonceRef.current); - return; + /** Passes itself as {@code reclaim} so Try again re-claims rather than opening a handshake. */ + async function claim(nonce: string, sessionRestored: boolean) { + publishRef.current({ state: "working", sessionRestored }); + const again = () => void claim(nonce, sessionRestored); + try { + const state = toViewState((await completeConnect(nonce)).phase); + publishRef.current({ + state, + sessionRestored, + reclaim: state === "retry" ? again : undefined, + }); + // Without this the page behind the dialog says unlinked until a reload. + if (state === "linked") await refreshRef.current(); + } catch { + // Our own backend is unreachable; the handshake is untouched, so retrying beats restarting. + publishRef.current({ state: "retry", sessionRestored, reclaim: again }); + } } - setState("working"); - // Same callback the modal sends. Without it the backend falls back to the bare - // origin, which drops the app's base path and lands the return on nothing. - void startConnect( - window.location.hostname, - new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(), - ) - .then((status) => { - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - } else { - setState("rejected"); - } - }) - .catch(() => setState("retry")); - }, [state, finish]); + }, [handover]); - // Drops the handover with it, so a back navigation does not reopen the result. - const done = useCallback(() => { - setState(null); - navigate(PORTAL_BASENAME, { replace: true }); - }, [navigate]); - - if (!state) return null; - - return ( - - - - ); + return null; } /** - * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it. + * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but + * unfinished, which is the same thing to do about it. */ function toViewState(phase: ConnectPhase): ConnectCallbackState { switch (phase) { diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx index 4a22592e45..ae9e268849 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx @@ -1,5 +1,7 @@ import { useTranslation } from "react-i18next"; -import { Banner, Button, Spinner } from "@app/ui"; +import { Banner, Spinner } from "@app/ui"; +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import "@portal/components/account-link/connect/connect.css"; /** Outcomes of returning from the approval page. */ export type ConnectCallbackState = @@ -10,26 +12,39 @@ export type ConnectCallbackState = | "rejected" | "malformed"; -export interface ConnectCallbackViewProps { +export interface ConnectOutcome { state: ConnectCallbackState; /** True once the SaaS session landed, regardless of how the link itself went. */ sessionRestored: boolean; - onRetry: () => void; + /** Present only while the handshake is still open, so a retry re-claims rather than opening one. */ + reclaim?: () => void; +} + +export function isRetryableOutcome(state: ConnectCallbackState): boolean { + return state !== "linked" && state !== "malformed"; +} + +export interface ConnectCallbackViewProps { + state: ConnectCallbackState; + sessionRestored: boolean; onDone: () => void; } -/** Presentation for the account-link callback. */ +/** + * Five states in one step: a failed link is still step 3 of the flow they started, and a separate + * error dialog would discard the progress bar that says where they are. Actions live in the + * dialog's footer, not here, so they stay where steps 1 and 2 put them. + */ export function ConnectCallbackView({ state, sessionRestored, - onRetry, onDone, }: ConnectCallbackViewProps) { const { t } = useTranslation(); if (state === "working") { return ( -
+

{t( @@ -44,21 +59,14 @@ export function ConnectCallbackView({ if (state === "linked") { return (

- +

{t( - "portal.accountLink.connect.callback.linked.body", - "This server is connected to your Stirling account.", + "portal.accountLink.connect.done.lede", + "This server now runs against your Stirling account.", )} - - {/* The inverse of the failure note below: the link took but the sign-in did - not, which otherwise only shows up later as "session expired" on a page - that gives no hint the two are related. */} +

+ {/* Link took, sign-in did not: otherwise this resurfaces later as "session expired" with + nothing tying it back here. */} {sessionRestored ? null : (

{t( @@ -67,22 +75,19 @@ export function ConnectCallbackView({ )}

)} - +
); } - const { tone, title, body, retryable } = failure(state, t); + const { tone, title, body } = failure(state, t); return (
{body} - {/* The SaaS sign-in and the server link are separate outcomes. Say so when - one worked and the other did not, or the admin re-runs the whole thing - to fix a problem that is already half solved. */} + {/* Two separate outcomes: without this the admin re-runs the lot to fix a half-solved + problem. */} {sessionRestored ? (

{t( @@ -91,11 +96,6 @@ export function ConnectCallbackView({ )}

) : null} -
); } @@ -115,7 +115,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.expired.body", "Connection requests are short lived. Start another one.", ), - retryable: true, }; case "rejected": return { @@ -128,7 +127,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.rejected.body", "This request was declined or has already been used. Start another one if that was not intended.", ), - retryable: true, }; case "malformed": return { @@ -141,7 +139,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.malformed.body", "This page was opened without a valid connection response. Start the connection from settings.", ), - retryable: false, }; default: return { @@ -156,7 +153,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.unfinished.body", "Stirling did not confirm the connection. This is usually temporary.", ), - retryable: true, }; } } diff --git a/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx new file mode 100644 index 0000000000..e5bd45eff9 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx @@ -0,0 +1,26 @@ +import { useEffect, type ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +interface Props { + children: ReactNode; + fallback: string; +} + +/** + * At the route, not on the buttons: the pipeline builder is reachable from its list, the Documents + * queue, the connect flow's next steps and a typed URL, and a guard per entry point is one more to + * remember each time someone adds a link. + */ +export function ConnectGuardedRoute({ children, fallback }: Props) { + const { gated, loading, connect } = useConnectGate(); + + useEffect(() => { + if (gated) connect(); + }, [gated, connect]); + + // Unknown is not gated: bouncing first would throw a linked admin off a page they are entitled to. + if (loading) return null; + if (gated) return ; + return <>{children}; +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css deleted file mode 100644 index bc96172f0a..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css +++ /dev/null @@ -1,26 +0,0 @@ -/* Connect-account modal. Imported by the component rather than relying on the - account-link view's stylesheet: this modal is mounted at the app root, so it - renders on pages that never import that view. */ - -.portal-link__modal-body { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.portal-link__steps { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin: 0; - padding-left: 1.25rem; - font-size: 0.875rem; - line-height: 1.5; - color: var(--c-text-muted); -} - -.portal-link__modal-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx index 018d435b31..515b740e59 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx @@ -1,34 +1,66 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, waitFor } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -/** The modal every "link account" CTA in the portal opens. */ -const { startConnect, startReauth } = vi.hoisted(() => ({ +/** The step machine: what drives each step, and what must not skip or repeat one. */ +const { startConnect, startReauth, fetchWallet, EMAIL } = vi.hoisted(() => ({ startConnect: vi.fn(), startReauth: vi.fn(), + fetchWallet: vi.fn(), + EMAIL: "admin@acme.example", })); vi.mock("@portal/api/link", () => ({ startConnect, startReauth })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); vi.mock("@portal/auth/saasSupabase", () => ({ isSaasSupabaseConfigured: true, + // Step 3 reads the connected account's email off this session. + ensureSaasSupabase: () => ({ + auth: { + getSession: () => + Promise.resolve({ data: { session: { user: { email: EMAIL } } } }), + }, + }), })); import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; const AUTHORIZE = "http://localhost:5174/link?request=req-1"; -function renderModal(mode?: "link" | "reauth") { +const BENEFITS = "Pipelines, policies, sources and audit"; +const GHOST = /Taking you to stirling\.com/; +const CONNECT = /Connect Stirling account/; + +function renderModal( + mode?: "link" | "reauth", + outcome: ConnectOutcome | null = null, +) { return render( - - {}} mode={mode} /> - , + + + {}} + mode={mode} + outcome={outcome} + /> + + , ); } -/** Clicks the primary action (the secondary one is Cancel). */ -function clickContinue(getAllByRole: (role: string) => HTMLElement[]) { - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); +function click(label: string | RegExp) { + act(() => screen.getByRole("button", { name: label }).click()); +} + +/** Read off the body because the dialog portals out; the badge itself is uninterpolated here. */ +function filledSteps(): number { + return document.body.querySelectorAll( + ".portal-stepmodal__progress .is-filled", + ).length; } describe("LinkAccountModal", () => { @@ -36,6 +68,7 @@ describe("LinkAccountModal", () => { beforeEach(() => { vi.clearAllMocks(); + fetchWallet.mockResolvedValue(freeWallet); startConnect.mockResolvedValue({ phase: "PENDING", authorizeUrl: AUTHORIZE, @@ -54,28 +87,39 @@ describe("LinkAccountModal", () => { value: { origin: "http://localhost:5173", hostname: "localhost", + href: "http://localhost:5173/app", + search: "", assign, }, }); }); + it("opens on the pitch, and asks nothing of the backend until told", () => { + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.getByRole("button", { name: CONNECT })).toBeTruthy(); + expect(filledSteps()).toBe(1); + expect(startConnect).not.toHaveBeenCalled(); + }); + it("offers no sign-in form, because a sign-in started here cannot complete", () => { const { container } = renderModal(); + click(CONNECT); - // The provider buttons this modal used to carry sent the admin to Stirling and - // abandoned them there. Nothing should collect credentials on this origin. + // A sign-in started on this origin cannot complete, so nothing here may collect credentials. expect(container.querySelector("input[type=password]")).toBeNull(); expect(container.querySelector("input[type=email]")).toBeNull(); }); - it("starts a link handshake and hands the browser to Stirling", async () => { - const { getAllByRole } = renderModal(); - - clickContinue(getAllByRole); + it("hands over on the first click, showing the ghost while it goes", async () => { + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); - // Callback built from this page's own origin, which the backend then checks - // against the request's Origin header. + expect(screen.getByText(GHOST)).toBeTruthy(); + expect(filledSteps()).toBe(2); + // The backend checks this against the request's Origin header. expect(startConnect).toHaveBeenCalledWith( "localhost", "http://localhost:5173/account-link/callback", @@ -84,13 +128,16 @@ describe("LinkAccountModal", () => { expect(startReauth).not.toHaveBeenCalled(); }); - it("uses the reauth endpoint when only the session needs renewing", async () => { - const { getAllByRole } = renderModal("reauth"); + it("uses the reauth endpoint, with no pitch and no steps", async () => { + renderModal("reauth"); - clickContinue(getAllByRole); + // A server that is already connected is not sold anything. + expect(screen.queryByText(BENEFITS)).toBeNull(); + expect(screen.queryByText(/Step 1 of 3/)).toBeNull(); - // A different endpoint on purpose: reauth presents the device credential so - // Stirling pins the handshake to the team that already owns this server. + click(/Sign in again/); + + // A different endpoint: reauth presents the credential, so the team is pinned server-side. await waitFor(() => expect(startReauth).toHaveBeenCalledWith( "http://localhost:5173/account-link/callback", @@ -100,14 +147,18 @@ describe("LinkAccountModal", () => { await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); }); - it("stays put and explains itself when the handshake cannot start", async () => { + it("falls back to step 1 with the reason when the handshake cannot start", async () => { startConnect.mockRejectedValue(new Error("offline")); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); + // The ghost unmounts when the request settles, so the reason lands on step 1. + expect(await screen.findByText(/outbound network access/)).toBeTruthy(); + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); }); it("does not navigate when there is nothing to navigate to", async () => { @@ -119,10 +170,85 @@ describe("LinkAccountModal", () => { teamId: 7, }); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); }); + + /** Busy is never cleared on success, because the page was meant to be gone. */ + describe("coming back from a hand-off that never completed", () => { + it("clears the in-flight flag when the page is shown again", async () => { + renderModal(); + click(CONNECT); + + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + act(() => { + window.dispatchEvent(new Event("pageshow")); + }); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); + }); + + /** + * Not the close path itself (the host unmounts, so a fresh mount is clean by construction) but + * the property behind it: hoist the flag into UIContext and the trap returns, failing here. + */ + it("keeps the in-flight flag local, so a fresh mount cannot inherit one", async () => { + const first = renderModal(); + click(CONNECT); + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + first.unmount(); + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.queryByText(GHOST)).toBeNull(); + }); + }); + + describe("resuming after the round trip", () => { + it("lands on step 3 rather than restarting the pitch", async () => { + renderModal("link", { state: "linked", sessionRestored: true }); + + expect( + await screen.findByText(/now runs against your Stirling account/), + ).toBeTruthy(); + expect(screen.queryByText(BENEFITS)).toBeNull(); + // Left on 2 of 3, arrived on 3: the whole reason the bar spans the redirect. + expect(filledSteps()).toBe(3); + expect(await screen.findByText(EMAIL)).toBeTruthy(); + expect(await screen.findByText("Invite your team")).toBeTruthy(); + }); + + it("opens a fresh handshake for a spent one, showing the ghost again", async () => { + renderModal("link", { state: "expired", sessionRestored: false }); + + expect(await screen.findByText("Request expired")).toBeTruthy(); + click(/Try again/); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + // Busy outranks the stale outcome, or they sit on "Request expired" until the browser goes. + expect(screen.getByText(GHOST)).toBeTruthy(); + }); + + it("offers no retry while the claim is still in flight", async () => { + renderModal("link", { state: "working", sessionRestored: false }); + + expect(await screen.findByText(/Finishing the connection/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + + it("does not offer a retry for a response it could not read", async () => { + renderModal("link", { state: "malformed", sessionRestored: false }); + + expect( + await screen.findByText(/Could not read the response/), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + }); }); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index 8bd47a9eb4..2b6315eb17 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -1,143 +1,205 @@ -import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, Modal } from "@app/ui"; -import { withBasePath } from "@app/constants/app"; -import { startConnect, startReauth } from "@portal/api/link"; -import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; -import "@portal/components/account-link/LinkAccountModal.css"; +import { Button } from "@app/ui"; +import { FlowModal } from "@portal/components/shared/FlowModal"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; +import { ConnectAskStep } from "@portal/components/account-link/connect/ConnectAskStep"; +import { ConnectHandoffGhost } from "@portal/components/account-link/connect/ConnectHandoffGhost"; +import { + ConnectCallbackView, + isRetryableOutcome, + type ConnectOutcome, +} from "@portal/components/account-link/ConnectCallbackView"; +import { useConnectHandoff } from "@portal/hooks/useConnectHandoff"; +import "@portal/views/ConnectCallback.css"; + +/** + * Ordered, so a step's position in this list is its number and the list's length is the total. + * Adding or removing a step means editing this and its arm of `stepBody`, nothing else. + */ +const STEP_ORDER = ["ask", "handoff", "outcome"] as const; + +type StepId = (typeof STEP_ORDER)[number]; interface Props { open: boolean; onClose: () => void; - /** - * "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked. - */ + /** "reauth" only re-establishes the browser session, so it stays one step with no pitch. */ mode?: "link" | "reauth"; + /** Published by the callback route; present means the admin is returning from Stirling. */ + outcome?: ConnectOutcome | null; } -/** Sends the admin off to Stirling to connect this server. */ -export function LinkAccountModal({ open, onClose, mode = "link" }: Props) { +/** + * The progress bar spans the redirect on purpose: the admin leaves on the hand-off and returns on + * the outcome step of the dialog they left, rather than being greeted by a different one. + */ +export function LinkAccountModal({ + open, + onClose, + mode = "link", + outcome = null, +}: Props) { const { t } = useTranslation(); const reauth = mode === "reauth"; - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const handoff = useConnectHandoff(reauth); - const begin = useCallback(async () => { - setBusy(true); - setError(null); - try { - const callbackUrl = new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(); - const status = reauth - ? await startReauth(callbackUrl) - : await startConnect(window.location.hostname, callbackUrl); - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - return; - } - // Already linked, or a handshake we cannot act on. Nothing to navigate to. - setError( - t( - "portal.accountLink.modal.noAuthorizeUrl", - "Stirling did not return somewhere to continue. Try again in a moment.", + // Busy outranks a stale outcome, or a retry sits on the old result until the browser leaves. + let step: StepId = "ask"; + if (handoff.busy) step = "handoff"; + else if (outcome) step = "outcome"; + + const title = stepTitle(); + const current = STEP_ORDER.indexOf(step) + 1; + + // Re-auth is one step, so it carries no count and no progress bar. + const stepChrome = reauth + ? {} + : { + step: current, + total: STEP_ORDER.length, + stepLabel: t( + "portal.accountLink.connect.step", + "Step {{current}} of {{total}}", + { current, total: STEP_ORDER.length }, ), - ); - } catch { - setError( - t( - "portal.accountLink.modal.startFailed", - "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", - ), - ); - } finally { - setBusy(false); - } - }, [reauth, t]); + }; return ( - -
-
    -
  1. - {t( - "portal.accountLink.modal.step1", - "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.", - )} -
  2. -
  3. - {t( - "portal.accountLink.modal.step2", - "You check this server's address and approve it. A team owner has to do this the first time.", - )} -
  4. -
  5. - {t( - "portal.accountLink.modal.step3", - "Stirling brings you straight back here and finishes up.", - )} -
  6. -
- - {!isSaasSupabaseConfigured && ( - - {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} - VITE_SUPABASE_URL{" "} - {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} - VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} - {t( - "portal.accountLink.modal.loginNotConfigured.after", - "so this server can finish the connection when you come back.", - )} - - )} - - {error && {error}} - -
- - -
-
-
+ + {stepBody()} + ); + + function stepTitle(): string { + if (reauth) { + return t("portal.accountLink.modal.reauthTitle", "Sign in again"); + } + if (step === "ask") { + return t( + "portal.accountLink.modal.linkTitle", + "Connect your Stirling account", + ); + } + if (step === "handoff") { + return t("portal.accountLink.connect.handoff.title", "Connecting"); + } + if (outcome?.state === "linked") { + return t("portal.accountLink.connect.done.title", "Connected"); + } + return t("portal.accountLink.connect.done.pendingTitle", "Almost there"); + } + + function stepBody() { + switch (step) { + case "ask": + return ; + case "handoff": + return ; + case "outcome": + return outcome ? ( + + ) : null; + } + } + + function closeButton() { + return ( + + ); + } + + function retryButton(onRetry: () => void) { + return ( + + ); + } + + function stepFooter() { + if (step === "ask") { + const dismiss = reauth + ? t("portal.accountLink.modal.cancel", "Cancel") + : t("portal.accountLink.connect.notNow", "Not now"); + const start = reauth + ? t("portal.accountLink.modal.continueReauth", "Sign in again") + : t("portal.accountLink.connect.start", "Connect Stirling account"); + return ( + <> + + + + ); + } + + // The request is out and the browser is leaving; Close so a stall is not a dead end. + if (step === "handoff") { + return ( + <> + + {closeButton()} + + ); + } + + // A retry over a call that has not answered is how you get two handshakes. + if (outcome?.state === "working") { + return ( + <> + + {closeButton()} + + ); + } + + // Still open: re-claim rather than spend the approval a leader gave by hand. + if (outcome?.reclaim) { + return ( + <> + {closeButton()} + {retryButton(outcome.reclaim)} + + ); + } + + if (outcome && isRetryableOutcome(outcome.state)) { + return ( + <> + {closeButton()} + {retryButton(handoff.begin)} + + ); + } + + return ( + <> + + + + ); + } } diff --git a/frontend/editor/src/portal/components/account-link/LinkGate.tsx b/frontend/editor/src/portal/components/account-link/LinkGate.tsx deleted file mode 100644 index d5743d0041..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkGate.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { Banner, Button } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; -import { useUI } from "@portal/contexts/UIContext"; - -interface Props { - /** The billable feature — rendered only when the org is linked. */ - children: ReactNode; - /** Feature name for the lock copy, e.g. "AI extraction". */ - feature?: string; -} - -/** - * Gates billable features on the account-link state. When the org is unlinked it - * renders a "link to unlock" prompt instead of the feature; once linked (free or - * subscribed) the children render. Drop this around any surface that should only - * work against a linked SaaS wallet. - */ -export function LinkGate({ children, feature }: Props) { - const { t } = useTranslation(); - const { featuresUnlocked } = useLink(); - const { openLinkModal } = useUI(); - - if (featuresUnlocked) return <>{children}; - - return ( - openLinkModal()}> - {t("portal.accountLink.gate.action", "Link account")} - - } - /> - ); -} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx new file mode 100644 index 0000000000..7c795645b7 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { Banner } from "@app/ui"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Re-auth says why it is being asked; a first link is pitched instead. */ + reauth: boolean; + /** A hand-off that failed to start drops back here, so this is where its reason belongs. */ + error?: string | null; +} + +export function ConnectAskStep({ reauth, error }: Props) { + const { t } = useTranslation(); + + return ( + <> + {reauth ? ( +

+ {t( + "portal.accountLink.connect.handoff.reauthLede", + "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way.", + )} +

+ ) : ( + + )} + + {!isSaasSupabaseConfigured && ( + + {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} + VITE_SUPABASE_URL{" "} + {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} + {t( + "portal.accountLink.modal.loginNotConfigured.after", + "so this server can finish the connection when you come back.", + )} + + )} + + {error && {error}} + + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx new file mode 100644 index 0000000000..bdad6f32b0 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; + +const meta: Meta = { + title: "Portal/AccountLink/Connect/BenefitsSlide", + component: ConnectBenefitsSlide, +}; +export default meta; +type Story = StoryObj; + +/** Step 1 of the Connect flow: the case for linking. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx new file mode 100644 index 0000000000..a0258f7802 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * Processor is one row naming its parts rather than four competing ones, and credits come last: + * first, and the screen reads as a price list. + * + *

TODO(#7712): the credits row promises a monthly allowance the billing model does not grant — + * {@code freeGrantUnits} is a one-time lifetime pool — so either the grant becomes recurring or the + * copy drops "per month" before this reaches customers. + */ +export function ConnectBenefitsSlide() { + const { t } = useTranslation(); + + const unlocks: { key: string; label: string; detail: string }[] = [ + { + key: "processor", + label: t( + "portal.accountLink.connect.benefits.processorLabel", + "Processor", + ), + detail: t( + "portal.accountLink.connect.benefits.processorDetail", + "Pipelines, policies, sources and audit", + ), + }, + { + key: "teams", + label: t("portal.accountLink.connect.benefits.teamsLabel", "Teams"), + detail: t( + "portal.accountLink.connect.benefits.teamsDetail", + "Free for up to 5 users", + ), + }, + { + key: "credits", + label: t("portal.accountLink.connect.benefits.creditsLabel", "Credits"), + detail: t( + "portal.accountLink.connect.benefits.creditsDetail", + "500 free per month", + ), + }, + ]; + + return ( +

+ {unlocks.map((unlock) => ( +
+
{unlock.label}
+
{unlock.detail}
+
+ ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx new file mode 100644 index 0000000000..fa57d33fbf --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; + +/** + * The figure is checkable against a real account, so it must be the wallet's and never a hardcoded + * 500. The upgrade row appears only on a nearly spent trial: a prompt to pay is the wrong note on a + * screen confirming a free connection. + */ +const { fetchWallet } = vi.hoisted(() => ({ fetchWallet: vi.fn() })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); + +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; + +/** The shared fixture, wound down to the balance under test. */ +const wallet = (freeRemaining: number) => ({ + ...freeWallet, + freeRemaining, + billableUsed: freeWallet.freeAllowance - freeRemaining, +}); + +const SWITCH_ON = /Switch on the Processor/; + +const renderDone = () => + render( + + + {}} /> + + , + ); + +describe("ConnectDoneSlide", () => { + it("shows the wallet's remaining balance, not a fixed grant", async () => { + fetchWallet.mockResolvedValue(wallet(128)); + renderDone(); + await waitFor(() => expect(screen.getByText("128")).toBeTruthy()); + }); + + it("shows a spent grant as zero rather than hiding it", async () => { + fetchWallet.mockResolvedValue(wallet(0)); + renderDone(); + await waitFor(() => expect(screen.getByText("0")).toBeTruthy()); + }); + + it("omits the meter entirely when the wallet cannot be read", async () => { + fetchWallet.mockRejectedValue(new Error("not linked")); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(document.querySelector(".paygf-meter")).toBeNull(); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("asks the admin to switch the Processor on once the trial is nearly gone", async () => { + fetchWallet.mockResolvedValue(wallet(40)); + renderDone(); + await waitFor(() => expect(screen.getByText(SWITCH_ON)).toBeTruthy()); + const rows = [...document.querySelectorAll(".portal-connect__next-item")]; + expect(rows[0]?.textContent).toMatch(SWITCH_ON); + }); + + it("leaves the upgrade unmentioned while there is trial left to use", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(document.querySelector(".paygf-meter")).toBeTruthy(), + ); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("always offers the next steps", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(screen.getByText("Set up a pipeline")).toBeTruthy(); + expect(screen.getByText("Add a policy")).toBeTruthy(); + }); +}); diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx new file mode 100644 index 0000000000..4854048ab8 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { MeterBar, remainingMeter } from "@app/billing"; +import { fetchWallet, type Wallet } from "@portal/api/billing"; +import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import "@portal/components/billing/billing.css"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Closes the dialog first, so a next step does not land behind the overlay. */ + onNavigate: () => void; +} + +/** Below this the meter grows an upgrade row; above it the meter is information, not a prompt. */ +const LOW_CREDITS = 100; + +function Chevron() { + return ( + + + + ); +} + +/** + * The bar without the plan card around it: at dialog width that card's headline and price wrapped + * over four lines. Upgrading is a row rather than a button on the bar for the same reason, and it + * goes to Usage rather than starting checkout, which already lives there with its quotes and + * resumable bundle. + */ +export function ConnectDoneSlide({ onNavigate }: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const email = useLinkedAccountEmail(); + const [wallet, setWallet] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchWallet() + .then((w) => { + if (!cancelled) setWallet(w); + }) + .catch(() => { + // The wallet needs a live SaaS session and a team that has finished provisioning. Neither + // is guaranteed the instant a link completes, and neither is worth blocking this screen on. + }); + return () => { + cancelled = true; + }; + }, []); + + const go = (path: string) => { + onNavigate(); + navigate(path); + }; + + const lowOnCredits = wallet != null && wallet.freeRemaining < LOW_CREDITS; + + const nextSteps: { key: string; label: string; path: string }[] = [ + ...(lowOnCredits + ? [ + { + key: "processor", + label: t( + "portal.accountLink.connect.done.switchOnProcessor", + "Switch on the Processor", + ), + path: toPortalPath(VIEW_PATHS.usage), + }, + ] + : []), + { + key: "team", + label: t( + "portal.accountLink.connect.done.inviteTeam", + "Invite your team", + ), + path: toPortalPath(VIEW_PATHS.users), + }, + { + key: "pipeline", + label: t( + "portal.accountLink.connect.done.buildPipeline", + "Set up a pipeline", + ), + path: `${toPortalPath(VIEW_PATHS.pipelines)}/new`, + }, + { + key: "policy", + label: t("portal.accountLink.connect.done.addPolicy", "Add a policy"), + path: toPortalPath(VIEW_PATHS.policies), + }, + ]; + + return ( + <> + {wallet && ( +
+ {/* No status chip: its tone goes red on an exhausted trial, which on a success screen + reads as something having gone wrong. */} + +
+ )} + + {/* Proves it landed on the account they meant. Read from the session the callback deposited, + so it is absent exactly when that hand-off failed — which the note above already says. */} + {email && ( +
+ + {t("portal.accountLink.connect.done.accountLabel", "Account")} + + {email} +
+ )} + +
    + {nextSteps.map((step) => ( +
  • + +
  • + ))} +
+ + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx new file mode 100644 index 0000000000..251652de73 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next"; +import { Skeleton } from "@app/ui"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * A ghost rather than a screen: the admin has already decided. It earns its place when the local + * backend is slow to open the handshake, where a blank dialog would look broken. + */ +export function ConnectHandoffGhost() { + const { t } = useTranslation(); + + return ( +
+

+ {t( + "portal.accountLink.connect.handoff.going", + "Taking you to stirling.com", + )} +

+ +
+ + + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/connect.css b/frontend/editor/src/portal/components/account-link/connect/connect.css new file mode 100644 index 0000000000..393695bb0a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/connect.css @@ -0,0 +1,112 @@ +/* Connect flow, the three account-link steps. + + Deliberately flat: bordered rows and plain type, not icon tiles or coloured + cards. FlowModal stacks and spaces the body, so these blocks own only their + own internals. */ + +.portal-connect__lede { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--c-text-muted); +} + +.portal-connect__list { + margin: 0; + border-top: 1px solid var(--c-border); +} + +.portal-connect__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--c-border); +} + +.portal-connect__row-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect__row-detail { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-muted); + text-align: right; +} + +/* ── Step 2: the ghost ──────────────────────────────────────────────────── */ + +.portal-connect__ghost { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-connect__ghost-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ── Step 3 ─────────────────────────────────────────────────────────────── */ + +/* Standalone, so it needs the top edge the benefits list would have given it. */ +.portal-connect__row--standalone { + border-top: 1px solid var(--c-border); +} + +/* The shared bar without the plan card it wears on Usage; padded into the row rhythm. */ +.portal-connect__meter { + padding: 0.25rem 0 0.5rem; +} + +/* Rows, not full-width buttons: three competing primaries were the loudest thing + on a screen whose job is to confirm. Same flat row as step 1, so both ends match. */ +.portal-connect__next { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--c-border); +} + +/* The Account row above draws a bottom edge; both would render as a double line. */ +.portal-connect__row + .portal-connect__next { + border-top: 0; +} + +.portal-connect__next-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.625rem 0.25rem; + border: 0; + border-bottom: 1px solid var(--c-border); + background: none; + font: inherit; + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); + text-align: left; + cursor: pointer; +} + +.portal-connect__next-item:hover { + color: var(--c-accent-text); +} + +.portal-connect__next-chevron { + flex: none; + color: var(--c-text-subtle); +} + +.portal-connect__next-item:hover .portal-connect__next-chevron { + color: var(--c-accent-text); +} diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx deleted file mode 100644 index 20a9ab2e80..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; -import "@portal/components/billing/billing.css"; - -const meta: Meta = { - title: "Portal/Billing/LinkAccountPrompt", - component: LinkAccountPrompt, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Unlinked billing page — CTA opens the login modal (UIProvider from the preview decorator). */ -export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx deleted file mode 100644 index 394668c56b..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@app/ui"; -import { useUI } from "@portal/contexts/UIContext"; - -/** - * Unlinked state — the billing page asks the admin to link their Stirling - * account to claim the 500-PDF free grant. The CTA opens the login modal - * directly (no detour through Settings). - */ -export function LinkAccountPrompt() { - const { t } = useTranslation(); - const { openLinkModal } = useUI(); - return ( - - openLinkModal()}> - {t("portal.billing.linkPrompt.cta", "Link Stirling account")} - - } - /> - - ); -} diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx index f15d8ade51..097eebd52e 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx @@ -1,39 +1,82 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; -const linkState = { isLinked: false }; +/** + * Usage must not render while unlinked: it reports `linked` as a fact from its wallet read, so a + * browser holding a SaaS session with no link to this server would flip the whole portal to linked. + */ +const gate = { gated: false, loading: false, available: true }; +const connect = vi.fn(); +const applyLinkFacts = vi.fn(); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); vi.mock("@portal/contexts/LinkContext", () => ({ - useLink: () => linkState, - useApplyLinkFacts: () => vi.fn(), + useApplyLinkFacts: () => applyLinkFacts, })); vi.mock("@portal/contexts/UIContext", () => ({ useUI: () => ({ openLinkModal: vi.fn() }), })); -vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({ - LinkAccountPrompt: () =>
, -})); vi.mock("@portal/views/Usage", () => ({ - Usage: () =>
, + Usage: ({ onWalletLoaded }: { onWalletLoaded?: (w: unknown) => void }) => { + onWalletLoaded?.({ status: "free" }); + return
; + }, })); import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +const renderGate = () => + render( + + + } /> + } /> + + , + ); + describe("PortalBillingGate — self-hosted", () => { beforeEach(() => { - linkState.isLinked = false; + connect.mockReset(); + applyLinkFacts.mockReset(); + gate.gated = false; + gate.loading = false; }); - it("shows the link prompt when unlinked (billing gated on link)", () => { - linkState.isLinked = false; - render(); - expect(screen.getByTestId("link-prompt")).toBeInTheDocument(); - expect(screen.queryByTestId("usage")).not.toBeInTheDocument(); + it("asks for the connection when reached unconnected", () => { + gate.gated = true; + renderGate(); + expect(connect).toHaveBeenCalledTimes(1); }); - it("renders the Usage page once linked", () => { - linkState.isLinked = true; - render(); + it("sends them back rather than onto a page about an account they lack", () => { + gate.gated = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.getByTestId("home")).toBeInTheDocument(); + }); + + it("never reports the instance as linked while it is not", () => { + gate.gated = true; + renderGate(); + // Not rendering the page is what stops the claim. + expect(applyLinkFacts).not.toHaveBeenCalled(); + }); + + it("holds while the capability is still unknown, rather than bouncing", () => { + gate.loading = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.queryByTestId("home")).toBeNull(); + }); + + it("renders the page once connected", () => { + renderGate(); + expect(connect).not.toHaveBeenCalled(); expect(screen.getByTestId("usage")).toBeInTheDocument(); - expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument(); + expect(applyLinkFacts).toHaveBeenCalledWith(true, false); }); }); diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx index e448cfb93a..b02ed7db29 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx @@ -1,23 +1,20 @@ import { useCallback } from "react"; -import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; import { useUI } from "@portal/contexts/UIContext"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { Usage } from "@portal/views/Usage"; import type { Wallet } from "@portal/api/billing"; /** - * Billing access gate — the seam the SaaS build overrides. + * The seam the SaaS build shadows, and the backstop for a typed URL — the nav already refuses to + * come here unlinked (the sidebar's requiresLink). * - *

Self-hosted (this base): billing only makes sense once the instance has - * linked its SaaS account, so gate on link state — unlinked shows the link prompt; - * linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the - * link/tier dimension: the wallet's subscription status refines the plan/tier - * badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps - * the "link" concept entirely out of the Usage page. The SaaS build shadows this - * with a passthrough — there is no linking there. + *

Usage must not render while unlinked: {@link onWalletLoaded} reports linked as a fact, and the + * browser can hold a SaaS session with no link to this server, so rendering it flipped the portal + * to linked. */ export function PortalBillingGate() { - const { isLinked } = useLink(); const applyLinkFacts = useApplyLinkFacts(); const { openLinkModal } = useUI(); @@ -27,6 +24,9 @@ export function PortalBillingGate() { ); const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]); - if (!isLinked) return ; - return ; + return ( + + + + ); } diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.css b/frontend/editor/src/portal/components/shared/StepModalHeader.css index 7296e9752f..fd918f1c62 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.css +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.css @@ -26,7 +26,7 @@ color: var(--c-text); } -/* Trademarked wordmark SVG (theme-switched via .wordmark-light-only/.wordmark-dark-only). Height +/* Trademarked wordmark SVG (theme-switched via .wordmark/.wordmark-dark-only). Height matches the portal nav's 22px wordmark so the modal and app read as one brand. No `display` here — the theme-switch utilities own visibility. */ .portal-stepmodal__wordmark { diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx index 3156d17a9e..9190893745 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx @@ -55,10 +55,12 @@ export function StepModalHeader({

{brand ? (
+ {/* `wordmark`, not `wordmark-light-only`: theme.css hides the former in dark mode and + has no rule for the latter, so both used to render at once. */} Stirling }, { id: "integrations", icon: }, { id: "infrastructure", icon: }, - { id: "usage", icon: }, + { id: "usage", icon: , requiresLink: true }, { id: "docs", icon: }, ]; diff --git a/frontend/editor/src/portal/contexts/LinkContext.tsx b/frontend/editor/src/portal/contexts/LinkContext.tsx index 7fd7cfe536..8e36b80cee 100644 --- a/frontend/editor/src/portal/contexts/LinkContext.tsx +++ b/frontend/editor/src/portal/contexts/LinkContext.tsx @@ -89,6 +89,14 @@ export function useLink(): LinkContextValue { return v; } +/** + * Null rather than throwing where there is no provider. The SaaS portal mounts none on purpose, so + * absent means "linking does not apply here" — a real answer, not a mistake. + */ +export function useLinkOptional(): LinkContextValue | null { + return useContext(LinkContext); +} + /** * Derives the linked state from raw facts: whether the org has linked its SaaS * account and whether it carries a live subscription. Keeps the unlinked / diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index b5ebada257..b116486271 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -5,6 +5,7 @@ import { useState, type ReactNode, } from "react"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; interface UIContextValue { /** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */ @@ -46,6 +47,13 @@ interface UIContextValue { linkModalMode: "link" | "reauth"; openLinkModal: (mode?: "link" | "reauth") => void; closeLinkModal: () => void; + /** + * A one-shot signal like {@link UIContextValue.trialSetupRequested}: the callback route and the + * dialog mount separately, and there must only ever be one link dialog. + */ + connectOutcome: ConnectOutcome | null; + publishConnectOutcome: (outcome: ConnectOutcome) => void; + clearConnectOutcome: () => void; /** * A request to begin the enterprise trial, raised from wherever the buyer said yes (the billing * upsell, a sales link). The deal controller lives on Home, so this is a one-shot signal rather @@ -91,6 +99,9 @@ export function UIProvider({ children }: { children: ReactNode }) { const [linkModalOpen, setLinkModalOpen] = useState(false); const [trialSetupRequested, setTrialSetupRequested] = useState(false); const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link"); + const [connectOutcome, setConnectOutcome] = useState( + null, + ); // When the link modal is opened from inside Settings, remember the section to // restore so closing the modal returns the admin to where they were. const [reopenSettingsAfterLink, setReopenSettingsAfterLink] = useState< @@ -155,9 +166,19 @@ export function UIProvider({ children }: { children: ReactNode }) { setTrialSetupRequested(true); }, clearTrialSetupRequest: () => setTrialSetupRequested(false), + connectOutcome, + publishConnectOutcome: (outcome: ConnectOutcome) => { + setMobileNavOpen(false); + setConnectOutcome(outcome); + setLinkModalMode("link"); + setLinkModalOpen(true); + }, + clearConnectOutcome: () => setConnectOutcome(null), closeLinkModal: () => { setLinkModalOpen(false); setLinkModalMode("link"); + // A reopen from a CTA is a fresh flow, not a handshake already dismissed. + setConnectOutcome(null); if (reopenSettingsAfterLink) { setSettingsInitialSection(reopenSettingsAfterLink); setSettingsInitialFocus(null); @@ -177,6 +198,7 @@ export function UIProvider({ children }: { children: ReactNode }) { linkModalMode, reopenSettingsAfterLink, trialSetupRequested, + connectOutcome, ], ); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.test.tsx b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx new file mode 100644 index 0000000000..00cd34d643 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; + +/** + * The gate answers two questions, and conflating them is the failure that matters: an instance + * running with the account-link flag off CANNOT link, so gating on link state alone would lock + * Pipelines, Policies, Users, Sources and Integrations on every default install with no way out. + */ +const { json } = vi.hoisted(() => ({ json: vi.fn() })); +vi.mock("@portal/api/http", () => ({ + apiClient: { local: { json } }, + errorMessage: (e: unknown) => String(e), +})); + +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +function Probe() { + const { gated, loading, available } = useConnectGate(); + return ( + + {loading + ? "loading" + : `${available ? "available" : "unavailable"}:${gated ? "gated" : "open"}`} + + ); +} + +function renderProbe(linkState: LinkState) { + return render( + + + + + + + , + ); +} + +const settled = async (expected: string) => + waitFor(() => expect(screen.getByTestId("state").textContent).toBe(expected)); + +describe("useConnectGate", () => { + beforeEach(() => json.mockReset()); + + it("gates an unlinked instance that can link", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + await settled("available:gated"); + }); + + it("does not gate when linking is unavailable, whatever the link state", async () => { + json.mockResolvedValue({ accountLinkAvailable: false }); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate a linked instance", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("linked-free"); + await settled("available:open"); + }); + + it("treats a missing flag as unavailable rather than gating on a guess", async () => { + json.mockResolvedValue({}); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate while the capability is still unknown", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + expect(screen.getByTestId("state").textContent).toBe("loading"); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.ts b/frontend/editor/src/portal/hooks/useConnectGate.ts new file mode 100644 index 0000000000..253ee26212 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@portal/api/http"; +import { qk } from "@portal/queries/keys"; +import { useLinkOptional } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +interface AppConfigShape { + accountLinkAvailable?: boolean; +} + +interface ConnectGate { + /** Can link but has not, so gated features must ask first. */ + gated: boolean; + /** Capability still unknown; hold the decision rather than flash a gate. */ + loading: boolean; + /** Whether linking is possible here at all, i.e. the feature flag is on. */ + available: boolean; + connect: () => void; + /** Wraps a create or edit handler so the click asks for a connection instead. */ + guard: ( + action: (...args: A) => void, + ) => (...args: A) => void; +} + +/** + * Two facts, not one: linked, and *could* be linked. The account-link endpoints 404 with the flag + * off, which the client cannot tell from "not linked yet", so gating on link state alone would lock + * these features on every default install. + */ +export function useConnectGate(): ConnectGate { + // Optional: the SaaS portal mounts no LinkProvider, and no provider means nothing to gate. + const link = useLinkOptional(); + const { openLinkModal } = useUI(); + const devBypass = useDevConnectBypass(); + + const query = useQuery({ + queryKey: qk.appConfig(), + queryFn: () => + apiClient.local.json("/api/v1/config/app-config"), + }); + + const available = Boolean(query.data?.accountLinkAvailable) && link != null; + const loading = query.isPending; + const gated = available && !link?.isLinked && !devBypass; + + const connect = useCallback(() => openLinkModal(), [openLinkModal]); + + const guard = useCallback( + (action: (...args: A) => void) => + (...args: A) => { + if (gated) { + openLinkModal(); + return; + } + action(...args); + }, + [gated, openLinkModal], + ); + + return { gated, loading, available, connect, guard }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectHandoff.ts b/frontend/editor/src/portal/hooks/useConnectHandoff.ts new file mode 100644 index 0000000000..1349086085 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectHandoff.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { withBasePath } from "@app/constants/app"; +import { startConnect, startReauth } from "@portal/api/link"; + +interface ConnectHandoff { + /** Stays true through a successful hand-off: the page is leaving, so nothing resolves. */ + busy: boolean; + error: string | null; + begin: () => void; +} + +export function useConnectHandoff(reauth: boolean): ConnectHandoff { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Back from Stirling can restore this page with its heap intact, leaving busy stuck on and the + // dialog pinned to the ghost step. Being shown at all means we are not mid-navigation. + const shown = () => setBusy(false); + window.addEventListener("pageshow", shown); + return () => window.removeEventListener("pageshow", shown); + }, []); + + const begin = useCallback(() => { + setBusy(true); + setError(null); + void (async () => { + try { + // Stated, not inferred: only the frontend knows its own base path. + const callbackUrl = new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(); + const status = reauth + ? await startReauth(callbackUrl) + : await startConnect(window.location.hostname, callbackUrl); + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + return; + } + // Already linked, or a handshake we cannot act on. Nothing to navigate to. + setError( + t( + "portal.accountLink.modal.noAuthorizeUrl", + "Stirling did not return somewhere to continue. Try again in a moment.", + ), + ); + setBusy(false); + } catch { + setError( + t( + "portal.accountLink.modal.startFailed", + "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", + ), + ); + setBusy(false); + } + })(); + }, [reauth, t]); + + return { busy, error, begin }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx new file mode 100644 index 0000000000..dc54fce35c --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +/** + * The cadence decision: dismissible, but it always comes back. Persisting "seen" would end the ask + * after one dismissal, so the marker has to be session scoped and has to be written when the prompt + * opens rather than when it closes, or an admin who ignores the dialog gets it again on every + * re-render. + */ +const { connect, gate } = vi.hoisted(() => ({ + connect: vi.fn(), + gate: { gated: true, loading: false, available: true }, +})); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); + +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; + +function Probe() { + useConnectPrompt(); + return null; +} + +describe("useConnectPrompt", () => { + beforeEach(() => { + connect.mockReset(); + sessionStorage.clear(); + gate.gated = true; + gate.loading = false; + }); + + it("opens the flow once while unlinked", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("does not open again in the same session", async () => { + const { unmount } = render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + unmount(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("asks again in a fresh session", async () => { + render().unmount(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + sessionStorage.clear(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(2)); + }); + + it("never persists beyond the session", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + expect(localStorage.length).toBe(0); + }); + + it("stays quiet when the instance is not gated", async () => { + gate.gated = false; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); + + it("waits for the capability rather than prompting on an unknown", async () => { + gate.loading = true; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.ts b/frontend/editor/src/portal/hooks/useConnectPrompt.ts new file mode 100644 index 0000000000..0a751b5c86 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +const PROMPTED_KEY = "portal::connect-prompted"; + +function alreadyPrompted(): boolean { + try { + return sessionStorage.getItem(PROMPTED_KEY) === "true"; + } catch { + return false; + } +} + +function markPrompted(): void { + try { + sessionStorage.setItem(PROMPTED_KEY, "true"); + } catch { + // Prompting again later is the harmless direction. + } +} + +/** + * Session storage, not the onboarding localStorage helpers: one dismissal should not end the ask + * for good, and asking once per visit needs no timer to tune. + */ +export function useConnectPrompt(): void { + const { gated, loading, connect } = useConnectGate(); + const fired = useRef(false); + + useEffect(() => { + if (loading || !gated || fired.current || alreadyPrompted()) return; + fired.current = true; + // Marked on open, not on close, so a session gets one whatever the admin does with it. + markPrompted(); + connect(); + }, [gated, loading, connect]); +} diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx new file mode 100644 index 0000000000..f4d28f5515 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; + +/** + * The gate is the only thing making these features need a link, so a switch a customer could reach + * would hand them over. The last test is the one that matters: with DEV folded off, the parameter + * does nothing at all. + */ +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +function Probe() { + return {String(useDevConnectBypass())}; +} + +/** Mounts fresh at the given URL, so a second call models a later visit rather than a re-render. */ +const at = (search: string) => { + cleanup(); + window.history.replaceState({}, "", `/processor${search}`); + render(); + return screen.getByTestId("bypass").textContent; +}; + +describe("useDevConnectBypass", () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => { + vi.unstubAllEnvs(); + window.history.replaceState({}, "", "/"); + }); + + it("is off by default, so dev still sees what customers see", () => { + expect(at("")).toBe("false"); + }); + + it("turns on with the parameter", () => { + expect(at("?bypassConnect=true")).toBe("true"); + }); + + it("survives the navigation the gate itself performs", () => { + at("?bypassConnect=true"); + expect(at("")).toBe("true"); + }); + + it("ignores any other value", () => { + expect(at("?bypassConnect=1")).toBe("false"); + }); + + it("does nothing in a build, which is what ships to customers", () => { + vi.stubEnv("DEV", false); + expect(at("?bypassConnect=true")).toBe("false"); + // And nothing was left behind for a later dev session to pick up. + expect(sessionStorage.getItem("accountLink::dev-bypass")).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.ts b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts new file mode 100644 index 0000000000..b201ac7d80 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +const PARAM = "bypassConnect"; +const SESSION_KEY = "accountLink::dev-bypass"; + +function stored(): boolean { + try { + return sessionStorage.getItem(SESSION_KEY) === "true"; + } catch { + return false; + } +} + +/** + * Dev-only escape from the connect gate, fenced behind {@code import.meta.env.DEV} so Vite folds + * the branch away entirely: in a shipped build there is no param and no key that does anything. + * It cannot be a setting — the gate is the only thing making these features need a link, so any + * switch a customer could reach would hand them over. + */ +export function useDevConnectBypass(): boolean { + // Session-scoped so it survives the navigation the gate itself performs. + const [bypassed, setBypassed] = useState( + () => import.meta.env.DEV && stored(), + ); + + useEffect(() => { + if (!import.meta.env.DEV) return; + const params = new URLSearchParams(window.location.search); + if (params.get(PARAM) !== "true") return; + try { + sessionStorage.setItem(SESSION_KEY, "true"); + } catch { + // Still bypassed for this render. + } + setBypassed(true); + }, []); + + return bypassed; +} diff --git a/frontend/editor/src/portal/test/TestQueryProvider.tsx b/frontend/editor/src/portal/test/TestQueryProvider.tsx index d843d816d3..1d3587c858 100644 --- a/frontend/editor/src/portal/test/TestQueryProvider.tsx +++ b/frontend/editor/src/portal/test/TestQueryProvider.tsx @@ -1,6 +1,8 @@ import { useState, type ReactNode } from "react"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; /** * Wraps a portal component under test in a fresh QueryClient (retries off for @@ -27,3 +29,20 @@ export function PortalTestProviders({ children }: { children: ReactNode }) { ); } + +/** {@link PortalTestProviders} plus the contexts the connect gate reads. Unlinked by default. */ +export function PortalViewProviders({ + children, + linkState = "unlinked", +}: { + children: ReactNode; + linkState?: LinkState; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.css b/frontend/editor/src/portal/views/ConnectCallback.css index 35e9b09ef7..99c0539658 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.css +++ b/frontend/editor/src/portal/views/ConnectCallback.css @@ -1,24 +1,13 @@ -/* Account-link callback. A transient page the admin passes through, so it is - centred and says one thing rather than trying to be a settings screen. */ +/* Step 3 of the connect flow: what the round trip to Stirling came back with. + + Was a standalone page, centred with its own margins and max-width. It is now a + step body inside FlowModal, which already owns the dialog's width, padding and + spacing, so this only stacks its own blocks. */ .portal-connect-callback { display: flex; flex-direction: column; - align-items: center; - gap: 1rem; - max-width: 30rem; - margin: 4rem auto; - padding: 0 1rem; - text-align: center; -} - -.portal-connect-callback > * { - width: 100%; -} - -/* The button is the one thing that should not stretch to the banner's width. */ -.portal-connect-callback button { - width: auto; + gap: 0.75rem; } .portal-connect-callback p { @@ -30,3 +19,11 @@ .portal-connect-callback__note { font-size: 0.8125rem; } + +/* Only "working" is centred: a spinner with a line under it, which has nothing to + align against. */ +.portal-connect-callback--working { + align-items: center; + gap: 0.625rem; + padding: 1.5rem 0; +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.test.tsx b/frontend/editor/src/portal/views/ConnectCallback.test.tsx index bdadee03e4..b934e11cd7 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.test.tsx +++ b/frontend/editor/src/portal/views/ConnectCallback.test.tsx @@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; +import { UIProvider, useUI } from "@portal/contexts/UIContext"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; -/** - * The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other. - */ +/** A live session token rides in the fragment: strip it at once, refuse what cannot be verified. */ const { completeConnect, startConnect, setSession, refresh } = vi.hoisted( () => ({ completeConnect: vi.fn(), @@ -32,19 +32,38 @@ function landOn(fragment: string) { window.history.replaceState(null, "", `/account-link/callback${fragment}`); } -/** - * Route and host together: the route reads the fragment, the portal renders the - * outcome. Exercising them apart would test the hand-off rather than the flow. - */ +/** Stands in for the dialog that consumes the outcome. */ +let published: ConnectOutcome[] = []; + +function OutcomeSpy() { + const { connectOutcome } = useUI(); + if ( + connectOutcome && + published[published.length - 1]?.state !== connectOutcome.state + ) { + published.push(connectOutcome); + } + return null; +} + +const lastOutcome = () => published[published.length - 1]; + +/** Route and host together: apart, this would test the hand-off rather than the flow. */ function renderFlow() { return render( - - - } /> - } /> - + + + + + } + /> + } /> + + , ); @@ -53,6 +72,7 @@ function renderFlow() { describe("account-link callback", () => { beforeEach(() => { vi.clearAllMocks(); + published = []; completeConnect.mockResolvedValue({ phase: "LINKED", authorizeUrl: null, @@ -67,8 +87,7 @@ describe("account-link callback", () => { renderFlow(); - // Synchronous, before any await: the fragment must not survive long enough - // to be read from the address bar or land in a history entry. + // Before any await: the fragment must not reach the address bar or a history entry. expect(window.location.hash).toBe(""); await waitFor(() => expect(completeConnect).toHaveBeenCalled()); }); @@ -109,8 +128,7 @@ describe("account-link callback", () => { renderFlow(); - // The two outcomes are independent: a failed sign-in must not strand the - // server unlinked. + // Independent outcomes: a failed sign-in must not strand the server unlinked. await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); }); @@ -131,6 +149,18 @@ describe("account-link callback", () => { await waitFor(() => expect(window.location.hash).toBe("")); expect(completeConnect).not.toHaveBeenCalled(); expect(setSession).not.toHaveBeenCalled(); + await waitFor(() => expect(lastOutcome()?.state).toBe("malformed")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); + + it("hands the result to the dialog rather than rendering its own", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + const { container } = renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("linked")); + expect(lastOutcome()?.sessionRestored).toBe(true); + expect(container.querySelector(".portal-connect-callback")).toBeNull(); }); it("refuses a fragment that is not a link response", async () => { @@ -160,16 +190,30 @@ describe("account-link callback", () => { teamId: null, }); - const { getAllByRole } = renderFlow(); + renderFlow(); await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1)); - // Last button, not the only one: the modal shell contributes a close button. - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); + await waitFor(() => expect(lastOutcome()?.state).toBe("retry")); - // Retries the existing handshake; starting a new one would waste the - // approval a human just gave. + act(() => lastOutcome()!.reclaim!()); + + // Re-claims rather than opening a new handshake, which would spend a leader's approval. await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2)); expect(startConnect).not.toHaveBeenCalled(); }); + + it("gives a spent handshake no re-claim, so the dialog asks for a new one", async () => { + landOn(`#type=link&nonce=${NONCE}`); + completeConnect.mockResolvedValue({ + phase: "EXPIRED", + authorizeUrl: null, + secondsRemaining: null, + teamId: null, + }); + + renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("expired")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); }); diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index 8c4e684bfa..acf1f1b1b1 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -13,6 +13,16 @@ import type { IntegrationConfig } from "@portal/api/integrations"; const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: MantineProvider }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 793c21f880..fc40feb1e9 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -31,6 +31,7 @@ import { } from "@portal/components/sources/connectionTypes"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { COMING_SOON_SOURCE_TYPES } from "@portal/components/sources/sourceTypes"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/theme/surface.css"; import "@portal/views/Integrations.css"; @@ -94,6 +95,7 @@ type IntegrationRow = { export function Integrations() { const { t } = useTranslation(); + const { guard } = useConnectGate(); const [connections, setConnections] = useState( null, ); @@ -212,13 +214,23 @@ export function Integrations() { return counts; }, [catalogue]); - const openCreate = useCallback((typeId: string) => { - setModal({ open: true, editing: null, fixedTypeId: typeId }); - }, []); + // Connecting an integration and editing one both need a linked account. Memoised + // because both land in the row-building useMemo deps below. + const openCreate = useMemo( + () => + guard((typeId: string) => { + setModal({ open: true, editing: null, fixedTypeId: typeId }); + }), + [guard], + ); - const openEdit = useCallback((connection: IntegrationConfig) => { - setModal({ open: true, editing: connection }); - }, []); + const openEdit = useMemo( + () => + guard((connection: IntegrationConfig) => { + setModal({ open: true, editing: connection }); + }), + [guard], + ); const remove = useCallback( async (connection: IntegrationConfig) => { diff --git a/frontend/editor/src/portal/views/Pipelines.gated.test.tsx b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx new file mode 100644 index 0000000000..993edc876b --- /dev/null +++ b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The page must look exactly as it always does: the ask is a dialog on the attempt, not a lock + * screen in place of the feature. The route half is what a later link cannot walk around. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: true, + loading: false, + available: true, + connect, + guard: + (_action: (...args: A) => void) => + () => + connect(), + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchPipelines = vi.fn(); +vi.mock("@portal/api/pipelines", () => ({ + fetchPipelines: () => fetchPipelines(), +})); + +import { Pipelines } from "@portal/views/Pipelines"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; + +const PIPELINE = { + id: "plc-1", + name: "Redact claims", + enabled: true, + status: "active", + trigger: "schedule", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["/api/v1/security/auto-redact"], + output: "inline", + owner: "security@acme.com", +}; + +function renderAt(initial: string) { + return render( + + + + } /> + +
builder
+ + } + /> +
+
+
, + ); +} + +describe("Pipelines when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + fetchPipelines.mockReset(); + }); + + it("leaves the empty state exactly as it is", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + }); + + it("still lists pipelines that already exist", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + expect(await screen.findByText("Redact claims")).toBeInTheDocument(); + }); + + it("asks to connect instead of opening the builder", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + await screen.findByText("portal.pipelines.empty.title"); + fireEvent.click(screen.getByText("portal.pipelines.actions.newPipeline")); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText("builder")).toBeNull(); + }); + + it("asks to connect instead of opening an existing pipeline", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + fireEvent.click(await screen.findByText("Redact claims")); + expect(connect).toHaveBeenCalled(); + }); + + it("turns away a direct arrival at the builder, however it was reached", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines/new"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + expect(screen.queryByText("builder")).toBeNull(); + expect(connect).toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 655abf62cb..250722706e 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -14,6 +14,16 @@ const render = ( options?: Parameters[1], ) => baseRender(ui, { wrapper: PortalTestProviders, ...options }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index f2203de972..6311e3c438 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -9,11 +9,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { PipelinesIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Pipelines.css"; export function Pipelines() { const { t } = useTranslation(); const navigate = useNavigate(); + const { guard } = useConnectGate(); const state = usePipelines(); const { data, loading } = state; const { isLoading } = useSectionFlags(state); @@ -26,13 +28,18 @@ export function Pipelines() { // the loading and empty states don't flash a row of placeholder cards. const hasPipelines = pipelines.length > 0; - const openCreate = () => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); - const connectSource = () => - navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); + // Building and editing a pipeline both need a linked account, so both ask for one first. + const openCreate = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`), + ); + // Guarded in its own right so the ask happens here rather than after a pointless hop to Sources. + const connectSource = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`), + ); // A row opens that pipeline's own page (view / edit / run / delete live there). - const openPipeline = (pipeline: PipelineView) => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); + const openPipeline = guard((pipeline: PipelineView) => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`), + ); return (
diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 9e316852fd..b8cb42112a 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -23,11 +23,13 @@ import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogu import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Policies.css"; export function Policies() { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { gated, connect } = useConnectGate(); const state = usePoliciesOverview(); const { data, loading, error: fetchError } = state; const { isLoading } = useSectionFlags(state); @@ -38,18 +40,27 @@ export function Policies() { const [pageError, setPageError] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + // Held in a ref so the effects below do not re-run on its identity. They write back to the URL, + // so a callback that changes each render would loop: strip the param, re-render, run again. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Deep link from the Home processor flow. It sets the wizard directly rather than going through + // openEntry, so the gate belongs here too: guarding openEntry alone would leave ?setup= as a way + // past it. useEffect(() => { const setupId = searchParams.get("setup"); if (!setupId || !data) return; const entry = data.catalogue.find((e) => e.category.id === setupId); if (entry && !entry.category.comingSoon) { - if (entry.policy) setDetail(entry); + if (gated) connectRef.current(); + else if (entry.policy) setDetail(entry); else setWizard(entry); } const next = new URLSearchParams(searchParams); next.delete("setup"); setSearchParams(next, { replace: true }); - }, [searchParams, data, setSearchParams]); + }, [searchParams, data, setSearchParams, gated]); const { enabled: aiEngineEnabled, loading: aiEngineLoading } = useAiEngineEnabled(); @@ -95,6 +106,13 @@ export function Policies() { const openEntry = useCallback( (entry: CatalogueEntry) => { + // Ask rather than open an editor whose save would fail; viewing the catalogue stays open. + // Via the ref so this keeps its identity: the deep-link effect depends on it and writes the + // URL back, which would otherwise loop. + if (gated) { + connectRef.current(); + return; + } // Block setup of an AI-required policy until the engine is confirmed on (so a // click during the app-config load can't open a wizard for a disabled // feature); a configured policy stays openable so it can be paused/deleted. @@ -103,7 +121,7 @@ export function Policies() { if (entry.policy) setDetail(entry); else setWizard(entry); }, - [aiEngineEnabled], + [aiEngineEnabled, gated], ); // Open a category passed as ?category= (deep link from the super diff --git a/frontend/editor/src/portal/views/Sources.gated.test.tsx b/frontend/editor/src/portal/views/Sources.gated.test.tsx new file mode 100644 index 0000000000..944ab8c37c --- /dev/null +++ b/frontend/editor/src/portal/views/Sources.gated.test.tsx @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The deep link into the create flow, which the gate has to cover in its own right. + * + * `?new=1` opens the modal from an effect rather than through the click handler, so guarding + * openCreate does nothing for it. Both the Documents review queue and the pipelines empty state + * arrive here that way, so each is a way past the gate unless the deep link is guarded too. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchSources = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), + fetchSource: vi.fn(), + createSource: vi.fn(), + deleteSource: vi.fn(), + isFolderAccessDeniedError: () => false, +})); +vi.mock("@portal/api/integrations", () => ({ + fetchIntegrations: () => Promise.resolve([]), + fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), + fetchS3Connections: () => Promise.resolve([]), + deleteIntegration: vi.fn(), +})); + +import { Sources } from "@portal/views/Sources"; + +const EDITOR_ROW = { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, +}; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Sources deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + fetchSources.mockReset(); + fetchSources.mockResolvedValue({ kpis: [], sources: [EDITOR_ROW] }); + }); + + // Renders only once the fetch resolves, so finding it is also the await. + const LIST = "portal.sources.table.source"; + const MODAL = "portal.sources.builder.createTitle"; + + it("asks to connect instead of opening the create modal", async () => { + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(MODAL)).toBeNull(); + }); + + it("leaves the page looking exactly as it always does", async () => { + renderAt("/processor/sources"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", async () => { + gate.gated = false; + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + expect(await screen.findByText(MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 166d4016fc..3891f1955d 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -22,6 +22,16 @@ const Providers = ({ children }: { children: ReactNode }) => ( const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: Providers }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index f676b4f3db..bb740f5d69 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useSearchParams } from "react-router-dom"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; @@ -10,11 +10,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; import { SourceModal } from "@portal/components/sources/SourceModal"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); + const { guard, gated, connect } = useConnectGate(); const state = useSources(); const { data, loading } = state; @@ -27,13 +29,20 @@ export function Sources() { sourceId: string | null; }>({ open: false, sourceId: null }); + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right: guarding openCreate would + // leave ?new=1 as a way past it. useEffect(() => { if (searchParams.get("new") !== "1") return; - setModal({ open: true, sourceId: null }); + if (gated) connectRef.current(); + else setModal({ open: true, sourceId: null }); const next = new URLSearchParams(searchParams); next.delete("new"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); const sources = data?.sources ?? []; @@ -42,9 +51,11 @@ export function Sources() { const configuredCount = sources.filter((s) => s.type !== "editor").length; const showKpis = isLoading || configuredCount > 0; - const openCreate = () => setModal({ open: true, sourceId: null }); - const openSource = (source: SourceView) => - setModal({ open: true, sourceId: source.id }); + // Connecting a source and editing one both need a linked account. + const openCreate = guard(() => setModal({ open: true, sourceId: null })); + const openSource = guard((source: SourceView) => + setModal({ open: true, sourceId: source.id }), + ); // The Connections tab moved to its own Integrations view. if (searchParams.get("tab") === "connections") { diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx index a619d883b7..1a9e3e28c6 100644 --- a/frontend/editor/src/portal/views/Users.caching.test.tsx +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -29,6 +29,16 @@ import { qk } from "@portal/queries/keys"; * one /team/my resolve. Same SaaS mocks as Users.saas.test.tsx. */ +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.gated.test.tsx b/frontend/editor/src/portal/views/Users.gated.test.tsx new file mode 100644 index 0000000000..b5286964fc --- /dev/null +++ b/frontend/editor/src/portal/views/Users.gated.test.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * `?invite` opens the modal from an effect, so guarding openInvite did nothing for it. Same hole as + * Sources' `?new=1`, and it survived that fix. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: () => ({ tier: "pro" }), +})); +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +vi.mock("@portal/hooks/useUsersData", () => ({ + useUsersData: () => ({ + usersState: { data: [], loading: false, error: null }, + grantsState: { data: [], loading: false, error: null }, + teamsState: { data: [], loading: false, error: null }, + authState: { data: null, loading: false, error: null }, + refresh: vi.fn(), + }), +})); + +import { Users } from "@portal/views/Users"; + +const INVITE_MODAL = "users.invite.title"; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Users deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + }); + + it("asks to connect instead of opening the invite modal", () => { + renderAt("/processor/users?invite"); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(INVITE_MODAL)).toBeNull(); + }); + + it("leaves the page alone when there is no deep link", () => { + renderAt("/processor/users"); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", () => { + gate.gated = false; + renderAt("/processor/users?invite"); + expect(connect).not.toHaveBeenCalled(); + expect(screen.getByText(INVITE_MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Users.saas.test.tsx b/frontend/editor/src/portal/views/Users.saas.test.tsx index 5313857c2c..1ea72e5a10 100644 --- a/frontend/editor/src/portal/views/Users.saas.test.tsx +++ b/frontend/editor/src/portal/views/Users.saas.test.tsx @@ -32,6 +32,16 @@ import { */ // Keep apiClient.local's transport hermetic (no real token / Supabase at import). +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 79c6c11611..95b51091f0 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -21,6 +21,7 @@ import { import { deleteTeam as apiDeleteTeam } from "@portal/api/teams"; import { errorMessage } from "@portal/api/http"; import { usersCapabilities as caps } from "@app/portal/usersCapabilities"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { UsersDirectory } from "@portal/components/users/UsersDirectory"; import { PendingInvitations } from "@portal/components/users/PendingInvitations"; import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; @@ -46,6 +47,7 @@ interface Confirm { */ export function Users() { const { t } = useTranslation(); + const { guard, gated, connect } = useConnectGate(); const { usersState, grantsState, teamsState, authState, refresh } = useUsersData(); @@ -62,13 +64,20 @@ export function Users() { const [confirm, setConfirm] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right. useEffect(() => { if (searchParams.get("invite") === null) return; - setInviteOpen(true); + if (gated) connectRef.current(); + else setInviteOpen(true); const next = new URLSearchParams(searchParams); next.delete("invite"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); // Scroll to and flash the row for ?member= (deep link from the super // search), once the roster has rendered; then strip the param. Scoped to the @@ -194,10 +203,12 @@ export function Users() { if (!grant) return; run(() => revokeGrant(grant.id)); } - function openInvite(teamId: number | null) { + // Teams need a linked account, so inviting or creating one asks for the connection first. + const openInvite = guard((teamId: number | null) => { setInviteTeamId(teamId); setInviteOpen(true); - } + }); + const openNewTeam = guard(() => setNewTeamOpen(true)); // Kebab actions function toggleEnabled(member: Member) { @@ -282,11 +293,7 @@ export function Users() {
{caps.createTeam && ( - )} diff --git a/frontend/editor/src/saas/routes/ConnectApproveView.tsx b/frontend/editor/src/saas/routes/ConnectApproveView.tsx index 26a2fe1150..c89a27ef49 100644 --- a/frontend/editor/src/saas/routes/ConnectApproveView.tsx +++ b/frontend/editor/src/saas/routes/ConnectApproveView.tsx @@ -1,8 +1,51 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "@app/hooks/useTranslation"; import { Banner, Button, Checkbox, Spinner } from "@app/ui"; import { LocalIcon } from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; + +/** + * This page is step 2 of a flow that started on the instance, so it wears the same chrome: the admin + * is being asked for a security decision by what would otherwise look like a different product. + * + *

TODO: re-auth still wears the first link's copy and consent checkbox, which asks the approver + * to agree to a binding that already exists. + */ +const TOTAL_STEPS = 3; + +function ApproveShell({ + title, + stepped, + children, +}: { + title: string; + /** Re-auth is one step on the instance side, so counting to three here would describe nothing. */ + stepped: boolean; + children: ReactNode; +}) { + const { t } = useTranslation(); + return ( +

+ {/* No onClose: this is a page, so there is nowhere to close back to. */} + + {children} +
+ ); +} export type ApprovePhase = | "loading" @@ -16,6 +59,8 @@ export interface PendingConnect { requestId: string; callbackOrigin: string; insecureTransport: boolean; + /** REAUTH cannot rebind: the team is pinned from the device credential at request time. */ + mode?: "LINK" | "REAUTH"; } export interface ConnectApproveViewProps { @@ -44,23 +89,31 @@ export function ConnectApproveView({ // Gates the primary action: anyone can create a request, so the approver reading // the address is the only thing between one and a linked team. const [acknowledged, setAcknowledged] = useState(false); + const stepped = pending?.mode !== "REAUTH"; if (phase === "loading" || phase === "redirecting") { return ( -
- -

- {phase === "redirecting" + -

+ : t("connect.loading", "Checking this request.") + } + > +
+ +
+ ); } if (phase === "notFound") { return ( -
+ -
+ ); } if (phase === "declined") { return ( -
+ -
+ ); } return ( -
-

- {t("connect.confirm.title", "Connect this server?")} -

+

{t( "connect.confirm.lead", @@ -177,6 +233,6 @@ export function ConnectApproveView({ {t("connect.confirm.approve", "Connect server")}

-
+ ); } diff --git a/frontend/editor/src/saas/routes/connect.css b/frontend/editor/src/saas/routes/connect.css index 5a709abfbe..391f9dad28 100644 --- a/frontend/editor/src/saas/routes/connect.css +++ b/frontend/editor/src/saas/routes/connect.css @@ -8,11 +8,10 @@ text-align: left; } -.saas-connect__title { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--c-text); +.saas-connect__waiting { + display: flex; + justify-content: center; + padding: 1.5rem 0; } .saas-connect__lead {