> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted). Entirely behind `stirling.billing.account-link.enabled` (default **off** → beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin migration → `v3`). ## What this does A self-hosted instance links a SaaS account in the **Portal**, gets a **device credential**, and authenticates unattended metering/entitlement with it — no long-lived user JWT on the server. The Portal then surfaces the team's **billing** (free trial → metered Processor plan) driven by the live wallet. ```mermaid sequenceDiagram participant Portal as Portal (browser) participant Supa as SaaS Supabase Auth participant Local as Self-hosted backend participant SaaS as SaaS Java (app/saas) Portal->>Supa: signIn / signUp (Supabase JS, short-lived JWT) Supa-->>Portal: JWT (SDK-refreshed, stays in browser) Portal->>Local: hand JWT (same-origin) Local->>SaaS: POST /account-link/register (Bearer JWT, leader) SaaS-->>Local: { device_id, device_secret } (secret once) Note over Local: store device_secret server-side loop unattended Local->>SaaS: /api/v1/instance/** (X-Device-Id + X-Device-Secret) SaaS-->>Local: entitlement / gate decision end ``` **Auth model:** human auth = Supabase JS (ephemeral JWT, kept for attended portal features). Durable instance auth = a team-bound **device_id + secret** (SHA-256 stored, shown once), non-user `ROLE_LINKED_INSTANCE`, path-scoped to `/api/v1/instance/**`. Instance binds to a **team**, never a user. ## Billing surface (Portal · Mode A states) `Usage & billing` is state-driven by the link/subscription dimension and built to the marketing designs, sharing one component layer across states: - **Unlinked** → link-account prompt. - **Linked · Free** — the *Processor trial*: a one-time 500-PDF free grant ("Process 500 PDFs free, then $X/PDF"), the team's free-editor fleet, and a leader-only **Switch on the Processor →** (embedded Stripe Checkout). - **Linked · Subscribed** — the *Processor plan* dashboard: PDFs-processed split (API / Agents / Automation), **spend this month** vs. a **spend limit** meter with a run-rate projection and an **in-place cap editor** (preset buckets + suggested value + guardrail), Stripe **invoices** (with billed PDFs per invoice), and the default **payment method**. Card / subscription changes deep-link to Stripe's hosted portal. Manual PDF editing is always free — only Automation / AI / API is metered; a `$0` cap blocks all metered work (≠ "no cap"). **Shared, not duplicated:** the editor-fleet card, the Enterprise upsell, and the meter (`@shared/billing` `MeterBar`) render in both the free and subscribed views; money/cap math lives once in `@shared/billing`. The page header is a sticky, full-bleed bar. **New SaaS reads** (defensive — degrade to empty/"—" when the Stripe mirror lacks a table, never 500): - `GET /api/v1/payg/payment-method` — default card (brand / last4 / expiry) from `stripe.payment_methods`. - Invoice **PDFs processed** — billed line-item quantity from `stripe.invoice_line_items`. ## Progress - [x] Schema: `V22 linked_instance` (+ Supabase twin in #313) - [x] `AccountLinkController` register / list / revoke (leader-only, team from caller) - [x] Device-credential filter (path-scoped, constant-time, revocation-aware) + `SupabaseSecurityConfig` wiring (conditional) - [x] `GET /api/v1/instance/whoami` + **`/entitlement`** (reuses `EntitlementService`/`TeamBillingService`) + tests - [x] Self-hosted backend (`app/proprietary`): orchestrator + instance gate (dark + **fail-open**) + tests - [x] Portal: in-app Supabase login modal + register hand-off + `LinkContext` (unlinked default) + "Linked instances" view — all `@shared` Storybook components - [x] **Portal billing surface** — free (Processor trial) + subscribed (Processor plan) Usage views to marketing spec; link-state derived from the **live wallet**; in-place cap editor; over-cap banner - [x] **SaaS reads** — payment-method endpoint + invoice billed-units (defensive `stripe.*` mirror DAOs) + tests - [x] Orphan guard: block leaving/accepting away from a team whose departure orphans its linked instances - [ ] Metering Step 2 (lease + reconcile loop) + bounded fail-open cutoff - [ ] Proprietary hardening (SaaS base-url config, secret-at-rest, finer billable classification) + HTTP integration test - [ ] Cross-repo Stripe lifecycle certified end-to-end (subscribe → meter → cancel → 402) - [ ] Admin ⟺ SaaS-leader enforcement (separate portal-team-mgmt workstream) ## Verification — all green | Gate | Result | |---|---| | `STIRLING_FLAVOR=saas :saas:test` | BUILD SUCCESSFUL (account-link + payg, incl. `PaygPaymentMethodControllerTest`, `PaygInvoicesControllerTest`) | | `:proprietary:test` | BUILD SUCCESSFUL (account-link + entitlement cache/interceptor) | | portal | tsc 0 · eslint 0 · **vitest 55** · storybook build (all billing stories) | | frontend post-sync | typecheck shared + portal + editor (saas + desktop): 0 | ## Screenshots — billing UI _Latest Storybook renders (Portal/Billing). Drag each capture below its caption — kept out of the repo._ **Linked · Free — Processor trial** <img width="1648" height="503" alt="01-free-processor-trial" src="https://github.com/user-attachments/assets/afe6238a-d3b4-47fd-8ea2-cbaed8b0a653" /> **Linked · Subscribed — Processor plan dashboard** <img width="1648" height="930" alt="02-subscribed-processor-plan" src="https://github.com/user-attachments/assets/329e6808-a9a9-4e65-99af-5a8a5e6bf4ab" /> **Spend limit — in-place cap editor** <img width="1648" height="411" alt="03-spend-limit-editor" src="https://github.com/user-attachments/assets/acc95096-bf8e-4ab0-a32c-3c20dc94f816" /> ## Review feedback applied Reworked the portal after first-pass feedback: linking signs in via the **shared Supabase login** (SSO + email/password) — no bespoke form; the **device secret is never shown in or sent to the FE** (the local backend registers + stores it server-side); billing copy reads **PDFs**, not "units"; the wallet surface uses **`@shared` components** matching the SaaS Plan page. Re-verified including an assertion the link response carries no `deviceSecret`/`deviceId`. **Synced onto unified auth + in-app login (2026-06-23).** Merged `main` incl. **#6725 unified auth** (`frontend/shared/auth`); the link flow uses a shared `useSupabaseLogin` hook + `SupabaseLoginForm`, a portal `LinkAccountModal`, and `useAccountLink.completeLink(session)` (+ on-mount SSO redirect-return). Config: `VITE_SAAS_SUPABASE_URL` + `VITE_SAAS_SUPABASE_ANON_KEY`. The local `/account-link/link` call carries the Spring admin bearer with the SaaS JWT in the body. **SSO** needs the SaaS Supabase project to allow-list the portal redirect URL (email/password works without it). ## Assumptions / open - **Proprietary remains a scaffold** (placeholder SaaS base-url, plaintext device secret at rest, coarse billable classification). - Payment-method + invoice-quantity render only when `stripe.payment_methods` / `stripe.invoice_line_items` are in the Sync-Engine target (confirm in the Supabase/Sync-Engine config); otherwise they degrade gracefully. - A self-contained local HTML report + manual E2E runbook live in `notes/account-link-report/` (dev artifacts, outside the repo). --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
250 lines
8.2 KiB
TypeScript
250 lines
8.2 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { type OAuthProvider } from "@shared/auth/spring/oauthTypes";
|
|
import { Button } from "@mantine/core";
|
|
import {
|
|
oauthIconUrl,
|
|
GENERIC_PROVIDER_ICON,
|
|
} from "@shared/auth/ui/oauthIcons";
|
|
|
|
// Debug flag to show all providers for UI testing
|
|
// Set to true to see all SSO options regardless of backend configuration
|
|
export const DEBUG_SHOW_ALL_PROVIDERS = false;
|
|
|
|
// OAuth provider configuration - maps provider ID to display info
|
|
// Known providers get custom icons; unknown providers use generic SSO icon
|
|
export const oauthProviderConfig: Record<
|
|
string,
|
|
{ label: string; file: string }
|
|
> = {
|
|
google: { label: "Google", file: "google.svg" },
|
|
github: { label: "GitHub", file: "github.svg" },
|
|
apple: { label: "Apple", file: "apple.svg" },
|
|
azure: { label: "Microsoft", file: "microsoft.svg" },
|
|
keycloak: { label: "Keycloak", file: "keycloak.svg" },
|
|
cloudron: { label: "Cloudron", file: "cloudron.svg" },
|
|
authentik: { label: "Authentik", file: "authentik.svg" },
|
|
oidc: { label: "OIDC", file: "oidc.svg" },
|
|
};
|
|
|
|
// Icon URLs + GENERIC_PROVIDER_ICON come from the shared oauthIcons resolver.
|
|
|
|
interface OAuthButtonsProps {
|
|
onProviderClick: (provider: OAuthProvider) => void;
|
|
isSubmitting: boolean;
|
|
/**
|
|
* `fullwidth` is the SaaS-login canonical look — rounded pill buttons stacked
|
|
* vertically. `vertical` (default) uses the Spring/Mantine button shape.
|
|
*/
|
|
layout?: "vertical" | "grid" | "icons" | "fullwidth";
|
|
enabledProviders?: OAuthProvider[]; // List of full auth paths from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling')
|
|
ctaPrefix?: string;
|
|
styleVariant?: "neutral" | "tinted" | "outline" | "light";
|
|
demoMode?: boolean;
|
|
useNewStyle?: boolean;
|
|
}
|
|
|
|
export default function OAuthButtons({
|
|
onProviderClick,
|
|
isSubmitting,
|
|
layout = "vertical",
|
|
enabledProviders = [],
|
|
ctaPrefix,
|
|
styleVariant = "neutral",
|
|
demoMode = false,
|
|
useNewStyle = false,
|
|
}: OAuthButtonsProps) {
|
|
const { t } = useTranslation();
|
|
|
|
// Debug mode: show all providers for UI testing
|
|
const providersToShow = DEBUG_SHOW_ALL_PROVIDERS
|
|
? Object.keys(oauthProviderConfig)
|
|
: enabledProviders;
|
|
|
|
// Build provider list - extract provider ID from full path for display
|
|
const providers = providersToShow.map((pathOrId) => {
|
|
// Extract provider ID from full path (e.g., '/saml2/authenticate/stirling' -> 'stirling')
|
|
const providerId = pathOrId.split("/").pop() || pathOrId;
|
|
|
|
if (providerId in oauthProviderConfig) {
|
|
// Known provider - use predefined icon and label
|
|
return {
|
|
id: pathOrId, // Keep full path for redirect
|
|
providerId, // Store extracted ID for display lookup
|
|
...oauthProviderConfig[providerId],
|
|
};
|
|
}
|
|
// Unknown provider - use generic icon and capitalize ID for label
|
|
return {
|
|
id: pathOrId, // Keep full path for redirect
|
|
providerId, // Store extracted ID for display lookup
|
|
label: providerId.charAt(0).toUpperCase() + providerId.slice(1),
|
|
file: GENERIC_PROVIDER_ICON,
|
|
};
|
|
});
|
|
|
|
// If no providers are enabled, don't render anything
|
|
if (providers.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const isSingleProvider = providers.length === 1;
|
|
const isTinted = styleVariant === "tinted";
|
|
const isOutline = styleVariant === "outline";
|
|
const isLight = styleVariant === "light";
|
|
const accentMap: Record<string, string> = {
|
|
google: "#4285F4",
|
|
github: "#111827",
|
|
apple: "#111827",
|
|
azure: "#0078D4",
|
|
keycloak: "#2C2C2C",
|
|
cloudron: "#3B82F6",
|
|
authentik: "#FA7B17",
|
|
oidc: "#334155",
|
|
};
|
|
|
|
if (layout === "icons") {
|
|
return (
|
|
<div className="oauth-container-icons">
|
|
{providers.map((p) => (
|
|
<div
|
|
key={p.id}
|
|
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
|
>
|
|
<Button
|
|
onClick={() => onProviderClick(p.id)}
|
|
disabled={isSubmitting}
|
|
className="oauth-button-icon"
|
|
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
|
variant="default"
|
|
>
|
|
<img
|
|
src={oauthIconUrl(p.file)}
|
|
alt={p.label}
|
|
className="oauth-icon-small"
|
|
/>
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (layout === "fullwidth") {
|
|
// Mirrors the SaaS editor login: rounded pill buttons stacked vertically,
|
|
// each with the provider's icon + "Sign in with X" label.
|
|
return (
|
|
<div className="oauth-container-fullwidth">
|
|
{providers.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
type="button"
|
|
onClick={() => onProviderClick(p.id)}
|
|
disabled={isSubmitting}
|
|
className="oauth-button-fullwidth"
|
|
title={p.label}
|
|
aria-label={`${ctaPrefix ?? ""}${p.label}`}
|
|
>
|
|
<span className="oauth-btn-group">
|
|
<img
|
|
src={oauthIconUrl(p.file)}
|
|
alt={p.label}
|
|
className="oauth-icon-medium"
|
|
style={{ marginRight: "0.5rem", flexShrink: 0 }}
|
|
/>
|
|
<span className="oauth-btn-label">
|
|
{ctaPrefix ?? ""}
|
|
{p.label}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (layout === "grid") {
|
|
return (
|
|
<div className="oauth-container-grid">
|
|
{providers.map((p) => (
|
|
<div
|
|
key={p.id}
|
|
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
|
>
|
|
<Button
|
|
onClick={() => onProviderClick(p.id)}
|
|
disabled={isSubmitting}
|
|
className="oauth-button-grid"
|
|
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
|
variant="default"
|
|
>
|
|
<img
|
|
src={oauthIconUrl(p.file)}
|
|
alt={p.label}
|
|
className="oauth-icon-medium"
|
|
/>
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`oauth-container-vertical${useNewStyle && isSingleProvider ? " oauth-container-single" : ""}`}
|
|
>
|
|
{providers.map((p) => (
|
|
<div
|
|
key={p.id}
|
|
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
|
>
|
|
<Button
|
|
onClick={() => onProviderClick(p.id)}
|
|
disabled={!demoMode && isSubmitting}
|
|
className={`oauth-button-vertical${useNewStyle && isSingleProvider ? " oauth-button-vertical-single" : ""}${!useNewStyle ? " oauth-button-vertical-legacy" : ""}${isTinted ? " oauth-button-vertical-tinted" : ""}${isOutline ? " oauth-button-vertical-outline" : ""}${isLight ? " oauth-button-vertical-light" : ""}`}
|
|
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
|
variant="default"
|
|
style={
|
|
isTinted
|
|
? ({
|
|
"--oauth-accent": accentMap[p.providerId] || "#334155",
|
|
} as React.CSSProperties)
|
|
: undefined
|
|
}
|
|
>
|
|
<span className="oauth-button-left">
|
|
<span className="oauth-icon-wrapper">
|
|
<img
|
|
src={oauthIconUrl(p.file)}
|
|
alt={p.label}
|
|
className="oauth-icon-tiny"
|
|
/>
|
|
</span>
|
|
<span className="oauth-button-text">
|
|
{ctaPrefix ? `${ctaPrefix} ${p.label}` : p.label}
|
|
</span>
|
|
</span>
|
|
{useNewStyle && isSingleProvider && (
|
|
<span className="oauth-button-right" aria-hidden="true">
|
|
<svg
|
|
className="oauth-arrow-icon"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
>
|
|
<path
|
|
d="M5 12h12m0 0-5-5m5 5-5 5"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|