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:
ConnorYoh
2026-09-01 10:39:57 +00:00
committed by GitHub
parent d55d8acbfa
commit 31d52d4c32
58 changed files with 2346 additions and 575 deletions
@@ -338,6 +338,19 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Whether this instance can link a Stirling (SaaS) account at all. The account-link
// beans live in :proprietary and are @ConditionalOnProperty on this same key, so when
// it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot
// tell that 404 apart from "not linked yet", so it needs this told to it explicitly
// before it can prompt anyone to link. Read from the environment rather than
// AccountLinkProperties because :core must not depend on :proprietary.
configData.put(
"accountLinkAvailable",
applicationContext
.getEnvironment()
.getProperty(
"stirling.billing.account-link.enabled", Boolean.class, false));
// AI Engine settings
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
@@ -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;
+12 -3
View File
@@ -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}
/>
);
}
+14 -2
View File
@@ -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>}
</>
);
}
@@ -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: () => ({
+13 -6
View File
@@ -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">
+22 -4
View File
@@ -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: () => ({
+17 -6
View File
@@ -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(),
+16 -9
View File
@@ -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>
);
}
+4 -5
View File
@@ -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 {