Guided Connect flow for self-hosted account linking

The self-hosted account-link modal was a bare Supabase login form subtitled
"the account this server should bill against", which frames linking as the
moment an admin starts paying rather than what they gain by it. Nothing in
the product mentioned the free credits, teams, the processor, pipelines or
policies, and nothing reassured them that manual PDF tools stay free.

Replaces it with a three-step Connect flow on the portal's own FlowModal and
StepModalHeader: what you unlock, sign in, then routes into the features just
unlocked. All five existing entry points route through UIContext.openLinkModal
and so pick this up unchanged.

Two copy constraints are load-bearing. The free grant is seeded per team at
team creation and is NOT granted by linking, so step 1 states it as a property
of a new account and step 3 shows the wallet's real freeRemaining rather than
a hardcoded 500. Teams are qualified as free at five users and under, matching
the free tier limit the server already reports. SSO is left off the slide until
its gating is settled.

Also adds accountLinkAvailable to the app config. The account-link endpoints
404 when the feature flag is off, which the client cannot tell apart from
"not linked yet", so nothing can prompt anyone to link until this is told to
it explicitly. Read from the environment because :core cannot depend on
:proprietary.

OAuth buttons are now hidden unless VITE_SAAS_OAUTH_ENABLED is set. Supabase
honours a redirectTo only when it matches the project's allow-list, and a
customer origin never will be, so the round trip finished on the SaaS site and
the instance never received a session. Email and password work regardless.

