mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Connect flow for self-hosted account linking, and the triggers that drive it (#7415)
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 <img width="1580" height="422" alt="image" src="https://github.com/user-attachments/assets/719e12fc-121a-4caa-bc72-124c5167b011" /> ## 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. <img width="817" height="503" alt="image" src="https://github.com/user-attachments/assets/4644ddd2-6181-44e1-9be9-7a961972195d" /> 2. **Sign in** — the existing `SupabaseLoginForm`, reseated. <img width="880" height="930" alt="image" src="https://github.com/user-attachments/assets/fc66cbbb-9f98-40a4-9daa-4f2447713f39" /> 3. **Connected** — confirms, then deep links into Users, Pipelines and Policies. <img width="876" height="752" alt="image" src="https://github.com/user-attachments/assets/28358e4d-a44f-4118-a8ae-8275984ebd00" /> 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`.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<LinkAccountModal
|
||||
open={linkModalOpen}
|
||||
open
|
||||
mode={linkModalMode}
|
||||
onClose={closeLinkModal}
|
||||
outcome={connectOutcome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route index element={<Home />} />
|
||||
<Route path={rel(VIEW_PATHS.users)} element={<Users />} />
|
||||
<Route path={rel(VIEW_PATHS.pipelines)} element={<Pipelines />} />
|
||||
{/* 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. */}
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.pipelines)}/new`}
|
||||
element={<PipelineBuilder />}
|
||||
element={
|
||||
<ConnectGuardedRoute fallback={toPortalPath(VIEW_PATHS.pipelines)}>
|
||||
<PipelineBuilder />
|
||||
</ConnectGuardedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.pipelines)}/:id`}
|
||||
element={<PipelineBuilder />}
|
||||
element={
|
||||
<ConnectGuardedRoute fallback={toPortalPath(VIEW_PATHS.pipelines)}>
|
||||
<PipelineBuilder />
|
||||
</ConnectGuardedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
|
||||
{/* Source create/edit is a modal on the list now; old deep links land there. */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) => (
|
||||
<LinkProvider initialState={state}>
|
||||
<Story />
|
||||
</LinkProvider>
|
||||
),
|
||||
];
|
||||
|
||||
const meta: Meta<typeof ConnectAccountRail> = {
|
||||
title: "Portal/AccountLink/ConnectAccountRail",
|
||||
component: ConnectAccountRail,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConnectAccountRail>;
|
||||
|
||||
/** 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"),
|
||||
};
|
||||
@@ -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 (
|
||||
<section className="portal-connect-rail">
|
||||
<div className="portal-connect-rail__text">
|
||||
<b className="portal-connect-rail__title">
|
||||
{t("portal.accountLink.rail.title", "Connect your Stirling account")}
|
||||
</b>
|
||||
<span className="portal-connect-rail__sub">
|
||||
{t(
|
||||
"portal.accountLink.rail.sub",
|
||||
"Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-connect-rail__actions">
|
||||
<Button variant="quiet" accent="neutral" onClick={dismiss}>
|
||||
{t("portal.accountLink.rail.later", "Not now")}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={connect}>
|
||||
{t("portal.accountLink.rail.cta", "Connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<ConnectAccountRail />
|
||||
{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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ConnectCallbackState | null>(null);
|
||||
const [sessionRestored, setSessionRestored] = useState(false);
|
||||
const nonceRef = useRef<string | null>(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 (
|
||||
<Modal
|
||||
open
|
||||
onClose={done}
|
||||
width="md"
|
||||
title={t(
|
||||
"portal.accountLink.connect.callback.modalTitle",
|
||||
"Connecting this server",
|
||||
)}
|
||||
>
|
||||
<ConnectCallbackView
|
||||
state={state}
|
||||
sessionRestored={sessionRestored}
|
||||
onRetry={onRetry}
|
||||
onDone={done}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
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) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="portal-connect-callback">
|
||||
<div className="portal-connect-callback portal-connect-callback--working">
|
||||
<Spinner size="md" />
|
||||
<p>
|
||||
{t(
|
||||
@@ -44,21 +59,14 @@ export function ConnectCallbackView({
|
||||
if (state === "linked") {
|
||||
return (
|
||||
<div className="portal-connect-callback">
|
||||
<Banner
|
||||
tone="success"
|
||||
title={t(
|
||||
"portal.accountLink.connect.callback.linked.title",
|
||||
"Server connected",
|
||||
)}
|
||||
>
|
||||
<p className="portal-connect__lede">
|
||||
{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.",
|
||||
)}
|
||||
</Banner>
|
||||
{/* 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. */}
|
||||
</p>
|
||||
{/* Link took, sign-in did not: otherwise this resurfaces later as "session expired" with
|
||||
nothing tying it back here. */}
|
||||
{sessionRestored ? null : (
|
||||
<p className="portal-connect-callback__note">
|
||||
{t(
|
||||
@@ -67,22 +75,19 @@ export function ConnectCallbackView({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="primary" onClick={onDone}>
|
||||
{t("portal.accountLink.connect.callback.continue", "Continue")}
|
||||
</Button>
|
||||
<ConnectDoneSlide onNavigate={onDone} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { tone, title, body, retryable } = failure(state, t);
|
||||
const { tone, title, body } = failure(state, t);
|
||||
return (
|
||||
<div className="portal-connect-callback">
|
||||
<Banner tone={tone} title={title}>
|
||||
{body}
|
||||
</Banner>
|
||||
{/* 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 ? (
|
||||
<p className="portal-connect-callback__note">
|
||||
{t(
|
||||
@@ -91,11 +96,6 @@ export function ConnectCallbackView({
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button variant="primary" onClick={retryable ? onRetry : onDone}>
|
||||
{retryable
|
||||
? t("portal.accountLink.connect.callback.retry", "Try again")
|
||||
: t("portal.accountLink.connect.callback.continue", "Continue")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <Navigate to={fallback} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
<MantineProvider>
|
||||
<LinkAccountModal open onClose={() => {}} mode={mode} />
|
||||
</MantineProvider>,
|
||||
<PortalTestProviders>
|
||||
<MemoryRouter>
|
||||
<LinkAccountModal
|
||||
open
|
||||
onClose={() => {}}
|
||||
mode={mode}
|
||||
outcome={outcome}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Modal
|
||||
<FlowModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="md"
|
||||
title={
|
||||
reauth
|
||||
? t("portal.accountLink.modal.reauthTitle", "Sign in again")
|
||||
: t(
|
||||
"portal.accountLink.modal.linkTitle",
|
||||
"Connect your Stirling account",
|
||||
)
|
||||
}
|
||||
subtitle={
|
||||
reauth
|
||||
? t(
|
||||
"portal.accountLink.modal.reauthSubtitle",
|
||||
"Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way.",
|
||||
)
|
||||
: t(
|
||||
"portal.accountLink.modal.linkSubtitle",
|
||||
"Connect this server to the Stirling account it should bill against.",
|
||||
)
|
||||
}
|
||||
label={title}
|
||||
footer={stepFooter()}
|
||||
>
|
||||
<div className="portal-link__modal-body">
|
||||
<ol className="portal-link__steps">
|
||||
<li>
|
||||
{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.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"portal.accountLink.modal.step2",
|
||||
"You check this server's address and approve it. A team owner has to do this the first time.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"portal.accountLink.modal.step3",
|
||||
"Stirling brings you straight back here and finishes up.",
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{!isSaasSupabaseConfigured && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t(
|
||||
"portal.accountLink.modal.loginNotConfigured.title",
|
||||
"Stirling connection not configured",
|
||||
)}
|
||||
>
|
||||
{t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SUPABASE_URL</code>{" "}
|
||||
{t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "}
|
||||
<code>VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>{" "}
|
||||
{t(
|
||||
"portal.accountLink.modal.loginNotConfigured.after",
|
||||
"so this server can finish the connection when you come back.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{error && <Banner tone="danger">{error}</Banner>}
|
||||
|
||||
<div className="portal-link__modal-actions">
|
||||
<Button variant="secondary" disabled={busy} onClick={onClose}>
|
||||
{t("portal.accountLink.modal.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" loading={busy} onClick={() => void begin()}>
|
||||
{reauth
|
||||
? t("portal.accountLink.modal.continueReauth", "Sign in again")
|
||||
: t(
|
||||
"portal.accountLink.modal.continueLink",
|
||||
"Continue to Stirling",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<StepModalHeader
|
||||
brand
|
||||
title={title}
|
||||
{...stepChrome}
|
||||
closeLabel={t("portal.accountLink.connect.close", "Close")}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{stepBody()}
|
||||
</FlowModal>
|
||||
);
|
||||
|
||||
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 <ConnectAskStep reauth={reauth} error={handoff.error} />;
|
||||
case "handoff":
|
||||
return <ConnectHandoffGhost />;
|
||||
case "outcome":
|
||||
return outcome ? (
|
||||
<ConnectCallbackView
|
||||
state={outcome.state}
|
||||
sessionRestored={outcome.sessionRestored}
|
||||
onDone={onClose}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeButton() {
|
||||
return (
|
||||
<Button variant="quiet" accent="neutral" onClick={onClose}>
|
||||
{t("portal.accountLink.connect.close", "Close")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function retryButton(onRetry: () => void) {
|
||||
return (
|
||||
<Button variant="primary" onClick={onRetry}>
|
||||
{t("portal.accountLink.connect.callback.retry", "Try again")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Button variant="quiet" accent="neutral" onClick={onClose}>
|
||||
{dismiss}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handoff.begin}>
|
||||
{start}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The request is out and the browser is leaving; Close so a stall is not a dead end.
|
||||
if (step === "handoff") {
|
||||
return (
|
||||
<>
|
||||
<span />
|
||||
{closeButton()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// A retry over a call that has not answered is how you get two handshakes.
|
||||
if (outcome?.state === "working") {
|
||||
return (
|
||||
<>
|
||||
<span />
|
||||
{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 (
|
||||
<>
|
||||
<span />
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t("portal.accountLink.connect.done.cta", "Done")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Banner
|
||||
tone="info"
|
||||
title={
|
||||
feature
|
||||
? t(
|
||||
"portal.accountLink.gate.titleFeature",
|
||||
"Link to unlock {{feature}}",
|
||||
{
|
||||
feature,
|
||||
},
|
||||
)
|
||||
: t("portal.accountLink.gate.title", "Link to unlock")
|
||||
}
|
||||
description={t(
|
||||
"portal.accountLink.gate.description",
|
||||
"Link this org's Stirling account to use billable features.",
|
||||
)}
|
||||
action={
|
||||
<Button size="sm" onClick={() => openLinkModal()}>
|
||||
{t("portal.accountLink.gate.action", "Link account")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<p className="portal-connect__lede">
|
||||
{t(
|
||||
"portal.accountLink.connect.handoff.reauthLede",
|
||||
"Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way.",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<ConnectBenefitsSlide />
|
||||
)}
|
||||
|
||||
{!isSaasSupabaseConfigured && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t(
|
||||
"portal.accountLink.modal.loginNotConfigured.title",
|
||||
"Stirling connection not configured",
|
||||
)}
|
||||
>
|
||||
{t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SUPABASE_URL</code>{" "}
|
||||
{t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "}
|
||||
<code>VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>{" "}
|
||||
{t(
|
||||
"portal.accountLink.modal.loginNotConfigured.after",
|
||||
"so this server can finish the connection when you come back.",
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{error && <Banner tone="danger">{error}</Banner>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide";
|
||||
|
||||
const meta: Meta<typeof ConnectBenefitsSlide> = {
|
||||
title: "Portal/AccountLink/Connect/BenefitsSlide",
|
||||
component: ConnectBenefitsSlide,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConnectBenefitsSlide>;
|
||||
|
||||
/** Step 1 of the Connect flow: the case for linking. */
|
||||
export const Default: Story = {};
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 (
|
||||
<dl className="portal-connect__list">
|
||||
{unlocks.map((unlock) => (
|
||||
<div className="portal-connect__row" key={unlock.key}>
|
||||
<dt className="portal-connect__row-label">{unlock.label}</dt>
|
||||
<dd className="portal-connect__row-detail">{unlock.detail}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<PortalTestProviders>
|
||||
<MemoryRouter>
|
||||
<ConnectDoneSlide onNavigate={() => {}} />
|
||||
</MemoryRouter>
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<svg
|
||||
className="portal-connect__next-chevron"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Wallet | null>(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 && (
|
||||
<div className="portal-connect__meter">
|
||||
{/* No status chip: its tone goes red on an exhausted trial, which on a success screen
|
||||
reads as something having gone wrong. */}
|
||||
<MeterBar
|
||||
{...remainingMeter(wallet.freeRemaining, wallet.freeAllowance)}
|
||||
figure={wallet.freeRemaining.toLocaleString()}
|
||||
capSuffix={t(
|
||||
"portal.accountLink.connect.done.creditsSuffix",
|
||||
"of {{allowance}} free credits left",
|
||||
{ allowance: wallet.freeAllowance.toLocaleString() },
|
||||
)}
|
||||
barLabel={t(
|
||||
"portal.accountLink.connect.done.creditsBarLabel",
|
||||
"Free credits remaining",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="portal-connect__row portal-connect__row--standalone">
|
||||
<span className="portal-connect__row-label">
|
||||
{t("portal.accountLink.connect.done.accountLabel", "Account")}
|
||||
</span>
|
||||
<span className="portal-connect__row-detail">{email}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="portal-connect__next">
|
||||
{nextSteps.map((step) => (
|
||||
<li key={step.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-connect__next-item"
|
||||
onClick={() => go(step.path)}
|
||||
>
|
||||
<span>{step.label}</span>
|
||||
<Chevron />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="portal-connect__ghost">
|
||||
<p className="portal-connect__lede">
|
||||
{t(
|
||||
"portal.accountLink.connect.handoff.going",
|
||||
"Taking you to stirling.com",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="portal-connect__ghost-bars" aria-hidden>
|
||||
<Skeleton height="2.25rem" />
|
||||
<Skeleton height="2.25rem" width="80%" />
|
||||
<Skeleton height="2.25rem" width="55%" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof LinkAccountPrompt> = {
|
||||
title: "Portal/Billing/LinkAccountPrompt",
|
||||
component: LinkAccountPrompt,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LinkAccountPrompt>;
|
||||
|
||||
/** Unlinked billing page — CTA opens the login modal (UIProvider from the preview decorator). */
|
||||
export const Default: Story = {};
|
||||
@@ -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 (
|
||||
<Card padding="loose">
|
||||
<EmptyState
|
||||
size="default"
|
||||
title={t(
|
||||
"portal.billing.linkPrompt.title",
|
||||
"Link your Stirling account",
|
||||
)}
|
||||
description={t(
|
||||
"portal.billing.linkPrompt.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.",
|
||||
)}
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => openLinkModal()}>
|
||||
{t("portal.billing.linkPrompt.cta", "Link Stirling account")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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: () => <div data-testid="link-prompt" />,
|
||||
}));
|
||||
vi.mock("@portal/views/Usage", () => ({
|
||||
Usage: () => <div data-testid="usage" />,
|
||||
Usage: ({ onWalletLoaded }: { onWalletLoaded?: (w: unknown) => void }) => {
|
||||
onWalletLoaded?.({ status: "free" });
|
||||
return <div data-testid="usage" />;
|
||||
},
|
||||
}));
|
||||
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
|
||||
const renderGate = () =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/processor/usage"]}>
|
||||
<Routes>
|
||||
<Route path="/processor/usage" element={<PortalBillingGate />} />
|
||||
<Route path="/processor" element={<div data-testid="home" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(<PortalBillingGate />);
|
||||
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(<PortalBillingGate />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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 <LinkAccountPrompt />;
|
||||
return <Usage onWalletLoaded={onWalletLoaded} onReauth={onReauth} />;
|
||||
return (
|
||||
<ConnectGuardedRoute fallback={toPortalPath(VIEW_PATHS.home)}>
|
||||
<Usage onWalletLoaded={onWalletLoaded} onReauth={onReauth} />
|
||||
</ConnectGuardedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,10 +55,12 @@ export function StepModalHeader({
|
||||
<div className="portal-stepmodal__head-top">
|
||||
{brand ? (
|
||||
<div className="portal-stepmodal__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. */}
|
||||
<img
|
||||
src={wordmarkLight}
|
||||
alt="Stirling"
|
||||
className="portal-stepmodal__wordmark wordmark-light-only"
|
||||
className="portal-stepmodal__wordmark wordmark"
|
||||
/>
|
||||
<img
|
||||
src={wordmarkDark}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface NavEntry {
|
||||
icon: ReactNode;
|
||||
/** When set, the tab opens this URL in a new tab instead of navigating in-app. */
|
||||
externalUrl?: string;
|
||||
/** The whole tab is facts about the linked account, so unlinked is asked rather than navigated. */
|
||||
requiresLink?: boolean;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
@@ -43,6 +45,6 @@ export const GROUP_PLATFORM: NavEntry[] = [
|
||||
{ id: "users", icon: <UsersIcon /> },
|
||||
{ id: "integrations", icon: <IntegrationsIcon /> },
|
||||
{ id: "infrastructure", icon: <InfrastructureIcon /> },
|
||||
{ id: "usage", icon: <UsageIcon /> },
|
||||
{ id: "usage", icon: <UsageIcon />, requiresLink: true },
|
||||
{ id: "docs", icon: <DocsIcon /> },
|
||||
];
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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<ConnectOutcome | null>(
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<span data-testid="state">
|
||||
{loading
|
||||
? "loading"
|
||||
: `${available ? "available" : "unavailable"}:${gated ? "gated" : "open"}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProbe(linkState: LinkState) {
|
||||
return render(
|
||||
<PortalTestProviders>
|
||||
<LinkProvider initialState={linkState}>
|
||||
<UIProvider>
|
||||
<Probe />
|
||||
</UIProvider>
|
||||
</LinkProvider>
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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: <A extends unknown[]>(
|
||||
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<AppConfigShape>("/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(
|
||||
<A extends unknown[]>(action: (...args: A) => void) =>
|
||||
(...args: A) => {
|
||||
if (gated) {
|
||||
openLinkModal();
|
||||
return;
|
||||
}
|
||||
action(...args);
|
||||
},
|
||||
[gated, openLinkModal],
|
||||
);
|
||||
|
||||
return { gated, loading, available, connect, guard };
|
||||
}
|
||||
@@ -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<string | null>(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 };
|
||||
}
|
||||
@@ -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(<Probe />);
|
||||
await waitFor(() => expect(connect).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("does not open again in the same session", async () => {
|
||||
const { unmount } = render(<Probe />);
|
||||
await waitFor(() => expect(connect).toHaveBeenCalledTimes(1));
|
||||
unmount();
|
||||
render(<Probe />);
|
||||
await waitFor(() => expect(connect).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("asks again in a fresh session", async () => {
|
||||
render(<Probe />).unmount();
|
||||
await waitFor(() => expect(connect).toHaveBeenCalledTimes(1));
|
||||
sessionStorage.clear();
|
||||
render(<Probe />);
|
||||
await waitFor(() => expect(connect).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("never persists beyond the session", async () => {
|
||||
render(<Probe />);
|
||||
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(<Probe />);
|
||||
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(<Probe />);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 <span data-testid="bypass">{String(useDevConnectBypass())}</span>;
|
||||
}
|
||||
|
||||
/** 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(<Probe />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }) {
|
||||
</TestQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** {@link PortalTestProviders} plus the contexts the connect gate reads. Unlinked by default. */
|
||||
export function PortalViewProviders({
|
||||
children,
|
||||
linkState = "unlinked",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
linkState?: LinkState;
|
||||
}) {
|
||||
return (
|
||||
<PortalTestProviders>
|
||||
<LinkProvider initialState={linkState}>
|
||||
<UIProvider>{children}</UIProvider>
|
||||
</LinkProvider>
|
||||
</PortalTestProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/account-link/callback"]}>
|
||||
<ConnectCallbackHost />
|
||||
<Routes>
|
||||
<Route path="/account-link/callback" element={<ConnectCallback />} />
|
||||
<Route path="/processor" element={<div data-testid="portal" />} />
|
||||
</Routes>
|
||||
<UIProvider>
|
||||
<ConnectCallbackHost />
|
||||
<OutcomeSpy />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/account-link/callback"
|
||||
element={<ConnectCallback />}
|
||||
/>
|
||||
<Route path="/processor" element={<div data-testid="portal" />} />
|
||||
</Routes>
|
||||
</UIProvider>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,16 @@ import type { IntegrationConfig } from "@portal/api/integrations";
|
||||
const render = (ui: Parameters<typeof baseRender>[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,
|
||||
|
||||
@@ -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<IntegrationConfig[] | null>(
|
||||
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) => {
|
||||
|
||||
@@ -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:
|
||||
<A extends unknown[]>(_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(
|
||||
<PortalViewProviders>
|
||||
<MemoryRouter initialEntries={[initial]}>
|
||||
<Routes>
|
||||
<Route path="/processor/pipelines" element={<Pipelines />} />
|
||||
<Route
|
||||
path="/processor/pipelines/new"
|
||||
element={
|
||||
<ConnectGuardedRoute fallback="/processor/pipelines">
|
||||
<div>builder</div>
|
||||
</ConnectGuardedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PortalViewProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,16 @@ const render = (
|
||||
options?: Parameters<typeof baseRender>[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: () => ({
|
||||
|
||||
@@ -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 (
|
||||
<div className="portal-pipelines">
|
||||
|
||||
@@ -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<string | null>(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=<id> (deep link from the super
|
||||
|
||||
@@ -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:
|
||||
<A extends unknown[]>(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(
|
||||
<PortalViewProviders>
|
||||
<MemoryRouter initialEntries={[initial]}>
|
||||
<Routes>
|
||||
<Route path="/processor/sources" element={<Sources />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PortalViewProviders>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,16 @@ const Providers = ({ children }: { children: ReactNode }) => (
|
||||
const render = (ui: Parameters<typeof baseRender>[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: () => ({
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
<A extends unknown[]>(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(
|
||||
<PortalViewProviders>
|
||||
<MemoryRouter initialEntries={[initial]}>
|
||||
<Routes>
|
||||
<Route path="/processor/users" element={<Users />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PortalViewProviders>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Confirm | null>(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=<id> (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() {
|
||||
</div>
|
||||
<div className="portal-users__head-actions">
|
||||
{caps.createTeam && (
|
||||
<Button
|
||||
fat
|
||||
variant="secondary"
|
||||
onClick={() => setNewTeamOpen(true)}
|
||||
>
|
||||
<Button fat variant="secondary" onClick={openNewTeam}>
|
||||
{t("users.newTeam.action", "+ New team")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 (
|
||||
<div className="saas-connect">
|
||||
{/* No onClose: this is a page, so there is nowhere to close back to. */}
|
||||
<StepModalHeader
|
||||
brand
|
||||
title={title}
|
||||
step={stepped ? 2 : undefined}
|
||||
total={stepped ? TOTAL_STEPS : undefined}
|
||||
stepLabel={
|
||||
stepped
|
||||
? t("connect.step", "Step {{current}} of {{total}}", {
|
||||
current: 2,
|
||||
total: TOTAL_STEPS,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="saas-connect">
|
||||
<Spinner size="md" />
|
||||
<p className="saas-connect__lead">
|
||||
{phase === "redirecting"
|
||||
<ApproveShell
|
||||
stepped={stepped}
|
||||
title={
|
||||
phase === "redirecting"
|
||||
? t("connect.redirecting", "Returning you to your server.")
|
||||
: t("connect.loading", "Checking this request.")}
|
||||
</p>
|
||||
</div>
|
||||
: t("connect.loading", "Checking this request.")
|
||||
}
|
||||
>
|
||||
<div className="saas-connect__waiting">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
</ApproveShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "notFound") {
|
||||
return (
|
||||
<div className="saas-connect">
|
||||
<ApproveShell
|
||||
stepped={stepped}
|
||||
title={t("connect.notFound.title", "Request not valid")}
|
||||
>
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("connect.notFound.title", "Request not valid")}
|
||||
@@ -70,13 +123,16 @@ export function ConnectApproveView({
|
||||
"This connection request is not valid. It may have expired, or already been used. Start another one from your server.",
|
||||
)}
|
||||
</Banner>
|
||||
</div>
|
||||
</ApproveShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "declined") {
|
||||
return (
|
||||
<div className="saas-connect">
|
||||
<ApproveShell
|
||||
stepped={stepped}
|
||||
title={t("connect.declined.title", "Request declined")}
|
||||
>
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t("connect.declined.title", "Request declined")}
|
||||
@@ -86,15 +142,15 @@ export function ConnectApproveView({
|
||||
"Nothing was connected. You can close this page.",
|
||||
)}
|
||||
</Banner>
|
||||
</div>
|
||||
</ApproveShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="saas-connect">
|
||||
<h1 className="saas-connect__title">
|
||||
{t("connect.confirm.title", "Connect this server?")}
|
||||
</h1>
|
||||
<ApproveShell
|
||||
stepped={stepped}
|
||||
title={t("connect.confirm.title", "Connect this server?")}
|
||||
>
|
||||
<p className="saas-connect__lead">
|
||||
{t(
|
||||
"connect.confirm.lead",
|
||||
@@ -177,6 +233,6 @@ export function ConnectApproveView({
|
||||
{t("connect.confirm.approve", "Connect server")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ApproveShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user