Step 2 is isolated in its own component on purpose: steps 1 and 3 do not care
how the credential is obtained, so a pairing-code flow can replace the middle
step without touching either.
This commit is contained in:
Connor Yoh
2026-08-13 15:50:29 +01:00
parent 1659ca964a
commit 4624643289
19 changed files with 897 additions and 110 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());
@@ -6322,11 +6322,50 @@ after = "to enable account linking against the hosted Stirling account. In dev y
before = "Set"
title = "SaaS login not configured"
[portal.accountLink.connect]
back = "Back"
close = "Close"
notNow = "Not now"
start = "Connect account"
step = "Step {{current}} of {{total}}"
[portal.accountLink.connect.benefits]
creditsDetail = "500 free on a new account"
creditsLabel = "Credits"
lede = "Connecting unlocks the platform features around the editor. Manual PDF editing stays free, connected or not."
pipelinesDetail = "Chain tools and run them unattended"
pipelinesLabel = "Pipelines"
policiesDetail = "Rules that run on every file"
policiesLabel = "Policies"
processorDetail = "Watch folders and act on files"
processorLabel = "Processor"
teamsDetail = "Free for 5 users and under"
teamsLabel = "Teams"
title = "Connect this server to a Stirling account"
usageDetail = "Pay only for what you run"
usageLabel = "Usage"
[portal.accountLink.connect.done]
addPolicy = "Add a policy"
buildPipeline = "Set up a pipeline"
creditsRemaining = "free credits remaining"
cta = "Done"
inviteTeam = "Invite your team"
lede = "This server now runs against your Stirling account."
title = "Connected"
titleNamed = "Connected to {{name}}"
[portal.accountLink.connect.signIn]
failed = "Couldn't connect this server"
lede = "Your credits and team live in your Stirling account. This server connects once, then runs on its own."
reauthLede = "Your session expired. Sign back in to your Stirling account. This server stays connected."
title = "Sign in to Stirling"
[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}}"
action = "Connect account"
description = "Connect this org's Stirling account to use this feature."
title = "Connect to unlock"
titleFeature = "Connect to unlock {{feature}}"
[portal.accountLink.instances]
active = "Active"
@@ -6356,9 +6395,6 @@ minutesAgo_other = "{{count}}m ago"
never = "never"
[portal.accountLink.modal]
linkSubtitle = "Sign in to the account this server should bill against."
linkTitle = "Link your Stirling account"
reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked."
reauthTitle = "Sign in again"
simulateSignIn = "Simulate sign-in (dev)"
@@ -6503,9 +6539,9 @@ 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"
cta = "Connect account"
description = "Manual PDF editing is always free, connected or not. Connecting adds teams, the processor, pipelines and policies, and a new Stirling account starts with 500 free credits for automation, AI and the API."
title = "Connect your Stirling account"
[portal.billing.paymentMethod]
billedMonthly = "Billed monthly"
@@ -6662,8 +6698,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."
@@ -6680,17 +6716,17 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs used"
capSuffix_one = "of {{allowance}} free PDFs used"
capSuffix_other = "of {{allowance}} free PDFs used"
barAria = "Free credits used"
capSuffix_one = "of {{allowance}} free credits used"
capSuffix_other = "of {{allowance}} free credits used"
eyebrow = "Processor trial"
statusLabel_one = "{{remaining}} left"
statusLabel_other = "{{remaining}} left"
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;
@@ -33,6 +33,8 @@ function LinkModalHost() {
mode={linkModalMode}
onClose={closeLinkModal}
onLinked={onLinked}
status={link.status}
linkError={link.error}
/>
);
}
@@ -22,8 +22,27 @@ const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
export const isSaasSupabaseConfigured = Boolean(url && key);
/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */
export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"];
/**
* Whether an OAuth sign-in can actually finish on this origin.
*
* Supabase honours a `redirectTo` only when it matches the project's Redirect URLs allow-list;
* anything else falls back to the project's Site URL, which is the SaaS app. A customer's own
* server (https://pdf.acme.internal, http://192.168.1.20:8080) will never be on that list, so the
* admin would complete the provider round trip, land on the SaaS site, and this instance would
* never receive a session — a button that silently cannot work. Wildcards can cover a domain we
* own, not the open set of customer hostnames, so this is opt-in per deployment rather than
* something we can detect.
*
* Set VITE_SAAS_OAUTH_ENABLED=true only for origins that ARE allow-listed on the Supabase project
* (deployments we host). Email and password work regardless.
*/
export const isSaasOAuthAvailable =
import.meta.env.VITE_SAAS_OAUTH_ENABLED === "true";
/** OAuth providers offered, empty when the round trip cannot return to this origin. */
export const SAAS_OAUTH_PROVIDERS = isSaasOAuthAvailable
? ["google", "github", "apple", "azure"]
: [];
/** sessionStorage marker set before an SSO redirect so the return can finish the link. */
export const PENDING_LINK_KEY = "stirling-account-link-pending";
@@ -14,10 +14,23 @@ const meta: Meta<typeof LinkAccountModal> = {
export default meta;
type Story = StoryObj<typeof LinkAccountModal>;
/** Default "link" mode — sign in to register this instance against a Stirling account. */
/** Step 1: the case for connecting. Click through to reach sign-in. */
export const Default: Story = {};
/** "reauth" mode — an already-linked instance's session expired and needs a fresh sign-in. */
/**
* Step 3, reached because the instance already reports linked. The balance needs a wallet call
* that has no MSW handler, so it is omitted here.
*/
export const Connected: Story = {
args: { status: { linked: true, name: "acme-corp" } },
};
/** A register failure after a good sign-in, shown on the sign-in step. */
export const LinkFailed: Story = {
args: { linkError: "Upstream rejected the token" },
};
/** "reauth" mode: an already-linked instance's session expired. Single step, no pitch. */
export const Reauth: Story = {
args: { mode: "reauth" },
};
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
/**
* The Connect flow's step machine. What matters here is that the pitch is never skipped for a
* fresh link, that re-auth never grows a pitch or a success screen it has no business showing,
* and that reaching the confirmation is driven by the link actually landing rather than by the
* sign-in promise resolving.
*/
const { fetchWallet } = vi.hoisted(() => ({ fetchWallet: vi.fn() }));
vi.mock("@portal/api/billing", () => ({ fetchWallet }));
vi.mock("@portal/auth/saasSupabase", () => ({
PENDING_LINK_KEY: "stirling_pending_link",
isSaasSupabaseConfigured: true,
isSaasOAuthAvailable: false,
SAAS_OAUTH_PROVIDERS: [],
ensureSaasSupabase: () => null,
}));
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
type Props = Parameters<typeof LinkAccountModal>[0];
const BENEFITS = /Connecting unlocks the platform features/;
const SIGN_IN = /This server connects once/;
const REAUTH = /Your session expired/;
const DONE = /now runs against your Stirling account/;
const wrap = (props: Props, open: boolean) => (
<PortalTestProviders>
<MemoryRouter>
<LinkAccountModal {...props} open={open} />
</MemoryRouter>
</PortalTestProviders>
);
function renderModal(overrides: Partial<Props> = {}) {
const props: Props = {
open: true,
onClose: vi.fn(),
onLinked: vi.fn(),
...overrides,
};
const utils = render(wrap(props, true));
return { ...utils, props };
}
describe("LinkAccountModal", () => {
it("opens on the benefits step, not the login form", () => {
renderModal();
expect(screen.getByText(BENEFITS)).toBeTruthy();
expect(screen.queryByText(SIGN_IN)).toBeNull();
});
it("advances to sign-in only once the admin asks to connect", () => {
renderModal();
fireEvent.click(screen.getByText("Connect account"));
expect(screen.getByText(SIGN_IN)).toBeTruthy();
});
it("goes back to the benefits from sign-in", () => {
renderModal();
fireEvent.click(screen.getByText("Connect account"));
fireEvent.click(screen.getByText("Back"));
expect(screen.getByText(BENEFITS)).toBeTruthy();
});
it("shows the confirmation once the instance reports linked", async () => {
fetchWallet.mockRejectedValue(new Error("no session"));
renderModal({ status: { linked: true, name: "acme-corp" } });
await waitFor(() => expect(screen.getByText(DONE)).toBeTruthy());
});
it("keeps re-auth a single sign-in step with no pitch or confirmation", () => {
renderModal({
mode: "reauth",
status: { linked: true, name: "acme-corp" },
});
expect(screen.getByText(REAUTH)).toBeTruthy();
expect(screen.queryByText(BENEFITS)).toBeNull();
expect(screen.queryByText(DONE)).toBeNull();
});
it("surfaces a failed link on the sign-in step rather than advancing", () => {
renderModal({ linkError: "Upstream rejected the token" });
fireEvent.click(screen.getByText("Connect account"));
expect(screen.getByText("Upstream rejected the token")).toBeTruthy();
expect(screen.queryByText(DONE)).toBeNull();
});
it("restarts the pitch after being dismissed", () => {
const { rerender, props } = renderModal();
fireEvent.click(screen.getByText("Connect account"));
rerender(wrap(props, false));
rerender(wrap(props, true));
expect(screen.getByText(BENEFITS)).toBeTruthy();
});
});
@@ -1,125 +1,174 @@
import { useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Modal } from "@app/ui";
import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm";
import { Button } from "@app/ui";
import {
useSupabaseLogin,
type SupabaseLoginSession,
} from "@app/auth/ui/useSupabaseLogin";
import "@app/auth/ui/auth-theme.css";
import { FlowModal } from "@portal/components/shared/FlowModal";
import { StepModalHeader } from "@portal/components/shared/StepModalHeader";
import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide";
import { ConnectSignInSlide } from "@portal/components/account-link/connect/ConnectSignInSlide";
import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide";
import {
ensureSaasSupabase,
isSaasSupabaseConfigured,
PENDING_LINK_KEY,
SAAS_OAUTH_PROVIDERS,
} from "@portal/auth/saasSupabase";
/** 1 = what you unlock, 2 = sign in, 3 = connected. */
type Step = 1 | 2 | 3;
const TOTAL_STEPS = 3;
interface Props {
open: boolean;
onClose: () => void;
/**
* "link" registers this instance against the signed-in account; "reauth" only
* refreshes an expired SaaS session (the instance is already linked). The mode
* is persisted across the OAuth redirect so the SSO-return handler doesn't
* re-register on a reauth.
* "link" runs the full three-step flow; "reauth" only refreshes an expired SaaS session for an
* already linked instance, so it stays a single sign-in step with no pitch and no success screen.
*/
mode?: "link" | "reauth";
/** Called with the SaaS session after a successful sign-in. */
onLinked: (session: SupabaseLoginSession) => void | Promise<void>;
/** Current link status, so the flow can tell that the register call landed. */
status?: { linked: boolean; name: string | null } | null;
/** Failure from the link call, as opposed to a failed sign-in. */
linkError?: string | null;
}
/**
* In-app account-link login. Signs the admin in to their Stirling (SaaS) account
* via the shared Supabase login (SSO + email/password), then hands the resulting
* session to the caller to register this instance. No popup; the device secret
* never reaches the browser. SSO redirects away and is finished by useAccountLink
* on return.
* The Connect flow: three steps that explain what linking a Stirling account gives you, sign the
* admin in, and route them into what they just unlocked.
*
* <p>Chrome is the portal's own {@link FlowModal} and {@link StepModalHeader}, the same shells the
* procurement and prepay flows wear, so this dialog cannot drift from them. The device secret still
* never reaches the browser: sign-in mints a SaaS JWT, the local backend exchanges it, and this
* component only ever sees linked or not.
*
* <p>Step 2 is deliberately isolated in its own component. Steps 1 and 3 are about value and
* activation and do not care how the credential is obtained, so a pairing-code flow can replace the
* middle step without touching either.
*/
export function LinkAccountModal({
open,
onClose,
mode = "link",
onLinked,
status,
linkError,
}: Props) {
const { t } = useTranslation();
const reauth = mode === "reauth";
const [step, setStep] = useState<Step>(1);
useEffect(() => {
if (open) ensureSaasSupabase();
}, [open]);
const reauth = mode === "reauth";
// Re-opening always restarts the pitch. Without this, a dismissal on step 2 would reopen there
// and the admin would never see the reason they were being asked.
useEffect(() => {
if (!open) setStep(1);
}, [open]);
// The register call is what decides success, and it resolves in the parent, so drive off the
// resulting status rather than the sign-in promise. This also lands an SSO return on the right
// step, since the redirect completes the link before the dialog is reopened.
useEffect(() => {
if (open && !reauth && status?.linked) setStep(3);
}, [open, reauth, status?.linked]);
const login = useSupabaseLogin({
providers: SAAS_OAUTH_PROVIDERS,
// Return to the current page after SSO; the SSO-return handler in
// useAccountLink reads the persisted mode so it links vs. only refreshes.
redirectTo: window.location.href,
onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode),
onSuccess: async (session) => {
await onLinked(session);
onClose();
// Re-auth has nothing to confirm: the session is refreshed and the instance never changed.
if (reauth) onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={
reauth
? t("portal.accountLink.modal.reauthTitle", "Sign in again")
: t(
"portal.accountLink.modal.linkTitle",
"Link your Stirling account",
)
}
subtitle={
reauth
const onSimulate = useCallback(
async (session: SupabaseLoginSession) => {
await onLinked(session);
if (reauth) onClose();
},
[onLinked, reauth, onClose],
);
const stepLabel = t(
"portal.accountLink.connect.step",
"Step {{current}} of {{total}}",
{ current: step, total: TOTAL_STEPS },
);
const title = reauth
? t("portal.accountLink.modal.reauthTitle", "Sign in again")
: step === 1
? t(
"portal.accountLink.connect.benefits.title",
"Connect this server to a Stirling account",
)
: step === 2
? t("portal.accountLink.connect.signIn.title", "Sign in to Stirling")
: status?.name
? t(
"portal.accountLink.modal.reauthSubtitle",
"Your session expired — sign back in to your Stirling account. Your instance stays linked.",
"portal.accountLink.connect.done.titleNamed",
"Connected to {{name}}",
{
name: status.name,
},
)
: t(
"portal.accountLink.modal.linkSubtitle",
"Sign in to the account this server should bill against.",
)
}
>
{isSaasSupabaseConfigured ? (
<SupabaseLoginForm state={login} />
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<Banner
tone="neutral"
title={t(
"portal.accountLink.modal.loginNotConfigured.title",
"SaaS login 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",
"to enable in-app linking against the hosted Stirling account.",
)}
</Banner>
{import.meta.env.DEV && (
<Button
variant="secondary"
onClick={async () => {
await onLinked({ access_token: "dev-stub-jwt" });
onClose();
}}
>
{t(
"portal.accountLink.modal.simulateSignIn",
"Simulate sign-in (dev)",
)}
</Button>
)}
</div>
: t("portal.accountLink.connect.done.title", "Connected");
const footer = reauth ? undefined : step === 1 ? (
<>
<Button variant="quiet" accent="neutral" onClick={onClose}>
{t("portal.accountLink.connect.notNow", "Not now")}
</Button>
<Button variant="primary" onClick={() => setStep(2)}>
{t("portal.accountLink.connect.start", "Connect account")}
</Button>
</>
) : step === 2 ? (
<Button variant="quiet" accent="neutral" onClick={() => setStep(1)}>
{t("portal.accountLink.connect.back", "Back")}
</Button>
) : (
<>
<span />
<Button variant="primary" onClick={onClose}>
{t("portal.accountLink.connect.done.cta", "Done")}
</Button>
</>
);
return (
<FlowModal open={open} onClose={onClose} label={title} footer={footer}>
<StepModalHeader
brand
title={title}
step={reauth ? undefined : step}
total={reauth ? undefined : TOTAL_STEPS}
stepLabel={reauth ? undefined : stepLabel}
closeLabel={t("portal.accountLink.connect.close", "Close")}
onClose={onClose}
/>
{!reauth && step === 1 && <ConnectBenefitsSlide />}
{(reauth || step === 2) && (
<ConnectSignInSlide
login={login}
linkError={linkError}
reauth={reauth}
onSimulate={onSimulate}
/>
)}
</Modal>
{!reauth && step === 3 && <ConnectDoneSlide onNavigate={onClose} />}
</FlowModal>
);
}
@@ -31,20 +31,20 @@ export function LinkGate({ children, feature }: Props) {
feature
? t(
"portal.accountLink.gate.titleFeature",
"Link to unlock {{feature}}",
"Connect to unlock {{feature}}",
{
feature,
},
)
: t("portal.accountLink.gate.title", "Link to unlock")
: t("portal.accountLink.gate.title", "Connect to unlock")
}
description={t(
"portal.accountLink.gate.description",
"Link this org's Stirling account to use billable features.",
"Connect this org's Stirling account to use this feature.",
)}
action={
<Button size="sm" onClick={() => openLinkModal()}>
{t("portal.accountLink.gate.action", "Link account")}
{t("portal.accountLink.gate.action", "Connect account")}
</Button>
}
/>
@@ -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, which the old modal never made. */
export const Default: Story = {};
@@ -0,0 +1,95 @@
import { useTranslation } from "react-i18next";
import "@portal/components/account-link/connect/connect.css";
/**
* Step 1 of the Connect flow: what linking a Stirling account actually gets you.
*
* <p>The step this flow was missing. Before it, the only thing an admin ever saw was a login form
* subtitled "the account this server should bill against", which frames linking as the moment they
* start paying rather than what they gain.
*
* <p>Two copy constraints are load-bearing. The free grant is seeded per team at team creation and
* is NOT granted by linking, so the allowance is stated as a property of a new account rather than
* a reward for connecting. Teams are free at five users and under, matching the free tier limit the
* server already reports, because an unqualified "free" breaks on the sixth invite.
*/
export function ConnectBenefitsSlide() {
const { t } = useTranslation();
const benefits: { key: string; label: string; detail: string }[] = [
{
key: "credits",
label: t("portal.accountLink.connect.benefits.creditsLabel", "Credits"),
detail: t(
"portal.accountLink.connect.benefits.creditsDetail",
"500 free on a new account",
),
},
{
key: "teams",
label: t("portal.accountLink.connect.benefits.teamsLabel", "Teams"),
detail: t(
"portal.accountLink.connect.benefits.teamsDetail",
"Free for 5 users and under",
),
},
{
key: "processor",
label: t(
"portal.accountLink.connect.benefits.processorLabel",
"Processor",
),
detail: t(
"portal.accountLink.connect.benefits.processorDetail",
"Watch folders and act on files",
),
},
{
key: "pipelines",
label: t(
"portal.accountLink.connect.benefits.pipelinesLabel",
"Pipelines",
),
detail: t(
"portal.accountLink.connect.benefits.pipelinesDetail",
"Chain tools and run them unattended",
),
},
{
key: "policies",
label: t("portal.accountLink.connect.benefits.policiesLabel", "Policies"),
detail: t(
"portal.accountLink.connect.benefits.policiesDetail",
"Rules that run on every file",
),
},
{
key: "usage",
label: t("portal.accountLink.connect.benefits.usageLabel", "Usage"),
detail: t(
"portal.accountLink.connect.benefits.usageDetail",
"Pay only for what you run",
),
},
];
return (
<>
<p className="portal-connect__lede">
{t(
"portal.accountLink.connect.benefits.lede",
"Connecting unlocks the platform features around the editor. Manual PDF editing stays free, connected or not.",
)}
</p>
<dl className="portal-connect__list">
{benefits.map((benefit) => (
<div className="portal-connect__row" key={benefit.key}>
<dt className="portal-connect__row-label">{benefit.label}</dt>
<dd className="portal-connect__row-detail">{benefit.detail}</dd>
</div>
))}
</dl>
</>
);
}
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide";
const meta: Meta<typeof ConnectDoneSlide> = {
title: "Portal/AccountLink/Connect/DoneSlide",
component: ConnectDoneSlide,
args: { onNavigate: () => {} },
};
export default meta;
type Story = StoryObj<typeof ConnectDoneSlide>;
/**
* Step 3, without a readable wallet. There are no billing MSW handlers, so the balance request
* fails here and the figure is omitted — which is the real behaviour whenever the wallet cannot be
* read, and the state worth eyeballing since it must not leave a gap or a placeholder number.
*/
export const Default: Story = {};
@@ -0,0 +1,57 @@
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 free-grant figure is the one number on this screen a customer can check against their
* account, so it must be the wallet's and never a hardcoded 500: the allowance is seeded per team
* at team creation, so an account that has already spent it has nothing left to show.
*/
const { fetchWallet } = vi.hoisted(() => ({ fetchWallet: vi.fn() }));
vi.mock("@portal/api/billing", () => ({ fetchWallet }));
import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide";
const renderSlide = () =>
render(
<PortalTestProviders>
<MemoryRouter>
<ConnectDoneSlide onNavigate={() => {}} />
</MemoryRouter>
</PortalTestProviders>,
);
describe("ConnectDoneSlide", () => {
it("shows the wallet's remaining balance, not a fixed grant", async () => {
fetchWallet.mockResolvedValue({ freeRemaining: 128 });
renderSlide();
await waitFor(() => expect(screen.getByText("128")).toBeTruthy());
expect(screen.queryByText("500")).toBeNull();
});
it("shows a spent grant as zero rather than hiding it", async () => {
fetchWallet.mockResolvedValue({ freeRemaining: 0 });
renderSlide();
await waitFor(() => expect(screen.getByText("0")).toBeTruthy());
});
it("omits the figure when the wallet cannot be read", async () => {
fetchWallet.mockRejectedValue(new Error("not linked"));
renderSlide();
await waitFor(() =>
expect(screen.getByText("Invite your team")).toBeTruthy(),
);
expect(screen.queryByText("free credits remaining")).toBeNull();
});
it("always offers the next steps", async () => {
fetchWallet.mockResolvedValue({ freeRemaining: 500 });
renderSlide();
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,113 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@app/ui";
import { fetchWallet } from "@portal/api/billing";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import "@portal/components/account-link/connect/connect.css";
interface Props {
/** Closes the dialog before navigating, so a next step doesn't land behind the overlay. */
onNavigate: () => void;
}
/**
* Step 3 of the Connect flow: confirm the link and route into what it just unlocked.
*
* <p>A flow that ends by closing itself wastes the one moment the admin is most willing to act, so
* the three rows deep link into the features step 1 promised.
*
* <p>The balance is read from the wallet rather than hardcoded. The free allowance is seeded per
* team at team creation, so an account that has already spent it has nothing left to show, and this
* is precisely the screen where a stale "500" would be caught. When the wallet cannot be read the
* figure is omitted rather than guessed.
*/
export function ConnectDoneSlide({ onNavigate }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const [freeRemaining, setFreeRemaining] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
void fetchWallet()
.then((wallet) => {
if (!cancelled) setFreeRemaining(wallet.freeRemaining);
})
.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 nextSteps: { key: string; label: string; path: string }[] = [
{
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 (
<>
<p className="portal-connect__lede">
{t(
"portal.accountLink.connect.done.lede",
"This server now runs against your Stirling account.",
)}
</p>
{freeRemaining != null && (
<p className="portal-connect__balance">
<span className="portal-connect__balance-figure">
{freeRemaining.toLocaleString()}
</span>
<span className="portal-connect__balance-label">
{t(
"portal.accountLink.connect.done.creditsRemaining",
"free credits remaining",
)}
</span>
</p>
)}
<ul className="portal-connect__next">
{nextSteps.map((step) => (
<li key={step.key} className="portal-connect__next-item">
<Button
variant="secondary"
accent="neutral"
fullWidth
onClick={() => go(step.path)}
>
{step.label}
</Button>
</li>
))}
</ul>
</>
);
}
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { SupabaseLoginState } from "@app/auth/ui/useSupabaseLogin";
import { ConnectSignInSlide } from "@portal/components/account-link/connect/ConnectSignInSlide";
const noop = () => {};
/** Idle login state. Providers are empty, which is the self-hosted default: see saasSupabase. */
const login: SupabaseLoginState = {
email: "",
setEmail: noop,
password: "",
setPassword: noop,
error: null,
setError: noop,
isSubmitting: false,
providers: [],
hasProviders: false,
signInWithEmail: async () => {},
signInWithProvider: async () => {},
};
const meta: Meta<typeof ConnectSignInSlide> = {
title: "Portal/AccountLink/Connect/SignInSlide",
component: ConnectSignInSlide,
args: { login },
};
export default meta;
type Story = StoryObj<typeof ConnectSignInSlide>;
/** Email and password only, the state a customer-hosted origin always gets. */
export const Default: Story = {};
/**
* With OAuth, which only appears where the origin is on the Supabase redirect allow-list. Anywhere
* else the round trip finishes on the SaaS site and this instance never receives a session.
*/
export const WithOAuth: Story = {
args: {
login: {
...login,
providers: ["google", "github", "apple", "azure"],
hasProviders: true,
},
},
};
/** The register call failed after a good sign-in, so the error belongs beside the form. */
export const LinkFailed: Story = {
args: { linkError: "Upstream rejected the token" },
};
/** Re-auth: an expired session on an instance that stays linked. */
export const Reauth: Story = {
args: { reauth: true },
};
@@ -0,0 +1,106 @@
import { useTranslation } from "react-i18next";
import { Banner, Button } from "@app/ui";
import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm";
import type {
SupabaseLoginSession,
SupabaseLoginState,
} from "@app/auth/ui/useSupabaseLogin";
import "@app/auth/ui/auth-theme.css";
import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase";
import "@portal/components/account-link/connect/connect.css";
interface Props {
/** Login state from useSupabaseLogin, owned by the host so it can react to success. */
login: SupabaseLoginState;
/** Failure from the link call itself, as opposed to a failed sign-in. */
linkError?: string | null;
/** Re-authenticating an already linked instance rather than linking a new one. */
reauth?: boolean;
/** Dev-only shortcut used when Supabase is unconfigured. */
onSimulate?: (session: SupabaseLoginSession) => void | Promise<void>;
}
/**
* Step 2 of the Connect flow: sign in to the Stirling account this server should run against.
*
* <p>This is the swappable step. Steps 1 and 3 are about value and activation and are independent
* of how the credential is obtained, so a future pairing-code flow replaces this component and the
* host's step-2 branch without touching them.
*
* <p>Sign-in reuses the shared {@link SupabaseLoginForm} rather than the SaaS login route
* components, which are coupled to route state (next path, login-landing marker, logged-in state,
* magic link) that has no meaning inside a dialog. OAuth appears only when the host supplies
* providers; see {@code saasSupabase} for why that is normally empty on self-hosted.
*/
export function ConnectSignInSlide({
login,
linkError,
reauth = false,
onSimulate,
}: Props) {
const { t } = useTranslation();
if (!isSaasSupabaseConfigured) {
return (
<div className="portal-connect__stack">
<Banner
tone="neutral"
title={t(
"portal.accountLink.modal.loginNotConfigured.title",
"SaaS login 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",
"to enable in-app linking against the hosted Stirling account.",
)}
</Banner>
{import.meta.env.DEV && onSimulate && (
<Button
variant="secondary"
onClick={() => void onSimulate({ access_token: "dev-stub-jwt" })}
>
{t(
"portal.accountLink.modal.simulateSignIn",
"Simulate sign-in (dev)",
)}
</Button>
)}
</div>
);
}
return (
<div className="portal-connect__stack">
<p className="portal-connect__lede">
{reauth
? t(
"portal.accountLink.connect.signIn.reauthLede",
"Your session expired. Sign back in to your Stirling account. This server stays connected.",
)
: t(
"portal.accountLink.connect.signIn.lede",
"Your credits and team live in your Stirling account. This server connects once, then runs on its own.",
)}
</p>
{linkError && (
<Banner
tone="danger"
title={t(
"portal.accountLink.connect.signIn.failed",
"Couldn't connect this server",
)}
>
{linkError}
</Banner>
)}
<SupabaseLoginForm state={login} />
</div>
);
}
@@ -0,0 +1,88 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* Connect flow — the three account-link steps */
/* ──────────────────────────────────────────────────────────────────────── */
/* Deliberately flat: bordered rows and plain type rather than icon tiles or
coloured cards. The value on step 1 is in the words, and a benefit grid that
competes with them reads as decoration. FlowModal already stacks and spaces
the body, so these blocks only own their own internals. */
.portal-connect__lede {
margin: 0;
font-size: 0.875rem;
line-height: 1.55;
color: var(--c-text-muted);
}
.portal-connect__stack {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
/* Benefit list. A definition list rather than a table: each row is a term and
its qualifier, which is what a screen reader should hear. */
.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;
}
/* Free-grant figure on the final step. Rendered only when the wallet answered,
so it never holds space for a number that may not arrive. */
.portal-connect__balance {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 0;
padding: 0.75rem 0.875rem;
border: 1px solid var(--c-border);
border-radius: 0.5rem;
background: var(--c-surface-sunken);
}
.portal-connect__balance-figure {
font-size: 1.5rem;
font-weight: 650;
line-height: 1;
color: var(--c-text);
}
.portal-connect__balance-label {
font-size: 0.8125rem;
color: var(--c-text-muted);
}
.portal-connect__next {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin: 0;
padding: 0;
list-style: none;
}
.portal-connect__next-item {
display: block;
}
@@ -3,9 +3,12 @@ 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).
* Unlinked state on the billing page. The CTA opens the Connect flow directly, so the admin gets
* the full case for connecting rather than a bare login box.
*
* <p>The copy states the free grant as a property of a new account, not as a reward for connecting:
* the allowance is seeded per team at team creation, so an existing account that has spent it gains
* nothing by linking.
*/
export function LinkAccountPrompt() {
const { t } = useTranslation();
@@ -16,15 +19,15 @@ export function LinkAccountPrompt() {
size="default"
title={t(
"portal.billing.linkPrompt.title",
"Link your Stirling account",
"Connect 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.",
"Manual PDF editing is always free, connected or not. Connecting adds teams, the processor, pipelines and policies, and a new Stirling account starts with 500 free credits for automation, AI and the API.",
)}
actions={
<Button variant="primary" onClick={() => openLinkModal()}>
{t("portal.billing.linkPrompt.cta", "Link Stirling account")}
{t("portal.billing.linkPrompt.cta", "Connect account")}
</Button>
}
/>
+2
View File
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
/** Stirling Supabase publishable/anon key (public). */
readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string;
/** "true" only when this origin is on the Supabase project's redirect allow-list, so an OAuth sign-in can return here. Otherwise the Connect flow offers email and password only. */
readonly VITE_SAAS_OAUTH_ENABLED: string;
/** Hosted SaaS Java backend base URL — attended portal→SaaS reads (wallet, invoices, …) via apiClient.saas with the admin's JWT. */
readonly VITE_SAAS_API_URL: string;
/** Stripe publishable key (pk_live_… / pk_test_…) used by embedded Checkout. */