mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(saas): account-link — connected self-hosted billing (Mode A) [WIP, flag-gated] (#6738)
> **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>
This commit is contained in:
co-authored by
James Brunton
parent
84739e8b0e
commit
14245d33d1
@@ -30,6 +30,10 @@ tasks:
|
|||||||
|
|
||||||
dev:proprietary:
|
dev:proprietary:
|
||||||
desc: "Start backend dev server in proprietary mode"
|
desc: "Start backend dev server in proprietary mode"
|
||||||
|
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||||
|
# subtaskfile is included with `dir: .`. Local overrides in
|
||||||
|
# .env.proprietary.local win over the committed .env.proprietary defaults.
|
||||||
|
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
|
||||||
ignore_error: true
|
ignore_error: true
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "8080"}}'
|
PORT: '{{.PORT | default "8080"}}'
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
|
||||||
|
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
|
||||||
|
|
||||||
|
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
|
||||||
|
# Flip to true in app/.env.proprietary.local to test linking locally.
|
||||||
|
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
|
||||||
|
# SaaS base URL the linked instance calls (register + entitlement).
|
||||||
|
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||||
# stays ignored via the root .gitignore.
|
# stays ignored via the root .gitignore.
|
||||||
!.env.saas
|
!.env.saas
|
||||||
|
!.env.proprietary
|
||||||
|
|||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||||
|
* A").
|
||||||
|
*
|
||||||
|
* <p>Two calls:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
|
||||||
|
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
|
||||||
|
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||||
|
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
|
||||||
|
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
|
||||||
|
* endpoint.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Profile("!saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class AccountLinkClient {
|
||||||
|
|
||||||
|
static final String HEADER_DEVICE_ID = "X-Device-Id";
|
||||||
|
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
|
||||||
|
|
||||||
|
private final AccountLinkProperties properties;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public AccountLinkClient(AccountLinkProperties properties, ObjectMapper mapper) {
|
||||||
|
this(
|
||||||
|
properties,
|
||||||
|
mapper,
|
||||||
|
HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(properties.getRequestTimeoutSeconds()))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private: lets tests inject a stub {@link HttpClient}. */
|
||||||
|
AccountLinkClient(
|
||||||
|
AccountLinkProperties properties, ObjectMapper mapper, HttpClient httpClient) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device credential a successful {@link #register} returns. */
|
||||||
|
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
|
||||||
|
* map auth failures (401/403) through rather than masking everything as a 502.
|
||||||
|
*/
|
||||||
|
public static class UpstreamException extends IOException {
|
||||||
|
private final int status;
|
||||||
|
|
||||||
|
public UpstreamException(int status, String body) {
|
||||||
|
super("SaaS account-link returned HTTP " + status + ": " + body);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int status() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
|
||||||
|
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
|
||||||
|
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
|
||||||
|
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
|
||||||
|
* try/catch.
|
||||||
|
*/
|
||||||
|
public static final class RevokedException extends RuntimeException {
|
||||||
|
private final int status;
|
||||||
|
|
||||||
|
public RevokedException(int status) {
|
||||||
|
super("SaaS entitlement denied (credential revoked/invalid): HTTP " + status);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int status() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
|
||||||
|
* credential.
|
||||||
|
*
|
||||||
|
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
|
||||||
|
* admin).
|
||||||
|
*/
|
||||||
|
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
|
||||||
|
String body =
|
||||||
|
instanceName == null || instanceName.isBlank()
|
||||||
|
? "{}"
|
||||||
|
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
|
||||||
|
HttpRequest request =
|
||||||
|
HttpRequest.newBuilder()
|
||||||
|
.uri(uri("/api/v1/account-link/register"))
|
||||||
|
.header("Authorization", "Bearer " + supabaseJwt)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.timeout(timeout())
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = send(request);
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
throw new UpstreamException(response.statusCode(), response.body());
|
||||||
|
}
|
||||||
|
JsonNode root = mapper.readTree(response.body());
|
||||||
|
String deviceId = text(root, "deviceId");
|
||||||
|
String deviceSecret = text(root, "deviceSecret");
|
||||||
|
if (deviceId == null || deviceSecret == null) {
|
||||||
|
throw new IOException("SaaS register response missing deviceId/deviceSecret");
|
||||||
|
}
|
||||||
|
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
|
||||||
|
return new RegisterResult(deviceId, deviceSecret, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes this instance's own credential on the SaaS side ({@code POST
|
||||||
|
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
|
||||||
|
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
|
||||||
|
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
|
||||||
|
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
|
||||||
|
*/
|
||||||
|
public boolean revokeSelf(String deviceId, String deviceSecret) {
|
||||||
|
try {
|
||||||
|
HttpRequest request =
|
||||||
|
HttpRequest.newBuilder()
|
||||||
|
.uri(uri("/api/v1/instance/revoke-self"))
|
||||||
|
.header(HEADER_DEVICE_ID, deviceId)
|
||||||
|
.header(HEADER_DEVICE_SECRET, deviceSecret)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.timeout(timeout())
|
||||||
|
.POST(HttpRequest.BodyPublishers.noBody())
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = send(request);
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
log.debug("Self-revoke returned HTTP {}", response.statusCode());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Self-revoke failed: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current entitlement using the stored device credential. Three outcomes:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>2xx → the parsed snapshot.
|
||||||
|
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
|
||||||
|
* the caller must BLOCK, not fail open.
|
||||||
|
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
|
||||||
|
* ("unknown" — the caller fails open).
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
|
||||||
|
HttpResponse<String> response;
|
||||||
|
try {
|
||||||
|
HttpRequest request =
|
||||||
|
HttpRequest.newBuilder()
|
||||||
|
.uri(uri("/api/v1/instance/entitlement"))
|
||||||
|
.header(HEADER_DEVICE_ID, deviceId)
|
||||||
|
.header(HEADER_DEVICE_SECRET, deviceSecret)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.timeout(timeout())
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
response = send(request);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Transport failure (timeout / connection refused / interrupted) → unknown, fail open.
|
||||||
|
log.debug("Entitlement fetch failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int status = response.statusCode();
|
||||||
|
if (status == 401 || status == 403) {
|
||||||
|
// Authoritative deny — the SaaS side rejected the credential (revoked/invalid).
|
||||||
|
throw new RevokedException(status);
|
||||||
|
}
|
||||||
|
if (status / 100 != 2) {
|
||||||
|
// Server / transient error → unknown, fail open (do NOT treat as a deny).
|
||||||
|
log.debug("Entitlement fetch returned HTTP {}", status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseEntitlement(response.body());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Entitlement parse failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private InstanceEntitlement parseEntitlement(String body) throws IOException {
|
||||||
|
JsonNode root = mapper.readTree(body);
|
||||||
|
boolean subscribed = root.path("subscribed").asBoolean(false);
|
||||||
|
long freeRemaining = root.path("freeRemainingUnits").asLong(0);
|
||||||
|
long periodSpend = root.path("periodSpendUnits").asLong(0);
|
||||||
|
Long periodCap =
|
||||||
|
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
|
||||||
|
EntitlementState state = mapState(root.path("state").asText(null));
|
||||||
|
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
|
||||||
|
private static EntitlementState mapState(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return EntitlementState.UNKNOWN;
|
||||||
|
}
|
||||||
|
return switch (raw) {
|
||||||
|
case "OK", "ACTIVE", "SUBSCRIBED", "FREE" -> EntitlementState.OK;
|
||||||
|
case "OVER_LIMIT", "PAYG_LIMIT_REACHED", "BLOCKED" -> EntitlementState.OVER_LIMIT;
|
||||||
|
default -> EntitlementState.UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResponse<String> send(HttpRequest request) throws IOException {
|
||||||
|
try {
|
||||||
|
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Interrupted calling SaaS account-link", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI uri(String path) {
|
||||||
|
String base = properties.getSaasBaseUrl().strip().replaceAll("/+$", "");
|
||||||
|
return URI.create(base + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Duration timeout() {
|
||||||
|
return Duration.ofSeconds(properties.getRequestTimeoutSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode node, String field) {
|
||||||
|
return node.hasNonNull(field) ? node.get(field).asText() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
|
||||||
|
*
|
||||||
|
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
|
||||||
|
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
|
||||||
|
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
|
||||||
|
* the portal's link card.
|
||||||
|
*
|
||||||
|
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
|
||||||
|
* stirling.billing.account-link.enabled} — off → bean absent → 404.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Hidden
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/account-link")
|
||||||
|
@Profile("!saas")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class AccountLinkController {
|
||||||
|
|
||||||
|
private final AccountLinkService service;
|
||||||
|
|
||||||
|
public AccountLinkController(AccountLinkService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
|
||||||
|
public record LinkRequest(String supabaseJwt, String name) {}
|
||||||
|
|
||||||
|
@PostMapping("/link")
|
||||||
|
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
|
||||||
|
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(java.util.Map.of("error", "supabaseJwt is required"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
|
||||||
|
} catch (AccountLinkClient.UpstreamException e) {
|
||||||
|
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
|
||||||
|
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
|
||||||
|
// raw upstream body back to the browser.
|
||||||
|
HttpStatus status =
|
||||||
|
e.status() == HttpStatus.UNAUTHORIZED.value()
|
||||||
|
|| e.status() == HttpStatus.FORBIDDEN.value()
|
||||||
|
? HttpStatus.valueOf(e.status())
|
||||||
|
: HttpStatus.BAD_GATEWAY;
|
||||||
|
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
|
||||||
|
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
|
||||||
|
// configured SaaS host/IP. Log it server-side; return the same opaque body the
|
||||||
|
// UpstreamException branch does.
|
||||||
|
log.warn("Account-link failed (transport): {}", e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||||
|
.body(java.util.Map.of("error", "LINK_FAILED"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/status")
|
||||||
|
public ResponseEntity<AccountLinkService.LinkStatus> status() {
|
||||||
|
return ResponseEntity.ok(service.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/unlink")
|
||||||
|
public ResponseEntity<Void> unlink() {
|
||||||
|
service.unlink();
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
|
||||||
|
*
|
||||||
|
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
|
||||||
|
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
|
||||||
|
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
|
||||||
|
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "stirling.billing.account-link")
|
||||||
|
public class AccountLinkProperties {
|
||||||
|
|
||||||
|
/** Master switch. When {@code false} (default) the feature is fully inert. */
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
|
||||||
|
*
|
||||||
|
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
|
||||||
|
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
|
||||||
|
*/
|
||||||
|
private String saasBaseUrl = "https://stirling.com/app";
|
||||||
|
|
||||||
|
/** Cached entitlement is reused for this long before a refresh is attempted. */
|
||||||
|
private long entitlementCacheSeconds = 300;
|
||||||
|
|
||||||
|
/** Connect/read timeout for the outbound SaaS calls. */
|
||||||
|
private int requestTimeoutSeconds = 10;
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
|
||||||
|
*
|
||||||
|
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
|
||||||
|
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
|
||||||
|
* The credential — not the JWT — authenticates all later unattended entitlement calls.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Profile("!saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class AccountLinkService {
|
||||||
|
|
||||||
|
private final AccountLinkClient client;
|
||||||
|
private final DeviceCredentialStore credentialStore;
|
||||||
|
private final EntitlementCache entitlementCache;
|
||||||
|
|
||||||
|
public AccountLinkService(
|
||||||
|
AccountLinkClient client,
|
||||||
|
DeviceCredentialStore credentialStore,
|
||||||
|
EntitlementCache entitlementCache) {
|
||||||
|
this.client = client;
|
||||||
|
this.credentialStore = credentialStore;
|
||||||
|
this.entitlementCache = entitlementCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status of this instance's link, for the portal's "Account link" card. */
|
||||||
|
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
|
||||||
|
* credential.
|
||||||
|
*
|
||||||
|
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
|
||||||
|
*/
|
||||||
|
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
|
||||||
|
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
|
||||||
|
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
|
||||||
|
entitlementCache.invalidate();
|
||||||
|
log.info("Account-link: instance linked to team {}", result.teamId());
|
||||||
|
return status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
|
||||||
|
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
|
||||||
|
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
|
||||||
|
*/
|
||||||
|
public void unlink() {
|
||||||
|
credentialStore
|
||||||
|
.get()
|
||||||
|
.ifPresent(
|
||||||
|
c -> {
|
||||||
|
boolean ok = client.revokeSelf(c.getDeviceId(), c.getDeviceSecret());
|
||||||
|
if (!ok) {
|
||||||
|
log.warn(
|
||||||
|
"Account-link: SaaS self-revoke failed for device {};"
|
||||||
|
+ " clearing locally anyway (admin can revoke"
|
||||||
|
+ " from the portal).",
|
||||||
|
c.getDeviceId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
credentialStore.clear();
|
||||||
|
entitlementCache.invalidate();
|
||||||
|
log.info("Account-link: instance unlinked");
|
||||||
|
}
|
||||||
|
|
||||||
|
public LinkStatus status() {
|
||||||
|
Optional<DeviceCredential> cred = credentialStore.get();
|
||||||
|
return cred.map(
|
||||||
|
c ->
|
||||||
|
new LinkStatus(
|
||||||
|
true,
|
||||||
|
c.getDeviceId(),
|
||||||
|
c.getTeamId(),
|
||||||
|
c.getLinkedAt() != null
|
||||||
|
? c.getLinkedAt().toString()
|
||||||
|
: null))
|
||||||
|
.orElseGet(() -> new LinkStatus(false, null, null, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the account-link entitlement gate. Path patterns cover the billable API surface; the
|
||||||
|
* interceptor itself re-checks billability (and short-circuits manual tools), but scoping here
|
||||||
|
* keeps the gate off the bulk of interactive endpoints entirely.
|
||||||
|
*
|
||||||
|
* <p>Whole config is gated behind {@code stirling.billing.account-link.enabled} +
|
||||||
|
* {@code @Profile("!saas")}; absent when off, so no interceptor is registered.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@Profile("!saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class AccountLinkWebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final InstanceEntitlementInterceptor gateInterceptor;
|
||||||
|
|
||||||
|
public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) {
|
||||||
|
this.gateInterceptor = gateInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual
|
||||||
|
// calls be gated too, while the interceptor lets genuine manual tools through.
|
||||||
|
registry.addInterceptor(gateInterceptor)
|
||||||
|
.addPathPatterns("/api/v1/**")
|
||||||
|
.excludePathPatterns("/api/v1/account-link/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import stirling.software.common.service.InternalApiClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
|
||||||
|
*
|
||||||
|
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
|
||||||
|
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
|
||||||
|
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
|
||||||
|
* sub-steps). Everything else — interactive manual PDF tools — is always free.
|
||||||
|
*/
|
||||||
|
public final class BillableOperationClassifier {
|
||||||
|
|
||||||
|
private static final String AI_PATH_PREFIX = "/api/v1/ai/";
|
||||||
|
|
||||||
|
private BillableOperationClassifier() {}
|
||||||
|
|
||||||
|
public static boolean isBillable(HttpServletRequest request) {
|
||||||
|
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
if (uri == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
|
||||||
|
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
String path =
|
||||||
|
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
|
||||||
|
? uri.substring(ctx.length())
|
||||||
|
: uri;
|
||||||
|
return path.startsWith(AI_PATH_PREFIX);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device credential this self-hosted instance received when it linked a SaaS account
|
||||||
|
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
|
||||||
|
*
|
||||||
|
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
|
||||||
|
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
|
||||||
|
* database (the same store that already holds API-key material and the license signature), so it is
|
||||||
|
* as secure-at-rest as the rest of the instance's secrets.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "account_link_device_credential")
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class DeviceCredential implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public static final Long SINGLETON_ID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id")
|
||||||
|
private Long id = SINGLETON_ID;
|
||||||
|
|
||||||
|
/** Public identifier minted by the SaaS register call; sent as {@code X-Device-Id}. */
|
||||||
|
@Column(name = "device_id", nullable = false, length = 64)
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
/** High-entropy secret returned once by register; sent as {@code X-Device-Secret}. */
|
||||||
|
@Column(name = "device_secret", nullable = false, length = 128)
|
||||||
|
private String deviceSecret;
|
||||||
|
|
||||||
|
/** SaaS team this instance is linked to; informational on the instance side. */
|
||||||
|
@Column(name = "team_id")
|
||||||
|
private Long teamId;
|
||||||
|
|
||||||
|
@Column(name = "linked_at", nullable = false)
|
||||||
|
private LocalDateTime linkedAt;
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface DeviceCredentialRepository extends JpaRepository<DeviceCredential, Long> {
|
||||||
|
|
||||||
|
/** The singleton credential, if this instance has linked. */
|
||||||
|
default Optional<DeviceCredential> findCredential() {
|
||||||
|
return findById(DeviceCredential.SINGLETON_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure-at-rest persistence for this instance's device credential. Thin wrapper over the
|
||||||
|
* singleton-row repository so the rest of the feature never touches JPA directly.
|
||||||
|
*
|
||||||
|
* <p>Gated + {@code @Profile("!saas")}: only the self-hosted profile links outward to a SaaS team.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Profile("!saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class DeviceCredentialStore {
|
||||||
|
|
||||||
|
private final DeviceCredentialRepository repo;
|
||||||
|
|
||||||
|
public DeviceCredentialStore(DeviceCredentialRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<DeviceCredential> get() {
|
||||||
|
return repo.findCredential();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isLinked() {
|
||||||
|
return repo.findCredential().isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists (or replaces) the credential returned by a SaaS register call. */
|
||||||
|
@Transactional
|
||||||
|
public void save(String deviceId, String deviceSecret, Long teamId) {
|
||||||
|
DeviceCredential cred = repo.findCredential().orElseGet(DeviceCredential::new);
|
||||||
|
cred.setId(DeviceCredential.SINGLETON_ID);
|
||||||
|
cred.setDeviceId(deviceId);
|
||||||
|
cred.setDeviceSecret(deviceSecret);
|
||||||
|
cred.setTeamId(teamId);
|
||||||
|
cred.setLinkedAt(LocalDateTime.now());
|
||||||
|
repo.save(cred);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unlinks this instance locally (idempotent). */
|
||||||
|
@Transactional
|
||||||
|
public void clear() {
|
||||||
|
repo.findCredential().ifPresent(repo::delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
|
||||||
|
* every billable request. Single-slot (one instance = one linked team), TTL-based.
|
||||||
|
*
|
||||||
|
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
|
||||||
|
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
|
||||||
|
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
|
||||||
|
* allow").
|
||||||
|
*
|
||||||
|
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
|
||||||
|
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
|
||||||
|
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
|
||||||
|
* rather than serving a stale entitled snapshot.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Profile("!saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class EntitlementCache {
|
||||||
|
|
||||||
|
private final DeviceCredentialStore credentialStore;
|
||||||
|
private final AccountLinkClient client;
|
||||||
|
private final Duration ttl;
|
||||||
|
|
||||||
|
/** Entitlement + fetch time, swapped atomically as one value so readers never tear. */
|
||||||
|
private record Snapshot(InstanceEntitlement entitlement, Instant fetchedAt) {}
|
||||||
|
|
||||||
|
private static final Snapshot EMPTY = new Snapshot(null, Instant.EPOCH);
|
||||||
|
|
||||||
|
/** Blocked entitlement synthesised on an authoritative deny (revoked/invalid credential). */
|
||||||
|
private static final InstanceEntitlement REVOKED =
|
||||||
|
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
|
||||||
|
|
||||||
|
private volatile Snapshot snapshot = EMPTY;
|
||||||
|
|
||||||
|
/** Single-flight guard: one thread refreshes while others serve the current snapshot. */
|
||||||
|
private final AtomicBoolean refreshing = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
public EntitlementCache(
|
||||||
|
DeviceCredentialStore credentialStore,
|
||||||
|
AccountLinkClient client,
|
||||||
|
AccountLinkProperties properties) {
|
||||||
|
this.credentialStore = credentialStore;
|
||||||
|
this.client = client;
|
||||||
|
this.ttl = Duration.ofSeconds(properties.getEntitlementCacheSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current entitlement, refreshing if stale. {@link Optional#empty()} means "unknown" — either
|
||||||
|
* not linked or the SaaS side is unreachable and we have no prior snapshot.
|
||||||
|
*/
|
||||||
|
public Optional<InstanceEntitlement> current() {
|
||||||
|
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
|
||||||
|
// call) while concurrent callers serve the last snapshot — no thundering herd of
|
||||||
|
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
|
||||||
|
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
|
||||||
|
try {
|
||||||
|
refresh();
|
||||||
|
} finally {
|
||||||
|
refreshing.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(snapshot.entitlement());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStale(Snapshot snap) {
|
||||||
|
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
|
||||||
|
// fetch backs off for a full TTL instead of every billable request re-triggering a
|
||||||
|
// blocking round-trip against a dead/slow SaaS endpoint.
|
||||||
|
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
|
||||||
|
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
|
||||||
|
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
|
||||||
|
*/
|
||||||
|
void refresh() {
|
||||||
|
Optional<DeviceCredential> cred = credentialStore.get();
|
||||||
|
if (cred.isEmpty()) {
|
||||||
|
// Unlinked: clear any stale snapshot so the gate sees "not linked".
|
||||||
|
snapshot = new Snapshot(null, Instant.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
InstanceEntitlement fresh =
|
||||||
|
client.fetchEntitlement(cred.get().getDeviceId(), cred.get().getDeviceSecret());
|
||||||
|
if (fresh != null) {
|
||||||
|
snapshot = new Snapshot(fresh, Instant.now());
|
||||||
|
} else {
|
||||||
|
// Unreachable / server error: keep the last known entitlement (may be null) but
|
||||||
|
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
|
||||||
|
log.debug(
|
||||||
|
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
|
||||||
|
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
|
||||||
|
}
|
||||||
|
} catch (AccountLinkClient.RevokedException e) {
|
||||||
|
// Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
|
||||||
|
// rather than serving the stale entitled snapshot until the next unlink.
|
||||||
|
log.info(
|
||||||
|
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
|
||||||
|
e.status());
|
||||||
|
snapshot = new Snapshot(REVOKED, Instant.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forces a refresh on the next {@link #current()} (e.g. right after linking). */
|
||||||
|
public void invalidate() {
|
||||||
|
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse entitlement state the local gate enforces against. Proprietary-local (no coupling to the
|
||||||
|
* saas billing module): the SaaS entitlement response is parsed into this minimal shape.
|
||||||
|
*/
|
||||||
|
public enum EntitlementState {
|
||||||
|
/** Within free pool or covered by an active subscription — billable work allowed. */
|
||||||
|
OK,
|
||||||
|
/** Free pool exhausted and no subscription / over the period cap — billable work blocked. */
|
||||||
|
OVER_LIMIT,
|
||||||
|
/**
|
||||||
|
* Device credential revoked/invalid on the SaaS side (authoritative 401/403 deny) — billable
|
||||||
|
* work blocked. Synthesised locally by {@code EntitlementCache}, never sent by SaaS.
|
||||||
|
*/
|
||||||
|
REVOKED,
|
||||||
|
/** Unrecognised/malformed reply — the gate falls back to its numeric checks, not this flag. */
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of {@link InstanceEntitlementGate}. {@link #allowed} is what the interceptor enforces;
|
||||||
|
* {@link #reason} carries the machine-readable signal the FE maps to a prompt (e.g. "link to
|
||||||
|
* activate"). Manual-tool and fail-open allows carry an informational reason but never block.
|
||||||
|
*/
|
||||||
|
public record GateDecision(boolean allowed, Reason reason) {
|
||||||
|
|
||||||
|
public enum Reason {
|
||||||
|
/** Feature flag is off — gate is fully inert. */
|
||||||
|
FLAG_OFF,
|
||||||
|
/** Operation is a manual tool — always free, never gated. */
|
||||||
|
MANUAL_FREE,
|
||||||
|
/** Linked + within entitlement — billable work allowed. */
|
||||||
|
ENTITLED,
|
||||||
|
/** Entitlement source unreachable — fail open, allow. */
|
||||||
|
FAIL_OPEN,
|
||||||
|
/** Not linked — block billable work; FE should prompt to link. */
|
||||||
|
NOT_LINKED,
|
||||||
|
/** Linked but over the limit / no subscription — block billable work. */
|
||||||
|
OVER_LIMIT,
|
||||||
|
/** Credential revoked/invalid on the SaaS side — block billable work. */
|
||||||
|
REVOKED
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GateDecision allow(Reason reason) {
|
||||||
|
return new GateDecision(true, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GateDecision block(Reason reason) {
|
||||||
|
return new GateDecision(false, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response —
|
||||||
|
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
|
||||||
|
* saas types.
|
||||||
|
*
|
||||||
|
* @param subscribed team has an active subscription
|
||||||
|
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
|
||||||
|
* @param periodSpendUnits paid units spent this period
|
||||||
|
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
|
||||||
|
* @param state coarse state classification (see {@link EntitlementState})
|
||||||
|
*/
|
||||||
|
public record InstanceEntitlement(
|
||||||
|
boolean subscribed,
|
||||||
|
long freeRemainingUnits,
|
||||||
|
long periodSpendUnits,
|
||||||
|
Long periodCapUnits,
|
||||||
|
EntitlementState state) {}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
|
||||||
|
*
|
||||||
|
* <p>Rules (in order):
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>Flag off → always allow (feature inert).
|
||||||
|
* <li>Manual tool → always allow (manual tools are free, never metered).
|
||||||
|
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
|
||||||
|
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
|
||||||
|
* <li>Billable + linked + entitled → allow.
|
||||||
|
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
|
||||||
|
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
|
||||||
|
* live flag / linked-state / entitlement. This is the unit-tested core.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Profile("!saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class InstanceEntitlementGate {
|
||||||
|
|
||||||
|
private final AccountLinkProperties properties;
|
||||||
|
private final DeviceCredentialStore credentialStore;
|
||||||
|
private final EntitlementCache entitlementCache;
|
||||||
|
|
||||||
|
public InstanceEntitlementGate(
|
||||||
|
AccountLinkProperties properties,
|
||||||
|
DeviceCredentialStore credentialStore,
|
||||||
|
EntitlementCache entitlementCache) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.credentialStore = credentialStore;
|
||||||
|
this.entitlementCache = entitlementCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluates the gate for a request, resolving live state from the store + cache. */
|
||||||
|
public GateDecision evaluate(boolean billable) {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
|
||||||
|
}
|
||||||
|
if (!billable) {
|
||||||
|
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
|
||||||
|
}
|
||||||
|
boolean linked = credentialStore.isLinked();
|
||||||
|
Optional<InstanceEntitlement> entitlement =
|
||||||
|
linked ? entitlementCache.current() : Optional.empty();
|
||||||
|
return decide(true, true, linked, entitlement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
|
||||||
|
* (unreachable): when linked, that fails open.
|
||||||
|
*/
|
||||||
|
public static GateDecision decide(
|
||||||
|
boolean flagEnabled,
|
||||||
|
boolean billable,
|
||||||
|
boolean linked,
|
||||||
|
Optional<InstanceEntitlement> entitlement) {
|
||||||
|
if (!flagEnabled) {
|
||||||
|
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
|
||||||
|
}
|
||||||
|
if (!billable) {
|
||||||
|
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
|
||||||
|
}
|
||||||
|
if (!linked) {
|
||||||
|
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
|
||||||
|
}
|
||||||
|
if (entitlement.isEmpty()) {
|
||||||
|
// Linked but entitlement source unreachable — never hard-block billable work on our
|
||||||
|
// inability to reach billing.
|
||||||
|
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
|
||||||
|
}
|
||||||
|
InstanceEntitlement e = entitlement.get();
|
||||||
|
if (e.state() == EntitlementState.REVOKED) {
|
||||||
|
// Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
|
||||||
|
return GateDecision.block(GateDecision.Reason.REVOKED);
|
||||||
|
}
|
||||||
|
return entitled(e)
|
||||||
|
? GateDecision.allow(GateDecision.Reason.ENTITLED)
|
||||||
|
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
|
||||||
|
private static boolean entitled(InstanceEntitlement e) {
|
||||||
|
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.subscribed()) {
|
||||||
|
// Subscribed: allowed unless a period cap is set and exceeded.
|
||||||
|
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
|
||||||
|
}
|
||||||
|
// Unsubscribed: only the free pool covers billable work.
|
||||||
|
return e.freeRemainingUnits() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
|
||||||
|
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
|
||||||
|
*
|
||||||
|
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
|
||||||
|
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
|
||||||
|
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
|
||||||
|
* responses). Fail-open and flag-off both let the request continue.
|
||||||
|
*
|
||||||
|
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
|
||||||
|
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Profile("!saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final InstanceEntitlementGate gate;
|
||||||
|
|
||||||
|
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
|
||||||
|
this.gate = gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(
|
||||||
|
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||||
|
throws Exception {
|
||||||
|
GateDecision decision;
|
||||||
|
try {
|
||||||
|
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
|
||||||
|
// turn into a hard block on billable work.
|
||||||
|
log.debug("Account-link gate evaluation failed; allowing request", e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (decision.allowed()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Account-link gate blocked {} ({})", request.getRequestURI(), decision.reason());
|
||||||
|
response.setStatus(HttpStatus.PAYMENT_REQUIRED.value());
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter()
|
||||||
|
.write(
|
||||||
|
"{\"error\":\"ACCOUNT_LINK_REQUIRED\",\"reason\":\""
|
||||||
|
+ decision.reason().name()
|
||||||
|
+ "\"}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -33,6 +33,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
|||||||
"stirling.software.proprietary.storage.repository",
|
"stirling.software.proprietary.storage.repository",
|
||||||
"stirling.software.proprietary.workflow.repository",
|
"stirling.software.proprietary.workflow.repository",
|
||||||
"stirling.software.proprietary.policy.store",
|
"stirling.software.proprietary.policy.store",
|
||||||
|
"stirling.software.proprietary.accountlink",
|
||||||
"stirling.software.proprietary.policy.source"
|
"stirling.software.proprietary.policy.source"
|
||||||
})
|
})
|
||||||
@EntityScan({
|
@EntityScan({
|
||||||
@@ -41,6 +42,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
|||||||
"stirling.software.proprietary.storage.model",
|
"stirling.software.proprietary.storage.model",
|
||||||
"stirling.software.proprietary.workflow.model",
|
"stirling.software.proprietary.workflow.model",
|
||||||
"stirling.software.proprietary.policy.store",
|
"stirling.software.proprietary.policy.store",
|
||||||
|
"stirling.software.proprietary.accountlink",
|
||||||
"stirling.software.proprietary.policy.source"
|
"stirling.software.proprietary.policy.source"
|
||||||
})
|
})
|
||||||
public class DatabaseConfig {
|
public class DatabaseConfig {
|
||||||
|
|||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
|
||||||
|
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
|
||||||
|
* unreachable) behaviour hold.
|
||||||
|
*/
|
||||||
|
class AccountLinkClientTest {
|
||||||
|
|
||||||
|
private AccountLinkProperties properties;
|
||||||
|
private HttpClient httpClient;
|
||||||
|
private AccountLinkClient client;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
properties = new AccountLinkProperties();
|
||||||
|
properties.setEnabled(true);
|
||||||
|
properties.setSaasBaseUrl("https://saas.example.com");
|
||||||
|
httpClient = mock(HttpClient.class);
|
||||||
|
client = new AccountLinkClient(properties, new ObjectMapper(), httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private HttpResponse<String> response(int status, String body) {
|
||||||
|
HttpResponse<String> resp = mock(HttpResponse.class);
|
||||||
|
when(resp.statusCode()).thenReturn(status);
|
||||||
|
when(resp.body()).thenReturn(body);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void registerRelaysJwtAndParsesCredential() throws Exception {
|
||||||
|
// Build the stub response first: nesting response() inside when() trips Mockito's
|
||||||
|
// unfinished-stubbing check (inner when() runs mid outer when()).
|
||||||
|
HttpResponse<String> resp =
|
||||||
|
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
|
||||||
|
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||||
|
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenReturn(resp);
|
||||||
|
|
||||||
|
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
|
||||||
|
|
||||||
|
assertEquals("dev-1", result.deviceId());
|
||||||
|
assertEquals("sec-1", result.deviceSecret());
|
||||||
|
assertEquals(42L, result.teamId());
|
||||||
|
|
||||||
|
HttpRequest sent = captor.getValue();
|
||||||
|
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
|
||||||
|
assertEquals(
|
||||||
|
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
|
||||||
|
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
|
||||||
|
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||||
|
AccountLinkClient.UpstreamException ex =
|
||||||
|
assertThrows(
|
||||||
|
AccountLinkClient.UpstreamException.class,
|
||||||
|
() -> client.register("jwt", null));
|
||||||
|
assertEquals(401, ex.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void fetchEntitlementParsesSnapshotAndSendsDeviceHeaders() throws Exception {
|
||||||
|
HttpResponse<String> resp =
|
||||||
|
response(
|
||||||
|
200,
|
||||||
|
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":10,\"periodCapUnits\":100,\"state\":\"OK\"}");
|
||||||
|
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||||
|
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenReturn(resp);
|
||||||
|
|
||||||
|
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
|
||||||
|
|
||||||
|
assertNotNull(e);
|
||||||
|
assertEquals(true, e.subscribed());
|
||||||
|
assertEquals(10, e.periodSpendUnits());
|
||||||
|
assertEquals(100L, e.periodCapUnits());
|
||||||
|
assertEquals(EntitlementState.OK, e.state());
|
||||||
|
|
||||||
|
HttpRequest sent = captor.getValue();
|
||||||
|
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
|
||||||
|
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void fetchEntitlementMapsOverLimitState() throws Exception {
|
||||||
|
// Pins the consume side of the wire contract: InstanceController emits "OVER_LIMIT" (for a
|
||||||
|
// DEGRADED team) and the client must map it to the gate-blocking state.
|
||||||
|
HttpResponse<String> resp =
|
||||||
|
response(
|
||||||
|
200,
|
||||||
|
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":1300,\"periodCapUnits\":1250,\"state\":\"OVER_LIMIT\"}");
|
||||||
|
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||||
|
|
||||||
|
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
|
||||||
|
|
||||||
|
assertNotNull(e);
|
||||||
|
assertEquals(EntitlementState.OVER_LIMIT, e.state());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void fetchEntitlementReturnsNullWhenUnreachable() throws Exception {
|
||||||
|
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenThrow(new ConnectException("refused"));
|
||||||
|
// Null = unknown → the cache/gate fail open.
|
||||||
|
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void fetchEntitlementReturnsNullOnServerError() throws Exception {
|
||||||
|
// 5xx is a transient/server failure, not a credential deny → null, the cache fails open.
|
||||||
|
HttpResponse<String> resp = response(503, "{}");
|
||||||
|
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||||
|
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void fetchEntitlementThrowsRevokedOnDeny() throws Exception {
|
||||||
|
// 401/403 = authoritative deny (revoked/invalid credential) → RevokedException, NOT null:
|
||||||
|
// the cache must block billable work rather than fail open on a stale snapshot.
|
||||||
|
for (int status : new int[] {401, 403}) {
|
||||||
|
HttpResponse<String> resp = response(status, "{}");
|
||||||
|
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||||
|
AccountLinkClient.RevokedException ex =
|
||||||
|
assertThrows(
|
||||||
|
AccountLinkClient.RevokedException.class,
|
||||||
|
() -> client.fetchEntitlement("dev-1", "sec-1"));
|
||||||
|
assertEquals(status, ex.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void revokeSelfSendsDeviceHeadersAndReturnsTrueOn2xx() throws Exception {
|
||||||
|
HttpResponse<String> resp = response(204, "");
|
||||||
|
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||||
|
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenReturn(resp);
|
||||||
|
|
||||||
|
assertEquals(true, client.revokeSelf("dev-1", "sec-1"));
|
||||||
|
|
||||||
|
HttpRequest sent = captor.getValue();
|
||||||
|
assertEquals("https://saas.example.com/api/v1/instance/revoke-self", sent.uri().toString());
|
||||||
|
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
|
||||||
|
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
|
||||||
|
assertEquals("POST", sent.method());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void revokeSelfReturnsFalseOnErrorStatus() throws Exception {
|
||||||
|
HttpResponse<String> resp = response(403, "{}");
|
||||||
|
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||||
|
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void revokeSelfReturnsFalseWhenUnreachable() throws Exception {
|
||||||
|
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenThrow(new ConnectException("refused"));
|
||||||
|
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
|
||||||
|
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
|
||||||
|
* faults are a 502.
|
||||||
|
*/
|
||||||
|
class AccountLinkControllerTest {
|
||||||
|
|
||||||
|
private AccountLinkService service;
|
||||||
|
private AccountLinkController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = mock(AccountLinkService.class);
|
||||||
|
controller = new AccountLinkController(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_missingJwt_returns400() {
|
||||||
|
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_upstreamUnauthorized_maps401() throws Exception {
|
||||||
|
when(service.link("jwt", null))
|
||||||
|
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
|
||||||
|
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_upstreamForbidden_maps403() throws Exception {
|
||||||
|
when(service.link("jwt", null))
|
||||||
|
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
|
||||||
|
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_upstreamServerError_maps502() throws Exception {
|
||||||
|
when(service.link("jwt", null))
|
||||||
|
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
|
||||||
|
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_transportFailure_maps502() throws Exception {
|
||||||
|
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
|
||||||
|
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class AccountLinkServiceTest {
|
||||||
|
|
||||||
|
private AccountLinkClient client;
|
||||||
|
private DeviceCredentialStore store;
|
||||||
|
private EntitlementCache cache;
|
||||||
|
private AccountLinkService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
client = mock(AccountLinkClient.class);
|
||||||
|
store = mock(DeviceCredentialStore.class);
|
||||||
|
cache = mock(EntitlementCache.class);
|
||||||
|
service = new AccountLinkService(client, store, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_storesCredentialAndInvalidatesCache() throws IOException {
|
||||||
|
when(client.register("jwt", "name"))
|
||||||
|
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
|
||||||
|
DeviceCredential stored = new DeviceCredential();
|
||||||
|
stored.setDeviceId("dev-1");
|
||||||
|
stored.setTeamId(7L);
|
||||||
|
stored.setLinkedAt(LocalDateTime.now());
|
||||||
|
when(store.get()).thenReturn(Optional.of(stored));
|
||||||
|
|
||||||
|
AccountLinkService.LinkStatus status = service.link("jwt", "name");
|
||||||
|
|
||||||
|
verify(store).save("dev-1", "sec-1", 7L);
|
||||||
|
verify(cache).invalidate();
|
||||||
|
assertTrue(status.linked());
|
||||||
|
assertEquals("dev-1", status.deviceId());
|
||||||
|
assertEquals(7L, status.teamId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void link_propagatesRegisterFailure() throws IOException {
|
||||||
|
when(client.register(any(), any())).thenThrow(new IOException("boom"));
|
||||||
|
org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
IOException.class, () -> service.link("jwt", null));
|
||||||
|
verify(cache, org.mockito.Mockito.never()).invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void status_unlinkedWhenNoCredential() {
|
||||||
|
when(store.get()).thenReturn(Optional.empty());
|
||||||
|
AccountLinkService.LinkStatus status = service.status();
|
||||||
|
assertFalse(status.linked());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlink_callsSaasRevokeBeforeClearingLocally() {
|
||||||
|
DeviceCredential cred = new DeviceCredential();
|
||||||
|
cred.setDeviceId("dev-1");
|
||||||
|
cred.setDeviceSecret("sec-1");
|
||||||
|
cred.setTeamId(7L);
|
||||||
|
cred.setLinkedAt(LocalDateTime.now());
|
||||||
|
when(store.get()).thenReturn(Optional.of(cred));
|
||||||
|
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(true);
|
||||||
|
|
||||||
|
service.unlink();
|
||||||
|
|
||||||
|
verify(client).revokeSelf("dev-1", "sec-1");
|
||||||
|
verify(store).clear();
|
||||||
|
verify(cache).invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlink_clearsLocallyEvenWhenSaasRevokeFails() {
|
||||||
|
DeviceCredential cred = new DeviceCredential();
|
||||||
|
cred.setDeviceId("dev-1");
|
||||||
|
cred.setDeviceSecret("sec-1");
|
||||||
|
cred.setLinkedAt(LocalDateTime.now());
|
||||||
|
when(store.get()).thenReturn(Optional.of(cred));
|
||||||
|
// SaaS unreachable / returns non-2xx.
|
||||||
|
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(false);
|
||||||
|
|
||||||
|
service.unlink();
|
||||||
|
|
||||||
|
// Local clear MUST still happen — admin's intent wins; orphan row is a follow-up.
|
||||||
|
verify(store).clear();
|
||||||
|
verify(cache).invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlink_whenAlreadyUnlinked_skipsSaasRevoke() {
|
||||||
|
when(store.get()).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
service.unlink();
|
||||||
|
|
||||||
|
org.mockito.Mockito.verifyNoInteractions(client);
|
||||||
|
verify(store).clear();
|
||||||
|
verify(cache).invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
|
||||||
|
import stirling.software.common.service.InternalApiClient;
|
||||||
|
|
||||||
|
class BillableOperationClassifierTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aiPathIsBillable() {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
|
||||||
|
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void automationHeaderIsBillable() {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||||
|
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
|
||||||
|
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainManualToolIsFree() {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||||
|
assertFalse(BillableOperationClassifier.isBillable(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aiSegmentNotAtPathStartIsFree() {
|
||||||
|
// Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
|
||||||
|
// proxy prefix) must NOT classify a manual tool as billable.
|
||||||
|
MockHttpServletRequest req =
|
||||||
|
new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
|
||||||
|
assertFalse(BillableOperationClassifier.isBillable(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aiPathUnderContextPathIsBillable() {
|
||||||
|
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
|
||||||
|
MockHttpServletRequest req =
|
||||||
|
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
|
||||||
|
req.setContextPath("/stirling");
|
||||||
|
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class EntitlementCacheTest {
|
||||||
|
|
||||||
|
private DeviceCredentialStore store;
|
||||||
|
private AccountLinkClient client;
|
||||||
|
private AccountLinkProperties properties;
|
||||||
|
private EntitlementCache cache;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
store = mock(DeviceCredentialStore.class);
|
||||||
|
client = mock(AccountLinkClient.class);
|
||||||
|
properties = new AccountLinkProperties();
|
||||||
|
properties.setEntitlementCacheSeconds(300);
|
||||||
|
cache = new EntitlementCache(store, client, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeviceCredential cred() {
|
||||||
|
DeviceCredential c = new DeviceCredential();
|
||||||
|
c.setDeviceId("dev-1");
|
||||||
|
c.setDeviceSecret("sec-1");
|
||||||
|
c.setTeamId(1L);
|
||||||
|
c.setLinkedAt(LocalDateTime.now());
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlinked_returnsEmpty() {
|
||||||
|
when(store.get()).thenReturn(Optional.empty());
|
||||||
|
assertTrue(cache.current().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linked_fetchesAndCachesWithinTtl() {
|
||||||
|
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
|
||||||
|
when(store.get()).thenReturn(Optional.of(cred()));
|
||||||
|
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
|
||||||
|
|
||||||
|
assertEquals(snap, cache.current().orElseThrow());
|
||||||
|
// Second read within TTL must not re-fetch.
|
||||||
|
assertEquals(snap, cache.current().orElseThrow());
|
||||||
|
verify(client, times(1)).fetchEntitlement(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linked_unreachable_keepsLastKnownSnapshot_failOpenFriendly() {
|
||||||
|
InstanceEntitlement snap = new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
|
||||||
|
when(store.get()).thenReturn(Optional.of(cred()));
|
||||||
|
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
|
||||||
|
assertEquals(snap, cache.current().orElseThrow());
|
||||||
|
|
||||||
|
// TTL elapsed → refresh attempted, but the SaaS side is now unreachable (null).
|
||||||
|
cache.invalidate();
|
||||||
|
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
|
||||||
|
assertEquals(snap, cache.current().orElseThrow(), "stale snapshot retained on failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linked_neverFetched_unreachable_backsOffWithinTtl() {
|
||||||
|
// No prior snapshot + SaaS unreachable: the gate fails open (empty), but a failed
|
||||||
|
// attempt stamps the TTL so a second read within the window does NOT re-fetch —
|
||||||
|
// no sustained hammer of blocking round-trips against a dead endpoint.
|
||||||
|
when(store.get()).thenReturn(Optional.of(cred()));
|
||||||
|
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
|
||||||
|
|
||||||
|
assertTrue(cache.current().isEmpty());
|
||||||
|
assertTrue(cache.current().isEmpty());
|
||||||
|
verify(client, times(1)).fetchEntitlement(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linked_revoked_blocksAndDropsStaleEntitlement() {
|
||||||
|
InstanceEntitlement entitled =
|
||||||
|
new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
|
||||||
|
when(store.get()).thenReturn(Optional.of(cred()));
|
||||||
|
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(entitled);
|
||||||
|
assertEquals(entitled, cache.current().orElseThrow());
|
||||||
|
|
||||||
|
// Credential revoked: the next refresh is an authoritative deny. The cache must NOT keep
|
||||||
|
// serving the stale entitled snapshot — it replaces it with a blocked REVOKED one.
|
||||||
|
cache.invalidate();
|
||||||
|
when(client.fetchEntitlement(anyString(), anyString()))
|
||||||
|
.thenThrow(new AccountLinkClient.RevokedException(401));
|
||||||
|
assertEquals(EntitlementState.REVOKED, cache.current().orElseThrow().state());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidate_forcesRefetch() {
|
||||||
|
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
|
||||||
|
when(store.get()).thenReturn(Optional.of(cred()));
|
||||||
|
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
|
||||||
|
|
||||||
|
cache.current();
|
||||||
|
cache.invalidate();
|
||||||
|
cache.current();
|
||||||
|
verify(client, times(2)).fetchEntitlement(any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.accountlink.GateDecision.Reason;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
|
||||||
|
* over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
|
||||||
|
* needed.
|
||||||
|
*/
|
||||||
|
class InstanceEntitlementGateTest {
|
||||||
|
|
||||||
|
private static InstanceEntitlement free() {
|
||||||
|
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InstanceEntitlement exhaustedUnsubscribed() {
|
||||||
|
return new InstanceEntitlement(false, 0, 0, null, EntitlementState.OVER_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InstanceEntitlement subscribedWithinCap() {
|
||||||
|
return new InstanceEntitlement(true, 0, 10, 100L, EntitlementState.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InstanceEntitlement subscribedOverCap() {
|
||||||
|
return new InstanceEntitlement(true, 0, 100, 100L, EntitlementState.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagOff_allowsEverything_evenBillableUnlinked() {
|
||||||
|
GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(Reason.FLAG_OFF, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void manualTool_alwaysFree_evenUnlinked() {
|
||||||
|
GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(Reason.MANUAL_FREE, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_notLinked_blocksWithLinkSignal() {
|
||||||
|
GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
|
||||||
|
assertFalse(d.allowed());
|
||||||
|
assertEquals(Reason.NOT_LINKED, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_linked_entitlementUnreachable_failsOpen() {
|
||||||
|
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(Reason.FAIL_OPEN, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_linked_freePoolAvailable_allows() {
|
||||||
|
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(Reason.ENTITLED, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
|
||||||
|
GateDecision d =
|
||||||
|
InstanceEntitlementGate.decide(
|
||||||
|
true, true, true, Optional.of(exhaustedUnsubscribed()));
|
||||||
|
assertFalse(d.allowed());
|
||||||
|
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_linked_subscribedWithinCap_allows() {
|
||||||
|
GateDecision d =
|
||||||
|
InstanceEntitlementGate.decide(
|
||||||
|
true, true, true, Optional.of(subscribedWithinCap()));
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(Reason.ENTITLED, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_linked_subscribedOverCap_blocks() {
|
||||||
|
GateDecision d =
|
||||||
|
InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
|
||||||
|
assertFalse(d.allowed());
|
||||||
|
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_linked_revoked_blocksWithRevokedSignal() {
|
||||||
|
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED —
|
||||||
|
// blocks distinctly from over-limit, even though the snapshot is "present".
|
||||||
|
InstanceEntitlement revoked =
|
||||||
|
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
|
||||||
|
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
|
||||||
|
assertFalse(d.allowed());
|
||||||
|
assertEquals(Reason.REVOKED, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() {
|
||||||
|
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
|
||||||
|
InstanceEntitlement conflicting =
|
||||||
|
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
|
||||||
|
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
|
||||||
|
assertFalse(d.allowed());
|
||||||
|
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/** Verifies {@link InstanceEntitlementGate#evaluate} resolves live state from store + cache. */
|
||||||
|
class InstanceEntitlementGateWiringTest {
|
||||||
|
|
||||||
|
private AccountLinkProperties properties;
|
||||||
|
private DeviceCredentialStore store;
|
||||||
|
private EntitlementCache cache;
|
||||||
|
private InstanceEntitlementGate gate;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
properties = new AccountLinkProperties();
|
||||||
|
properties.setEnabled(true);
|
||||||
|
store = mock(DeviceCredentialStore.class);
|
||||||
|
cache = mock(EntitlementCache.class);
|
||||||
|
gate = new InstanceEntitlementGate(properties, store, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void manualNeverConsultsStoreOrCache() {
|
||||||
|
GateDecision d = gate.evaluate(false);
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(GateDecision.Reason.MANUAL_FREE, d.reason());
|
||||||
|
verify(store, never()).isLinked();
|
||||||
|
verify(cache, never()).current();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billableUnlinkedDoesNotHitCache() {
|
||||||
|
when(store.isLinked()).thenReturn(false);
|
||||||
|
GateDecision d = gate.evaluate(true);
|
||||||
|
assertFalse(d.allowed());
|
||||||
|
assertEquals(GateDecision.Reason.NOT_LINKED, d.reason());
|
||||||
|
verify(cache, never()).current();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void billableLinkedConsultsCache() {
|
||||||
|
when(store.isLinked()).thenReturn(true);
|
||||||
|
when(cache.current())
|
||||||
|
.thenReturn(
|
||||||
|
Optional.of(
|
||||||
|
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
|
||||||
|
GateDecision d = gate.evaluate(true);
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagOffShortCircuits() {
|
||||||
|
properties.setEnabled(false);
|
||||||
|
GateDecision d = gate.evaluate(true);
|
||||||
|
assertTrue(d.allowed());
|
||||||
|
assertEquals(GateDecision.Reason.FLAG_OFF, d.reason());
|
||||||
|
verify(store, never()).isLinked();
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package stirling.software.proprietary.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class InstanceEntitlementInterceptorTest {
|
||||||
|
|
||||||
|
@Mock private InstanceEntitlementGate gate;
|
||||||
|
|
||||||
|
private boolean preHandle(MockHttpServletResponse response) throws Exception {
|
||||||
|
return new InstanceEntitlementInterceptor(gate)
|
||||||
|
.preHandle(
|
||||||
|
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsWhenGateAllows() throws Exception {
|
||||||
|
when(gate.evaluate(anyBoolean()))
|
||||||
|
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
assertTrue(preHandle(response));
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocksWith402AndLinkSignalWhenGateBlocks() throws Exception {
|
||||||
|
when(gate.evaluate(anyBoolean()))
|
||||||
|
.thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED));
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
assertFalse(preHandle(response));
|
||||||
|
assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), response.getStatus());
|
||||||
|
assertEquals("application/json", response.getContentType());
|
||||||
|
assertTrue(response.getContentAsString().contains("ACCOUNT_LINK_REQUIRED"));
|
||||||
|
assertTrue(response.getContentAsString().contains("NOT_LINKED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failsOpenWhenGateThrows() throws Exception {
|
||||||
|
// A DB / SaaS blip while resolving entitlement must never hard-block billable work.
|
||||||
|
when(gate.evaluate(anyBoolean()))
|
||||||
|
.thenThrow(new RuntimeException("entitlement source down"));
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
assertTrue(preHandle(response));
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.model.enumeration.TeamRole;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.saas.model.TeamMembership;
|
||||||
|
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||||
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-link registration surface (combined-billing "Mode A").
|
||||||
|
*
|
||||||
|
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
|
||||||
|
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
|
||||||
|
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
|
||||||
|
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
|
||||||
|
* JWT.
|
||||||
|
*
|
||||||
|
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
|
||||||
|
* 404. Leader-only, and the team is always derived from the caller (never the request body).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Hidden
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/account-link")
|
||||||
|
@Profile("saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class AccountLinkController {
|
||||||
|
|
||||||
|
private final AccountLinkService service;
|
||||||
|
private final TeamMembershipRepository memberRepo;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public AccountLinkController(
|
||||||
|
AccountLinkService service,
|
||||||
|
TeamMembershipRepository memberRepo,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.service = service;
|
||||||
|
this.memberRepo = memberRepo;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional display name for the instance (hostname / label). */
|
||||||
|
public record RegisterRequest(String name) {}
|
||||||
|
|
||||||
|
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
|
||||||
|
public record RegisterResponse(
|
||||||
|
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
|
||||||
|
|
||||||
|
public record InstanceRow(
|
||||||
|
Long instanceId,
|
||||||
|
String deviceId,
|
||||||
|
String name,
|
||||||
|
String createdAt,
|
||||||
|
String lastSeenAt,
|
||||||
|
boolean revoked) {}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ResponseEntity<RegisterResponse> register(
|
||||||
|
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
|
||||||
|
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||||
|
if (lt.error() != null) {
|
||||||
|
return ResponseEntity.status(lt.error()).build();
|
||||||
|
}
|
||||||
|
String name = req != null ? req.name() : null;
|
||||||
|
AccountLinkService.RegisteredInstance reg =
|
||||||
|
service.register(lt.teamId(), lt.userId(), name);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(
|
||||||
|
new RegisterResponse(
|
||||||
|
reg.instanceId(),
|
||||||
|
lt.teamId(),
|
||||||
|
reg.deviceId(),
|
||||||
|
reg.deviceSecret(),
|
||||||
|
reg.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/instances")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
|
||||||
|
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||||
|
if (lt.error() != null) {
|
||||||
|
return ResponseEntity.status(lt.error()).build();
|
||||||
|
}
|
||||||
|
List<InstanceRow> rows =
|
||||||
|
service.list(lt.teamId()).stream()
|
||||||
|
.map(
|
||||||
|
i ->
|
||||||
|
new InstanceRow(
|
||||||
|
i.getInstanceId(),
|
||||||
|
i.getDeviceId(),
|
||||||
|
i.getName(),
|
||||||
|
i.getCreatedAt() != null
|
||||||
|
? i.getCreatedAt().toString()
|
||||||
|
: null,
|
||||||
|
i.getLastSeenAt() != null
|
||||||
|
? i.getLastSeenAt().toString()
|
||||||
|
: null,
|
||||||
|
i.getRevokedAt() != null))
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/instances/{instanceId}/revoke")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
|
||||||
|
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||||
|
if (lt.error() != null) {
|
||||||
|
return ResponseEntity.status(lt.error()).build();
|
||||||
|
}
|
||||||
|
boolean ok = service.revoke(lt.teamId(), instanceId);
|
||||||
|
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
|
||||||
|
*/
|
||||||
|
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
|
||||||
|
|
||||||
|
private LeaderTeam resolveLeaderTeam(Authentication auth) {
|
||||||
|
User user;
|
||||||
|
try {
|
||||||
|
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
TeamMembership m = rows.get(0);
|
||||||
|
if (m.getRole() != TeamRole.LEADER) {
|
||||||
|
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-link instance registration + lifecycle (combined-billing "Mode A").
|
||||||
|
*
|
||||||
|
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
|
||||||
|
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
|
||||||
|
* unattended entitlement reads with that credential.
|
||||||
|
*
|
||||||
|
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
|
||||||
|
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Profile("saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class AccountLinkService {
|
||||||
|
|
||||||
|
/** 32 bytes of entropy → URL-safe secret; high enough that an unsalted SHA-256 hash is fine. */
|
||||||
|
private static final int SECRET_BYTES = 32;
|
||||||
|
|
||||||
|
private final LinkedInstanceRepository repo;
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
public AccountLinkService(LinkedInstanceRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of {@link #register}; {@code deviceSecret} is plaintext and returned exactly once. */
|
||||||
|
public record RegisteredInstance(
|
||||||
|
Long instanceId, String deviceId, String deviceSecret, String name) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new linked instance for {@code teamId}, returning the one-time plaintext secret.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public RegisteredInstance register(Long teamId, Long createdByUserId, String name) {
|
||||||
|
String deviceId = UUID.randomUUID().toString();
|
||||||
|
String deviceSecret = randomSecret();
|
||||||
|
|
||||||
|
LinkedInstance instance = new LinkedInstance();
|
||||||
|
instance.setTeamId(teamId);
|
||||||
|
instance.setCreatedByUserId(createdByUserId);
|
||||||
|
instance.setDeviceId(deviceId);
|
||||||
|
instance.setDeviceSecretHash(sha256Hex(deviceSecret));
|
||||||
|
instance.setName(name);
|
||||||
|
repo.save(instance);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Account-link: registered instance {} (device {}) for team {}",
|
||||||
|
instance.getInstanceId(),
|
||||||
|
deviceId,
|
||||||
|
teamId);
|
||||||
|
return new RegisteredInstance(instance.getInstanceId(), deviceId, deviceSecret, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All instances for a team, newest first (includes revoked, for the "Linked instances" list).
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<LinkedInstance> list(Long teamId) {
|
||||||
|
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
|
||||||
|
* a different team (so a caller can never revoke another team's instance). Idempotent.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean revoke(Long teamId, Long instanceId) {
|
||||||
|
Optional<LinkedInstance> found = repo.findById(instanceId);
|
||||||
|
if (found.isEmpty() || !found.get().getTeamId().equals(teamId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LinkedInstance instance = found.get();
|
||||||
|
if (instance.getRevokedAt() == null) {
|
||||||
|
instance.setRevokedAt(LocalDateTime.now());
|
||||||
|
repo.save(instance);
|
||||||
|
log.info("Account-link: revoked instance {} for team {}", instanceId, teamId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String randomSecret() {
|
||||||
|
byte[] buf = new byte[SECRET_BYTES];
|
||||||
|
random.nextBytes(buf);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
|
||||||
|
static String sha256Hex(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(md.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
|
||||||
|
*
|
||||||
|
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
|
||||||
|
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
|
||||||
|
* stored hash. On a match it sets a {@link LinkedInstanceAuthenticationToken} (team-scoped, {@code
|
||||||
|
* ROLE_LINKED_INSTANCE}); otherwise it does nothing and lets the chain continue (→ 401 on a
|
||||||
|
* protected endpoint).
|
||||||
|
*
|
||||||
|
* <p>Read-only and <b>path-scoped to {@code /api/v1/instance/**}</b>: the device principal is never
|
||||||
|
* established for user-facing endpoints, so a leaked secret can only reach the instance surface.
|
||||||
|
* Gated behind {@code stirling.billing.account-link.enabled}; absent when the flag is off, so
|
||||||
|
* {@code SupabaseSecurityConfig} never wires it in.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Profile("saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class DeviceCredentialAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
static final String HEADER_DEVICE_ID = "X-Device-Id";
|
||||||
|
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
|
||||||
|
static final String INSTANCE_PATH_PREFIX = "/api/v1/instance/";
|
||||||
|
|
||||||
|
private final LinkedInstanceRepository repo;
|
||||||
|
|
||||||
|
public DeviceCredentialAuthenticationFilter(LinkedInstanceRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the instance surface uses the device credential; everything else skips this filter. */
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
return !request.getRequestURI().startsWith(INSTANCE_PATH_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(
|
||||||
|
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
String deviceId = request.getHeader(HEADER_DEVICE_ID);
|
||||||
|
String secret = request.getHeader(HEADER_DEVICE_SECRET);
|
||||||
|
|
||||||
|
if (deviceId != null
|
||||||
|
&& secret != null
|
||||||
|
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
|
||||||
|
.ifPresent(
|
||||||
|
instance -> {
|
||||||
|
if (constantTimeEquals(
|
||||||
|
AccountLinkService.sha256Hex(secret),
|
||||||
|
instance.getDeviceSecretHash())) {
|
||||||
|
SecurityContextHolder.getContext()
|
||||||
|
.setAuthentication(
|
||||||
|
new LinkedInstanceAuthenticationToken(
|
||||||
|
instance.getInstanceId(),
|
||||||
|
instance.getTeamId()));
|
||||||
|
// Stamp liveness, best-effort. Auth is already set above; a
|
||||||
|
// transient write failure must NOT 500 an otherwise-valid
|
||||||
|
// request, so swallow it. Targeted single-column UPDATE (not a
|
||||||
|
// full save) so a concurrent revoke between the read above and
|
||||||
|
// this write can't be clobbered back to active.
|
||||||
|
try {
|
||||||
|
repo.touchLastSeen(
|
||||||
|
instance.getInstanceId(), LocalDateTime.now());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.debug(
|
||||||
|
"last_seen_at update failed for device {}: {}",
|
||||||
|
deviceId,
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.debug("Device credential mismatch for device {}", deviceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean constantTimeEquals(String a, String b) {
|
||||||
|
if (a == null || b == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return MessageDigest.isEqual(
|
||||||
|
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||||
|
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||||
|
import stirling.software.saas.payg.entitlement.EntitlementService;
|
||||||
|
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
|
||||||
|
import stirling.software.saas.payg.model.EntitlementState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
|
||||||
|
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
|
||||||
|
* credential is scoped here and nowhere else.
|
||||||
|
*
|
||||||
|
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
|
||||||
|
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
|
||||||
|
* /entitlement} is the read the local gate consumes — the same team-scoped snapshot the FE wallet
|
||||||
|
* sees, trimmed to the fields the gate needs (subscription, free pool, period spend/cap, state),
|
||||||
|
* and built on the same device-credential auth.
|
||||||
|
*
|
||||||
|
* <p>Gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → 404.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Hidden
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/instance")
|
||||||
|
@Profile("saas")
|
||||||
|
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||||
|
public class InstanceController {
|
||||||
|
|
||||||
|
private final EntitlementService entitlementService;
|
||||||
|
private final TeamBillingService billingService;
|
||||||
|
private final AccountLinkService accountLinkService;
|
||||||
|
|
||||||
|
public InstanceController(
|
||||||
|
EntitlementService entitlementService,
|
||||||
|
TeamBillingService billingService,
|
||||||
|
AccountLinkService accountLinkService) {
|
||||||
|
this.entitlementService = entitlementService;
|
||||||
|
this.billingService = billingService;
|
||||||
|
this.accountLinkService = accountLinkService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WhoAmIResponse(Long instanceId, Long teamId) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal entitlement view the local gate enforces against. {@code periodCapUnits} null =
|
||||||
|
* uncapped. {@code state} is the coarse OK / OVER_LIMIT vocabulary the instance gate parses
|
||||||
|
* (see {@link #coarseState}), not the SaaS feature-state enum.
|
||||||
|
*/
|
||||||
|
public record EntitlementResponse(
|
||||||
|
boolean subscribed,
|
||||||
|
long freeRemainingUnits,
|
||||||
|
long periodSpendUnits,
|
||||||
|
Long periodCapUnits,
|
||||||
|
String state) {}
|
||||||
|
|
||||||
|
@GetMapping("/whoami")
|
||||||
|
@PreAuthorize("hasRole('LINKED_INSTANCE')")
|
||||||
|
public ResponseEntity<WhoAmIResponse> whoami(Authentication auth) {
|
||||||
|
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
|
||||||
|
// Belt-and-braces: hasRole already guarantees this, but never leak a non-instance
|
||||||
|
// principal.
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(new WhoAmIResponse(token.getInstanceId(), token.getTeamId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes this instance's own credential — a credential can mark itself revoked the same way a
|
||||||
|
* session logs itself out. Called by the proprietary backend on local unlink so the SaaS row
|
||||||
|
* gets {@code revoked_at} set; idempotent (already-revoked → still 204).
|
||||||
|
*/
|
||||||
|
@PostMapping("/revoke-self")
|
||||||
|
@PreAuthorize("hasRole('LINKED_INSTANCE')")
|
||||||
|
public ResponseEntity<Void> revokeSelf(Authentication auth) {
|
||||||
|
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
accountLinkService.revoke(token.getTeamId(), token.getInstanceId());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/entitlement")
|
||||||
|
@PreAuthorize("hasRole('LINKED_INSTANCE')")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ResponseEntity<EntitlementResponse> entitlement(Authentication auth) {
|
||||||
|
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
Long teamId = token.getTeamId();
|
||||||
|
|
||||||
|
// Same composition the FE wallet uses: billing facts (subscription, free pool) from
|
||||||
|
// TeamBillingService, period spend/cap + state from the entitlement snapshot.
|
||||||
|
TeamBillingContext billing = billingService.forTeam(teamId);
|
||||||
|
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new EntitlementResponse(
|
||||||
|
billing.subscribed(),
|
||||||
|
billing.freeRemainingUnits(),
|
||||||
|
snap.periodSpendUnits(),
|
||||||
|
snap.periodCapUnits(),
|
||||||
|
coarseState(snap.state())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses the SaaS feature-state machine into the OK / OVER_LIMIT vocabulary the instance
|
||||||
|
* gate parses. DEGRADED means automation + AI are gated off — which, for a gate that governs
|
||||||
|
* only billable work (manual tools are free-pathed before it), is exactly OVER_LIMIT; FULL and
|
||||||
|
* WARNED are OK.
|
||||||
|
*/
|
||||||
|
private static String coarseState(EntitlementState state) {
|
||||||
|
return state == EntitlementState.DEGRADED ? "OVER_LIMIT" : "OK";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
|
||||||
|
* linked_instance}, V22).
|
||||||
|
*
|
||||||
|
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
|
||||||
|
* short-lived Supabase JWT. Registration mints a {@code device_id} (public) plus a {@code
|
||||||
|
* device_secret} (high-entropy, returned once and stored only on the instance — we keep an unsalted
|
||||||
|
* SHA-256 hash, the same posture as API keys). The instance authenticates its unattended
|
||||||
|
* entitlement reads with that device credential, so no long-lived user JWT lives on the server
|
||||||
|
* side.
|
||||||
|
*
|
||||||
|
* <p>{@code revoked_at IS NULL} means active; revoking sets it and the credential stops
|
||||||
|
* authenticating. The whole surface is gated behind {@code stirling.billing.account-link.enabled}.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "linked_instance")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class LinkedInstance {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "instance_id")
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
@Column(name = "team_id", nullable = false)
|
||||||
|
private Long teamId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin who registered the instance; informational (no FK, so a user delete never offlines it).
|
||||||
|
*/
|
||||||
|
@Column(name = "created_by_user_id")
|
||||||
|
private Long createdByUserId;
|
||||||
|
|
||||||
|
/** Public, non-secret identifier the instance presents on every request. */
|
||||||
|
@Column(name = "device_id", nullable = false, unique = true, length = 64)
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
/** SHA-256 hex of the device secret; the secret itself is never stored. */
|
||||||
|
@Column(name = "device_secret_hash", nullable = false, length = 64)
|
||||||
|
private String deviceSecretHash;
|
||||||
|
|
||||||
|
/** Operator-set display label (hostname etc.) for the "Linked instances" list. */
|
||||||
|
@Column(name = "name", length = 255)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Insert time; Hibernate populates this on persist (DB DEFAULT is belt-and-braces). */
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** Stamped when the device credential last authenticated; powers staleness display. */
|
||||||
|
@Column(name = "last_seen_at")
|
||||||
|
private LocalDateTime lastSeenAt;
|
||||||
|
|
||||||
|
/** NULL = active. Set on unlink/revoke; a revoked credential fails authentication. */
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private LocalDateTime revokedAt;
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
|
||||||
|
*
|
||||||
|
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
|
||||||
|
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
|
||||||
|
* User} and creates no user row — a device credential can never act as a person, only as its team's
|
||||||
|
* instance, and only on the instance-facing endpoints.
|
||||||
|
*/
|
||||||
|
public class LinkedInstanceAuthenticationToken extends AbstractAuthenticationToken {
|
||||||
|
|
||||||
|
private final Long instanceId;
|
||||||
|
private final Long teamId;
|
||||||
|
|
||||||
|
public LinkedInstanceAuthenticationToken(Long instanceId, Long teamId) {
|
||||||
|
super(List.of(new SimpleGrantedAuthority("ROLE_LINKED_INSTANCE")));
|
||||||
|
this.instanceId = instanceId;
|
||||||
|
this.teamId = teamId;
|
||||||
|
setAuthenticated(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getCredentials() {
|
||||||
|
return null; // the secret is never retained on the authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getPrincipal() {
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getInstanceId() {
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTeamId() {
|
||||||
|
return teamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data access for {@link LinkedInstance}. Plain Spring Data JPA against {@code stirling_pdf} —
|
||||||
|
* native schema access, no RPC, consistent with the rest of the SaaS backend.
|
||||||
|
*/
|
||||||
|
public interface LinkedInstanceRepository extends JpaRepository<LinkedInstance, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active-credential lookup for the device-credential auth filter (revoked rows never match).
|
||||||
|
*/
|
||||||
|
Optional<LinkedInstance> findByDeviceIdAndRevokedAtIsNull(String deviceId);
|
||||||
|
|
||||||
|
/** Backs the portal "Linked instances" list (includes revoked, newest first). */
|
||||||
|
List<LinkedInstance> findByTeamIdOrderByCreatedAtDesc(Long teamId);
|
||||||
|
|
||||||
|
/** Active (non-revoked) linked instances on a team — the orphan guard's count. */
|
||||||
|
long countByTeamIdAndRevokedAtIsNull(Long teamId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps liveness on a single instance. A targeted single-column UPDATE rather than a
|
||||||
|
* full-entity {@code save}: the auth filter loads the instance outside a transaction, so a full
|
||||||
|
* save would write back the stale (in-memory {@code null}) {@code revoked_at} and could
|
||||||
|
* silently un-revoke a credential that was revoked between the read and the write. The {@code
|
||||||
|
* revoked_at IS NULL} guard makes this a no-op once revoked.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query(
|
||||||
|
"UPDATE LinkedInstance li SET li.lastSeenAt = :now "
|
||||||
|
+ "WHERE li.instanceId = :instanceId AND li.revokedAt IS NULL")
|
||||||
|
int touchLastSeen(@Param("instanceId") Long instanceId, @Param("now") LocalDateTime now);
|
||||||
|
}
|
||||||
@@ -14,12 +14,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
|||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
@EnableJpaRepositories(
|
@EnableJpaRepositories(
|
||||||
basePackages = {
|
basePackages = {
|
||||||
|
"stirling.software.saas.accountlink",
|
||||||
"stirling.software.saas.repository",
|
"stirling.software.saas.repository",
|
||||||
"stirling.software.saas.billing.repository",
|
"stirling.software.saas.billing.repository",
|
||||||
"stirling.software.saas.ai.repository",
|
"stirling.software.saas.ai.repository",
|
||||||
"stirling.software.saas.payg.repository"
|
"stirling.software.saas.payg.repository"
|
||||||
})
|
})
|
||||||
@EntityScan({
|
@EntityScan({
|
||||||
|
"stirling.software.saas.accountlink",
|
||||||
"stirling.software.saas.model",
|
"stirling.software.saas.model",
|
||||||
"stirling.software.saas.billing.model",
|
"stirling.software.saas.billing.model",
|
||||||
"stirling.software.saas.ai.model",
|
"stirling.software.saas.ai.model",
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package stirling.software.saas.payg.api;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.saas.model.TeamMembership;
|
||||||
|
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||||
|
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||||
|
import stirling.software.saas.payg.stripe.StripeInvoiceDao;
|
||||||
|
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||||
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only Stripe-invoices surface for the linked org's billing page.
|
||||||
|
*
|
||||||
|
* <p>{@code GET /api/v1/payg/invoices?limit=N} returns the team's most recent Stripe invoices,
|
||||||
|
* sourced from the {@code stripe.invoices} table the Sync Engine maintains. The caller's team is
|
||||||
|
* resolved from the authenticated principal (same pattern as {@link PaygWalletController}); we
|
||||||
|
* never trust a team id from the request.
|
||||||
|
*
|
||||||
|
* <p>Defensive: when the team has no {@code stripe_customer_id} (not subscribed, or pre-checkout)
|
||||||
|
* or the {@code stripe} schema isn't synced (H2 tests, sync engine off), we return {@code 200} with
|
||||||
|
* an empty list rather than 500 — the UI renders "no invoices yet". This keeps the page working
|
||||||
|
* through every link/subscription state.
|
||||||
|
*
|
||||||
|
* <p>{@code hostedInvoiceUrl} + {@code invoicePdf} are Stripe-hosted links the portal can deep-link
|
||||||
|
* from. We don't proxy the PDF ourselves; Stripe handles auth + caching.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Hidden
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/payg")
|
||||||
|
@Profile("saas")
|
||||||
|
public class PaygInvoicesController {
|
||||||
|
|
||||||
|
private static final int DEFAULT_LIMIT = 20;
|
||||||
|
private static final int MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
private final StripeInvoiceDao invoiceDao;
|
||||||
|
private final PaygTeamExtensionsRepository extRepo;
|
||||||
|
private final TeamMembershipRepository memberRepo;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public PaygInvoicesController(
|
||||||
|
StripeInvoiceDao invoiceDao,
|
||||||
|
PaygTeamExtensionsRepository extRepo,
|
||||||
|
TeamMembershipRepository memberRepo,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.invoiceDao = Objects.requireNonNull(invoiceDao, "invoiceDao");
|
||||||
|
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
|
||||||
|
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
|
||||||
|
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shape the portal renders. Trimmed; never echoes raw Stripe object fields verbatim. */
|
||||||
|
public record InvoiceResponse(
|
||||||
|
String id,
|
||||||
|
String number,
|
||||||
|
String status,
|
||||||
|
Long totalMinor,
|
||||||
|
String currency,
|
||||||
|
String createdAt,
|
||||||
|
String periodStart,
|
||||||
|
String periodEnd,
|
||||||
|
String hostedInvoiceUrl,
|
||||||
|
String invoicePdf,
|
||||||
|
String description,
|
||||||
|
Long pdfsProcessed) {}
|
||||||
|
|
||||||
|
@GetMapping("/invoices")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ResponseEntity<List<InvoiceResponse>> list(
|
||||||
|
@RequestParam(name = "limit", required = false) Integer limit, Authentication auth) {
|
||||||
|
|
||||||
|
User user;
|
||||||
|
try {
|
||||||
|
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the caller's team from their primary membership — same pattern as
|
||||||
|
// PaygWalletController. The team id NEVER comes from the request.
|
||||||
|
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(List.of());
|
||||||
|
}
|
||||||
|
Long teamId = rows.get(0).getTeam().getId();
|
||||||
|
|
||||||
|
// No PAYG extension row OR no Stripe customer id → team has never subscribed → no
|
||||||
|
// invoices. Empty list, not 404 — the UI distinguishes "no invoices yet" from a
|
||||||
|
// genuine error and we don't want to error a happy free team.
|
||||||
|
Optional<PaygTeamExtensions> ext = extRepo.findById(teamId);
|
||||||
|
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
|
||||||
|
return ResponseEntity.ok(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
int safeLimit = clampLimit(limit);
|
||||||
|
List<InvoiceResponse> body =
|
||||||
|
invoiceDao.findRecentByCustomer(ext.get().getStripeCustomerId(), safeLimit).stream()
|
||||||
|
.map(PaygInvoicesController::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clampLimit(Integer requested) {
|
||||||
|
if (requested == null) return DEFAULT_LIMIT;
|
||||||
|
return Math.max(1, Math.min(requested, MAX_LIMIT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InvoiceResponse toResponse(StripeInvoiceDao.InvoiceRow r) {
|
||||||
|
return new InvoiceResponse(
|
||||||
|
r.id(),
|
||||||
|
r.number(),
|
||||||
|
r.status(),
|
||||||
|
r.totalMinor(),
|
||||||
|
r.currency(),
|
||||||
|
iso(r.createdAt()),
|
||||||
|
iso(r.periodStart()),
|
||||||
|
iso(r.periodEnd()),
|
||||||
|
r.hostedInvoiceUrl(),
|
||||||
|
r.invoicePdf(),
|
||||||
|
r.description(),
|
||||||
|
r.pdfsProcessed());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String iso(LocalDateTime ldt) {
|
||||||
|
return ldt == null ? null : ldt.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package stirling.software.saas.payg.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.saas.model.TeamMembership;
|
||||||
|
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||||
|
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||||
|
import stirling.software.saas.payg.stripe.StripePaymentMethodDao;
|
||||||
|
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||||
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only default-payment-method surface for the subscribed billing page.
|
||||||
|
*
|
||||||
|
* <p>{@code GET /api/v1/payg/payment-method} returns the team's default card (brand / last4 /
|
||||||
|
* expiry), sourced from {@code stripe.payment_methods} (Sync Engine mirror). The caller's team is
|
||||||
|
* resolved from the authenticated principal — never trusted from the request — exactly as {@link
|
||||||
|
* PaygInvoicesController} does.
|
||||||
|
*
|
||||||
|
* <p>Defensive: no team, no {@code stripe_customer_id} (free / pre-checkout), or the card simply
|
||||||
|
* not in the mirror all degrade to {@code 200 present=false} rather than an error. Card edits never
|
||||||
|
* happen here; the portal deep-links to Stripe's hosted customer portal for that.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Hidden
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/payg")
|
||||||
|
@Profile("saas")
|
||||||
|
public class PaygPaymentMethodController {
|
||||||
|
|
||||||
|
/** Trimmed default-card shape. {@code present=false} carries no card fields. */
|
||||||
|
public record PaymentMethodResponse(
|
||||||
|
boolean present, String brand, String last4, Integer expMonth, Integer expYear) {
|
||||||
|
static PaymentMethodResponse absent() {
|
||||||
|
return new PaymentMethodResponse(false, null, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final StripePaymentMethodDao paymentMethodDao;
|
||||||
|
private final PaygTeamExtensionsRepository extRepo;
|
||||||
|
private final TeamMembershipRepository memberRepo;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public PaygPaymentMethodController(
|
||||||
|
StripePaymentMethodDao paymentMethodDao,
|
||||||
|
PaygTeamExtensionsRepository extRepo,
|
||||||
|
TeamMembershipRepository memberRepo,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.paymentMethodDao = Objects.requireNonNull(paymentMethodDao, "paymentMethodDao");
|
||||||
|
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
|
||||||
|
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
|
||||||
|
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/payment-method")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ResponseEntity<PaymentMethodResponse> get(Authentication auth) {
|
||||||
|
User user;
|
||||||
|
try {
|
||||||
|
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(PaymentMethodResponse.absent());
|
||||||
|
}
|
||||||
|
Long teamId = rows.get(0).getTeam().getId();
|
||||||
|
|
||||||
|
Optional<PaygTeamExtensions> ext = extRepo.findById(teamId);
|
||||||
|
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
|
||||||
|
return ResponseEntity.ok(PaymentMethodResponse.absent());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
paymentMethodDao
|
||||||
|
.findDefaultCard(ext.get().getStripeCustomerId())
|
||||||
|
.map(
|
||||||
|
c ->
|
||||||
|
new PaymentMethodResponse(
|
||||||
|
true,
|
||||||
|
c.brand(),
|
||||||
|
c.last4(),
|
||||||
|
c.expMonth(),
|
||||||
|
c.expYear()))
|
||||||
|
.orElseGet(PaymentMethodResponse::absent));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ import stirling.software.saas.payg.model.FeatureSet;
|
|||||||
* <p>State transitions:
|
* <p>State transitions:
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally.
|
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} (uncapped).
|
||||||
|
* <li>{@code capUnits <= 0} (an explicit $0 cap) → {@code DEGRADED}: metered work blocked, only
|
||||||
|
* the free grant + manual tools run.
|
||||||
* <li>{@code spend / cap < warnPct} → {@code FULL}.
|
* <li>{@code spend / cap < warnPct} → {@code FULL}.
|
||||||
* <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link
|
* <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link
|
||||||
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
|
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
|
||||||
@@ -49,9 +51,19 @@ public final class CapEvaluator {
|
|||||||
int degradeAtPct,
|
int degradeAtPct,
|
||||||
FeatureSet degradedFeatureSet) {
|
FeatureSet degradedFeatureSet) {
|
||||||
|
|
||||||
if (capUnits == null || capUnits <= 0) {
|
if (capUnits == null) {
|
||||||
|
// No cap configured → uncapped, full feature set.
|
||||||
return full();
|
return full();
|
||||||
}
|
}
|
||||||
|
if (capUnits <= 0) {
|
||||||
|
// An explicit cap that buys zero paid documents (a $0 cap, or one set
|
||||||
|
// below the per-document rate): metered work is blocked outright —
|
||||||
|
// only the free grant and manual tools run. DEGRADED, same as hitting
|
||||||
|
// a positive cap.
|
||||||
|
FeatureSet effective =
|
||||||
|
degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL;
|
||||||
|
return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective));
|
||||||
|
}
|
||||||
if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
|
if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
|
||||||
// Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise
|
// Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise
|
||||||
// degradation. The admin endpoints that set the policy should validate; this
|
// degradation. The admin endpoints that set the policy should validate; this
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package stirling.software.saas.payg.stripe;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only accessor for {@code stripe.invoices} (synced into Postgres by the Stripe Sync Engine).
|
||||||
|
*
|
||||||
|
* <p>Same defensive posture as {@link StripeSubscriptionDao}: when the {@code stripe} schema is
|
||||||
|
* absent (H2 unit tests, sync engine not yet provisioned, or invoices not in the Sync Engine's
|
||||||
|
* target list), the lookup degrades to an empty list with a WARN — the caller renders "no invoices
|
||||||
|
* yet" rather than 500ing the page.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Repository
|
||||||
|
@Profile("saas")
|
||||||
|
public class StripeInvoiceDao {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One invoice row as the portal needs it. Money is in minor units of {@code currency} (e.g.
|
||||||
|
* cents for USD). {@code hostedInvoiceUrl} and {@code invoicePdf} are Stripe-hosted links that
|
||||||
|
* are stable for the lifetime of the invoice; safe to use as deep links from the UI.
|
||||||
|
*
|
||||||
|
* <p>{@code description} is the product name from the subscription chain — the portal renders
|
||||||
|
* this as the row label (matching Stripe's customer-portal row layout). Falls back to the
|
||||||
|
* invoice's own {@code description} field, then to null when neither is set.
|
||||||
|
*/
|
||||||
|
public record InvoiceRow(
|
||||||
|
String id,
|
||||||
|
String number,
|
||||||
|
String status,
|
||||||
|
Long totalMinor,
|
||||||
|
String currency,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime periodStart,
|
||||||
|
LocalDateTime periodEnd,
|
||||||
|
String hostedInvoiceUrl,
|
||||||
|
String invoicePdf,
|
||||||
|
String description,
|
||||||
|
/** Billed units (PDFs) on this invoice — summed line-item quantity; null if unknown. */
|
||||||
|
Long pdfsProcessed) {}
|
||||||
|
|
||||||
|
// Drafts are excluded: Stripe's API returns null for both
|
||||||
|
// {@code hosted_invoice_url} and {@code invoice_pdf} on unfinalized
|
||||||
|
// invoices, and Stripe's own customer portal hides drafts too — there's no
|
||||||
|
// user-facing artefact to surface yet. The next finalize / webhook flips
|
||||||
|
// the status and the invoice shows up automatically.
|
||||||
|
//
|
||||||
|
// The LATERAL join walks the same subscription → subscription_items → prices
|
||||||
|
// → products chain {@link StripeSubscriptionDao} uses to get the per-doc
|
||||||
|
// rate; here we use it to get the product NAME (e.g. "Stirling Processor
|
||||||
|
// Plan") so the portal can render Stripe's row label rather than the
|
||||||
|
// monospace invoice id. Falls back to {@code i.description}, then null.
|
||||||
|
private static final String QUERY =
|
||||||
|
"SELECT i.id, i.number, i.status::text AS status,"
|
||||||
|
+ " i.total, i.currency,"
|
||||||
|
+ " i.created, i.period_start, i.period_end,"
|
||||||
|
+ " i.hosted_invoice_url, i.invoice_pdf,"
|
||||||
|
+ " COALESCE(prod.name, i.description) AS description"
|
||||||
|
+ " FROM stripe.invoices i"
|
||||||
|
+ " LEFT JOIN LATERAL ("
|
||||||
|
+ " SELECT si.price FROM stripe.subscription_items si"
|
||||||
|
+ " WHERE si.subscription = i.subscription"
|
||||||
|
+ " AND COALESCE(si.deleted, false) = false"
|
||||||
|
+ " ORDER BY si.created DESC NULLS LAST LIMIT 1"
|
||||||
|
+ " ) item ON true"
|
||||||
|
+ " LEFT JOIN stripe.prices p ON p.id = item.price"
|
||||||
|
+ " LEFT JOIN stripe.products prod ON prod.id = p.product"
|
||||||
|
+ " WHERE i.customer = ?"
|
||||||
|
+ " AND i.status::text <> 'draft'"
|
||||||
|
+ " ORDER BY i.created DESC NULLS LAST"
|
||||||
|
+ " LIMIT ?";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public StripeInvoiceDao(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent {@code limit} invoices for {@code stripeCustomerId}, newest first. Empty list
|
||||||
|
* on missing schema / no rows / connectivity blip — the controller surfaces this as 200 with an
|
||||||
|
* empty body rather than 500.
|
||||||
|
*/
|
||||||
|
public List<InvoiceRow> findRecentByCustomer(String stripeCustomerId, int limit) {
|
||||||
|
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, 100));
|
||||||
|
List<InvoiceRow> rows;
|
||||||
|
try {
|
||||||
|
rows =
|
||||||
|
jdbcTemplate.query(
|
||||||
|
QUERY,
|
||||||
|
(rs, i) ->
|
||||||
|
new InvoiceRow(
|
||||||
|
rs.getString("id"),
|
||||||
|
rs.getString("number"),
|
||||||
|
rs.getString("status"),
|
||||||
|
nullableLong(rs, "total"),
|
||||||
|
rs.getString("currency"),
|
||||||
|
toLocal(rs.getLong("created"), rs.wasNull()),
|
||||||
|
toLocal(rs.getLong("period_start"), rs.wasNull()),
|
||||||
|
toLocal(rs.getLong("period_end"), rs.wasNull()),
|
||||||
|
rs.getString("hosted_invoice_url"),
|
||||||
|
rs.getString("invoice_pdf"),
|
||||||
|
rs.getString("description"),
|
||||||
|
null),
|
||||||
|
stripeCustomerId,
|
||||||
|
safeLimit);
|
||||||
|
} catch (DataAccessException e) {
|
||||||
|
log.warn(
|
||||||
|
"stripe.invoices lookup failed for customer {}: {}",
|
||||||
|
stripeCustomerId,
|
||||||
|
e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
Map<String, Long> billed = sumBilledUnits(rows.stream().map(InvoiceRow::id).toList());
|
||||||
|
if (billed.isEmpty()) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.stream()
|
||||||
|
.map(
|
||||||
|
r ->
|
||||||
|
new InvoiceRow(
|
||||||
|
r.id(),
|
||||||
|
r.number(),
|
||||||
|
r.status(),
|
||||||
|
r.totalMinor(),
|
||||||
|
r.currency(),
|
||||||
|
r.createdAt(),
|
||||||
|
r.periodStart(),
|
||||||
|
r.periodEnd(),
|
||||||
|
r.hostedInvoiceUrl(),
|
||||||
|
r.invoicePdf(),
|
||||||
|
r.description(),
|
||||||
|
billed.get(r.id())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sums billed quantity (PDFs) per invoice from the {@code stripe.invoices.lines} JSONB the Sync
|
||||||
|
* Engine mirrors — line items live in {@code lines->'data'}, NOT a separate {@code
|
||||||
|
* invoice_line_items} table (the sync engine never creates one).
|
||||||
|
*
|
||||||
|
* <p>Only the <b>metered</b> usage line counts: a Processor invoice can also carry flat
|
||||||
|
* subscription-fee, proration and tax lines, each with its own {@code quantity}, so summing
|
||||||
|
* every line would inflate the headline PDF count (usage 500 + a fee line of 1 → "501"). We
|
||||||
|
* filter on {@code price.recurring.usage_type = 'metered'}. When no metered line is present the
|
||||||
|
* subquery is {@code NULL} and the invoice is <b>omitted</b> from the map, so {@code
|
||||||
|
* InvoiceRow.pdfsProcessed} stays {@code null} and the column renders "—" rather than "0".
|
||||||
|
*
|
||||||
|
* <p>Run SEPARATELY from the invoice query and defensively wrapped, so a missing/changed schema
|
||||||
|
* degrades to an empty map (every row renders "—") instead of failing the whole invoice list.
|
||||||
|
*/
|
||||||
|
private Map<String, Long> sumBilledUnits(List<String> invoiceIds) {
|
||||||
|
if (invoiceIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
String placeholders = invoiceIds.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||||
|
String sql =
|
||||||
|
"SELECT i.id AS invoice_id,"
|
||||||
|
+ " (SELECT SUM((l->>'quantity')::int)"
|
||||||
|
+ " FROM jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l"
|
||||||
|
+ " WHERE l->'price'->'recurring'->>'usage_type' = 'metered') AS qty"
|
||||||
|
+ " FROM stripe.invoices i"
|
||||||
|
+ " WHERE i.id IN ("
|
||||||
|
+ placeholders
|
||||||
|
+ ")";
|
||||||
|
try {
|
||||||
|
Map<String, Long> map = new HashMap<>();
|
||||||
|
jdbcTemplate.query(
|
||||||
|
sql,
|
||||||
|
(java.sql.ResultSet rs) -> {
|
||||||
|
long qty = rs.getLong("qty");
|
||||||
|
if (!rs.wasNull()) {
|
||||||
|
// null (no metered line) → leave the key absent → renders "—".
|
||||||
|
map.put(rs.getString("invoice_id"), qty);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
invoiceIds.toArray());
|
||||||
|
return map;
|
||||||
|
} catch (DataAccessException e) {
|
||||||
|
log.warn("stripe.invoices line-quantity sum failed: {}", e.getMessage());
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long nullableLong(java.sql.ResultSet rs, String column)
|
||||||
|
throws java.sql.SQLException {
|
||||||
|
long v = rs.getLong(column);
|
||||||
|
return rs.wasNull() ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime toLocal(long epochSeconds, boolean wasNull) {
|
||||||
|
if (wasNull) return null;
|
||||||
|
return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package stirling.software.saas.payg.stripe;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only accessor for a team's default card off the Stripe Sync Engine schema ({@code
|
||||||
|
* stripe.payment_methods}). Prefers the customer's {@code invoice_settings.default_payment_method};
|
||||||
|
* falls back to their most recently created card. Card details (brand / last4 / expiry) live in the
|
||||||
|
* {@code card} JSONB column the sync engine mirrors.
|
||||||
|
*
|
||||||
|
* <p>Same defensive posture as {@link StripeInvoiceDao}/{@link StripeSubscriptionDao}: a missing
|
||||||
|
* schema or table — H2 unit tests, sync engine not provisioned, or {@code payment_methods} simply
|
||||||
|
* absent from the sync target list — degrades to {@link Optional#empty()} with a WARN, so the
|
||||||
|
* endpoint reports "no card on file" rather than 500ing the page. Editing always happens in
|
||||||
|
* Stripe's hosted portal; this never writes.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Repository
|
||||||
|
@Profile("saas")
|
||||||
|
public class StripePaymentMethodDao {
|
||||||
|
|
||||||
|
/** Card brand (e.g. "visa"), last 4 digits, and numeric expiry; any field may be null. */
|
||||||
|
public record CardSummary(String brand, String last4, Integer expMonth, Integer expYear) {}
|
||||||
|
|
||||||
|
private static final String QUERY =
|
||||||
|
"SELECT pm.card->>'brand' AS brand, pm.card->>'last4' AS last4,"
|
||||||
|
+ " pm.card->>'exp_month' AS exp_month, pm.card->>'exp_year' AS exp_year"
|
||||||
|
+ " FROM stripe.payment_methods pm"
|
||||||
|
+ " WHERE pm.customer = ? AND pm.type = 'card'"
|
||||||
|
+ " ORDER BY (pm.id = ("
|
||||||
|
+ " SELECT c.invoice_settings->>'default_payment_method'"
|
||||||
|
+ " FROM stripe.customers c WHERE c.id = ?"
|
||||||
|
+ " )) DESC NULLS LAST, pm.created DESC NULLS LAST"
|
||||||
|
+ " LIMIT 1";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public StripePaymentMethodDao(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customer's default card; empty on missing schema / no card / connectivity blip. */
|
||||||
|
public Optional<CardSummary> findDefaultCard(String stripeCustomerId) {
|
||||||
|
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<CardSummary> rows =
|
||||||
|
jdbcTemplate.query(
|
||||||
|
QUERY,
|
||||||
|
(rs, i) ->
|
||||||
|
new CardSummary(
|
||||||
|
rs.getString("brand"),
|
||||||
|
rs.getString("last4"),
|
||||||
|
parseIntOrNull(rs, "exp_month"),
|
||||||
|
parseIntOrNull(rs, "exp_year")),
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeCustomerId);
|
||||||
|
return rows.stream().filter(Objects::nonNull).findFirst();
|
||||||
|
} catch (DataAccessException e) {
|
||||||
|
log.warn(
|
||||||
|
"stripe.payment_methods lookup failed for customer {}: {}",
|
||||||
|
stripeCustomerId,
|
||||||
|
e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer parseIntOrNull(ResultSet rs, String column) throws SQLException {
|
||||||
|
String raw = rs.getString(column);
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(raw.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import java.util.Locale;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -49,6 +50,7 @@ import stirling.software.common.util.RequestUriUtils;
|
|||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.service.TeamService;
|
import stirling.software.proprietary.security.service.TeamService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
|
||||||
import stirling.software.saas.service.SaasTeamService;
|
import stirling.software.saas.service.SaasTeamService;
|
||||||
import stirling.software.saas.service.SupabaseUserService;
|
import stirling.software.saas.service.SupabaseUserService;
|
||||||
|
|
||||||
@@ -80,7 +82,10 @@ public class SupabaseSecurityConfig {
|
|||||||
private long clockSkewSeconds;
|
private long clockSkewSeconds;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder)
|
SecurityFilterChain saasSecurityFilterChain(
|
||||||
|
HttpSecurity http,
|
||||||
|
JwtDecoder jwtDecoder,
|
||||||
|
ObjectProvider<DeviceCredentialAuthenticationFilter> deviceCredentialFilterProvider)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in
|
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in
|
||||||
// Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
|
// Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
|
||||||
@@ -135,6 +140,16 @@ public class SupabaseSecurityConfig {
|
|||||||
.jwtAuthenticationConverter(
|
.jwtAuthenticationConverter(
|
||||||
SupabaseSecurityConfig
|
SupabaseSecurityConfig
|
||||||
::toAuthentication)));
|
::toAuthentication)));
|
||||||
|
|
||||||
|
// Device-credential auth for linked self-hosted instances (combined-billing Mode A).
|
||||||
|
// The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
|
||||||
|
// is absent here, so the instance surface cannot authenticate at all until release.
|
||||||
|
DeviceCredentialAuthenticationFilter deviceFilter =
|
||||||
|
deviceCredentialFilterProvider.getIfAvailable();
|
||||||
|
if (deviceFilter != null) {
|
||||||
|
http.addFilterBefore(deviceFilter, BearerTokenAuthenticationFilter.class);
|
||||||
|
}
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import stirling.software.proprietary.model.Team;
|
|||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
|
import stirling.software.saas.accountlink.LinkedInstanceRepository;
|
||||||
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
|
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
|
||||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||||
import stirling.software.saas.model.TeamInvitation;
|
import stirling.software.saas.model.TeamInvitation;
|
||||||
@@ -45,6 +46,7 @@ public class SaasTeamService {
|
|||||||
private final UserRoleService userRoleService;
|
private final UserRoleService userRoleService;
|
||||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||||
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
|
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
|
||||||
|
private final LinkedInstanceRepository linkedInstanceRepository;
|
||||||
private final stirling.software.proprietary.security.service.UserService userService;
|
private final stirling.software.proprietary.security.service.UserService userService;
|
||||||
|
|
||||||
public static final String DEFAULT_TEAM_NAME = "Default";
|
public static final String DEFAULT_TEAM_NAME = "Default";
|
||||||
@@ -458,22 +460,42 @@ public class SaasTeamService {
|
|||||||
* accept. The message points them at the right remedy — cancel the plan if the team is paid,
|
* accept. The message points them at the right remedy — cancel the plan if the team is paid,
|
||||||
* otherwise transfer leadership first.
|
* otherwise transfer leadership first.
|
||||||
*
|
*
|
||||||
|
* <p>Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code
|
||||||
|
* linked_instance.team_id}, so they too orphan a team that is left memberless — a personal team
|
||||||
|
* that accept deletes, or a non-personal team left by its last leader. They're checked in that
|
||||||
|
* same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to
|
||||||
|
* revoke them.
|
||||||
|
*
|
||||||
* @param user the user attempting to accept an invitation
|
* @param user the user attempting to accept an invitation
|
||||||
* @throws IllegalStateException if accepting would orphan a team the user leads
|
* @throws IllegalStateException if accepting would orphan a team the user leads or its
|
||||||
|
* instances
|
||||||
*/
|
*/
|
||||||
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
|
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
|
||||||
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
|
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
|
||||||
Team team = membership.getTeam();
|
Team team = membership.getTeam();
|
||||||
if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) {
|
boolean personal = saasTeamExtensionService.isPersonal(team);
|
||||||
// Personal teams are deleted on accept; non-leaders leaving never orphans a team.
|
if (!personal && !membership.isLeader()) {
|
||||||
|
// A non-leader leaving a shared team never orphans it.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Only reached for a non-personal team the user leads — at most one such team in the
|
if (!personal
|
||||||
// one-team-per-user model — so this count runs ~once, not per membership.
|
&& membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER)
|
||||||
if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) {
|
> 1) {
|
||||||
// Another leader remains, so the team keeps an owner.
|
// Another leader remains, so the team keeps an owner.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Leaving here orphans the team: a personal team is deleted on accept; a non-personal
|
||||||
|
// team is being left by its last leader. Either way its linked self-hosted instances
|
||||||
|
// lose their billing team, so block until they're revoked.
|
||||||
|
if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Revoke linked self-hosted instances on this team before joining another"
|
||||||
|
+ " team.");
|
||||||
|
}
|
||||||
|
if (personal) {
|
||||||
|
// Personal teams are disposable (deleted on accept) and never billed/shared.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (hasActivePaidSubscription(team)) {
|
if (hasActivePaidSubscription(team)) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"Your team has an active plan and you are its last leader. Cancel the plan"
|
"Your team has an active plan and you are its last leader. Cancel the plan"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Account-link instances. One row per self-hosted instance that has linked a SaaS account.
|
||||||
|
--
|
||||||
|
-- Part of the combined-billing "Mode A" (connected self-hosted) flow:
|
||||||
|
-- 1. An admin signs into their SaaS account in the Stirling Portal via the Supabase JS SDK
|
||||||
|
-- (a short-lived Supabase JWT, refreshed client-side — it never reaches the server long-term).
|
||||||
|
-- 2. That JWT is used ONCE to call POST /api/v1/account-link/register, which mints a
|
||||||
|
-- device_id + device_secret bound to the admin's team. The secret is returned once and
|
||||||
|
-- stored only on the instance; we keep a SHA-256 hash here (the secret is high-entropy,
|
||||||
|
-- so an unsalted hash is sufficient — same posture as API keys).
|
||||||
|
-- 3. The instance authenticates all unattended metering / entitlement calls with that device
|
||||||
|
-- credential. No long-lived user JWT lives on the server side.
|
||||||
|
--
|
||||||
|
-- Twin of supabase/migrations/20260619000000_account_link_instances.sql (Stirling-PDF-SaaS).
|
||||||
|
-- Inert until release: the AccountLinkController + device-credential filter are gated behind
|
||||||
|
-- stirling.billing.account-link.enabled (default off). The table itself is harmless additive.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stirling_pdf.linked_instance (
|
||||||
|
instance_id BIGSERIAL PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||||
|
created_by_user_id BIGINT,
|
||||||
|
-- admin who registered the instance; informational only (no FK so a user delete never
|
||||||
|
-- cascades a working instance offline).
|
||||||
|
device_id VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
-- public, non-secret identifier the instance presents on every request.
|
||||||
|
device_secret_hash VARCHAR(64) NOT NULL,
|
||||||
|
-- SHA-256 hex of the device secret; the secret itself is never stored.
|
||||||
|
name VARCHAR(255),
|
||||||
|
-- operator-set display label (hostname etc.) for the "Linked instances" list.
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TIMESTAMP,
|
||||||
|
-- stamped when the device credential last authenticated; powers staleness display.
|
||||||
|
revoked_at TIMESTAMP
|
||||||
|
-- NULL = active. Set on unlink/revoke; a revoked credential fails authentication.
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_instance_team
|
||||||
|
ON stirling_pdf.linked_instance (team_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE stirling_pdf.linked_instance IS
|
||||||
|
'One row per self-hosted instance linked to a SaaS account (combined-billing Mode A). '
|
||||||
|
'device_id is the public identifier; device_secret_hash is the SHA-256 of the bearer '
|
||||||
|
'secret (returned once at registration, stored only on the instance). The instance '
|
||||||
|
'authenticates unattended metering / entitlement calls with this credential; revoked_at '
|
||||||
|
'IS NULL means active.';
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
|
import stirling.software.common.model.enumeration.TeamRole;
|
||||||
|
import stirling.software.proprietary.model.Team;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest;
|
||||||
|
import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse;
|
||||||
|
import stirling.software.saas.model.TeamMembership;
|
||||||
|
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||||
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-Mockito unit tests for {@link AccountLinkController} — the leader-only auth ladder, and that
|
||||||
|
* the team is always derived from the caller's membership (never the request). Mirrors {@code
|
||||||
|
* PaygInvoicesControllerTest}'s static-mock of {@link AuthenticationUtils}.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AccountLinkControllerTest {
|
||||||
|
|
||||||
|
@Mock private AccountLinkService service;
|
||||||
|
@Mock private TeamMembershipRepository memberRepo;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
|
||||||
|
private AccountLinkController controller;
|
||||||
|
private Authentication auth;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
controller = new AccountLinkController(service, memberRepo, userRepository);
|
||||||
|
auth =
|
||||||
|
new AnonymousAuthenticationToken(
|
||||||
|
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_unauthenticated_returns401() {
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenThrow(new SecurityException("not authenticated"));
|
||||||
|
|
||||||
|
ResponseEntity<RegisterResponse> resp =
|
||||||
|
controller.register(new RegisterRequest("host"), auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_noMembership_returns403() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_nonLeader_returns403() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
TeamMembership member = membership(7L, TeamRole.MEMBER);
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member));
|
||||||
|
|
||||||
|
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_leader_mintsCredentialForCallerTeam() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
TeamMembership leader = membership(7L, TeamRole.LEADER);
|
||||||
|
when(service.register(7L, 42L, "host"))
|
||||||
|
.thenReturn(
|
||||||
|
new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host"));
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
|
||||||
|
|
||||||
|
ResponseEntity<RegisterResponse> resp =
|
||||||
|
controller.register(new RegisterRequest("host"), auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||||
|
RegisterResponse body = resp.getBody();
|
||||||
|
assertThat(body).isNotNull();
|
||||||
|
// Team comes from the caller's membership and is surfaced in the response.
|
||||||
|
assertThat(body.teamId()).isEqualTo(7L);
|
||||||
|
assertThat(body.instanceId()).isEqualTo(99L);
|
||||||
|
assertThat(body.deviceSecret()).isEqualTo("sec-x");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_leader_returns204WhenServiceRevokes() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
TeamMembership leader = membership(7L, TeamRole.LEADER);
|
||||||
|
when(service.revoke(7L, 11L)).thenReturn(true);
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
|
||||||
|
|
||||||
|
ResponseEntity<Void> resp = controller.revoke(11L, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_leader_returns404WhenServiceReportsNotFound() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
TeamMembership leader = membership(7L, TeamRole.LEADER);
|
||||||
|
when(service.revoke(7L, 11L)).thenReturn(false);
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
|
||||||
|
|
||||||
|
ResponseEntity<Void> resp = controller.revoke(11L, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User mockUser(long id) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamMembership membership(long teamId, TeamRole role) {
|
||||||
|
Team team = new Team();
|
||||||
|
team.setId(teamId);
|
||||||
|
TeamMembership tm = new TeamMembership();
|
||||||
|
tm.setTeam(team);
|
||||||
|
tm.setRole(role);
|
||||||
|
return tm;
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import stirling.software.saas.accountlink.AccountLinkService.RegisteredInstance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-Mockito unit tests for {@link AccountLinkService}: register returns the plaintext secret
|
||||||
|
* once but persists only its hash, and revoke is team-scoped + idempotent — a caller can never
|
||||||
|
* revoke another team's instance.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AccountLinkServiceTest {
|
||||||
|
|
||||||
|
@Mock private LinkedInstanceRepository repo;
|
||||||
|
|
||||||
|
private AccountLinkService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new AccountLinkService(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_returnsPlaintextSecretOnce_persistsOnlyHash() {
|
||||||
|
ArgumentCaptor<LinkedInstance> captor = ArgumentCaptor.forClass(LinkedInstance.class);
|
||||||
|
|
||||||
|
RegisteredInstance reg = service.register(42L, 7L, "host-a");
|
||||||
|
|
||||||
|
verify(repo).save(captor.capture());
|
||||||
|
LinkedInstance saved = captor.getValue();
|
||||||
|
assertThat(reg.deviceSecret()).isNotBlank();
|
||||||
|
assertThat(reg.deviceId()).isEqualTo(saved.getDeviceId());
|
||||||
|
assertThat(saved.getDeviceSecretHash())
|
||||||
|
.isEqualTo(AccountLinkService.sha256Hex(reg.deviceSecret()))
|
||||||
|
.isNotEqualTo(reg.deviceSecret());
|
||||||
|
assertThat(saved.getTeamId()).isEqualTo(42L);
|
||||||
|
assertThat(saved.getCreatedByUserId()).isEqualTo(7L);
|
||||||
|
assertThat(saved.getName()).isEqualTo("host-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_owningTeam_setsRevokedAtAndReturnsTrue() {
|
||||||
|
LinkedInstance inst = instance(11L, 42L, null);
|
||||||
|
when(repo.findById(11L)).thenReturn(Optional.of(inst));
|
||||||
|
|
||||||
|
assertThat(service.revoke(42L, 11L)).isTrue();
|
||||||
|
assertThat(inst.getRevokedAt()).isNotNull();
|
||||||
|
verify(repo).save(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_alreadyRevoked_isIdempotentAndDoesNotResave() {
|
||||||
|
LocalDateTime revoked = LocalDateTime.now().minusDays(1);
|
||||||
|
LinkedInstance inst = instance(11L, 42L, revoked);
|
||||||
|
when(repo.findById(11L)).thenReturn(Optional.of(inst));
|
||||||
|
|
||||||
|
assertThat(service.revoke(42L, 11L)).isTrue();
|
||||||
|
assertThat(inst.getRevokedAt()).isEqualTo(revoked);
|
||||||
|
verify(repo, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_otherTeamsInstance_returnsFalseAndDoesNotSave() {
|
||||||
|
LinkedInstance inst = instance(11L, 99L, null);
|
||||||
|
when(repo.findById(11L)).thenReturn(Optional.of(inst));
|
||||||
|
|
||||||
|
assertThat(service.revoke(42L, 11L)).isFalse();
|
||||||
|
assertThat(inst.getRevokedAt()).isNull();
|
||||||
|
verify(repo, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revoke_unknownInstance_returnsFalse() {
|
||||||
|
when(repo.findById(404L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThat(service.revoke(42L, 404L)).isFalse();
|
||||||
|
verify(repo, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LinkedInstance instance(Long id, Long teamId, LocalDateTime revokedAt) {
|
||||||
|
LinkedInstance i = new LinkedInstance();
|
||||||
|
i.setInstanceId(id);
|
||||||
|
i.setTeamId(teamId);
|
||||||
|
i.setRevokedAt(revokedAt);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.mock.web.MockFilterChain;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class DeviceCredentialAuthenticationFilterTest {
|
||||||
|
|
||||||
|
@Mock private LinkedInstanceRepository repo;
|
||||||
|
|
||||||
|
private DeviceCredentialAuthenticationFilter filter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
filter = new DeviceCredentialAuthenticationFilter(repo);
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LinkedInstance instanceWithSecret(String secret) {
|
||||||
|
LinkedInstance i = new LinkedInstance();
|
||||||
|
i.setInstanceId(1L);
|
||||||
|
i.setTeamId(42L);
|
||||||
|
i.setDeviceId("dev-1");
|
||||||
|
i.setDeviceSecretHash(AccountLinkService.sha256Hex(secret));
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MockHttpServletRequest instanceRequest(String deviceId, String secret) {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/instance/whoami");
|
||||||
|
if (deviceId != null) {
|
||||||
|
req.addHeader("X-Device-Id", deviceId);
|
||||||
|
}
|
||||||
|
if (secret != null) {
|
||||||
|
req.addHeader("X-Device-Secret", secret);
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validCredentialAuthenticatesAsInstanceBoundToTeam() throws ServletException, IOException {
|
||||||
|
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1"))
|
||||||
|
.thenReturn(Optional.of(instanceWithSecret("s3cr3t")));
|
||||||
|
|
||||||
|
filter.doFilter(
|
||||||
|
instanceRequest("dev-1", "s3cr3t"),
|
||||||
|
new MockHttpServletResponse(),
|
||||||
|
new MockFilterChain());
|
||||||
|
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
assertInstanceOf(LinkedInstanceAuthenticationToken.class, auth);
|
||||||
|
LinkedInstanceAuthenticationToken token = (LinkedInstanceAuthenticationToken) auth;
|
||||||
|
assertEquals(42L, token.getTeamId());
|
||||||
|
assertEquals(1L, token.getInstanceId());
|
||||||
|
assertEquals(
|
||||||
|
"ROLE_LINKED_INSTANCE", token.getAuthorities().iterator().next().getAuthority());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successfulAuthStampsLastSeen() throws ServletException, IOException {
|
||||||
|
LinkedInstance instance = instanceWithSecret("s3cr3t");
|
||||||
|
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance));
|
||||||
|
|
||||||
|
filter.doFilter(
|
||||||
|
instanceRequest("dev-1", "s3cr3t"),
|
||||||
|
new MockHttpServletResponse(),
|
||||||
|
new MockFilterChain());
|
||||||
|
|
||||||
|
// Targeted single-column update (guarded by revoked_at IS NULL), not a full-entity save.
|
||||||
|
verify(repo).touchLastSeen(eq(1L), any(LocalDateTime.class));
|
||||||
|
verify(repo, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lastSeenWriteFailureDoesNotBreakAuth() throws ServletException, IOException {
|
||||||
|
LinkedInstance instance = instanceWithSecret("s3cr3t");
|
||||||
|
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance));
|
||||||
|
doThrow(new RuntimeException("transient db"))
|
||||||
|
.when(repo)
|
||||||
|
.touchLastSeen(anyLong(), any(LocalDateTime.class));
|
||||||
|
|
||||||
|
// A liveness-write failure must NOT propagate — auth is already set, so the
|
||||||
|
// request stays authenticated rather than 500ing.
|
||||||
|
filter.doFilter(
|
||||||
|
instanceRequest("dev-1", "s3cr3t"),
|
||||||
|
new MockHttpServletResponse(),
|
||||||
|
new MockFilterChain());
|
||||||
|
|
||||||
|
assertInstanceOf(
|
||||||
|
LinkedInstanceAuthenticationToken.class,
|
||||||
|
SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongSecretDoesNotAuthenticate() throws ServletException, IOException {
|
||||||
|
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1"))
|
||||||
|
.thenReturn(Optional.of(instanceWithSecret("right-secret")));
|
||||||
|
|
||||||
|
filter.doFilter(
|
||||||
|
instanceRequest("dev-1", "wrong-secret"),
|
||||||
|
new MockHttpServletResponse(),
|
||||||
|
new MockFilterChain());
|
||||||
|
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownOrRevokedDeviceDoesNotAuthenticate() throws ServletException, IOException {
|
||||||
|
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
filter.doFilter(
|
||||||
|
instanceRequest("dev-1", "whatever"),
|
||||||
|
new MockHttpServletResponse(),
|
||||||
|
new MockFilterChain());
|
||||||
|
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonInstancePathIsSkippedEntirely() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payg/wallet");
|
||||||
|
req.addHeader("X-Device-Id", "dev-1");
|
||||||
|
req.addHeader("X-Device-Secret", "s3cr3t");
|
||||||
|
|
||||||
|
filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain());
|
||||||
|
|
||||||
|
// Path-scoped: the device credential never even reaches the repo on a non-instance path.
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
verifyNoInteractions(repo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
package stirling.software.saas.accountlink;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
|
import stirling.software.saas.accountlink.InstanceController.EntitlementResponse;
|
||||||
|
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||||
|
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||||
|
import stirling.software.saas.payg.entitlement.EntitlementService;
|
||||||
|
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
|
||||||
|
import stirling.software.saas.payg.model.EntitlementState;
|
||||||
|
import stirling.software.saas.payg.model.FeatureGate;
|
||||||
|
import stirling.software.saas.payg.model.FeatureSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read.
|
||||||
|
* The team is resolved from the {@link LinkedInstanceAuthenticationToken} principal, never a path
|
||||||
|
* or body, and the minimal DTO maps straight off the billing context + entitlement snapshot.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class InstanceControllerTest {
|
||||||
|
|
||||||
|
@Mock private EntitlementService entitlementService;
|
||||||
|
@Mock private TeamBillingService billingService;
|
||||||
|
@Mock private AccountLinkService accountLinkService;
|
||||||
|
|
||||||
|
private InstanceController controller() {
|
||||||
|
return new InstanceController(entitlementService, billingService, accountLinkService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entitlement_resolvesTeamFromTokenAndMapsSnapshot() {
|
||||||
|
Authentication token = new LinkedInstanceAuthenticationToken(1L, 42L);
|
||||||
|
when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L));
|
||||||
|
when(entitlementService.getSnapshot(42L))
|
||||||
|
.thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L));
|
||||||
|
|
||||||
|
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
EntitlementResponse body = resp.getBody();
|
||||||
|
assertThat(body).isNotNull();
|
||||||
|
assertThat(body.subscribed()).isTrue();
|
||||||
|
assertThat(body.freeRemainingUnits()).isEqualTo(120L);
|
||||||
|
assertThat(body.periodSpendUnits()).isEqualTo(90L);
|
||||||
|
assertThat(body.periodCapUnits()).isEqualTo(1250L);
|
||||||
|
// WARNED is still within budget for the gate's purposes → coarse OK.
|
||||||
|
assertThat(body.state()).isEqualTo("OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entitlement_uncapped_returnsNullCapUnits() {
|
||||||
|
Authentication token = new LinkedInstanceAuthenticationToken(2L, 7L);
|
||||||
|
when(billingService.forTeam(7L)).thenReturn(freeBilling(500L));
|
||||||
|
when(entitlementService.getSnapshot(7L))
|
||||||
|
.thenReturn(snapshot(EntitlementState.FULL, 0L, null));
|
||||||
|
|
||||||
|
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
|
||||||
|
|
||||||
|
EntitlementResponse body = resp.getBody();
|
||||||
|
assertThat(body).isNotNull();
|
||||||
|
assertThat(body.subscribed()).isFalse();
|
||||||
|
assertThat(body.freeRemainingUnits()).isEqualTo(500L);
|
||||||
|
assertThat(body.periodCapUnits()).isNull();
|
||||||
|
assertThat(body.state()).isEqualTo("OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entitlement_degradedMapsToOverLimit() {
|
||||||
|
// The instance gate parses OK / OVER_LIMIT, never the SaaS FULL/WARNED/DEGRADED enum.
|
||||||
|
// DEGRADED (automation + AI gated) must reach the wire as OVER_LIMIT.
|
||||||
|
Authentication token = new LinkedInstanceAuthenticationToken(3L, 8L);
|
||||||
|
when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L));
|
||||||
|
when(entitlementService.getSnapshot(8L))
|
||||||
|
.thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L));
|
||||||
|
|
||||||
|
EntitlementResponse body = controller().entitlement(token).getBody();
|
||||||
|
|
||||||
|
assertThat(body).isNotNull();
|
||||||
|
assertThat(body.state()).isEqualTo("OVER_LIMIT");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entitlement_nonInstancePrincipalIsRejected() {
|
||||||
|
Authentication anon =
|
||||||
|
new AnonymousAuthenticationToken(
|
||||||
|
"k",
|
||||||
|
"anonymousUser",
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
|
||||||
|
|
||||||
|
ResponseEntity<EntitlementResponse> resp = controller().entitlement(anon);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
verifyNoInteractions(entitlementService, billingService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeSelf_callsServiceWithTokenIdentityAndReturns204() {
|
||||||
|
Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L);
|
||||||
|
|
||||||
|
ResponseEntity<Void> resp = controller().revokeSelf(token);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||||
|
verify(accountLinkService).revoke(22L, 11L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeSelf_rejectsNonInstancePrincipal() {
|
||||||
|
Authentication anon =
|
||||||
|
new AnonymousAuthenticationToken(
|
||||||
|
"k",
|
||||||
|
"anonymousUser",
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
|
||||||
|
|
||||||
|
ResponseEntity<Void> resp = controller().revokeSelf(anon);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
verifyNoInteractions(accountLinkService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whoami_returnsResolvedInstanceAndTeam() {
|
||||||
|
Authentication token = new LinkedInstanceAuthenticationToken(5L, 9L);
|
||||||
|
|
||||||
|
ResponseEntity<InstanceController.WhoAmIResponse> resp = controller().whoami(token);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(resp.getBody().instanceId()).isEqualTo(5L);
|
||||||
|
assertThat(resp.getBody().teamId()).isEqualTo(9L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamBillingContext freeBilling(long freeRemaining) {
|
||||||
|
LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
|
||||||
|
return new TeamBillingContext(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
start,
|
||||||
|
start.plusMonths(1),
|
||||||
|
freeRemaining,
|
||||||
|
freeRemaining,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamBillingContext subscribedBilling(String subId, long freeRemaining) {
|
||||||
|
LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
|
||||||
|
return new TeamBillingContext(
|
||||||
|
true,
|
||||||
|
subId,
|
||||||
|
start,
|
||||||
|
start.plusMonths(1),
|
||||||
|
500L,
|
||||||
|
freeRemaining,
|
||||||
|
BigDecimal.valueOf(2),
|
||||||
|
"usd",
|
||||||
|
2500L,
|
||||||
|
1250L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EntitlementSnapshot snapshot(EntitlementState state, long spend, Long cap) {
|
||||||
|
LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
|
||||||
|
return new EntitlementSnapshot(
|
||||||
|
state,
|
||||||
|
FeatureSet.FULL,
|
||||||
|
List.of(FeatureGate.OFFSITE_PROCESSING),
|
||||||
|
spend,
|
||||||
|
cap,
|
||||||
|
start,
|
||||||
|
start.plusMonths(1),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
package stirling.software.saas.payg.api;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.model.Team;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.saas.model.TeamMembership;
|
||||||
|
import stirling.software.saas.payg.api.PaygInvoicesController.InvoiceResponse;
|
||||||
|
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||||
|
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||||
|
import stirling.software.saas.payg.stripe.StripeInvoiceDao;
|
||||||
|
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||||
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-Mockito unit tests for {@link PaygInvoicesController}. Confirms team is resolved from the
|
||||||
|
* authenticated principal (never request), and the empty-list degrade paths (no team, no Stripe
|
||||||
|
* customer, no rows) all return 200 + [] rather than 4xx/5xx.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PaygInvoicesControllerTest {
|
||||||
|
|
||||||
|
@Mock private StripeInvoiceDao invoiceDao;
|
||||||
|
@Mock private PaygTeamExtensionsRepository extRepo;
|
||||||
|
@Mock private TeamMembershipRepository memberRepo;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
|
||||||
|
private PaygInvoicesController controller;
|
||||||
|
private Authentication auth;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
controller = new PaygInvoicesController(invoiceDao, extRepo, memberRepo, userRepository);
|
||||||
|
auth =
|
||||||
|
new AnonymousAuthenticationToken(
|
||||||
|
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_unauthenticated_returns401() {
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenThrow(new SecurityException("not authenticated"));
|
||||||
|
|
||||||
|
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
verifyNoInteractions(invoiceDao, extRepo, memberRepo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_noTeam_returnsEmpty() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(resp.getBody()).isEmpty();
|
||||||
|
verifyNoInteractions(invoiceDao, extRepo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_noStripeCustomer_returnsEmpty() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
TeamMembership tm = mockMembership(7L);
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
|
||||||
|
when(extRepo.findById(7L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(resp.getBody()).isEmpty();
|
||||||
|
verifyNoInteractions(invoiceDao);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_mapsRowsAndClampsLimit() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
TeamMembership tm = mockMembership(7L);
|
||||||
|
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||||
|
ext.setTeamId(7L);
|
||||||
|
ext.setStripeCustomerId("cus_abc");
|
||||||
|
|
||||||
|
StripeInvoiceDao.InvoiceRow row =
|
||||||
|
new StripeInvoiceDao.InvoiceRow(
|
||||||
|
"in_1",
|
||||||
|
"STIR-0001",
|
||||||
|
"paid",
|
||||||
|
2500L,
|
||||||
|
"usd",
|
||||||
|
LocalDateTime.of(2026, 6, 1, 10, 0),
|
||||||
|
LocalDateTime.of(2026, 5, 1, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 5, 31, 23, 59),
|
||||||
|
"https://stripe/invoice/1",
|
||||||
|
"https://stripe/invoice/1.pdf",
|
||||||
|
"Stirling Processor Plan",
|
||||||
|
50000L);
|
||||||
|
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
|
||||||
|
when(extRepo.findById(7L)).thenReturn(Optional.of(ext));
|
||||||
|
// 1000 should clamp to MAX_LIMIT (100) inside the controller.
|
||||||
|
when(invoiceDao.findRecentByCustomer(eq("cus_abc"), eq(100))).thenReturn(List.of(row));
|
||||||
|
|
||||||
|
ResponseEntity<List<InvoiceResponse>> resp = controller.list(1000, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(resp.getBody()).hasSize(1);
|
||||||
|
InvoiceResponse body = resp.getBody().get(0);
|
||||||
|
assertThat(body.id()).isEqualTo("in_1");
|
||||||
|
assertThat(body.number()).isEqualTo("STIR-0001");
|
||||||
|
assertThat(body.status()).isEqualTo("paid");
|
||||||
|
assertThat(body.totalMinor()).isEqualTo(2500L);
|
||||||
|
assertThat(body.currency()).isEqualTo("usd");
|
||||||
|
assertThat(body.hostedInvoiceUrl()).isEqualTo("https://stripe/invoice/1");
|
||||||
|
assertThat(body.description()).isEqualTo("Stirling Processor Plan");
|
||||||
|
assertThat(body.pdfsProcessed()).isEqualTo(50000L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_emptyDaoResult_returnsEmpty() {
|
||||||
|
User user = mockUser(42L);
|
||||||
|
TeamMembership tm = mockMembership(7L);
|
||||||
|
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||||
|
ext.setTeamId(7L);
|
||||||
|
ext.setStripeCustomerId("cus_xyz");
|
||||||
|
|
||||||
|
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||||
|
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||||
|
.thenReturn(user);
|
||||||
|
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
|
||||||
|
when(extRepo.findById(7L)).thenReturn(Optional.of(ext));
|
||||||
|
when(invoiceDao.findRecentByCustomer(anyString(), anyInt())).thenReturn(List.of());
|
||||||
|
|
||||||
|
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(resp.getBody()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User mockUser(long id) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamMembership mockMembership(long teamId) {
|
||||||
|
Team team = new Team();
|
||||||
|
team.setId(teamId);
|
||||||
|
TeamMembership tm = new TeamMembership();
|
||||||
|
tm.setTeam(team);
|
||||||
|
return tm;
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package stirling.software.saas.payg.api;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
import stirling.software.common.model.enumeration.TeamRole;
|
||||||
|
import stirling.software.proprietary.model.Team;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.saas.model.TeamMembership;
|
||||||
|
import stirling.software.saas.payg.api.PaygPaymentMethodController.PaymentMethodResponse;
|
||||||
|
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||||
|
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||||
|
import stirling.software.saas.payg.stripe.StripePaymentMethodDao;
|
||||||
|
import stirling.software.saas.payg.stripe.StripePaymentMethodDao.CardSummary;
|
||||||
|
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||||
|
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-Mockito unit tests for {@link PaygPaymentMethodController}: the auth/team-resolution and
|
||||||
|
* defensive-degrade branches, plus the happy path mapping a DAO {@link CardSummary} to the trimmed
|
||||||
|
* response.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PaygPaymentMethodControllerTest {
|
||||||
|
|
||||||
|
@Mock private StripePaymentMethodDao paymentMethodDao;
|
||||||
|
@Mock private PaygTeamExtensionsRepository extRepo;
|
||||||
|
@Mock private TeamMembershipRepository memberRepo;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
|
||||||
|
private PaygPaymentMethodController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
controller =
|
||||||
|
new PaygPaymentMethodController(
|
||||||
|
paymentMethodDao, extRepo, memberRepo, userRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anonymousIsRejected() {
|
||||||
|
Authentication anon =
|
||||||
|
new AnonymousAuthenticationToken(
|
||||||
|
"k",
|
||||||
|
"anonymousUser",
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
|
||||||
|
|
||||||
|
ResponseEntity<PaymentMethodResponse> resp = controller.get(anon);
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
verifyNoInteractions(paymentMethodDao, extRepo, memberRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noTeam_returnsAbsent() {
|
||||||
|
User user = userWithId(5L, UUID.randomUUID());
|
||||||
|
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||||
|
when(memberRepo.findPrimaryMembership(5L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
ResponseEntity<PaymentMethodResponse> resp = controller.get(jwtAuth(user.getSupabaseId()));
|
||||||
|
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat(resp.getBody()).isNotNull();
|
||||||
|
assertThat(resp.getBody().present()).isFalse();
|
||||||
|
verifyNoInteractions(paymentMethodDao);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noStripeCustomer_returnsAbsent() {
|
||||||
|
User user = userWithId(6L, UUID.randomUUID());
|
||||||
|
Team team = teamWithId(60L);
|
||||||
|
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||||
|
when(memberRepo.findPrimaryMembership(6L))
|
||||||
|
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
|
||||||
|
PaygTeamExtensions ext = mock(PaygTeamExtensions.class);
|
||||||
|
when(ext.getStripeCustomerId()).thenReturn(null);
|
||||||
|
when(extRepo.findById(60L)).thenReturn(Optional.of(ext));
|
||||||
|
|
||||||
|
ResponseEntity<PaymentMethodResponse> resp = controller.get(jwtAuth(user.getSupabaseId()));
|
||||||
|
|
||||||
|
assertThat(resp.getBody()).isNotNull();
|
||||||
|
assertThat(resp.getBody().present()).isFalse();
|
||||||
|
verifyNoInteractions(paymentMethodDao);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cardOnFile_returnsPresentWithFields() {
|
||||||
|
User user = userWithId(7L, UUID.randomUUID());
|
||||||
|
Team team = teamWithId(70L);
|
||||||
|
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||||
|
when(memberRepo.findPrimaryMembership(7L))
|
||||||
|
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
|
||||||
|
PaygTeamExtensions ext = mock(PaygTeamExtensions.class);
|
||||||
|
when(ext.getStripeCustomerId()).thenReturn("cus_123");
|
||||||
|
when(extRepo.findById(70L)).thenReturn(Optional.of(ext));
|
||||||
|
when(paymentMethodDao.findDefaultCard("cus_123"))
|
||||||
|
.thenReturn(Optional.of(new CardSummary("visa", "4242", 8, 2027)));
|
||||||
|
|
||||||
|
ResponseEntity<PaymentMethodResponse> resp = controller.get(jwtAuth(user.getSupabaseId()));
|
||||||
|
|
||||||
|
PaymentMethodResponse body = resp.getBody();
|
||||||
|
assertThat(body).isNotNull();
|
||||||
|
assertThat(body.present()).isTrue();
|
||||||
|
assertThat(body.brand()).isEqualTo("visa");
|
||||||
|
assertThat(body.last4()).isEqualTo("4242");
|
||||||
|
assertThat(body.expMonth()).isEqualTo(8);
|
||||||
|
assertThat(body.expYear()).isEqualTo(2027);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mirrorMissingCard_returnsAbsent() {
|
||||||
|
User user = userWithId(8L, UUID.randomUUID());
|
||||||
|
Team team = teamWithId(80L);
|
||||||
|
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||||
|
when(memberRepo.findPrimaryMembership(8L))
|
||||||
|
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
|
||||||
|
PaygTeamExtensions ext = mock(PaygTeamExtensions.class);
|
||||||
|
when(ext.getStripeCustomerId()).thenReturn("cus_456");
|
||||||
|
when(extRepo.findById(80L)).thenReturn(Optional.of(ext));
|
||||||
|
when(paymentMethodDao.findDefaultCard("cus_456")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
ResponseEntity<PaymentMethodResponse> resp = controller.get(jwtAuth(user.getSupabaseId()));
|
||||||
|
|
||||||
|
assertThat(resp.getBody()).isNotNull();
|
||||||
|
assertThat(resp.getBody().present()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
// Fixtures (mirroring PaygWalletControllerTest)
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static User userWithId(Long id, UUID supabaseId) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
u.setSupabaseId(supabaseId);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Team teamWithId(Long id) {
|
||||||
|
Team t = new Team();
|
||||||
|
t.setId(id);
|
||||||
|
t.setName("t-" + id);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamMembership membership(Team team, User user, TeamRole role) {
|
||||||
|
TeamMembership m = new TeamMembership();
|
||||||
|
m.setTeam(team);
|
||||||
|
m.setUser(user);
|
||||||
|
m.setRole(role);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Authentication jwtAuth(UUID supabaseId) {
|
||||||
|
Jwt jwt =
|
||||||
|
Jwt.withTokenValue("token")
|
||||||
|
.header("alg", "RS256")
|
||||||
|
.claim("sub", supabaseId.toString())
|
||||||
|
.claim("email", "user@example.com")
|
||||||
|
.build();
|
||||||
|
return new EnhancedJwtAuthenticationToken(
|
||||||
|
jwt, List.of(), "user@example.com", supabaseId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,10 +29,22 @@ class CapEvaluatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void zeroCap_treatedAsUnlimitedForSafety() {
|
void zeroCap_blocksMeteredWork() {
|
||||||
// Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL).
|
// An explicit $0 cap buys zero paid documents → metered work is blocked
|
||||||
|
// (DEGRADED/MINIMAL); only the free grant + manual tools run. (Uncapped is the
|
||||||
|
// separate capUnits==null case, covered by nullCap_returnsFullStateAndFullGates.)
|
||||||
Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL);
|
Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL);
|
||||||
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
|
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
|
||||||
|
assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
|
||||||
|
assertThat(e.enabledGates())
|
||||||
|
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroCap_blocksEvenAtZeroSpend() {
|
||||||
|
// A $0 cap blocks from the first metered op — not gated on spend.
|
||||||
|
Evaluation e = CapEvaluator.evaluate(0L, 0L, 80, 100, FeatureSet.MINIMAL);
|
||||||
|
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+10
-1
@@ -243,6 +243,7 @@ class SupabaseSecurityConfigMoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("builds and returns the SecurityFilterChain from http.build()")
|
@DisplayName("builds and returns the SecurityFilterChain from http.build()")
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
void buildsFilterChain() throws Exception {
|
void buildsFilterChain() throws Exception {
|
||||||
HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS);
|
HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS);
|
||||||
// http.build() returns DefaultSecurityFilterChain, so stub with that concrete type.
|
// http.build() returns DefaultSecurityFilterChain, so stub with that concrete type.
|
||||||
@@ -250,8 +251,16 @@ class SupabaseSecurityConfigMoreTest {
|
|||||||
mock(org.springframework.security.web.DefaultSecurityFilterChain.class);
|
mock(org.springframework.security.web.DefaultSecurityFilterChain.class);
|
||||||
when(http.build()).thenReturn(built);
|
when(http.build()).thenReturn(built);
|
||||||
|
|
||||||
|
// Device-credential filter is wired via an ObjectProvider; getIfAvailable() returns
|
||||||
|
// null here, so the optional filter is simply not added (fine for a build-only check).
|
||||||
|
org.springframework.beans.factory.ObjectProvider<
|
||||||
|
stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter>
|
||||||
|
deviceFilterProvider =
|
||||||
|
mock(org.springframework.beans.factory.ObjectProvider.class);
|
||||||
|
|
||||||
SecurityFilterChain result =
|
SecurityFilterChain result =
|
||||||
config(new ApplicationProperties()).saasSecurityFilterChain(http, jwtDecoder);
|
config(new ApplicationProperties())
|
||||||
|
.saasSecurityFilterChain(http, jwtDecoder, deviceFilterProvider);
|
||||||
|
|
||||||
assertThat(result).isSameAs(built);
|
assertThat(result).isSameAs(built);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import org.mockito.ArgumentCaptor;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.junit.jupiter.MockitoSettings;
|
||||||
|
import org.mockito.quality.Strictness;
|
||||||
|
|
||||||
import stirling.software.common.model.enumeration.InvitationStatus;
|
import stirling.software.common.model.enumeration.InvitationStatus;
|
||||||
import stirling.software.common.model.enumeration.Role;
|
import stirling.software.common.model.enumeration.Role;
|
||||||
@@ -32,6 +34,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
|||||||
import stirling.software.proprietary.security.model.Authority;
|
import stirling.software.proprietary.security.model.Authority;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
|
import stirling.software.saas.accountlink.LinkedInstanceRepository;
|
||||||
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
|
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
|
||||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||||
import stirling.software.saas.model.TeamInvitation;
|
import stirling.software.saas.model.TeamInvitation;
|
||||||
@@ -62,6 +65,7 @@ class SaasTeamServiceTest {
|
|||||||
@Mock private UserRoleService userRoleService;
|
@Mock private UserRoleService userRoleService;
|
||||||
@Mock private SaasTeamExtensionService saasTeamExtensionService;
|
@Mock private SaasTeamExtensionService saasTeamExtensionService;
|
||||||
@Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository;
|
@Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository;
|
||||||
|
@Mock private LinkedInstanceRepository linkedInstanceRepository;
|
||||||
@Mock private stirling.software.proprietary.security.service.UserService userService;
|
@Mock private stirling.software.proprietary.security.service.UserService userService;
|
||||||
|
|
||||||
@InjectMocks private SaasTeamService service;
|
@InjectMocks private SaasTeamService service;
|
||||||
@@ -1383,4 +1387,92 @@ class SaasTeamServiceTest {
|
|||||||
return saved;
|
return saved;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* acceptInvitation's orphan guard against linked self-hosted instances (combined-billing "Mode
|
||||||
|
* A"). The guard ({@code assertCanLeaveCurrentTeamsToJoinAnother}) is private; it's exercised
|
||||||
|
* through its only caller up to the point where a team with active linked instances must block
|
||||||
|
* the move. LENIENT because the pass-through case stubs the full leave/join path while the
|
||||||
|
* blocking case short-circuits before reaching all of it.
|
||||||
|
*/
|
||||||
|
@Nested
|
||||||
|
@DisplayName("acceptInvitation - linked self-hosted instance orphan guard")
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
|
class AcceptInvitationLinkedInstanceGuard {
|
||||||
|
|
||||||
|
private static final long USER_ID = 7L;
|
||||||
|
private static final long OLD_TEAM_ID = 100L;
|
||||||
|
private static final long NEW_TEAM_ID = 200L;
|
||||||
|
private static final String TOKEN = "tok-1";
|
||||||
|
private static final String EMAIL = "joiner@example.com";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("blocks accept when the current team has active linked instances")
|
||||||
|
void blocksWhenCurrentTeamHasActiveLinkedInstances() {
|
||||||
|
User joiner = user(USER_ID, EMAIL, EMAIL);
|
||||||
|
Team oldTeam = team(OLD_TEAM_ID, "old-team");
|
||||||
|
Team newTeam = team(NEW_TEAM_ID, "new-team");
|
||||||
|
TeamInvitation invitation = pendingInvitation(newTeam, joiner);
|
||||||
|
|
||||||
|
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner));
|
||||||
|
when(invitationRepository.findByInvitationToken(TOKEN))
|
||||||
|
.thenReturn(Optional.of(invitation));
|
||||||
|
when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
|
||||||
|
when(membershipRepository.findByUserId(USER_ID))
|
||||||
|
.thenReturn(List.of(membership(oldTeam, joiner, TeamRole.LEADER)));
|
||||||
|
when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID))
|
||||||
|
.thenReturn(1L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.acceptInvitation(TOKEN, joiner))
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessage(
|
||||||
|
"Revoke linked self-hosted instances on this team before joining another"
|
||||||
|
+ " team.");
|
||||||
|
|
||||||
|
// Guard fires before any team mutation.
|
||||||
|
verify(membershipRepository, never()).delete(any());
|
||||||
|
verify(userRepository, never()).updateUserTeamId(anyLong(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("lets accept through when the current team has no linked instances")
|
||||||
|
void passesGuardWhenNoLinkedInstances() {
|
||||||
|
User joiner = user(USER_ID, EMAIL, EMAIL);
|
||||||
|
Team oldTeam = team(OLD_TEAM_ID, "old-team");
|
||||||
|
Team newTeam = team(NEW_TEAM_ID, "new-team");
|
||||||
|
TeamInvitation invitation = pendingInvitation(newTeam, joiner);
|
||||||
|
TeamMembership oldMembership = membership(oldTeam, joiner, TeamRole.LEADER);
|
||||||
|
|
||||||
|
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner));
|
||||||
|
when(invitationRepository.findByInvitationToken(TOKEN))
|
||||||
|
.thenReturn(Optional.of(invitation));
|
||||||
|
when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
|
||||||
|
when(membershipRepository.findByUserId(USER_ID)).thenReturn(List.of(oldMembership));
|
||||||
|
when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID))
|
||||||
|
.thenReturn(0L);
|
||||||
|
// Personal old team → guard skips the last-leader check and leave/join proceeds.
|
||||||
|
when(saasTeamExtensionService.isPersonal(oldTeam)).thenReturn(true);
|
||||||
|
when(membershipRepository.countByTeamId(OLD_TEAM_ID)).thenReturn(0L);
|
||||||
|
when(saasTeamExtensionsRepository.incrementSeatsUsed(NEW_TEAM_ID)).thenReturn(1);
|
||||||
|
|
||||||
|
service.acceptInvitation(TOKEN, joiner);
|
||||||
|
|
||||||
|
// Guard let the move through: the old membership was left and the user re-pointed.
|
||||||
|
verify(membershipRepository).delete(oldMembership);
|
||||||
|
verify(userRepository).updateUserTeamId(USER_ID, NEW_TEAM_ID);
|
||||||
|
verify(invitationRepository).save(invitation);
|
||||||
|
assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TeamInvitation pendingInvitation(Team team, User invitee) {
|
||||||
|
TeamInvitation inv = new TeamInvitation();
|
||||||
|
inv.setTeam(team);
|
||||||
|
inv.setInviter(invitee);
|
||||||
|
inv.setInviteeEmail(invitee.getEmail());
|
||||||
|
inv.setStatus(InvitationStatus.PENDING);
|
||||||
|
inv.setInvitationToken(TOKEN);
|
||||||
|
inv.setExpiresAt(LocalDateTime.now().plusDays(1));
|
||||||
|
return inv;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -24,10 +24,10 @@
|
|||||||
/editor/.env.local
|
/editor/.env.local
|
||||||
/editor/.env.*.local
|
/editor/.env.*.local
|
||||||
|
|
||||||
# Root .gitignore ignores all .env* - whitelist our committed ones here
|
# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored
|
||||||
!.env
|
# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS
|
||||||
!.env.desktop
|
# mock env is injected via .storybook/main.ts, not a file).
|
||||||
!.env.saas
|
!/portal/.env
|
||||||
!/editor/.env
|
!/editor/.env
|
||||||
!/editor/.env.desktop
|
!/editor/.env.desktop
|
||||||
!/editor/.env.saas
|
!/editor/.env.saas
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ const config: StorybookConfig = {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
|
||||||
|
// (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
|
||||||
|
// their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host
|
||||||
|
// never receives a real request — MSW answers first. Injected here, next to the
|
||||||
|
// MSW setup, rather than via a frontend/.env so no stray env file can leak into a
|
||||||
|
// real portal/editor build (those load env from their own roots).
|
||||||
|
config.define = {
|
||||||
|
...(config.define ?? {}),
|
||||||
|
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
|
||||||
|
};
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
void React;
|
void React;
|
||||||
|
|
||||||
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||||
|
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||||
import { ThemeProvider } from "@portal/contexts/ThemeContext";
|
import { ThemeProvider } from "@portal/contexts/ThemeContext";
|
||||||
import { UIProvider } from "@portal/contexts/UIContext";
|
import { UIProvider } from "@portal/contexts/UIContext";
|
||||||
import { mantineTheme } from "@portal/theme/mantineTheme";
|
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||||
import { handlers } from "@portal/mocks/handlers";
|
import { handlers } from "@portal/mocks/handlers";
|
||||||
|
import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
|
||||||
|
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@shared/tokens/tokens.css";
|
import "@shared/tokens/tokens.css";
|
||||||
@@ -26,6 +28,27 @@ import "@shared/tokens/base.css";
|
|||||||
// Start MSW once. Storybook runs in a browser so this uses the service worker.
|
// Start MSW once. Storybook runs in a browser so this uses the service worker.
|
||||||
initialize({ onUnhandledRequest: "bypass" }, handlers);
|
initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||||
|
|
||||||
|
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
|
||||||
|
// method, wallet) clear the session check and reach the MSW handlers instead of
|
||||||
|
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
|
||||||
|
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
|
||||||
|
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
|
||||||
|
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||||
|
const saasStub = configureSupabase({
|
||||||
|
url: "http://saas.mock",
|
||||||
|
key: "storybook-anon-key",
|
||||||
|
authOptions: {
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
detectSessionInUrl: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
saasStub.auth.getSession = async () =>
|
||||||
|
({
|
||||||
|
data: { session: { access_token: "storybook-fake-jwt" } },
|
||||||
|
error: null,
|
||||||
|
}) as Awaited<ReturnType<typeof saasStub.auth.getSession>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
|
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
|
||||||
* Without this the toolbar would just change a label; with it, every story
|
* Without this the toolbar would just change a label; with it, every story
|
||||||
@@ -67,6 +90,8 @@ function ThemeWatcher() {
|
|||||||
|
|
||||||
const withProviders: Decorator = (Story, context) => {
|
const withProviders: Decorator = (Story, context) => {
|
||||||
const tier = (context.globals.tier as Tier) ?? "pro";
|
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||||
|
const linkState =
|
||||||
|
(context.globals.linkState as LinkState) ?? "linked-subscribed";
|
||||||
// withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
|
// withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
|
||||||
// Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
|
// Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
|
||||||
// default surfaces) follows the dark toggle alongside the SUI CSS variables.
|
// default surfaces) follows the dark toggle alongside the SUI CSS variables.
|
||||||
@@ -78,12 +103,16 @@ const withProviders: Decorator = (Story, context) => {
|
|||||||
<MemoryRouter initialEntries={["/"]}>
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
|
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
|
||||||
<TierKey tier={tier}>
|
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||||
<UIProvider>
|
from useLink() (matches App.tsx's nesting). */}
|
||||||
<ThemeWatcher />
|
<LinkProvider key={linkState} initialState={linkState}>
|
||||||
<Story />
|
<TierKey tier={tier}>
|
||||||
</UIProvider>
|
<UIProvider>
|
||||||
</TierKey>
|
<ThemeWatcher />
|
||||||
|
<Story />
|
||||||
|
</UIProvider>
|
||||||
|
</TierKey>
|
||||||
|
</LinkProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -128,6 +157,20 @@ const preview: Preview = {
|
|||||||
dynamicTitle: true,
|
dynamicTitle: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
linkState: {
|
||||||
|
name: "Link",
|
||||||
|
description: "Account-link state — drives useLink() everywhere",
|
||||||
|
defaultValue: "linked-subscribed",
|
||||||
|
toolbar: {
|
||||||
|
icon: "link",
|
||||||
|
items: [
|
||||||
|
{ value: "unlinked", title: "Unlinked" },
|
||||||
|
{ value: "linked-free", title: "Linked · Free" },
|
||||||
|
{ value: "linked-subscribed", title: "Linked · PAYG" },
|
||||||
|
],
|
||||||
|
dynamicTitle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
withProviders,
|
withProviders,
|
||||||
|
|||||||
+32
-223
@@ -1,250 +1,59 @@
|
|||||||
/**
|
/**
|
||||||
* Reusable monthly spend-cap control.
|
* Editor cloud adapter over the shared {@code @shared/billing} spend-cap control:
|
||||||
*
|
* supplies the i18n copy (the shared control is copy-agnostic) and the editor's
|
||||||
* One inline row — preset chips, a custom-entry pill that matches the presets,
|
* {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
|
||||||
* a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs /
|
* onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
|
||||||
* month" estimate. Extracted from the subscribed plan-page cap editor so the
|
* unchanged, so the plan-page cap editor and the upgrade-checkout flow keep
|
||||||
* exact same control drives the upgrade checkout flow.
|
* consuming it as before.
|
||||||
*
|
|
||||||
* <h2>Currency-agnostic by design</h2>
|
|
||||||
*
|
|
||||||
* The control never decides a currency. It takes {@code pricePerDocMinor} +
|
|
||||||
* {@code currency} and renders whatever it's handed: the subscribed plan page
|
|
||||||
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
|
|
||||||
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
|
|
||||||
* yet) plus a {@code note} explaining the cap is editable later. When no rate
|
|
||||||
* is supplied the estimate simply hides.
|
|
||||||
*
|
|
||||||
* <h2>Controlled</h2>
|
|
||||||
*
|
|
||||||
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
|
|
||||||
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
|
|
||||||
* the working value. When {@code onSave} is provided the control renders the
|
|
||||||
* inline Save button and computes "dirty" against {@code savedCapUsd}.
|
|
||||||
*/
|
*/
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { Button } from "@mantine/core";
|
|
||||||
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
|
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
DEFAULT_CAP_PRESETS,
|
||||||
|
SpendCapControl as SharedSpendCapControl,
|
||||||
|
} from "@shared/billing";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import "./SpendCapControl.css";
|
import "./SpendCapControl.css";
|
||||||
|
|
||||||
// Quick amounts offered everywhere — recognition over recall.
|
export { DEFAULT_CAP_PRESETS };
|
||||||
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
|
|
||||||
|
|
||||||
export interface SpendCapControlProps {
|
export interface SpendCapControlProps {
|
||||||
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
|
|
||||||
capUsd: number | null;
|
capUsd: number | null;
|
||||||
/** Working-value setter. {@code null} signals no-cap. */
|
|
||||||
onChange: (capUsd: number | null) => void;
|
onChange: (capUsd: number | null) => void;
|
||||||
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
|
|
||||||
pricePerDocMinor?: number | null;
|
pricePerDocMinor?: number | null;
|
||||||
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
|
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
|
|
||||||
presets?: readonly number[];
|
presets?: readonly number[];
|
||||||
/**
|
|
||||||
* When provided, the control renders an inline Save button. Receives whole
|
|
||||||
* major units, or {@code null} for no-cap.
|
|
||||||
*/
|
|
||||||
onSave?: (capUsd: number | null) => Promise<void> | void;
|
onSave?: (capUsd: number | null) => Promise<void> | void;
|
||||||
/** Label for the Save button. */
|
|
||||||
saveLabel?: string;
|
saveLabel?: string;
|
||||||
/**
|
|
||||||
* The persisted value to diff against for the dirty check. Same encoding as
|
|
||||||
* {@link #capUsd} ({@code null} = persisted no-cap). Only used with
|
|
||||||
* {@link #onSave}.
|
|
||||||
*/
|
|
||||||
savedCapUsd?: number | null;
|
savedCapUsd?: number | null;
|
||||||
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
|
|
||||||
note?: React.ReactNode;
|
note?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
|
|
||||||
function formatMinor(
|
|
||||||
minor: number,
|
|
||||||
currency: string | null | undefined,
|
|
||||||
): string {
|
|
||||||
const code = (currency ?? "usd").toUpperCase();
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(undefined, {
|
|
||||||
style: "currency",
|
|
||||||
currency: code,
|
|
||||||
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
|
|
||||||
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
|
|
||||||
maximumFractionDigits: 3,
|
|
||||||
}).format(minor / 100);
|
|
||||||
} catch {
|
|
||||||
return `${(minor / 100).toFixed(2)} ${code}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
|
||||||
function currencySymbol(currency: string | null | undefined): string {
|
|
||||||
switch ((currency ?? "").toLowerCase()) {
|
|
||||||
case "usd":
|
|
||||||
case "":
|
|
||||||
return "$";
|
|
||||||
case "eur":
|
|
||||||
return "€";
|
|
||||||
case "gbp":
|
|
||||||
return "£";
|
|
||||||
default:
|
|
||||||
return currency!.toUpperCase() + " ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SpendCapControl: React.FC<SpendCapControlProps> = ({
|
const SpendCapControl: React.FC<SpendCapControlProps> = ({
|
||||||
capUsd,
|
|
||||||
onChange,
|
|
||||||
pricePerDocMinor,
|
|
||||||
currency,
|
|
||||||
presets = DEFAULT_CAP_PRESETS,
|
|
||||||
onSave,
|
|
||||||
saveLabel,
|
saveLabel,
|
||||||
savedCapUsd,
|
...rest
|
||||||
note,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const sym = currencySymbol(currency);
|
|
||||||
const isNoCap = capUsd === null;
|
|
||||||
const presetSelected = capUsd != null && presets.includes(capUsd);
|
|
||||||
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
|
|
||||||
// the value came from the custom pill.
|
|
||||||
const customActive = capUsd != null && !presets.includes(capUsd);
|
|
||||||
|
|
||||||
// Local mirror of the custom field's text so partial/empty entry doesn't get
|
|
||||||
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
|
|
||||||
const [customText, setCustomText] = useState<string>(
|
|
||||||
customActive ? String(capUsd) : "",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
|
|
||||||
// one-time free grant is a separate lifetime pool and is NOT added here —
|
|
||||||
// this is the paid PDFs the monthly cap buys.
|
|
||||||
const rate =
|
|
||||||
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
|
|
||||||
const previewDocs =
|
|
||||||
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
|
|
||||||
|
|
||||||
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
|
|
||||||
|
|
||||||
const selectPreset = (preset: number) => {
|
|
||||||
setCustomText("");
|
|
||||||
onChange(preset);
|
|
||||||
};
|
|
||||||
const selectNoCap = () => {
|
|
||||||
setCustomText("");
|
|
||||||
onChange(null);
|
|
||||||
};
|
|
||||||
const onCustomInput = (raw: string) => {
|
|
||||||
// Digits only; an empty field reads as "no custom value yet" → 0 so the
|
|
||||||
// estimate still renders sensibly without flipping to no-cap.
|
|
||||||
const cleaned = raw.replace(/[^0-9]/g, "");
|
|
||||||
setCustomText(cleaned);
|
|
||||||
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
|
|
||||||
onChange(Number.isNaN(v) ? 0 : v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!onSave) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scc">
|
<SharedSpendCapControl
|
||||||
<div className="scc-row">
|
{...rest}
|
||||||
{presets.map((preset) => (
|
labels={{
|
||||||
<button
|
custom: t("payg.cap.custom", "Custom"),
|
||||||
key={preset}
|
amountAria: t("payg.cap.amount", "Cap amount"),
|
||||||
type="button"
|
noCap: t("payg.cap.noCapLabel", "No cap"),
|
||||||
className="scc-chip"
|
save: saveLabel ?? t("payg.cap.save", "Update cap"),
|
||||||
data-selected={presetSelected && capUsd === preset}
|
docsEstimate: (docs) =>
|
||||||
onClick={() => selectPreset(preset)}
|
t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
|
||||||
>
|
docs,
|
||||||
{sym}
|
}),
|
||||||
{preset.toLocaleString()}
|
docsRate: (rate) =>
|
||||||
</button>
|
t("payg.cap.docsRate", "at {{rate}} / PDF", { rate }),
|
||||||
))}
|
noCapDesc: t(
|
||||||
|
"payg.cap.noCapDesc",
|
||||||
{/* Custom-entry pill — dashed until it carries a value, then it fills
|
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
|
||||||
like a selected chip. */}
|
),
|
||||||
<label className="scc-custom" data-active={customActive}>
|
}}
|
||||||
<span className="scc-custom__symbol">{sym}</span>
|
/>
|
||||||
<input
|
|
||||||
className="scc-custom__input"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={customActive ? customText : ""}
|
|
||||||
placeholder={t("payg.cap.custom", "Custom")}
|
|
||||||
aria-label={t("payg.cap.amount", "Cap amount")}
|
|
||||||
onChange={(e) => onCustomInput(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
|
|
||||||
data-selected={isNoCap}
|
|
||||||
onClick={selectNoCap}
|
|
||||||
>
|
|
||||||
{t("payg.cap.noCapLabel", "No cap")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onSave && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="xs"
|
|
||||||
className="scc-row__spacer"
|
|
||||||
disabled={!dirty || saving}
|
|
||||||
loading={saving}
|
|
||||||
leftSection={<LocalIcon icon="check-rounded" />}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{saveLabel ?? t("payg.cap.save", "Update cap")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewDocs != null && (
|
|
||||||
<div className="scc-estimate">
|
|
||||||
<DescriptionIcon
|
|
||||||
className="scc-estimate__icon"
|
|
||||||
sx={{ fontSize: 22 }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="scc-estimate__main">
|
|
||||||
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
|
|
||||||
docs: previewDocs.toLocaleString(),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="scc-estimate__sub">
|
|
||||||
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
|
|
||||||
rate: formatMinor(pricePerDocMinor ?? 0, currency),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isNoCap && (
|
|
||||||
<div className="scc-note">
|
|
||||||
{t(
|
|
||||||
"payg.cap.noCapDesc",
|
|
||||||
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{note && <div className="scc-note">{note}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,36 +7,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useWallet, type Wallet } from "@app/hooks/useWallet";
|
import { useWallet, type Wallet } from "@app/hooks/useWallet";
|
||||||
|
import { currencySymbol, MeterBar, meterState } from "@shared/billing";
|
||||||
import "@app/components/shared/config/configSections/Payg.css";
|
import "@app/components/shared/config/configSections/Payg.css";
|
||||||
import "@app/components/shared/config/configSections/PaygFree.css";
|
import "@app/components/shared/config/configSections/PaygFree.css";
|
||||||
|
|
||||||
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
|
|
||||||
|
|
||||||
/** Warn/degrade band for a usage meter (mirrors the BE thresholds). */
|
|
||||||
export function meterState(
|
|
||||||
used: number,
|
|
||||||
limit: number,
|
|
||||||
): { state: MeterState; pct: number } {
|
|
||||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
|
|
||||||
const state: MeterState =
|
|
||||||
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
|
|
||||||
return { state, pct };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
|
||||||
function currencySymbol(currency: string | null): string {
|
|
||||||
switch ((currency ?? "").toLowerCase()) {
|
|
||||||
case "usd":
|
|
||||||
return "$";
|
|
||||||
case "eur":
|
|
||||||
return "€";
|
|
||||||
case "gbp":
|
|
||||||
return "£";
|
|
||||||
default:
|
|
||||||
return currency ? currency.toUpperCase() + " " : "$";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── One-time free grant meter ──────────────────────────────────────────────
|
// ─── One-time free grant meter ──────────────────────────────────────────────
|
||||||
|
|
||||||
export interface FreeSnapshot {
|
export interface FreeSnapshot {
|
||||||
@@ -78,38 +52,20 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
|||||||
: t("payg.free.state.plentyLeft", "Plenty left");
|
: t("payg.free.state.plentyLeft", "Plenty left");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="paygf-meter" data-state={state}>
|
<MeterBar
|
||||||
<div className="paygf-meter__top">
|
state={state}
|
||||||
<div className="paygf-meter__figure">
|
pct={pct}
|
||||||
<span className="paygf-meter__num">
|
figure={snap.billableUsed.toLocaleString()}
|
||||||
{snap.billableUsed.toLocaleString()}
|
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||||
</span>
|
limit: snap.billableLimit.toLocaleString(),
|
||||||
<span className="paygf-meter__cap">
|
})}
|
||||||
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
statusLabel={stateLabel}
|
||||||
limit: snap.billableLimit.toLocaleString(),
|
meta={
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="payg-status" data-state={state}>
|
|
||||||
<span className="payg-status__dot" />
|
|
||||||
{stateLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="payg-bar">
|
|
||||||
<div
|
|
||||||
className="payg-bar__fill"
|
|
||||||
data-state={state}
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="paygf-meter__meta">
|
|
||||||
<span>
|
<span>
|
||||||
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
|
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,45 +114,28 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
|
|||||||
const symbol = currencySymbol(snap.currency);
|
const symbol = currencySymbol(snap.currency);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="paygf-meter" data-state={state}>
|
<MeterBar
|
||||||
<div className="paygf-meter__top">
|
state={state}
|
||||||
<div className="paygf-meter__figure">
|
pct={pct}
|
||||||
<span className="paygf-meter__num">
|
figure={`${symbol}${snap.spent.toLocaleString()}`}
|
||||||
{symbol}
|
capSuffix={t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
|
||||||
{snap.spent.toLocaleString()}
|
amount: `${symbol}${snap.cap.toLocaleString()}`,
|
||||||
|
})}
|
||||||
|
statusLabel={stateLabel}
|
||||||
|
meta={
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"payg.spendCapMeter.metaCategories",
|
||||||
|
"Automation · AI · API spend",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="paygf-meter__cap">
|
<span className="payg-hero__meta-dot">•</span>
|
||||||
{t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
|
<span>
|
||||||
amount: `${symbol}${snap.cap.toLocaleString()}`,
|
{t("payg.spendCapMeter.resets", "Resets each billing period")}
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</>
|
||||||
<span className="payg-status" data-state={state}>
|
}
|
||||||
<span className="payg-status__dot" />
|
/>
|
||||||
{stateLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="payg-bar">
|
|
||||||
<div
|
|
||||||
className="payg-bar__fill"
|
|
||||||
data-state={state}
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="paygf-meter__meta">
|
|
||||||
<span>
|
|
||||||
{t(
|
|
||||||
"payg.spendCapMeter.metaCategories",
|
|
||||||
"Automation · AI · API spend",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="payg-hero__meta-dot">•</span>
|
|
||||||
<span>
|
|
||||||
{t("payg.spendCapMeter.resets", "Resets each billing period")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,123 +50,26 @@ import apiClient from "@app/services/apiClient";
|
|||||||
import { createPortalSession } from "@app/services/billing";
|
import { createPortalSession } from "@app/services/billing";
|
||||||
import { openExternal } from "@app/platform/openExternal";
|
import { openExternal } from "@app/platform/openExternal";
|
||||||
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
|
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
|
||||||
|
import type {
|
||||||
|
Wallet,
|
||||||
|
WalletStatus,
|
||||||
|
WalletRole,
|
||||||
|
WalletMember,
|
||||||
|
WalletCategoryBreakdown,
|
||||||
|
WalletActivityRow,
|
||||||
|
} from "@shared/billing";
|
||||||
|
|
||||||
// ─── Public types ───────────────────────────────────────────────────────
|
// ─── Public types ───────────────────────────────────────────────────────
|
||||||
|
// The wallet contract lives in @shared/billing (shared with the admin portal).
|
||||||
export type WalletStatus = "free" | "subscribed";
|
// Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
|
||||||
export type WalletRole = "leader" | "member";
|
export type {
|
||||||
|
Wallet,
|
||||||
/**
|
WalletStatus,
|
||||||
* A single team member's billing-relevant info — name + email for the avatar
|
WalletRole,
|
||||||
* row, {@code spendUnits} for their per-member usage display. Mirrors a row of
|
WalletMember,
|
||||||
* the backend's {@code members} array on {@code WalletSnapshot} (joined with
|
WalletCategoryBreakdown,
|
||||||
* {@code team_memberships}).
|
WalletActivityRow,
|
||||||
*/
|
};
|
||||||
export interface WalletMember {
|
|
||||||
/** Supabase user id of the member. */
|
|
||||||
userId: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
/** Member's current-period billable spend. */
|
|
||||||
spendUnits: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-category breakdown of current-period spend in billable units. The
|
|
||||||
* categories mirror the {@code FeatureGate} buckets the backend tracks:
|
|
||||||
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
|
|
||||||
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
|
|
||||||
* billableUsed} (modulo rounding in mock data).
|
|
||||||
*/
|
|
||||||
export interface WalletCategoryBreakdown {
|
|
||||||
api: number;
|
|
||||||
ai: number;
|
|
||||||
automation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
|
|
||||||
export interface Wallet {
|
|
||||||
/**
|
|
||||||
* The caller's primary team_id. Needed when invoking Supabase edge functions
|
|
||||||
* (create-checkout-session, etc.) that run outside Spring Security and have
|
|
||||||
* no other way to resolve the caller's team. May be null on the synthetic
|
|
||||||
* empty snapshot returned to anonymous / team-less callers.
|
|
||||||
*/
|
|
||||||
teamId: number | null;
|
|
||||||
status: WalletStatus;
|
|
||||||
role: WalletRole;
|
|
||||||
/**
|
|
||||||
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
|
|
||||||
* the calendar month for free teams.
|
|
||||||
*/
|
|
||||||
billingPeriodStart: string;
|
|
||||||
billingPeriodEnd: string;
|
|
||||||
/**
|
|
||||||
* For a free team: the one-time free documents used so far ({@code
|
|
||||||
* freeAllowance − freeRemaining}). For a subscribed team: documents
|
|
||||||
* processed this month across automation + AI + API.
|
|
||||||
*/
|
|
||||||
billableUsed: number;
|
|
||||||
/**
|
|
||||||
* The team's document ceiling for the matching window: the one-time free
|
|
||||||
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
|
|
||||||
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
|
|
||||||
* subscribed with no cap (uncapped).
|
|
||||||
*/
|
|
||||||
billableLimit: number | null;
|
|
||||||
/**
|
|
||||||
* The team's one-time free document grant size — the "N" in "X of N free".
|
|
||||||
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
|
|
||||||
* and is not lost when the team subscribes.
|
|
||||||
*/
|
|
||||||
freeAllowance: number;
|
|
||||||
/**
|
|
||||||
* One-time free documents still available to the team
|
|
||||||
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
|
||||||
* Survives subscribing — a subscribed team keeps any unused grant.
|
|
||||||
*/
|
|
||||||
freeRemaining: number;
|
|
||||||
/**
|
|
||||||
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
|
|
||||||
* fractional); null when the rate can't be resolved — render "unknown",
|
|
||||||
* never substitute.
|
|
||||||
*/
|
|
||||||
pricePerDocMinor: number | null;
|
|
||||||
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
|
|
||||||
currency: string | null;
|
|
||||||
/**
|
|
||||||
* Estimated charges so far this period in minor units of currency: paid
|
|
||||||
* (Stripe-metered) documents this period × rate. The free portion was
|
|
||||||
* already netted out at charge time. Informational — the Stripe invoice
|
|
||||||
* is authoritative. Null when the rate is unknown.
|
|
||||||
*/
|
|
||||||
estimatedBillMinor: number | null;
|
|
||||||
/** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */
|
|
||||||
capUsd: number | null;
|
|
||||||
/** Only meaningful when status=='subscribed'. */
|
|
||||||
noCap: boolean;
|
|
||||||
/** Stripe subscription id when subscribed; null when free. */
|
|
||||||
stripeSubscriptionId: string | null;
|
|
||||||
/** Current-period spend in billable units. */
|
|
||||||
spendUnitsThisPeriod: number;
|
|
||||||
/** Per-category spend breakdown (api / ai / automation). */
|
|
||||||
categoryBreakdown: WalletCategoryBreakdown;
|
|
||||||
/**
|
|
||||||
* Team members, populated for the leader view; empty for members or
|
|
||||||
* single-seat tenants. Leader-vs-member is still resolved via {@link
|
|
||||||
* Wallet#role} — this field just carries the per-member rows the leader's
|
|
||||||
* sub-cap table needs.
|
|
||||||
*/
|
|
||||||
members: WalletMember[];
|
|
||||||
/**
|
|
||||||
* Recent billable-activity rows. V1 returns {@code []} from the backend;
|
|
||||||
* the field exists so the Plan page can render an empty state without
|
|
||||||
* branching on undefined. Each entry is a {@code Record<string, unknown>}
|
|
||||||
* because the activity-row shape is not yet finalised — when the meter-
|
|
||||||
* event surface lands, this widens to a real interface.
|
|
||||||
*/
|
|
||||||
recent: Array<Record<string, unknown>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseWalletResult {
|
export interface UseWalletResult {
|
||||||
wallet: Wallet | null;
|
wallet: Wallet | null;
|
||||||
|
|||||||
@@ -1,55 +1,3 @@
|
|||||||
/* SaaS-specific auth styles — imported alongside the base auth.css */
|
|
||||||
|
|
||||||
.oauth-container-fullwidth {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem; /* 12px */
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-button-fullwidth {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: #ffffff;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #000000;
|
|
||||||
cursor: pointer;
|
|
||||||
gap: 0.5rem;
|
|
||||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
|
|
||||||
transition:
|
|
||||||
background-color 150ms ease,
|
|
||||||
box-shadow 150ms ease,
|
|
||||||
border-color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-button-fullwidth:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-button-fullwidth:hover:not(:disabled) {
|
|
||||||
background-color: #fafafa;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .oauth-button-fullwidth {
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--border-default);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"]
|
|
||||||
.oauth-button-fullwidth:hover:not(:disabled) {
|
|
||||||
background-color: var(--bg-raised);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-dropdown-wrapper {
|
.auth-dropdown-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,22 @@ VITE_EDITOR_URL=/
|
|||||||
# in production builds). The single-origin proxy task sets this to "false" so the
|
# in production builds). The single-origin proxy task sets this to "false" so the
|
||||||
# portal uses the real backend.
|
# portal uses the real backend.
|
||||||
VITE_PORTAL_MOCKS=
|
VITE_PORTAL_MOCKS=
|
||||||
|
|
||||||
|
# Hosted SaaS Supabase project for IN-APP account linking (both values are
|
||||||
|
# public). Set per deploy; absent → the account-link UI shows a "configure"
|
||||||
|
# state. For local e2e, point these at the SaaS Supabase project the local
|
||||||
|
# backend links against (e.g. the V3 branch project).
|
||||||
|
VITE_SAAS_SUPABASE_URL=
|
||||||
|
VITE_SAAS_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used
|
||||||
|
# for ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
|
||||||
|
# admin's Supabase JWT. Distinct from the local backend (which the portal
|
||||||
|
# reaches same-origin via the vite proxy). Absent → wallet/billing surfaces
|
||||||
|
# stay on the MSW mock.
|
||||||
|
VITE_SAAS_API_URL=
|
||||||
|
|
||||||
|
# Stripe publishable key (pk_live_… / pk_test_…) used by the embedded Checkout
|
||||||
|
# in the billing surface. Public by design. Empty → the checkout modal shows a
|
||||||
|
# "configure" state instead of mounting Stripe.
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ appProcessor = "Processor"
|
|||||||
appEditor = "Editor"
|
appEditor = "Editor"
|
||||||
docsProcessed = "Docs processed"
|
docsProcessed = "Docs processed"
|
||||||
docsCount = "{{docs}} docs"
|
docsCount = "{{docs}} docs"
|
||||||
planPayAsYouGo = "Pay-as-you-go"
|
planProcessor = "Processor plan"
|
||||||
planEnterprise = "Enterprise Plan"
|
linkAccount = "Link Stirling account"
|
||||||
|
planEnterprise = "Enterprise plan"
|
||||||
|
|
||||||
[search]
|
[search]
|
||||||
ariaLabel = "Search"
|
ariaLabel = "Search"
|
||||||
@@ -94,6 +95,7 @@ general = "General"
|
|||||||
authentication = "Authentication"
|
authentication = "Authentication"
|
||||||
sessions = "Active sessions"
|
sessions = "Active sessions"
|
||||||
early-access = "Early access"
|
early-access = "Early access"
|
||||||
|
account-link = "Account link"
|
||||||
|
|
||||||
[settings.profile]
|
[settings.profile]
|
||||||
accountFallback = "Account"
|
accountFallback = "Account"
|
||||||
@@ -420,180 +422,6 @@ snapshot = "Snapshot: re-read the folder every run"
|
|||||||
[sources.types.unknown]
|
[sources.types.unknown]
|
||||||
label = "Source"
|
label = "Source"
|
||||||
|
|
||||||
[usage]
|
|
||||||
title = "Usage & Billing"
|
|
||||||
subtitle = "Your last 30 days of processing, plan, and charges."
|
|
||||||
|
|
||||||
[usage.chart.empty]
|
|
||||||
title = "No usage yet"
|
|
||||||
description = "Once documents are processed, your 30-day usage appears here."
|
|
||||||
|
|
||||||
[usage.kpi.docsThisPeriod]
|
|
||||||
label = "Docs this period"
|
|
||||||
description = "of {{included}} included"
|
|
||||||
|
|
||||||
[usage.kpi.costThisMonth]
|
|
||||||
label = "Cost this month"
|
|
||||||
description = "incl. {{fee}} platform"
|
|
||||||
freePlan = "free plan"
|
|
||||||
|
|
||||||
[usage.kpi.nextBillingDate]
|
|
||||||
label = "Next billing date"
|
|
||||||
resetsMonthly = "resets monthly"
|
|
||||||
autoCharge = "auto-charge"
|
|
||||||
|
|
||||||
[usage.kpi.remainingInPlan]
|
|
||||||
label = "Remaining in plan"
|
|
||||||
description = "docs before cap"
|
|
||||||
|
|
||||||
[usage.kpi.commitUtilisation]
|
|
||||||
label = "Commit utilisation"
|
|
||||||
description = "of committed volume"
|
|
||||||
|
|
||||||
[usage.kpi.overage]
|
|
||||||
label = "Overage (${{rate}}/doc)"
|
|
||||||
description_one = "{{docs}} doc past cap"
|
|
||||||
description_other = "{{docs}} docs past cap"
|
|
||||||
|
|
||||||
[usage.currentPlan]
|
|
||||||
eyebrow = "Current plan"
|
|
||||||
|
|
||||||
[usage.currentPlan.badge]
|
|
||||||
free = "Free"
|
|
||||||
pro = "Pay-as-you-go"
|
|
||||||
enterprise = "Committed"
|
|
||||||
|
|
||||||
[usage.currentPlan.free]
|
|
||||||
progressLabel = "Free plan usage"
|
|
||||||
|
|
||||||
[usage.currentPlan.free.capReached]
|
|
||||||
title = "You've hit your free plan cap"
|
|
||||||
body = "New documents are paused until next cycle. Upgrade to keep processing without interruption."
|
|
||||||
|
|
||||||
[usage.currentPlan.free.approaching]
|
|
||||||
title = "Approaching your free plan cap"
|
|
||||||
body = "You're at {{pct}}% of 500 docs/month. Upgrade to pay-as-you-go to avoid a pause."
|
|
||||||
|
|
||||||
[usage.currentPlan.pro]
|
|
||||||
platformFee = "Platform fee"
|
|
||||||
includedDocs = "Included docs"
|
|
||||||
overage = "Overage · {{docs}} docs @ ${{rate}}"
|
|
||||||
projected = "Projected this month"
|
|
||||||
|
|
||||||
[usage.currentPlan.enterprise]
|
|
||||||
committedVolume = "Committed volume"
|
|
||||||
committedVolumeValue = "{{docs}} docs/mo"
|
|
||||||
drawnThisPeriod = "Drawn this period"
|
|
||||||
drawnThisPeriodValue = "{{docs}} docs"
|
|
||||||
effectiveRate = "Effective rate"
|
|
||||||
effectiveRateValue = "${{rate}} / doc"
|
|
||||||
monthlyDraw = "Monthly draw"
|
|
||||||
|
|
||||||
[usage.currentPlan.actions]
|
|
||||||
upgrade = "Upgrade plan"
|
|
||||||
talkToSales = "Talk to sales"
|
|
||||||
adjustCommitment = "Adjust commitment"
|
|
||||||
downloadInvoices = "Download invoices"
|
|
||||||
|
|
||||||
[usage.spendCap.free]
|
|
||||||
title = "Spend cap"
|
|
||||||
description = "The free plan can't accrue spend — your usage is hard-capped at 500 docs/month. Upgrade to pay-as-you-go to set a monthly spend cap."
|
|
||||||
|
|
||||||
[usage.spendCap.enterprise]
|
|
||||||
title = "Spend controls"
|
|
||||||
description = "Spend is governed by your committed-volume contract. Overage terms and alert thresholds are managed with your account team."
|
|
||||||
badge = "Committed contract"
|
|
||||||
overage = "Overage billed at ${{rate}}/doc"
|
|
||||||
|
|
||||||
[usage.spendCap.pro]
|
|
||||||
title = "Monthly spend cap"
|
|
||||||
subtitle = "Pause processing automatically when spend reaches your limit."
|
|
||||||
disable = "Disable cap"
|
|
||||||
enable = "Enable cap"
|
|
||||||
projected = "Projected {{projected}} of {{cap}} cap"
|
|
||||||
progressLabel = "Spend against cap"
|
|
||||||
|
|
||||||
[usage.plans]
|
|
||||||
title = "Plans"
|
|
||||||
subtitle = "Move up or down at any time — changes take effect next cycle."
|
|
||||||
|
|
||||||
[usage.planCard]
|
|
||||||
current = "Current"
|
|
||||||
yourPlan = "Your plan"
|
|
||||||
contactSales = "Contact sales"
|
|
||||||
choosePlan = "Choose plan"
|
|
||||||
|
|
||||||
[usage.history]
|
|
||||||
title = "Billing history"
|
|
||||||
subtitle = "Line items from the current and prior billing cycles."
|
|
||||||
emptyRows = "No line items"
|
|
||||||
|
|
||||||
[usage.history.columns]
|
|
||||||
date = "Date"
|
|
||||||
description = "Description"
|
|
||||||
docs = "Docs"
|
|
||||||
amount = "Amount"
|
|
||||||
status = "Status"
|
|
||||||
|
|
||||||
[usage.history.status]
|
|
||||||
paid = "Paid"
|
|
||||||
due = "Due"
|
|
||||||
pending = "Pending"
|
|
||||||
refunded = "Refunded"
|
|
||||||
|
|
||||||
[usage.history.empty]
|
|
||||||
title = "No billing history"
|
|
||||||
description = "Charges and credits appear here once your first cycle closes."
|
|
||||||
|
|
||||||
[usage.upgrade]
|
|
||||||
notNow = "Not now"
|
|
||||||
|
|
||||||
[usage.upgrade.free]
|
|
||||||
title = "Upgrade to keep processing"
|
|
||||||
subtitle = "Pay-as-you-go · $0.05 / doc"
|
|
||||||
body = "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly — you only pay for what you process beyond the included 25,000 docs."
|
|
||||||
bullets = [
|
|
||||||
"Lift the 500 doc/month cap immediately",
|
|
||||||
"25,000 docs included, then $0.05/doc",
|
|
||||||
"Unlimited pipelines, agents, and sources",
|
|
||||||
"Set a monthly spend cap to stay in control",
|
|
||||||
]
|
|
||||||
cta = "Switch to pay-as-you-go"
|
|
||||||
|
|
||||||
[usage.upgrade.proToEnterprise]
|
|
||||||
title = "Move to a committed plan"
|
|
||||||
subtitle = "Enterprise · committed annual volume"
|
|
||||||
body = "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM."
|
|
||||||
bullets = [
|
|
||||||
"Lower effective rate vs metered overage",
|
|
||||||
"Dedicated & on-prem region options",
|
|
||||||
"SSO, audit-log export, signed DPA",
|
|
||||||
"Named CSM and 99.99% SLA",
|
|
||||||
]
|
|
||||||
cta = "Talk to sales"
|
|
||||||
|
|
||||||
[usage.upgrade.pro]
|
|
||||||
title = "You're already on pay-as-you-go"
|
|
||||||
subtitle = "Considering a committed plan?"
|
|
||||||
body = "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate."
|
|
||||||
bullets = [
|
|
||||||
"Predictable monthly spend",
|
|
||||||
"Lower effective per-doc rate at volume",
|
|
||||||
"Volume discounts kick in past 1M docs/mo",
|
|
||||||
]
|
|
||||||
cta = "Explore committed pricing"
|
|
||||||
|
|
||||||
[usage.upgrade.enterprise]
|
|
||||||
title = "Adjust your commitment"
|
|
||||||
subtitle = "Enterprise · bespoke terms"
|
|
||||||
body = "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team — they'll model the right shape with you."
|
|
||||||
bullets = [
|
|
||||||
"Re-model committed volume up or down",
|
|
||||||
"Add dedicated or on-prem regions",
|
|
||||||
"Adjust SLA, DPA, and overage terms",
|
|
||||||
]
|
|
||||||
cta = "Contact your CSM"
|
|
||||||
|
|
||||||
[documents]
|
[documents]
|
||||||
title = "Documents"
|
title = "Documents"
|
||||||
subtitle = "Review and approve documents moving through your pipelines."
|
subtitle = "Review and approve documents moving through your pipelines."
|
||||||
@@ -1774,3 +1602,252 @@ redirectingToEditor = "Redirecting to the editor..."
|
|||||||
title = "Something went wrong on this page"
|
title = "Something went wrong on this page"
|
||||||
description = "This view hit an unexpected error. Try again, or pick another section from the sidebar."
|
description = "This view hit an unexpected error. Try again, or pick another section from the sidebar."
|
||||||
retry = "Try again"
|
retry = "Try again"
|
||||||
|
|
||||||
|
# ── Account link (combined-billing Mode A) ───────────────────────────────────
|
||||||
|
[accountLink.state]
|
||||||
|
unlinked = "Not linked"
|
||||||
|
free = "Editor plan"
|
||||||
|
subscribed = "Processor plan"
|
||||||
|
|
||||||
|
[accountLink.panel]
|
||||||
|
sub = "Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet."
|
||||||
|
instancesTitle = "Linked instances"
|
||||||
|
instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access."
|
||||||
|
revokeError = "Couldn't revoke instance"
|
||||||
|
|
||||||
|
[accountLink.panel.loadError]
|
||||||
|
title = "Couldn't load linked instances"
|
||||||
|
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."
|
||||||
|
|
||||||
|
[accountLink.card]
|
||||||
|
eyebrow = "Account link"
|
||||||
|
title = "Link this org to its Stirling account"
|
||||||
|
linked = "Linked"
|
||||||
|
notLinked = "Not linked"
|
||||||
|
unlink = "Unlink"
|
||||||
|
linkButton = "Link your Stirling account"
|
||||||
|
linkedAs = "Linked as {{name}}."
|
||||||
|
linkedGeneric = "This instance is linked."
|
||||||
|
billingNote = "Unattended processing bills against your org wallet."
|
||||||
|
|
||||||
|
[accountLink.card.error]
|
||||||
|
title = "Couldn't link"
|
||||||
|
|
||||||
|
[accountLink.card.loginNotConfigured]
|
||||||
|
title = "SaaS login not configured"
|
||||||
|
before = "Set"
|
||||||
|
after = "to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog."
|
||||||
|
|
||||||
|
[accountLink.modal]
|
||||||
|
linkTitle = "Link your Stirling account"
|
||||||
|
reauthTitle = "Sign in again"
|
||||||
|
linkSubtitle = "Sign in to the account this server should bill against."
|
||||||
|
reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked."
|
||||||
|
simulateSignIn = "Simulate sign-in (dev)"
|
||||||
|
|
||||||
|
[accountLink.modal.loginNotConfigured]
|
||||||
|
title = "SaaS login not configured"
|
||||||
|
before = "Set"
|
||||||
|
and = "and"
|
||||||
|
after = "to enable in-app linking against the hosted Stirling account."
|
||||||
|
|
||||||
|
[accountLink.gate]
|
||||||
|
title = "Link to unlock"
|
||||||
|
titleFeature = "Link to unlock {{feature}}"
|
||||||
|
description = "Link this org's Stirling account to use billable features."
|
||||||
|
action = "Link account"
|
||||||
|
|
||||||
|
[accountLink.instances]
|
||||||
|
unnamed = "Unnamed instance"
|
||||||
|
revoked = "Revoked"
|
||||||
|
active = "Active"
|
||||||
|
revoke = "Revoke"
|
||||||
|
|
||||||
|
[accountLink.instances.columns]
|
||||||
|
instance = "Instance"
|
||||||
|
status = "Status"
|
||||||
|
lastSeen = "Last seen"
|
||||||
|
linked = "Linked"
|
||||||
|
|
||||||
|
[accountLink.instances.empty]
|
||||||
|
title = "No linked instances"
|
||||||
|
description = "Link this org's account, then register your self-hosted instances to see them here."
|
||||||
|
|
||||||
|
[accountLink.instances.time]
|
||||||
|
never = "never"
|
||||||
|
justNow = "just now"
|
||||||
|
minutesAgo_one = "{{count}}m ago"
|
||||||
|
minutesAgo_other = "{{count}}m ago"
|
||||||
|
hoursAgo_one = "{{count}}h ago"
|
||||||
|
hoursAgo_other = "{{count}}h ago"
|
||||||
|
daysAgo_one = "{{count}}d ago"
|
||||||
|
daysAgo_other = "{{count}}d ago"
|
||||||
|
|
||||||
|
# ── Billing surface (Usage & billing) ────────────────────────────────────────
|
||||||
|
[billing.enterpriseUpsell]
|
||||||
|
eyebrow = "Volume discount · 1M+ PDFs"
|
||||||
|
title = "Stirling Enterprise"
|
||||||
|
description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships."
|
||||||
|
cta = "Build your Enterprise quote"
|
||||||
|
|
||||||
|
[billing.freeEditors]
|
||||||
|
title = "Free PDF Editors"
|
||||||
|
previewBadge = "Preview · sample data"
|
||||||
|
subtitle = "Deploy anywhere, for your whole team."
|
||||||
|
editorsDeployed = "Editors deployed"
|
||||||
|
activeThisMonth = "Active this month"
|
||||||
|
pdfsEdited = "PDFs edited"
|
||||||
|
cost = "Cost"
|
||||||
|
inviteTeammates = "Invite teammates"
|
||||||
|
|
||||||
|
[billing.freePlan]
|
||||||
|
currentPlan = "Current plan"
|
||||||
|
planName = "Editor"
|
||||||
|
freeForever = "Free forever"
|
||||||
|
ssoIncluded = "SSO included"
|
||||||
|
unlimitedUsers = "Unlimited users"
|
||||||
|
switchOnProcessor = "Switch on the Processor →"
|
||||||
|
noTeamResolved = "No team is resolved on your wallet yet — refresh and try again."
|
||||||
|
checkoutErrorTitle = "Couldn't start checkout"
|
||||||
|
ownerOnly = "Only the team owner can switch on the Processor plan."
|
||||||
|
|
||||||
|
[billing.linkPrompt]
|
||||||
|
title = "Link your 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."
|
||||||
|
cta = "Link Stirling account"
|
||||||
|
|
||||||
|
[billing.walletMeter]
|
||||||
|
eyebrow = "Processor trial"
|
||||||
|
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"
|
||||||
|
capSuffix_one = "of {{allowance}} free PDFs used"
|
||||||
|
capSuffix_other = "of {{allowance}} free PDFs used"
|
||||||
|
statusLabel_one = "{{remaining}} left"
|
||||||
|
statusLabel_other = "{{remaining}} left"
|
||||||
|
|
||||||
|
[billing.pdfsProcessed]
|
||||||
|
eyebrow = "PDFs processed this period"
|
||||||
|
unit = "metered PDFs"
|
||||||
|
segbarAriaLabel = "Metered PDFs split by category"
|
||||||
|
segmentApiLabel = "API"
|
||||||
|
segmentApiDesc = "Direct API requests"
|
||||||
|
segmentAgentsLabel = "Agents"
|
||||||
|
segmentAgentsDesc = "AI agent actions"
|
||||||
|
segmentAutomationLabel = "Automation"
|
||||||
|
segmentAutomationDesc = "Automations & pipelines"
|
||||||
|
legendValue_one = "{{formatted}} PDFs"
|
||||||
|
legendValue_other = "{{formatted}} PDFs"
|
||||||
|
emptyPeriod = "No metered processing yet this period."
|
||||||
|
|
||||||
|
[billing.spendThisMonth]
|
||||||
|
eyebrow = "Spend this month"
|
||||||
|
processed_one = "{{formattedCount}} PDF processed."
|
||||||
|
processed_other = "{{formattedCount}} PDFs processed."
|
||||||
|
processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each."
|
||||||
|
processedWithRate_other = "{{formattedCount}} PDFs processed, at {{rate}} each."
|
||||||
|
|
||||||
|
[billing.spendLimit]
|
||||||
|
eyebrow = "Spend limit"
|
||||||
|
editTitle = "Set your monthly ceiling"
|
||||||
|
capControlNote = "Changes apply immediately — raise or lower the ceiling any time."
|
||||||
|
useSuggested = "Use suggested · {{amount}} / month"
|
||||||
|
guardrailLabel = "Your guardrail:"
|
||||||
|
guardrailBody = "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost."
|
||||||
|
saveError = "Couldn't save limit"
|
||||||
|
cancel = "Cancel"
|
||||||
|
save = "Save limit"
|
||||||
|
displaySub = "You're only billed for what you process automatically — never past the ceiling."
|
||||||
|
adjustLimit = "Adjust limit"
|
||||||
|
capSuffix = "/ month"
|
||||||
|
capSuffixWithDocs = "/ month · ≈ {{documents}} documents"
|
||||||
|
noCap = "no cap"
|
||||||
|
pctUsed = "{{pct}}% used"
|
||||||
|
usedThisMonth = "{{amount}} used this month"
|
||||||
|
remaining = "{{amount}} remaining"
|
||||||
|
thisPeriodUncapped = "{{amount}} this period · uncapped"
|
||||||
|
|
||||||
|
[billing.spendLimit.projection]
|
||||||
|
label = "Projected to exceed."
|
||||||
|
body_one = "At {{rate}}/day you reach the cap in ~{{count}} day (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}."
|
||||||
|
body_other = "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}."
|
||||||
|
|
||||||
|
[billing.invoices]
|
||||||
|
title = "Invoice history"
|
||||||
|
columnDate = "Date"
|
||||||
|
columnPdfsProcessed = "PDFs processed"
|
||||||
|
columnAmount = "Amount"
|
||||||
|
columnStatus = "Status"
|
||||||
|
columnDescription = "Description"
|
||||||
|
descriptionFallback = "Invoice"
|
||||||
|
viewLink = "View ↗"
|
||||||
|
viewAriaLabel = "View invoice {{number}} in Stripe"
|
||||||
|
pdfLink = "PDF ↓"
|
||||||
|
downloadAriaLabel = "Download invoice {{number}} as PDF"
|
||||||
|
loadError = "Couldn't load invoices: {{error}}"
|
||||||
|
emptyTitle = "No invoices yet"
|
||||||
|
emptyDescription = "Once your team subscribes and the first cycle closes, your invoices appear here."
|
||||||
|
showFewer_one = "Show fewer (top {{count}})"
|
||||||
|
showFewer_other = "Show fewer (top {{count}})"
|
||||||
|
showMostRecent_one = "Show {{count}} most recent"
|
||||||
|
showMostRecent_other = "Show {{count}} most recent"
|
||||||
|
showAll_one = "Show all {{count}}"
|
||||||
|
showAll_other = "Show all {{count}}"
|
||||||
|
fetchLimitNote_one = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal."
|
||||||
|
fetchLimitNote_other = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal."
|
||||||
|
|
||||||
|
[billing.paymentMethod]
|
||||||
|
eyebrow = "Payment method"
|
||||||
|
cardEnding = "{{brand}} ending {{last4}}"
|
||||||
|
cardFallback = "Card"
|
||||||
|
expiresBilledMonthly = "Expires {{expiry}} · billed monthly"
|
||||||
|
billedMonthly = "Billed monthly"
|
||||||
|
managedTitle = "Managed in Stripe"
|
||||||
|
managedSub = "Your card and billing details are kept securely in Stripe's customer portal."
|
||||||
|
update = "Update"
|
||||||
|
|
||||||
|
[billing.checkout]
|
||||||
|
title = "Turn on the Processor plan"
|
||||||
|
subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest."
|
||||||
|
noClientSecret = "Edge function returned no client_secret."
|
||||||
|
|
||||||
|
[billing.checkout.notConfigured]
|
||||||
|
title = "Stripe not configured"
|
||||||
|
bodyBefore = "Set"
|
||||||
|
bodyAfter = "in the portal env to enable in-app checkout."
|
||||||
|
|
||||||
|
[billing.checkout.error]
|
||||||
|
title = "Couldn't start checkout"
|
||||||
|
|
||||||
|
[billing.subscribedPlan.capWarn]
|
||||||
|
reachedTitle = "Monthly spend limit reached"
|
||||||
|
approachingTitle = "You're at {{pct}}% of your monthly spend limit"
|
||||||
|
raiseLimit = "Raise limit"
|
||||||
|
reachedBody = "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working."
|
||||||
|
approachingBody = "Raise it now so automated processing never pauses."
|
||||||
|
|
||||||
|
[billing.subscribedPlan.portalError]
|
||||||
|
title = "Couldn't open Stripe portal"
|
||||||
|
|
||||||
|
# ── Usage & billing view ─────────────────────────────────────────────────────
|
||||||
|
[usage]
|
||||||
|
title = "Usage & billing"
|
||||||
|
subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console."
|
||||||
|
managePayment = "Manage Payment"
|
||||||
|
|
||||||
|
[usage.finalizing]
|
||||||
|
title = "Finalizing your subscription…"
|
||||||
|
body = "It can take a few seconds for your subscription to activate. This page updates automatically."
|
||||||
|
|
||||||
|
[usage.sessionExpired]
|
||||||
|
title = "Session expired"
|
||||||
|
action = "Sign in again"
|
||||||
|
body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked."
|
||||||
|
|
||||||
|
[usage.error]
|
||||||
|
loadWallet = "Couldn't load wallet"
|
||||||
|
openStripePortal = "Couldn't open Stripe portal"
|
||||||
|
walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}"
|
||||||
|
|||||||
+66
-18
@@ -5,6 +5,8 @@ import { AuthProvider } from "@shared/auth";
|
|||||||
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
||||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||||
import { TierProvider } from "@portal/contexts/TierContext";
|
import { TierProvider } from "@portal/contexts/TierContext";
|
||||||
|
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
|
||||||
|
import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin";
|
||||||
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||||
import { mantineTheme } from "@portal/theme/mantineTheme";
|
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||||
import { AppShell } from "@portal/components/AppShell";
|
import { AppShell } from "@portal/components/AppShell";
|
||||||
@@ -13,6 +15,11 @@ import { AssistantButton } from "@portal/components/AssistantButton";
|
|||||||
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||||
import { SearchModal } from "@portal/components/SearchModal";
|
import { SearchModal } from "@portal/components/SearchModal";
|
||||||
import { SettingsModal } from "@portal/components/SettingsModal";
|
import { SettingsModal } from "@portal/components/SettingsModal";
|
||||||
|
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
|
||||||
|
import {
|
||||||
|
AccountLinkProvider,
|
||||||
|
useAccountLinkContext,
|
||||||
|
} from "@portal/contexts/AccountLinkContext";
|
||||||
import { ViewRouter } from "@portal/ViewRouter";
|
import { ViewRouter } from "@portal/ViewRouter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,8 +65,42 @@ function GlobalShortcuts() {
|
|||||||
|
|
||||||
/** Bridges the Settings modal's open/close props to UIContext state. */
|
/** Bridges the Settings modal's open/close props to UIContext state. */
|
||||||
function SettingsHost() {
|
function SettingsHost() {
|
||||||
const { settingsOpen, closeSettings } = useUI();
|
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
|
||||||
return <SettingsModal open={settingsOpen} onClose={closeSettings} />;
|
return (
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={closeSettings}
|
||||||
|
initialSection={settingsInitialSection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one and only account-link login modal. Mounted at the app root (never
|
||||||
|
* nested in another overlay) and driven by UIContext, so any "Link account" CTA
|
||||||
|
* — sidebar, billing prompt, feature gate, Settings panel — opens this exact
|
||||||
|
* instance. Linking is finished by the shared {@link useAccountLinkContext}
|
||||||
|
* orchestration.
|
||||||
|
*/
|
||||||
|
function LinkModalHost() {
|
||||||
|
const { linkModalOpen, linkModalMode, closeLinkModal } = useUI();
|
||||||
|
const { markSaasSessionChanged } = useLink();
|
||||||
|
const link = useAccountLinkContext();
|
||||||
|
// "reauth" only refreshes the browser SaaS session for attended reads — the
|
||||||
|
// sign-in already applied it to the Supabase client, so we just signal a
|
||||||
|
// refetch. It must NOT call completeLink (that re-registers → duplicate row).
|
||||||
|
const onLinked =
|
||||||
|
linkModalMode === "reauth"
|
||||||
|
? () => markSaasSessionChanged()
|
||||||
|
: (session: SupabaseLoginSession) => link.completeLink(session);
|
||||||
|
return (
|
||||||
|
<LinkAccountModal
|
||||||
|
open={linkModalOpen}
|
||||||
|
mode={linkModalMode}
|
||||||
|
onClose={closeLinkModal}
|
||||||
|
onLinked={onLinked}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,22 +128,29 @@ export function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<PortalMantineProvider>
|
<PortalMantineProvider>
|
||||||
<AuthProvider mode="spring">
|
<AuthProvider mode="spring">
|
||||||
<TierProvider initialTier="pro">
|
<LinkProvider initialState="unlinked">
|
||||||
<BrowserRouter basename={basename}>
|
{/* TierProvider sits INSIDE LinkProvider so it can derive the tier
|
||||||
<UIProvider>
|
from the real link/subscription state when MSW mocks are off. */}
|
||||||
<GlobalShortcuts />
|
<TierProvider initialTier="pro">
|
||||||
<AuthGate>
|
<BrowserRouter basename={basename}>
|
||||||
<AppShell>
|
<UIProvider>
|
||||||
<RoutedContent />
|
<GlobalShortcuts />
|
||||||
</AppShell>
|
<AuthGate>
|
||||||
<AssistantButton />
|
<AccountLinkProvider>
|
||||||
<AssistantPanel />
|
<AppShell>
|
||||||
<SearchModal />
|
<RoutedContent />
|
||||||
<SettingsHost />
|
</AppShell>
|
||||||
</AuthGate>
|
<AssistantButton />
|
||||||
</UIProvider>
|
<AssistantPanel />
|
||||||
</BrowserRouter>
|
<SearchModal />
|
||||||
</TierProvider>
|
<SettingsHost />
|
||||||
|
<LinkModalHost />
|
||||||
|
</AccountLinkProvider>
|
||||||
|
</AuthGate>
|
||||||
|
</UIProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</TierProvider>
|
||||||
|
</LinkProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</PortalMantineProvider>
|
</PortalMantineProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export function ViewRouter() {
|
|||||||
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
|
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
|
||||||
<Route path={VIEW_PATHS.usage} element={<Usage />} />
|
<Route path={VIEW_PATHS.usage} element={<Usage />} />
|
||||||
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
|
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
|
||||||
|
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
|
||||||
|
<Route path="/account-link" element={<Navigate to="/" replace />} />
|
||||||
{/* Settings is a modal overlay, not a route (see AppShell + UIContext). */}
|
{/* Settings is a modal overlay, not a route (see AppShell + UIContext). */}
|
||||||
{/* Unknown paths land on Home. */}
|
{/* Unknown paths land on Home. */}
|
||||||
<Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} />
|
<Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { AgentsResponse } from "@portal/mocks/agents";
|
import type { AgentsResponse } from "@portal/mocks/agents";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export { AGENT_STATUS_TONE, TOOL_CATALOGUE } from "@portal/mocks/agents";
|
|||||||
|
|
||||||
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
|
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
|
||||||
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
|
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
|
||||||
return httpJson<AgentsResponse>(
|
return apiClient.local.json<AgentsResponse>(
|
||||||
`/v1/agents?tier=${encodeURIComponent(tier)}`,
|
`/v1/agents?tier=${encodeURIComponent(tier)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
|
|
||||||
/** GET /v1/assistant/suggestions */
|
/** GET /v1/assistant/suggestions */
|
||||||
export async function fetchAssistantSuggestions(): Promise<readonly string[]> {
|
export async function fetchAssistantSuggestions(): Promise<readonly string[]> {
|
||||||
return httpJson<readonly string[]>("/v1/assistant/suggestions");
|
return apiClient.local.json<readonly string[]>("/v1/assistant/suggestions");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /v1/assistant/messages */
|
/** POST /v1/assistant/messages */
|
||||||
export async function getAssistantReply(input: string): Promise<string> {
|
export async function getAssistantReply(input: string): Promise<string> {
|
||||||
const res = await httpJson<{ reply: string }>("/v1/assistant/messages", {
|
const res = await apiClient.local.json<{ reply: string }>(
|
||||||
method: "POST",
|
"/v1/assistant/messages",
|
||||||
body: { input },
|
{
|
||||||
});
|
method: "POST",
|
||||||
|
body: { input },
|
||||||
|
},
|
||||||
|
);
|
||||||
return res.reply;
|
return res.reply;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { apiClient } from "@portal/api/http";
|
||||||
|
import type { Wallet } from "@shared/billing";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real wallet + billing surface. All calls go to apiClient.saas — the hosted
|
||||||
|
* SaaS Java backend, authed by the admin's Supabase JWT. The wallet contract
|
||||||
|
* itself lives in {@code @shared/billing} (shared with the editor cloud surface).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export the shared contract so existing `@portal/api/billing` importers keep working.
|
||||||
|
export type {
|
||||||
|
Wallet,
|
||||||
|
WalletStatus,
|
||||||
|
WalletRole,
|
||||||
|
WalletMember,
|
||||||
|
WalletCategoryBreakdown,
|
||||||
|
WalletActivityRow,
|
||||||
|
} from "@shared/billing";
|
||||||
|
|
||||||
|
export async function fetchWallet(): Promise<Wallet> {
|
||||||
|
return apiClient.saas.json<Wallet>("/api/v1/payg/wallet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Cap — leader-only PATCH (real endpoint).
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function updateCap(capUsd: number | null): Promise<void> {
|
||||||
|
await apiClient.saas.json<void>("/api/v1/payg/cap", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { capUsd: capUsd ?? 0, noCap: capUsd === null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Invoices — backed by GET /api/v1/payg/invoices (reads stripe.invoices via
|
||||||
|
// the Sync Engine). Returns [] for free teams + missing schema.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
number: string | null;
|
||||||
|
status: string;
|
||||||
|
totalMinor: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
periodStart: string | null;
|
||||||
|
periodEnd: string | null;
|
||||||
|
hostedInvoiceUrl: string | null;
|
||||||
|
invoicePdf: string | null;
|
||||||
|
/** Product name from the subscription chain (e.g. "Stirling Processor Plan"). */
|
||||||
|
description: string | null;
|
||||||
|
/** Billed units (PDFs) on this invoice; null when the line-item table isn't synced. */
|
||||||
|
pdfsProcessed: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInvoices(limit: number = 20): Promise<Invoice[]> {
|
||||||
|
return apiClient.saas.json<Invoice[]>(
|
||||||
|
`/api/v1/payg/invoices?limit=${encodeURIComponent(String(limit))}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Payment method — GET /api/v1/payg/payment-method. Reads the default card off
|
||||||
|
// the Stripe mirror; `present: false` when the mirror doesn't carry one (table
|
||||||
|
// not synced / no card). Card edits happen in Stripe's portal, not here.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PaymentMethod {
|
||||||
|
present: boolean;
|
||||||
|
brand: string | null;
|
||||||
|
last4: string | null;
|
||||||
|
expMonth: number | null;
|
||||||
|
expYear: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPaymentMethod(): Promise<PaymentMethod> {
|
||||||
|
return apiClient.saas.json<PaymentMethod>("/api/v1/payg/payment-method");
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
import type { DocsContent, DocsNavSection } from "@portal/mocks/docs";
|
import type { DocsContent, DocsNavSection } from "@portal/mocks/docs";
|
||||||
|
|
||||||
@@ -18,10 +18,10 @@ export type {
|
|||||||
|
|
||||||
/** GET /v1/docs/nav — the docs nav tree. */
|
/** GET /v1/docs/nav — the docs nav tree. */
|
||||||
export async function fetchDocsNav(): Promise<DocsNavSection[]> {
|
export async function fetchDocsNav(): Promise<DocsNavSection[]> {
|
||||||
return httpJson<DocsNavSection[]>("/v1/docs/nav");
|
return apiClient.local.json<DocsNavSection[]>("/v1/docs/nav");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/docs/content — the tier-scaled reference content. */
|
/** GET /v1/docs/content — the tier-scaled reference content. */
|
||||||
export async function fetchDocsContent(tier: Tier): Promise<DocsContent> {
|
export async function fetchDocsContent(tier: Tier): Promise<DocsContent> {
|
||||||
return httpJson<DocsContent>(`/v1/docs/content?tier=${tier}`);
|
return apiClient.local.json<DocsContent>(`/v1/docs/content?tier=${tier}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { DocumentsResponse } from "@portal/mocks/documents";
|
import type { DocumentsResponse } from "@portal/mocks/documents";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export {
|
|||||||
|
|
||||||
/** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */
|
/** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */
|
||||||
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
|
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
|
||||||
return httpJson<DocumentsResponse>(
|
return apiClient.local.json<DocumentsResponse>(
|
||||||
`/v1/documents?tier=${encodeURIComponent(tier)}`,
|
`/v1/documents?tier=${encodeURIComponent(tier)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export {
|
|||||||
export async function fetchEditorDeployment(
|
export async function fetchEditorDeployment(
|
||||||
tier: Tier,
|
tier: Tier,
|
||||||
): Promise<EditorDeploymentResponse> {
|
): Promise<EditorDeploymentResponse> {
|
||||||
return httpJson<EditorDeploymentResponse>(
|
return apiClient.local.json<EditorDeploymentResponse>(
|
||||||
`/v1/editor/deployment?tier=${encodeURIComponent(tier)}`,
|
`/v1/editor/deployment?tier=${encodeURIComponent(tier)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type {
|
import type {
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
KpiEntry,
|
KpiEntry,
|
||||||
@@ -23,25 +23,29 @@ export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home";
|
|||||||
|
|
||||||
/** GET /v1/analytics/usage?window=30d */
|
/** GET /v1/analytics/usage?window=30d */
|
||||||
export async function fetchUsageSeries(): Promise<UsageSeriesResponse> {
|
export async function fetchUsageSeries(): Promise<UsageSeriesResponse> {
|
||||||
return httpJson<UsageSeriesResponse>("/v1/analytics/usage?window=30d");
|
return apiClient.local.json<UsageSeriesResponse>(
|
||||||
|
"/v1/analytics/usage?window=30d",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/activity?limit=8 */
|
/** GET /v1/activity?limit=8 */
|
||||||
export async function fetchRecentActivity(): Promise<ActivityEvent[]> {
|
export async function fetchRecentActivity(): Promise<ActivityEvent[]> {
|
||||||
return httpJson<ActivityEvent[]>("/v1/activity?limit=8");
|
return apiClient.local.json<ActivityEvent[]>("/v1/activity?limit=8");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/home/kpis?tier=… */
|
/** GET /v1/home/kpis?tier=… */
|
||||||
export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
|
export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
|
||||||
return httpJson<KpiEntry[]>(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`);
|
return apiClient.local.json<KpiEntry[]>(
|
||||||
|
`/v1/home/kpis?tier=${encodeURIComponent(tier)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/regions/health (Enterprise) */
|
/** GET /v1/regions/health (Enterprise) */
|
||||||
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
|
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
|
||||||
return httpJson<RegionHealth[]>("/v1/regions/health");
|
return apiClient.local.json<RegionHealth[]>("/v1/regions/health");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/onboarding (Free) */
|
/** GET /v1/onboarding (Free) */
|
||||||
export async function fetchOnboarding(): Promise<OnboardingStep[]> {
|
export async function fetchOnboarding(): Promise<OnboardingStep[]> {
|
||||||
return httpJson<OnboardingStep[]>("/v1/onboarding");
|
return apiClient.local.json<OnboardingStep[]>("/v1/onboarding");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* api/http apiClient routing + error branches — the module exists specifically
|
||||||
|
* to make portal→backend routing explicit after /v1/billing/wallet once fell
|
||||||
|
* through to the local backend. The happy-path routing (saas hits the absolute
|
||||||
|
* base with the Supabase bearer) is covered in api/link.test.ts; here we pin the
|
||||||
|
* error/edge branches that gate the billing UI's error surface.
|
||||||
|
*/
|
||||||
|
const { getSession, getStoredTokenMock } = vi.hoisted(() => ({
|
||||||
|
getSession: vi.fn(),
|
||||||
|
getStoredTokenMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@shared/auth", () => ({ getStoredToken: getStoredTokenMock }));
|
||||||
|
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||||
|
getSupabaseClient: () => ({ auth: { getSession } }),
|
||||||
|
configureSupabase: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
apiClient,
|
||||||
|
HttpError,
|
||||||
|
SaasNotLinkedError,
|
||||||
|
SaasUnconfiguredError,
|
||||||
|
} from "@portal/api/http";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
fetchMock.mockReset();
|
||||||
|
getSession.mockReset();
|
||||||
|
getStoredTokenMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function ok(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("apiClient.saas", () => {
|
||||||
|
it("throws SaasUnconfiguredError when VITE_SAAS_API_URL is unset", async () => {
|
||||||
|
vi.stubEnv("VITE_SAAS_API_URL", "");
|
||||||
|
await expect(
|
||||||
|
apiClient.saas.json("/api/v1/payg/wallet"),
|
||||||
|
).rejects.toBeInstanceOf(SaasUnconfiguredError);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws SaasNotLinkedError when there is no SaaS session", async () => {
|
||||||
|
vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
|
||||||
|
getSession.mockResolvedValue({ data: { session: null } });
|
||||||
|
await expect(
|
||||||
|
apiClient.saas.json("/api/v1/payg/wallet"),
|
||||||
|
).rejects.toBeInstanceOf(SaasNotLinkedError);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the Supabase bearer and hits the absolute SaaS base", async () => {
|
||||||
|
vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
|
||||||
|
getSession.mockResolvedValue({
|
||||||
|
data: { session: { access_token: "supabase_tok" } },
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(ok({ status: "free" }));
|
||||||
|
|
||||||
|
const body = await apiClient.saas.json<{ status: string }>(
|
||||||
|
"/api/v1/payg/wallet",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body.status).toBe("free");
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("https://saas.test.local/api/v1/payg/wallet");
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||||
|
"Bearer supabase_tok",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("apiClient.local", () => {
|
||||||
|
it("attaches the Spring admin bearer and stays same-origin", async () => {
|
||||||
|
getStoredTokenMock.mockReturnValue("spring_tok");
|
||||||
|
fetchMock.mockResolvedValue(ok({ linked: false }));
|
||||||
|
|
||||||
|
await apiClient.local.json("/api/v1/account-link/status");
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("/api/v1/account-link/status");
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||||
|
"Bearer spring_tok",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a 204 response", async () => {
|
||||||
|
getStoredTokenMock.mockReturnValue("spring_tok");
|
||||||
|
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||||
|
|
||||||
|
const result = await apiClient.local.json("/api/v1/account-link/unlink", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws HttpError with the status and parsed body on non-2xx", async () => {
|
||||||
|
getStoredTokenMock.mockReturnValue("spring_tok");
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ error: "nope" }), {
|
||||||
|
status: 500,
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const err = await apiClient.local
|
||||||
|
.json("/api/v1/account-link/status")
|
||||||
|
.catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(HttpError);
|
||||||
|
expect((err as HttpError).status).toBe(500);
|
||||||
|
expect((err as HttpError).body).toEqual({ error: "nope" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+150
-25
@@ -1,16 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* Shared HTTP plumbing for the portal's service layer.
|
* Portal API client — explicit per-backend, per-credential routing.
|
||||||
*
|
*
|
||||||
* Every `api/*.ts` module calls {@link httpJson}, which issues a real `fetch`.
|
* ## Domains
|
||||||
* In dev and Storybook those requests are intercepted by the MSW handlers in
|
|
||||||
* `mocks/` and answered with fixture data; pointing at a real backend is just
|
|
||||||
* a matter of not registering MSW. Consumers don't change either way.
|
|
||||||
*
|
*
|
||||||
* The shared `stirling_jwt` bearer token (set by the auth gate, and shared
|
* apiClient.local Same-origin (vite proxy → this instance's local
|
||||||
* same-origin with the editor) is attached automatically so portal data calls
|
* Stirling backend on :8080). Spring admin bearer
|
||||||
* are authenticated once real backend endpoints exist.
|
* (`stirling_jwt` from @shared/auth) auto-attached.
|
||||||
|
* USE FOR: actions on this instance —
|
||||||
|
* /api/v1/account-link/{status,link,unlink}, etc.
|
||||||
|
*
|
||||||
|
* apiClient.saas VITE_SAAS_API_URL (hosted SaaS Java). The admin's
|
||||||
|
* Supabase JWT (from the account-link login,
|
||||||
|
* persisted + SDK-refreshed) is auto-attached.
|
||||||
|
* USE FOR: attended portal→SaaS reads —
|
||||||
|
* /api/v1/payg/wallet, etc.
|
||||||
|
* Throws SaasUnconfiguredError when VITE_SAAS_API_URL
|
||||||
|
* is missing — callers surface a clear "configure"
|
||||||
|
* state rather than silently routing to the wrong
|
||||||
|
* domain.
|
||||||
|
*
|
||||||
|
* Endpoints that don't have a real backend yet still target their eventual
|
||||||
|
* domain (almost always `.local`): with Mocks=on the MSW handlers intercept;
|
||||||
|
* with Mocks=off they hit the real backend and 404 until the route ships, then
|
||||||
|
* self-heal — no call-site migration needed.
|
||||||
|
*
|
||||||
|
* ## Why this is split, not a single function
|
||||||
|
*
|
||||||
|
* The two backends speak two credentials and resolve different identities. A
|
||||||
|
* single generic fetch reading the path prefix to pick a domain is implicit +
|
||||||
|
* fragile (the bug we hit: /v1/billing/wallet fell through to the local
|
||||||
|
* backend on a real run). Forcing the call site to say `.local` / `.saas`
|
||||||
|
* keeps the routing intent reviewable in diffs.
|
||||||
|
*
|
||||||
|
* ## Device credential isn't here
|
||||||
|
*
|
||||||
|
* The instance↔SaaS device credential ({@code X-Device-Id}+{@code X-Device-Secret})
|
||||||
|
* is a server-side credential the local backend uses for UNATTENDED metering /
|
||||||
|
* entitlement calls. It never enters the portal — the browser is the human
|
||||||
|
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
|
||||||
*/
|
*/
|
||||||
import { getStoredToken } from "@shared/auth";
|
import { getStoredToken } from "@shared/auth";
|
||||||
|
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||||
|
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||||
|
|
||||||
|
/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */
|
||||||
|
function saasBaseUrl(): string | null {
|
||||||
|
const raw = import.meta.env.VITE_SAAS_API_URL;
|
||||||
|
if (!raw) return null;
|
||||||
|
return raw.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
export interface HttpRequestOptions {
|
export interface HttpRequestOptions {
|
||||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
@@ -20,6 +58,7 @@ export interface HttpRequestOptions {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Thrown by any apiClient call on non-2xx response, with the parsed body. */
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
@@ -31,6 +70,26 @@ export class HttpError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Thrown by apiClient.saas.* when VITE_SAAS_API_URL isn't set. */
|
||||||
|
export class SaasUnconfiguredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"SaaS API not configured — set VITE_SAAS_API_URL to enable portal→SaaS reads.",
|
||||||
|
);
|
||||||
|
this.name = "SaasUnconfiguredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown by apiClient.saas.* when the admin has no SaaS session yet. */
|
||||||
|
export class SaasNotLinkedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"No SaaS session — admin must link an account before attended SaaS reads.",
|
||||||
|
);
|
||||||
|
this.name = "SaasNotLinkedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort human-readable message from a thrown error: unwraps an
|
* Best-effort human-readable message from a thrown error: unwraps an
|
||||||
* {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`)
|
* {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`)
|
||||||
@@ -49,16 +108,38 @@ export function errorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function authHeader(): Record<string, string> {
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Shared response handler
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function unwrap<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
// ignore — non-JSON error response
|
||||||
|
}
|
||||||
|
throw new HttpError(res.status, res.statusText, body);
|
||||||
|
}
|
||||||
|
// 204 / empty-body responses have nothing to parse.
|
||||||
|
if (res.status === 204 || res.headers.get("Content-Length") === "0") {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// local — same-origin Stirling backend, Spring admin bearer
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function localAuthHeader(): Record<string, string> {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function localJson<T>(
|
||||||
* Thin JSON fetch wrapper used by every api module. In dev/Storybook the
|
|
||||||
* request is served by MSW; against a real backend it hits the network.
|
|
||||||
*/
|
|
||||||
export async function httpJson<T>(
|
|
||||||
path: string,
|
path: string,
|
||||||
options: HttpRequestOptions = {},
|
options: HttpRequestOptions = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -69,20 +150,64 @@ export async function httpJson<T>(
|
|||||||
...(options.body !== undefined
|
...(options.body !== undefined
|
||||||
? { "Content-Type": "application/json" }
|
? { "Content-Type": "application/json" }
|
||||||
: {}),
|
: {}),
|
||||||
...authHeader(),
|
...localAuthHeader(),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
return unwrap<T>(res);
|
||||||
let body: unknown = null;
|
|
||||||
try {
|
|
||||||
body = await res.json();
|
|
||||||
} catch {
|
|
||||||
// ignore — non-JSON error response
|
|
||||||
}
|
|
||||||
throw new HttpError(res.status, res.statusText, body);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// saas — hosted SaaS Java, admin's Supabase JWT
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getSaasAccessToken(): Promise<string | null> {
|
||||||
|
ensureSaasSupabase();
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
if (!supabase) return null;
|
||||||
|
const { data } = await supabase.auth.getSession();
|
||||||
|
return data.session?.access_token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saasJson<T>(
|
||||||
|
path: string,
|
||||||
|
options: HttpRequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const base = saasBaseUrl();
|
||||||
|
if (!base) throw new SaasUnconfiguredError();
|
||||||
|
const token = await getSaasAccessToken();
|
||||||
|
if (!token) throw new SaasNotLinkedError();
|
||||||
|
const res = await fetch(`${base}${path}`, {
|
||||||
|
method: options.method ?? "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(options.body !== undefined
|
||||||
|
? { "Content-Type": "application/json" }
|
||||||
|
: {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
return unwrap<T>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Exported API client
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
/** Local backend (this instance). Spring admin bearer auto-attached. */
|
||||||
|
local: {
|
||||||
|
json: localJson,
|
||||||
|
},
|
||||||
|
/** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */
|
||||||
|
saas: {
|
||||||
|
json: saasJson,
|
||||||
|
/** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */
|
||||||
|
isConfigured: (): boolean => Boolean(saasBaseUrl()),
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
import type {
|
import type {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
@@ -57,32 +57,42 @@ const q = (tier: Tier) => `?tier=${encodeURIComponent(tier)}`;
|
|||||||
export async function fetchDeployments(
|
export async function fetchDeployments(
|
||||||
tier: Tier,
|
tier: Tier,
|
||||||
): Promise<DeploymentsResponse> {
|
): Promise<DeploymentsResponse> {
|
||||||
return httpJson<DeploymentsResponse>(
|
return apiClient.local.json<DeploymentsResponse>(
|
||||||
`/v1/infrastructure/deployments${q(tier)}`,
|
`/v1/infrastructure/deployments${q(tier)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/infrastructure/api-keys?tier=… */
|
/** GET /v1/infrastructure/api-keys?tier=… */
|
||||||
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
|
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
|
||||||
return httpJson<ApiKey[]>(`/v1/infrastructure/api-keys${q(tier)}`);
|
return apiClient.local.json<ApiKey[]>(
|
||||||
|
`/v1/infrastructure/api-keys${q(tier)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/infrastructure/security?tier=… */
|
/** GET /v1/infrastructure/security?tier=… */
|
||||||
export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
|
export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
|
||||||
return httpJson<SecurityConfig>(`/v1/infrastructure/security${q(tier)}`);
|
return apiClient.local.json<SecurityConfig>(
|
||||||
|
`/v1/infrastructure/security${q(tier)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/infrastructure/models?tier=… */
|
/** GET /v1/infrastructure/models?tier=… */
|
||||||
export async function fetchModels(tier: Tier): Promise<ModelsResponse> {
|
export async function fetchModels(tier: Tier): Promise<ModelsResponse> {
|
||||||
return httpJson<ModelsResponse>(`/v1/infrastructure/models${q(tier)}`);
|
return apiClient.local.json<ModelsResponse>(
|
||||||
|
`/v1/infrastructure/models${q(tier)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/infrastructure/storage?tier=… */
|
/** GET /v1/infrastructure/storage?tier=… */
|
||||||
export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
|
export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
|
||||||
return httpJson<StorageConfig>(`/v1/infrastructure/storage${q(tier)}`);
|
return apiClient.local.json<StorageConfig>(
|
||||||
|
`/v1/infrastructure/storage${q(tier)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/infrastructure/audit-log?tier=… */
|
/** GET /v1/infrastructure/audit-log?tier=… */
|
||||||
export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> {
|
export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> {
|
||||||
return httpJson<AuditLogResponse>(`/v1/infrastructure/audit-log${q(tier)}`);
|
return apiClient.local.json<AuditLogResponse>(
|
||||||
|
`/v1/infrastructure/audit-log${q(tier)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { linkHandlers } from "@portal/mocks/handlers/link";
|
||||||
|
import { resetLinkStore } from "@portal/mocks/link";
|
||||||
|
|
||||||
|
// Mock the shared Supabase client used by apiClient.saas. The team-wide
|
||||||
|
// /instances + /instances/:id/revoke calls go to SaaS now (auto-attached
|
||||||
|
// Bearer = current Supabase access token). Hoisted so vi.mock can see it.
|
||||||
|
const { getSession } = vi.hoisted(() => ({
|
||||||
|
getSession: vi.fn().mockResolvedValue({
|
||||||
|
data: { session: { access_token: "supabase_jwt_test" } },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||||
|
getSupabaseClient: () => ({ auth: { getSession } }),
|
||||||
|
configureSupabase: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Pretend the SaaS base URL is configured so apiClient.saas calls don't throw
|
||||||
|
// SaasUnconfiguredError. MSW's wildcard handlers (`*/...`) intercept the
|
||||||
|
// absolute URL the same way they do the relative one.
|
||||||
|
vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchInstances,
|
||||||
|
fetchStatus,
|
||||||
|
linkInstance,
|
||||||
|
revokeInstance,
|
||||||
|
unlinkInstance,
|
||||||
|
} from "@portal/api/link";
|
||||||
|
|
||||||
|
const server = setupServer(...linkHandlers);
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
beforeEach(() => resetLinkStore());
|
||||||
|
|
||||||
|
describe("api/link — local backend (this instance)", () => {
|
||||||
|
it("starts not-linked", async () => {
|
||||||
|
const status = await fetchStatus();
|
||||||
|
expect(status.linked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links this instance via the local endpoint, never returning a secret", async () => {
|
||||||
|
const status = await linkInstance({
|
||||||
|
supabaseJwt: "jwt_abc",
|
||||||
|
name: "node-1",
|
||||||
|
});
|
||||||
|
expect(status.linked).toBe(true);
|
||||||
|
expect(status.name).toBe("node-1");
|
||||||
|
// Contract: the device secret is stored server-side, never sent to the portal.
|
||||||
|
expect(status).not.toHaveProperty("deviceSecret");
|
||||||
|
expect(status).not.toHaveProperty("deviceId");
|
||||||
|
expect(await (await fetchStatus()).linked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlinks this instance", async () => {
|
||||||
|
await linkInstance({ supabaseJwt: "jwt_abc" });
|
||||||
|
// unlink returns 204 (no body); the status is read back separately.
|
||||||
|
await unlinkInstance();
|
||||||
|
expect((await fetchStatus()).linked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the SaaS JWT in the link body", async () => {
|
||||||
|
let seenBody: unknown = null;
|
||||||
|
server.events.on("request:start", async ({ request }) => {
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/link")) {
|
||||||
|
seenBody = await request.clone().json();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" });
|
||||||
|
expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" });
|
||||||
|
server.events.removeAllListeners();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("api/link — SaaS backend (team-wide)", () => {
|
||||||
|
it("fetches the instance list", async () => {
|
||||||
|
const rows = await fetchInstances();
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
expect(rows[0]).toHaveProperty("deviceId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes an instance", async () => {
|
||||||
|
const active = (await fetchInstances()).find((r) => !r.revoked)!;
|
||||||
|
await revokeInstance(active.instanceId);
|
||||||
|
const after = await fetchInstances();
|
||||||
|
expect(after.find((r) => r.instanceId === active.instanceId)?.revoked).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits the absolute SaaS URL with the Supabase JWT as Bearer", async () => {
|
||||||
|
let seenUrl: string | null = null;
|
||||||
|
let seenAuth: string | null = null;
|
||||||
|
const capture = ({ request }: { request: Request }) => {
|
||||||
|
if (request.url.includes("/account-link/instances")) {
|
||||||
|
seenUrl = request.url;
|
||||||
|
seenAuth = request.headers.get("authorization");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
server.events.on("request:start", capture);
|
||||||
|
await fetchInstances();
|
||||||
|
expect(seenUrl).toBe(
|
||||||
|
"https://saas.test.local/api/v1/account-link/instances",
|
||||||
|
);
|
||||||
|
expect(seenAuth).toBe("Bearer supabase_jwt_test");
|
||||||
|
server.events.removeAllListeners();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { apiClient } from "@portal/api/http";
|
||||||
|
import type {
|
||||||
|
LinkInstanceRequest,
|
||||||
|
LinkStatus,
|
||||||
|
LinkedInstanceRow,
|
||||||
|
} from "@portal/mocks/link";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
LinkInstanceRequest,
|
||||||
|
LinkStatus,
|
||||||
|
LinkedInstanceRow,
|
||||||
|
} from "@portal/mocks/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-link client (combined-billing "Mode A"). Two distinct surfaces:
|
||||||
|
*
|
||||||
|
* THIS instance — apiClient.local (Spring admin bearer auto-attached):
|
||||||
|
* - POST /api/v1/account-link/link — hand the local backend the admin's
|
||||||
|
* SaaS JWT in the body. It registers
|
||||||
|
* with SaaS + stores the device
|
||||||
|
* secret SERVER-SIDE; the portal
|
||||||
|
* NEVER receives or renders it.
|
||||||
|
* - GET /api/v1/account-link/status — Linked / Not-linked for this
|
||||||
|
* instance.
|
||||||
|
* - POST /api/v1/account-link/unlink — drop this instance's link (local
|
||||||
|
* backend best-effort tells SaaS).
|
||||||
|
*
|
||||||
|
* TEAM-WIDE management — apiClient.saas (admin's Supabase JWT auto-attached
|
||||||
|
* from the in-app account-link login):
|
||||||
|
* - GET /api/v1/account-link/instances — every linked instance
|
||||||
|
* - POST /api/v1/account-link/instances/{id}/revoke
|
||||||
|
*
|
||||||
|
* The team-wide endpoints are served by the hosted SaaS Java backend (the
|
||||||
|
* local backend has no such routes), so they go through apiClient.saas. They're
|
||||||
|
* MSW-intercepted in dev/Storybook via wildcard handlers that match both the
|
||||||
|
* local and absolute SaaS URLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = "/api/v1/account-link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link THIS instance. The local backend takes the SaaS JWT, registers with
|
||||||
|
* SaaS, and persists the device secret itself; the response carries only the
|
||||||
|
* resulting link status. No secret is returned.
|
||||||
|
*/
|
||||||
|
export async function linkInstance(
|
||||||
|
req: LinkInstanceRequest,
|
||||||
|
): Promise<LinkStatus> {
|
||||||
|
return apiClient.local.json<LinkStatus>(`${BASE}/link`, {
|
||||||
|
method: "POST",
|
||||||
|
body: req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linked / Not-linked for this instance. */
|
||||||
|
export async function fetchStatus(): Promise<LinkStatus> {
|
||||||
|
return apiClient.local.json<LinkStatus>(`${BASE}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop this instance's link. The local backend best-effort tells SaaS to
|
||||||
|
* revoke before clearing the credential locally, then returns 204 — there's no
|
||||||
|
* body, so the caller sets the known unlinked status itself.
|
||||||
|
*/
|
||||||
|
export async function unlinkInstance(): Promise<void> {
|
||||||
|
await apiClient.local.json<void>(`${BASE}/unlink`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every linked instance for the team — SaaS-direct call with the admin's
|
||||||
|
* Supabase JWT (no longer takes an accessToken parameter; the saas client
|
||||||
|
* resolves the live session itself).
|
||||||
|
*/
|
||||||
|
export async function fetchInstances(): Promise<LinkedInstanceRow[]> {
|
||||||
|
return apiClient.saas.json<LinkedInstanceRow[]>(`${BASE}/instances`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT.
|
||||||
|
*/
|
||||||
|
export async function revokeInstance(instanceId: number): Promise<void> {
|
||||||
|
await apiClient.saas.json<void>(`${BASE}/instances/${instanceId}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type {
|
import type {
|
||||||
Notification,
|
Notification,
|
||||||
NotificationCategory,
|
NotificationCategory,
|
||||||
@@ -8,12 +8,12 @@ export type { Notification, NotificationCategory };
|
|||||||
|
|
||||||
/** GET /v1/notifications */
|
/** GET /v1/notifications */
|
||||||
export async function fetchNotifications(): Promise<Notification[]> {
|
export async function fetchNotifications(): Promise<Notification[]> {
|
||||||
return httpJson<Notification[]>("/v1/notifications");
|
return apiClient.local.json<Notification[]>("/v1/notifications");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /v1/notifications/mark-all-read */
|
/** POST /v1/notifications/mark-all-read */
|
||||||
export async function markAllNotificationsRead(): Promise<void> {
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", {
|
await apiClient.local.json<{ ok: true }>("/v1/notifications/mark-all-read", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { HttpError, httpJson } from "@portal/api/http";
|
import { apiClient, HttpError } from "@portal/api/http";
|
||||||
import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops";
|
import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops";
|
||||||
|
|
||||||
export type { FeaturedOp, OpResultMap };
|
export type { FeaturedOp, OpResultMap };
|
||||||
|
|
||||||
/** GET /v1/ops/featured */
|
/** GET /v1/ops/featured */
|
||||||
export async function fetchFeaturedOps(): Promise<FeaturedOp[]> {
|
export async function fetchFeaturedOps(): Promise<FeaturedOp[]> {
|
||||||
return httpJson<FeaturedOp[]>("/v1/ops/featured");
|
return apiClient.local.json<FeaturedOp[]>("/v1/ops/featured");
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnknownOpError extends Error {
|
export class UnknownOpError extends Error {
|
||||||
@@ -21,13 +21,13 @@ export async function runSingleOp(
|
|||||||
sample: string,
|
sample: string,
|
||||||
): Promise<{ result: OpResultMap; durationMs: number }> {
|
): Promise<{ result: OpResultMap; durationMs: number }> {
|
||||||
try {
|
try {
|
||||||
return await httpJson<{ result: OpResultMap; durationMs: number }>(
|
return await apiClient.local.json<{
|
||||||
`/v1/ops/${encodeURIComponent(opId)}/run`,
|
result: OpResultMap;
|
||||||
{
|
durationMs: number;
|
||||||
method: "POST",
|
}>(`/v1/ops/${encodeURIComponent(opId)}/run`, {
|
||||||
body: { sample },
|
method: "POST",
|
||||||
},
|
body: { sample },
|
||||||
);
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpError && err.status === 404) {
|
if (err instanceof HttpError && err.status === 404) {
|
||||||
throw new UnknownOpError(opId);
|
throw new UnknownOpError(opId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { PipelinesResponse } from "@portal/mocks/pipelines";
|
import type { PipelinesResponse } from "@portal/mocks/pipelines";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export type {
|
|||||||
|
|
||||||
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
|
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
|
||||||
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
|
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
|
||||||
return httpJson<PipelinesResponse>(
|
return apiClient.local.json<PipelinesResponse>(
|
||||||
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
|
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
|
|||||||
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
|
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
|
||||||
*/
|
*/
|
||||||
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
|
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
|
||||||
return httpJson<{ ok: true }>(
|
return apiClient.local.json<{ ok: true }>(
|
||||||
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
|
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
|
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,12 +45,14 @@ export {
|
|||||||
|
|
||||||
/** GET /api/v1/policies — the catalogue + every configured policy. */
|
/** GET /api/v1/policies — the catalogue + every configured policy. */
|
||||||
export async function fetchPolicies(): Promise<PoliciesResponse> {
|
export async function fetchPolicies(): Promise<PoliciesResponse> {
|
||||||
return httpJson<PoliciesResponse>("/api/v1/policies");
|
return apiClient.local.json<PoliciesResponse>("/api/v1/policies");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
|
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
|
||||||
export async function fetchPolicy(id: string): Promise<Policy> {
|
export async function fetchPolicy(id: string): Promise<Policy> {
|
||||||
return httpJson<Policy>(`/api/v1/policies/${encodeURIComponent(id)}`);
|
return apiClient.local.json<Policy>(
|
||||||
|
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,14 +60,20 @@ export async function fetchPolicy(id: string): Promise<Policy> {
|
|||||||
* assigns owner + team server-side and returns the stored policy with its id.
|
* assigns owner + team server-side and returns the stored policy with its id.
|
||||||
*/
|
*/
|
||||||
export async function savePolicy(policy: Policy): Promise<Policy> {
|
export async function savePolicy(policy: Policy): Promise<Policy> {
|
||||||
return httpJson<Policy>("/api/v1/policies", { method: "POST", body: policy });
|
return apiClient.local.json<Policy>("/api/v1/policies", {
|
||||||
|
method: "POST",
|
||||||
|
body: policy,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /api/v1/policies/{id} — remove a stored policy. */
|
/** DELETE /api/v1/policies/{id} — remove a stored policy. */
|
||||||
export async function deletePolicy(id: string): Promise<void> {
|
export async function deletePolicy(id: string): Promise<void> {
|
||||||
await httpJson<void>(`/api/v1/policies/${encodeURIComponent(id)}`, {
|
await apiClient.local.json<void>(
|
||||||
method: "DELETE",
|
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||||
});
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The async run acknowledgement: a run id to poll for status. */
|
/** The async run acknowledgement: a run id to poll for status. */
|
||||||
@@ -83,7 +91,7 @@ export interface PolicyRunResponse {
|
|||||||
* run id. Runs regardless of the policy's enabled flag.
|
* run id. Runs regardless of the policy's enabled flag.
|
||||||
*/
|
*/
|
||||||
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
|
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
|
||||||
return httpJson<PolicyRunResponse>(
|
return apiClient.local.json<PolicyRunResponse>(
|
||||||
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { ComponentsResponse } from "@portal/mocks/sdkComponents";
|
import type { ComponentsResponse } from "@portal/mocks/sdkComponents";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export {
|
|||||||
|
|
||||||
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
|
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
|
||||||
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
|
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
|
||||||
return httpJson<ComponentsResponse>(
|
return apiClient.local.json<ComponentsResponse>(
|
||||||
`/v1/components?tier=${encodeURIComponent(tier)}`,
|
`/v1/components?tier=${encodeURIComponent(tier)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { QuickAction } from "@portal/mocks/search";
|
import type { QuickAction } from "@portal/mocks/search";
|
||||||
|
|
||||||
export type { QuickAction };
|
export type { QuickAction };
|
||||||
|
|
||||||
/** GET /v1/search/quick-actions */
|
/** GET /v1/search/quick-actions */
|
||||||
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
||||||
return httpJson<QuickAction[]>("/v1/search/quick-actions");
|
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { SettingsSnapshot } from "@portal/mocks/settings";
|
import type { SettingsSnapshot } from "@portal/mocks/settings";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ export type {
|
|||||||
|
|
||||||
/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */
|
/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */
|
||||||
export async function fetchSettings(tier: Tier): Promise<SettingsSnapshot> {
|
export async function fetchSettings(tier: Tier): Promise<SettingsSnapshot> {
|
||||||
return httpJson<SettingsSnapshot>(
|
return apiClient.local.json<SettingsSnapshot>(
|
||||||
`/v1/settings?tier=${encodeURIComponent(tier)}`,
|
`/v1/settings?tier=${encodeURIComponent(tier)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sources service layer: the backend contract.
|
* Sources service layer: the backend contract.
|
||||||
@@ -59,22 +59,30 @@ export interface Source {
|
|||||||
|
|
||||||
/** GET /api/v1/sources: KPI strip + one row per source for the admin. */
|
/** GET /api/v1/sources: KPI strip + one row per source for the admin. */
|
||||||
export async function fetchSources(): Promise<SourcesResponse> {
|
export async function fetchSources(): Promise<SourcesResponse> {
|
||||||
return httpJson<SourcesResponse>("/api/v1/sources");
|
return apiClient.local.json<SourcesResponse>("/api/v1/sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */
|
/** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */
|
||||||
export async function fetchSource(id: string): Promise<Source> {
|
export async function fetchSource(id: string): Promise<Source> {
|
||||||
return httpJson<Source>(`/api/v1/sources/${encodeURIComponent(id)}`);
|
return apiClient.local.json<Source>(
|
||||||
|
`/api/v1/sources/${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/v1/sources: create (blank id) or update (matched id) a source. */
|
/** POST /api/v1/sources: create (blank id) or update (matched id) a source. */
|
||||||
export async function createSource(source: Source): Promise<Source> {
|
export async function createSource(source: Source): Promise<Source> {
|
||||||
return httpJson<Source>("/api/v1/sources", { method: "POST", body: source });
|
return apiClient.local.json<Source>("/api/v1/sources", {
|
||||||
|
method: "POST",
|
||||||
|
body: source,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */
|
/** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */
|
||||||
export async function deleteSource(id: string): Promise<void> {
|
export async function deleteSource(id: string): Promise<void> {
|
||||||
await httpJson<void>(`/api/v1/sources/${encodeURIComponent(id)}`, {
|
await apiClient.local.json<void>(
|
||||||
method: "DELETE",
|
`/api/v1/sources/${encodeURIComponent(id)}`,
|
||||||
});
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
|
||||||
import type {
|
|
||||||
BillingHistoryRow,
|
|
||||||
BillingSummary,
|
|
||||||
PlanOption,
|
|
||||||
UsageSeriesResponse,
|
|
||||||
} from "@portal/mocks/usage";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
BillingHistoryRow,
|
|
||||||
BillingSummary,
|
|
||||||
InvoiceStatus,
|
|
||||||
PlanOption,
|
|
||||||
UsagePoint,
|
|
||||||
UsageSeriesResponse,
|
|
||||||
} from "@portal/mocks/usage";
|
|
||||||
export { OVERAGE_RATE } from "@portal/mocks/usage";
|
|
||||||
|
|
||||||
/** GET /v1/billing/usage — 30-day docs-processed series. */
|
|
||||||
export async function fetchBillingUsage(): Promise<UsageSeriesResponse> {
|
|
||||||
return httpJson<UsageSeriesResponse>("/v1/billing/usage");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /v1/billing/summary?tier=… — KPI strip + current-plan figures. */
|
|
||||||
export async function fetchBillingSummary(tier: Tier): Promise<BillingSummary> {
|
|
||||||
return httpJson<BillingSummary>(
|
|
||||||
`/v1/billing/summary?tier=${encodeURIComponent(tier)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /v1/billing/plans — available plan catalogue. */
|
|
||||||
export async function fetchPlanOptions(): Promise<PlanOption[]> {
|
|
||||||
return httpJson<PlanOption[]>("/v1/billing/plans");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /v1/billing/history?tier=… — invoice / line-item history. */
|
|
||||||
export async function fetchBillingHistory(
|
|
||||||
tier: Tier,
|
|
||||||
): Promise<BillingHistoryRow[]> {
|
|
||||||
return httpJson<BillingHistoryRow[]>(
|
|
||||||
`/v1/billing/history?tier=${encodeURIComponent(tier)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpJson } from "@portal/api/http";
|
import { apiClient } from "@portal/api/http";
|
||||||
import type { UsersResponse } from "@portal/mocks/users";
|
import type { UsersResponse } from "@portal/mocks/users";
|
||||||
import type { Tier } from "@portal/contexts/TierContext";
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
@@ -20,5 +20,7 @@ export {
|
|||||||
|
|
||||||
/** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */
|
/** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */
|
||||||
export async function fetchUsers(tier: Tier): Promise<UsersResponse> {
|
export async function fetchUsers(tier: Tier): Promise<UsersResponse> {
|
||||||
return httpJson<UsersResponse>(`/v1/users?tier=${encodeURIComponent(tier)}`);
|
return apiClient.local.json<UsersResponse>(
|
||||||
|
`/v1/users?tier=${encodeURIComponent(tier)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
configureSupabase,
|
||||||
|
getSupabaseClient,
|
||||||
|
} from "@shared/auth/supabase/supabaseClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the shared Supabase client against the hosted SaaS project so the
|
||||||
|
* portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a
|
||||||
|
* separate, transient SaaS auth — the portal's own session stays Spring (the
|
||||||
|
* local instance admin); calls to the local backend still carry the Spring
|
||||||
|
* bearer, and the SaaS JWT is passed only in the link request body.
|
||||||
|
*
|
||||||
|
* Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public).
|
||||||
|
* Absent → {@link isSaasSupabaseConfigured} is false and the link UI degrades to
|
||||||
|
* a "configure the SaaS Supabase URL" state.
|
||||||
|
*/
|
||||||
|
const url = import.meta.env.VITE_SAAS_SUPABASE_URL;
|
||||||
|
const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_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"];
|
||||||
|
|
||||||
|
/** sessionStorage marker set before an SSO redirect so the return can finish the link. */
|
||||||
|
export const PENDING_LINK_KEY = "stirling-account-link-pending";
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the shared Supabase client once (idempotent). Returns the client, or
|
||||||
|
* null when the SaaS Supabase env isn't set. `detectSessionInUrl` (on by default)
|
||||||
|
* means an SSO redirect back to the portal is picked up here.
|
||||||
|
*/
|
||||||
|
export function ensureSaasSupabase() {
|
||||||
|
if (!isSaasSupabaseConfigured) return null;
|
||||||
|
if (!configured) {
|
||||||
|
configureSupabase({ url: url as string, key: key as string });
|
||||||
|
configured = true;
|
||||||
|
}
|
||||||
|
return getSupabaseClient();
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Mock the shared Supabase client the in-app link login is wired to. Declared
|
||||||
|
// via vi.hoisted so the vi.mock factory can reference them.
|
||||||
|
const { signInWithPassword, signInWithOAuth } = vi.hoisted(() => ({
|
||||||
|
signInWithPassword: vi.fn(),
|
||||||
|
signInWithOAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||||
|
getSupabaseClient: () => ({ auth: { signInWithPassword, signInWithOAuth } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useSupabaseLogin } from "@shared/auth/ui/useSupabaseLogin";
|
||||||
|
|
||||||
|
describe("useSupabaseLogin (in-app account-link login)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
signInWithPassword.mockReset();
|
||||||
|
signInWithOAuth.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onSuccess with the access token on a successful email sign-in", async () => {
|
||||||
|
signInWithPassword.mockResolvedValue({
|
||||||
|
data: { session: { access_token: "tok-123" } },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSupabaseLogin({ onSuccess }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setEmail("admin@org.com");
|
||||||
|
result.current.setPassword("pw");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signInWithEmail();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signInWithPassword).toHaveBeenCalledWith({
|
||||||
|
email: "admin@org.com",
|
||||||
|
password: "pw",
|
||||||
|
});
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith({ access_token: "tok-123" });
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an email sign-in error and skips onSuccess", async () => {
|
||||||
|
signInWithPassword.mockResolvedValue({
|
||||||
|
data: { session: null },
|
||||||
|
error: { message: "Invalid login credentials" },
|
||||||
|
});
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSupabaseLogin({ onSuccess }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setEmail("admin@org.com");
|
||||||
|
result.current.setPassword("nope");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signInWithEmail();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.error).toBe("Invalid login credentials");
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kicks off OAuth with the provider, redirect, and pre-redirect hook", async () => {
|
||||||
|
signInWithOAuth.mockResolvedValue({ data: {}, error: null });
|
||||||
|
const onBeforeOAuth = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSupabaseLogin({
|
||||||
|
providers: ["google", "github"],
|
||||||
|
redirectTo: "http://portal.local/account-link",
|
||||||
|
onBeforeOAuth,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.hasProviders).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signInWithProvider("google");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onBeforeOAuth).toHaveBeenCalledWith("google");
|
||||||
|
expect(signInWithOAuth).toHaveBeenCalledWith({
|
||||||
|
provider: "google",
|
||||||
|
options: { redirectTo: "http://portal.local/account-link" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates both fields before calling Supabase", async () => {
|
||||||
|
const { result } = renderHook(() => useSupabaseLogin());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signInWithEmail();
|
||||||
|
});
|
||||||
|
expect(signInWithPassword).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
currencySymbol,
|
||||||
|
docCapForMoney,
|
||||||
|
formatMinor,
|
||||||
|
formatPeriodDate,
|
||||||
|
meterState,
|
||||||
|
} from "@shared/billing";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the @shared/billing money/meter helpers the portal billing
|
||||||
|
* surface (and the editor cloud surface) depend on. docCapForMoney mirrors the
|
||||||
|
* backend's cap→PDF conversion and meterState mirrors the BE warn/degrade bands,
|
||||||
|
* so these invariants matter beyond cosmetics.
|
||||||
|
*/
|
||||||
|
describe("docCapForMoney", () => {
|
||||||
|
it("returns null when there is no cap", () => {
|
||||||
|
expect(docCapForMoney(null, 2)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the rate is unresolved or non-positive", () => {
|
||||||
|
expect(docCapForMoney(1000, null)).toBeNull();
|
||||||
|
expect(docCapForMoney(1000, 0)).toBeNull();
|
||||||
|
expect(docCapForMoney(1000, -5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors capMinor / rate (the backend mirror)", () => {
|
||||||
|
// $1000 cap, 2 minor units / doc → floor(100000 / 2) = 50000 PDFs.
|
||||||
|
expect(docCapForMoney(1000, 2)).toBe(50000);
|
||||||
|
// Sub-cent rate (0.5 minor) → floor(100000 / 0.5) = 200000.
|
||||||
|
expect(docCapForMoney(1000, 0.5)).toBe(200000);
|
||||||
|
// Floors a partial PDF down.
|
||||||
|
expect(docCapForMoney(10, 3)).toBe(333);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a $0 cap as zero paid PDFs", () => {
|
||||||
|
expect(docCapForMoney(0, 2)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("meterState", () => {
|
||||||
|
it("is FULL below the warn band", () => {
|
||||||
|
expect(meterState(10, 100).state).toBe("FULL");
|
||||||
|
expect(meterState(79, 100).state).toBe("FULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is WARNED from 80% up to (not including) 100%", () => {
|
||||||
|
expect(meterState(80, 100).state).toBe("WARNED");
|
||||||
|
expect(meterState(99, 100).state).toBe("WARNED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is DEGRADED at and above 100%, with pct clamped to 100", () => {
|
||||||
|
expect(meterState(100, 100)).toEqual({ state: "DEGRADED", pct: 100 });
|
||||||
|
const over = meterState(500, 100);
|
||||||
|
expect(over.state).toBe("DEGRADED");
|
||||||
|
expect(over.pct).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a non-positive limit as fully consumed", () => {
|
||||||
|
expect(meterState(0, 0)).toEqual({ state: "DEGRADED", pct: 100 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currencySymbol", () => {
|
||||||
|
it("maps known currencies and defaults empty/usd to $", () => {
|
||||||
|
expect(currencySymbol("usd")).toBe("$");
|
||||||
|
expect(currencySymbol("")).toBe("$");
|
||||||
|
expect(currencySymbol(null)).toBe("$");
|
||||||
|
expect(currencySymbol("eur")).toBe("€");
|
||||||
|
expect(currencySymbol("gbp")).toBe("£");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the upper-cased code for anything unmapped", () => {
|
||||||
|
expect(currencySymbol("cad")).toBe("CAD ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatMinor", () => {
|
||||||
|
it("formats whole and fractional cents", () => {
|
||||||
|
expect(formatMinor(224, "usd")).toContain("2.24");
|
||||||
|
expect(formatMinor(5, "usd")).toContain("0.05");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps up to 3 fraction digits so sub-cent rates don't round to $0", () => {
|
||||||
|
expect(formatMinor(0.5, "usd")).toContain("0.005");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatPeriodDate", () => {
|
||||||
|
it("returns an empty string for null", () => {
|
||||||
|
expect(formatPeriodDate(null)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats the date part of an ISO string", () => {
|
||||||
|
const out = formatPeriodDate("2026-06-24");
|
||||||
|
expect(out).toContain("Jun");
|
||||||
|
expect(out).toContain("24");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the year only when asked", () => {
|
||||||
|
expect(formatPeriodDate("2026-06-24")).not.toContain("2026");
|
||||||
|
expect(formatPeriodDate("2026-06-24", { year: true })).toContain("2026");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch coverage for the Stripe edge-function client: the embedded-checkout vs
|
||||||
|
* already-subscribed-redirect vs neither-secret-nor-url mapping, the mock flag,
|
||||||
|
* unconfigured Supabase, and the portal-session path.
|
||||||
|
*/
|
||||||
|
const { getClient, invoke } = vi.hoisted(() => ({
|
||||||
|
getClient: vi.fn(),
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
|
||||||
|
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||||
|
getSupabaseClient: () => getClient(),
|
||||||
|
configureSupabase: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCheckoutSession,
|
||||||
|
createPortalSession,
|
||||||
|
StripeFunctionError,
|
||||||
|
} from "@portal/billing/stripe";
|
||||||
|
|
||||||
|
const req = { teamId: 1, successUrl: "s", cancelUrl: "c" } as const;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
invoke.mockReset();
|
||||||
|
getClient.mockReset().mockReturnValue({ functions: { invoke } });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe("createCheckoutSession", () => {
|
||||||
|
it("maps embedded checkout (client_secret)", async () => {
|
||||||
|
invoke.mockResolvedValue({
|
||||||
|
data: { success: true, client_secret: "cs_123" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const s = await createCheckoutSession(req);
|
||||||
|
expect(s).toEqual({
|
||||||
|
clientSecret: "cs_123",
|
||||||
|
redirectUrl: null,
|
||||||
|
alreadySubscribed: false,
|
||||||
|
mock: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits already-subscribed to the portal URL (no client secret)", async () => {
|
||||||
|
invoke.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
already_subscribed: true,
|
||||||
|
portal_url: "https://portal",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const s = await createCheckoutSession(req);
|
||||||
|
expect(s.alreadySubscribed).toBe(true);
|
||||||
|
expect(s.redirectUrl).toBe("https://portal");
|
||||||
|
expect(s.clientSecret).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a mock client secret", async () => {
|
||||||
|
invoke.mockResolvedValue({
|
||||||
|
data: { success: true, client_secret: "cs_mock_abc" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect((await createCheckoutSession(req)).mock).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when success is false", async () => {
|
||||||
|
invoke.mockResolvedValue({
|
||||||
|
data: { success: false, error: "no team" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
await expect(createCheckoutSession(req)).rejects.toBeInstanceOf(
|
||||||
|
StripeFunctionError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when neither client_secret nor url is returned", async () => {
|
||||||
|
invoke.mockResolvedValue({ data: { success: true }, error: null });
|
||||||
|
await expect(createCheckoutSession(req)).rejects.toThrow(/neither/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws unconfigured when there is no Supabase client", async () => {
|
||||||
|
getClient.mockReturnValue(null);
|
||||||
|
const err = await createCheckoutSession(req).catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(StripeFunctionError);
|
||||||
|
expect((err as StripeFunctionError).code).toBe("unconfigured");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createPortalSession", () => {
|
||||||
|
it("returns the portal URL", async () => {
|
||||||
|
invoke.mockResolvedValue({
|
||||||
|
data: { success: true, url: "https://billing" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(await createPortalSession({ teamId: 1, returnUrl: "r" })).toBe(
|
||||||
|
"https://billing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a free team (no url)", async () => {
|
||||||
|
invoke.mockResolvedValue({
|
||||||
|
data: { success: false, error: "team_not_subscribed" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
createPortalSession({ teamId: 1, returnUrl: "r" }),
|
||||||
|
).rejects.toBeInstanceOf(StripeFunctionError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||||
|
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe checkout + portal sessions, minted via the SaaS Supabase edge
|
||||||
|
* functions (no new Java endpoints). Same pattern the SaaS web app uses for
|
||||||
|
* its Plan page — `supabase.functions.invoke` carries the admin's JWT
|
||||||
|
* automatically, and the edge functions resolve the team via the
|
||||||
|
* `payg_get_checkout_context` RPC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class StripeFunctionError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "StripeFunctionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Currencies the SaaS PAYG offering supports. Default for new checkouts is "usd". */
|
||||||
|
export type SaasCurrency = "usd" | "eur" | "gbp";
|
||||||
|
|
||||||
|
interface CheckoutSessionRequest {
|
||||||
|
teamId: number;
|
||||||
|
/** Where Stripe redirects on success — typically the portal billing page. */
|
||||||
|
successUrl: string;
|
||||||
|
/** Where Stripe redirects on cancel/close. */
|
||||||
|
cancelUrl: string;
|
||||||
|
/** ISO 4217 lower-case. Defaults to "usd"; portal uses the wallet's currency when set. */
|
||||||
|
currency?: SaasCurrency;
|
||||||
|
/** Optional prefill for the Stripe Checkout email field. */
|
||||||
|
billingOwnerEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortalSessionRequest {
|
||||||
|
teamId: number;
|
||||||
|
returnUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout response shape. The edge function defaults to embedded Stripe
|
||||||
|
* Checkout (returns {@code client_secret}); it can also return:
|
||||||
|
* - {@code portal_url} + {@code already_subscribed: true} when the team is
|
||||||
|
* already on PAYG (short-circuit so the click still does something useful)
|
||||||
|
* - {@code url} alongside or instead of {@code client_secret} for hosted /
|
||||||
|
* redirect-mode flows (rare; embedded is the default for the SaaS UX).
|
||||||
|
*/
|
||||||
|
interface CheckoutResponse {
|
||||||
|
success: boolean;
|
||||||
|
client_secret?: string;
|
||||||
|
url?: string;
|
||||||
|
portal_url?: string;
|
||||||
|
already_subscribed?: boolean;
|
||||||
|
mock?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortalResponse {
|
||||||
|
success: boolean;
|
||||||
|
url?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke<T>(
|
||||||
|
name: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
ensureSaasSupabase();
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
if (!supabase) {
|
||||||
|
throw new StripeFunctionError(
|
||||||
|
"SaaS Supabase not configured — set VITE_SAAS_SUPABASE_URL.",
|
||||||
|
"unconfigured",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { data, error } = await supabase.functions.invoke<T>(name, { body });
|
||||||
|
if (error) {
|
||||||
|
throw new StripeFunctionError(
|
||||||
|
error.message ?? `Edge function ${name} failed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data == null) {
|
||||||
|
throw new StripeFunctionError(`Edge function ${name} returned no data`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of {@link createCheckoutSession}. Exactly ONE of {@code clientSecret}
|
||||||
|
* or {@code redirectUrl} is set: clientSecret drives embedded Stripe Checkout
|
||||||
|
* (the default UX, matching the SaaS web app); redirectUrl is used for the
|
||||||
|
* already-subscribed short-circuit (portal URL) or any hosted-mode fallback.
|
||||||
|
*/
|
||||||
|
export interface CheckoutSession {
|
||||||
|
clientSecret: string | null;
|
||||||
|
redirectUrl: string | null;
|
||||||
|
alreadySubscribed: boolean;
|
||||||
|
mock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a Stripe Checkout session for PAYG subscription. Defaults to embedded
|
||||||
|
* Checkout (returns {@code clientSecret}) so the portal can mount
|
||||||
|
* <EmbeddedCheckoutProvider> inline. If the team is already subscribed the
|
||||||
|
* edge function short-circuits to a Customer Portal URL — surfaced as
|
||||||
|
* {@code redirectUrl} + {@code alreadySubscribed=true} so the caller can open it
|
||||||
|
* in a new tab instead of trying to mount a checkout iframe with no secret.
|
||||||
|
*/
|
||||||
|
export async function createCheckoutSession(
|
||||||
|
req: CheckoutSessionRequest,
|
||||||
|
): Promise<CheckoutSession> {
|
||||||
|
const res = await invoke<CheckoutResponse>("create-checkout-session", {
|
||||||
|
team_id: req.teamId,
|
||||||
|
currency: req.currency ?? "usd",
|
||||||
|
success_url: req.successUrl,
|
||||||
|
cancel_url: req.cancelUrl,
|
||||||
|
...(req.billingOwnerEmail
|
||||||
|
? { billing_owner_email: req.billingOwnerEmail }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
throw new StripeFunctionError(
|
||||||
|
res.error ?? "create-checkout-session failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const alreadySubscribed = Boolean(res.already_subscribed);
|
||||||
|
const redirectUrl = alreadySubscribed
|
||||||
|
? (res.portal_url ?? null)
|
||||||
|
: (res.url ?? null);
|
||||||
|
const clientSecret = alreadySubscribed ? null : (res.client_secret ?? null);
|
||||||
|
if (!clientSecret && !redirectUrl) {
|
||||||
|
throw new StripeFunctionError(
|
||||||
|
"create-checkout-session returned neither client_secret nor URL",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
clientSecret,
|
||||||
|
redirectUrl,
|
||||||
|
alreadySubscribed,
|
||||||
|
mock: Boolean(res.mock) || clientSecret?.startsWith("cs_mock_") === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code VITE_STRIPE_PUBLISHABLE_KEY} — the Stripe pk used by embedded Checkout. */
|
||||||
|
export function getStripePublishableKey(): string {
|
||||||
|
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a Stripe Customer Portal session. The admin can manage their card,
|
||||||
|
* view invoices, and cancel from Stripe's hosted UI. The edge function returns
|
||||||
|
* 404 with {@code team_not_subscribed} if called for a free team — surfaced
|
||||||
|
* here as a StripeFunctionError the caller can toast.
|
||||||
|
*/
|
||||||
|
export async function createPortalSession(
|
||||||
|
req: PortalSessionRequest,
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await invoke<PortalResponse>("create-customer-portal-session", {
|
||||||
|
team_id: req.teamId,
|
||||||
|
return_url: req.returnUrl,
|
||||||
|
});
|
||||||
|
if (!res.success || !res.url) {
|
||||||
|
throw new StripeFunctionError(
|
||||||
|
res.error ?? "create-customer-portal-session failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.url;
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
|
/* Fixed-height shell so the MAIN COLUMN scrolls, not the document. With
|
||||||
|
min-height:100vh the shell grows with content and the document scrolls, which
|
||||||
|
means .portal-shell__view's overflow-y never engages and any `position:sticky`
|
||||||
|
inside it (e.g. a page header) rides away with the document. Pinning the shell
|
||||||
|
to the viewport (+ min-height:0 on the flex descendants so they can shrink
|
||||||
|
below content) makes .portal-shell__view the scroll container, so sticky
|
||||||
|
page headers stick under the global header. */
|
||||||
.portal-shell {
|
.portal-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
}
|
}
|
||||||
@@ -10,10 +18,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0; /* prevent grid blowout on narrow content */
|
min-width: 0; /* prevent grid blowout on narrow content */
|
||||||
|
min-height: 0; /* allow the column to bound its children */
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-shell__view {
|
.portal-shell__view {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0; /* scroll instead of growing past the viewport */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
animation: fadeInUp var(--motion-enter) both;
|
animation: fadeInUp var(--motion-enter) both;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ function ThemeToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TierSwitcher() {
|
function TierSwitcher() {
|
||||||
const { tier, setTier } = useTier();
|
const { tier, setTier, isDerived } = useTier();
|
||||||
const info = TIER_INFO[tier];
|
const info = TIER_INFO[tier];
|
||||||
|
// When mocks are off, the tier is derived from the real link/wallet state —
|
||||||
|
// pair the dropdown with the mocks toggle (hidden in prod) so testing real
|
||||||
|
// billing flows can't be perturbed by accidentally flipping the mock tier.
|
||||||
|
if (isDerived) return null;
|
||||||
return (
|
return (
|
||||||
<Dropdown.Root align="end">
|
<Dropdown.Root align="end">
|
||||||
<Dropdown.Trigger>
|
<Dropdown.Trigger>
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ import {
|
|||||||
PoliciesIcon,
|
PoliciesIcon,
|
||||||
InfrastructureIcon,
|
InfrastructureIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
|
LinkIcon,
|
||||||
} from "@portal/components/icons";
|
} from "@portal/components/icons";
|
||||||
|
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
|
||||||
import "@portal/components/SettingsModal.css";
|
import "@portal/components/SettingsModal.css";
|
||||||
|
|
||||||
type SettingsSection =
|
type SettingsSection =
|
||||||
@@ -41,7 +43,21 @@ type SettingsSection =
|
|||||||
| "general"
|
| "general"
|
||||||
| "authentication"
|
| "authentication"
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "early-access";
|
| "early-access"
|
||||||
|
| "account-link";
|
||||||
|
|
||||||
|
function isSettingsSection(value: string | null): value is SettingsSection {
|
||||||
|
return (
|
||||||
|
value === "profile" ||
|
||||||
|
value === "appearance" ||
|
||||||
|
value === "notifications" ||
|
||||||
|
value === "general" ||
|
||||||
|
value === "authentication" ||
|
||||||
|
value === "sessions" ||
|
||||||
|
value === "early-access" ||
|
||||||
|
value === "account-link"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Org-wide auth posture the Admin sections edit, mirrored into local state. */
|
/** Org-wide auth posture the Admin sections edit, mirrored into local state. */
|
||||||
interface SecurityForm {
|
interface SecurityForm {
|
||||||
@@ -54,6 +70,12 @@ interface SecurityForm {
|
|||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Optional section to land on when opening. When `null`/unsupported the modal
|
||||||
|
* picks the default ("profile"). Set by callers like the sidebar's "Link
|
||||||
|
* account" affordance → "account-link".
|
||||||
|
*/
|
||||||
|
initialSection?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +105,11 @@ const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const;
|
|||||||
* state. Save is a no-op for the demo — it closes — but the theme control
|
* state. Save is a no-op for the demo — it closes — but the theme control
|
||||||
* writes straight through to ThemeProvider so the change is real and visible.
|
* writes straight through to ThemeProvider so the change is real and visible.
|
||||||
*/
|
*/
|
||||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
export function SettingsModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
initialSection,
|
||||||
|
}: SettingsModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { tier } = useTier();
|
const { tier } = useTier();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -124,6 +150,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
{
|
{
|
||||||
title: t("settings.groups.admin"),
|
title: t("settings.groups.admin"),
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
key: "account-link",
|
||||||
|
label: t("settings.sections.account-link"),
|
||||||
|
icon: <LinkIcon size={16} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "authentication",
|
key: "authentication",
|
||||||
label: t("settings.sections.authentication"),
|
label: t("settings.sections.authentication"),
|
||||||
@@ -189,8 +220,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}, [snapshot]);
|
}, [snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setSection("profile");
|
if (!open) return;
|
||||||
}, [open]);
|
const requested = initialSection ?? null;
|
||||||
|
setSection(isSettingsSection(requested) ? requested : "profile");
|
||||||
|
}, [open, initialSection]);
|
||||||
|
|
||||||
const regionOptions = useMemo<SelectOption[]>(() => {
|
const regionOptions = useMemo<SelectOption[]>(() => {
|
||||||
if (!snapshot) return [];
|
if (!snapshot) return [];
|
||||||
@@ -301,6 +334,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section === "account-link" && <AccountLinkPanel />}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
|||||||
import { useTier } from "@portal/contexts/TierContext";
|
import { useTier } from "@portal/contexts/TierContext";
|
||||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||||
import { useUI } from "@portal/contexts/UIContext";
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
import { useLink } from "@portal/contexts/LinkContext";
|
||||||
import { useAsync } from "@portal/hooks/useAsync";
|
import { useAsync } from "@portal/hooks/useAsync";
|
||||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
ComponentsIcon,
|
ComponentsIcon,
|
||||||
InfrastructureIcon,
|
InfrastructureIcon,
|
||||||
UsageIcon,
|
UsageIcon,
|
||||||
|
LinkIcon,
|
||||||
DocsIcon,
|
DocsIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -47,6 +49,27 @@ const GROUP_PLATFORM: NavEntry[] = [
|
|||||||
{ id: "docs", icon: <DocsIcon /> },
|
{ id: "docs", icon: <DocsIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar-footer link-account CTA. Only visible when the org is unlinked — once
|
||||||
|
* linked, the linked-instances row + plan badge already communicate the state,
|
||||||
|
* so a permanent footer button would be noise. Click → opens the login modal
|
||||||
|
* directly.
|
||||||
|
*/
|
||||||
|
function LinkAccountFooterItem() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { openLinkModal } = useUI();
|
||||||
|
const { linkState } = useLink();
|
||||||
|
if (linkState !== "unlinked") return null;
|
||||||
|
return (
|
||||||
|
<NavItem
|
||||||
|
id="account-link"
|
||||||
|
label={t("shell.sidebar.linkAccount", "Link Stirling account")}
|
||||||
|
icon={<LinkIcon />}
|
||||||
|
onClick={() => openLinkModal()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UsageFooter() {
|
function UsageFooter() {
|
||||||
const { tier } = useTier();
|
const { tier } = useTier();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -90,8 +113,8 @@ function UsageFooter() {
|
|||||||
|
|
||||||
const planLabel =
|
const planLabel =
|
||||||
tier === "pro"
|
tier === "pro"
|
||||||
? t("shell.sidebar.planPayAsYouGo")
|
? t("shell.sidebar.planProcessor", "Processor plan")
|
||||||
: t("shell.sidebar.planEnterprise");
|
: t("shell.sidebar.planEnterprise", "Enterprise plan");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="portal-sidebar__usage">
|
<div className="portal-sidebar__usage">
|
||||||
@@ -200,6 +223,7 @@ export function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="portal-sidebar__footer">
|
<div className="portal-sidebar__footer">
|
||||||
|
<LinkAccountFooterItem />
|
||||||
<NavItem
|
<NavItem
|
||||||
id="settings"
|
id="settings"
|
||||||
label={t("nav.settings")}
|
label={t("nav.settings")}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Banner, Skeleton, StatusBadge } from "@shared/components";
|
||||||
|
import { useAsync } from "@portal/hooks/useAsync";
|
||||||
|
import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext";
|
||||||
|
import { useLink, LINK_INFO } from "@portal/contexts/LinkContext";
|
||||||
|
import { HttpError } from "@portal/api/http";
|
||||||
|
import {
|
||||||
|
fetchInstances,
|
||||||
|
revokeInstance as apiRevokeInstance,
|
||||||
|
type LinkedInstanceRow,
|
||||||
|
} from "@portal/api/link";
|
||||||
|
import { LinkAccountCard } from "@portal/components/account-link/LinkAccountCard";
|
||||||
|
import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable";
|
||||||
|
import "@portal/views/AccountLink.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-link surface rendered inside the Settings modal (Admin group). Same
|
||||||
|
* content as the former /account-link view: the LinkAccountCard for THIS
|
||||||
|
* instance + the team-wide LinkedInstancesTable. Lives inline so admins find it
|
||||||
|
* intentionally rather than via a top-level sidebar nav entry.
|
||||||
|
*/
|
||||||
|
export function AccountLinkPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const link = useAccountLinkContext();
|
||||||
|
const { linkState } = useLink();
|
||||||
|
|
||||||
|
const linked = link.status?.linked ?? false;
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
// Only fetch the team-wide instance list when THIS instance is linked. When
|
||||||
|
// unlinked, the portal has no team to display — the admin's SaaS session may
|
||||||
|
// still be valid in the browser, but the local instance isn't part of a team
|
||||||
|
// (so showing the team's other instances would be confusing).
|
||||||
|
const instancesState = useAsync<LinkedInstanceRow[]>(
|
||||||
|
() => (linked ? fetchInstances() : Promise.resolve([])),
|
||||||
|
[reloadKey, linked],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [revokingId, setRevokingId] = useState<number | null>(null);
|
||||||
|
const [revokeError, setRevokeError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const revoke = useCallback(async (instance: LinkedInstanceRow) => {
|
||||||
|
setRevokingId(instance.instanceId);
|
||||||
|
setRevokeError(null);
|
||||||
|
try {
|
||||||
|
await apiRevokeInstance(instance.instanceId);
|
||||||
|
setReloadKey((k) => k + 1);
|
||||||
|
} catch (e) {
|
||||||
|
setRevokeError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setRevokingId(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="portal-link portal-link--in-settings">
|
||||||
|
<header className="portal-link__header">
|
||||||
|
<div>
|
||||||
|
<p className="portal-link__page-sub">{t("accountLink.panel.sub")}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge
|
||||||
|
tone={
|
||||||
|
linkState === "linked-subscribed"
|
||||||
|
? "success"
|
||||||
|
: linkState === "linked-free"
|
||||||
|
? "info"
|
||||||
|
: "neutral"
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{t(LINK_INFO[linkState].labelKey)}
|
||||||
|
</StatusBadge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<LinkAccountCard link={link} />
|
||||||
|
|
||||||
|
{linked && (
|
||||||
|
<section className="portal-link__instances">
|
||||||
|
<div className="portal-link__section-head">
|
||||||
|
<h2 className="portal-link__section-title">
|
||||||
|
{t("accountLink.panel.instancesTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="portal-link__section-sub">
|
||||||
|
{t("accountLink.panel.instancesSub")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{instancesState.loading ? (
|
||||||
|
<div className="portal-link__skeleton" aria-hidden>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height="3rem" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : instancesState.error ? (
|
||||||
|
<Banner
|
||||||
|
tone="danger"
|
||||||
|
title={t("accountLink.panel.loadError.title")}
|
||||||
|
>
|
||||||
|
{instancesState.error instanceof HttpError &&
|
||||||
|
instancesState.error.status === 403
|
||||||
|
? t("accountLink.panel.loadError.forbidden")
|
||||||
|
: t("accountLink.panel.loadError.generic")}
|
||||||
|
</Banner>
|
||||||
|
) : (
|
||||||
|
<LinkedInstancesTable
|
||||||
|
instances={instancesState.data ?? []}
|
||||||
|
onRevoke={revoke}
|
||||||
|
revokingId={revokingId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{revokeError && (
|
||||||
|
<Banner tone="danger" title={t("accountLink.panel.revokeError")}>
|
||||||
|
{revokeError}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import type { UseAccountLink } from "@portal/hooks/useAccountLink";
|
||||||
|
import { LinkAccountCard } from "@portal/components/account-link/LinkAccountCard";
|
||||||
|
import "@portal/views/AccountLink.css";
|
||||||
|
|
||||||
|
// A no-op UseAccountLink for static stories; overridden per story.
|
||||||
|
const base: UseAccountLink = {
|
||||||
|
loginConfigured: true,
|
||||||
|
status: { linked: false, name: null },
|
||||||
|
phase: "idle",
|
||||||
|
error: null,
|
||||||
|
completeLink: async () => {},
|
||||||
|
unlink: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof LinkAccountCard> = {
|
||||||
|
title: "Portal/AccountLink/LinkAccountCard",
|
||||||
|
component: LinkAccountCard,
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof LinkAccountCard>;
|
||||||
|
|
||||||
|
/** Not linked — the "Link your Stirling account" button opens the login modal. */
|
||||||
|
export const NotLinked: Story = {
|
||||||
|
args: { link: base },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Linking — login completed, button shows progress while registering. */
|
||||||
|
export const Linking: Story = {
|
||||||
|
args: { link: { ...base, phase: "linking" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Linked — status only; the device secret is never shown. */
|
||||||
|
export const Linked: Story = {
|
||||||
|
args: {
|
||||||
|
link: { ...base, status: { linked: true, name: "prod-eu-gateway" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** SaaS Supabase not configured — explains the in-app dev simulate fallback. */
|
||||||
|
export const Unconfigured: Story = {
|
||||||
|
args: { link: { ...base, loginConfigured: false } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Link error surfaced inline. */
|
||||||
|
export const Error: Story = {
|
||||||
|
args: {
|
||||||
|
link: {
|
||||||
|
...base,
|
||||||
|
phase: "error",
|
||||||
|
error: "Couldn't register this instance with the SaaS backend.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Banner, Button, Card, StatusBadge } from "@shared/components";
|
||||||
|
import type { UseAccountLink } from "@portal/hooks/useAccountLink";
|
||||||
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
link: UseAccountLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status + actions for THIS instance's account link. The "Link" button opens
|
||||||
|
* the single top-level login modal (UIContext.openLinkModal) — never a nested
|
||||||
|
* modal. The portal posts the returned JWT to the local backend, which stores
|
||||||
|
* the device secret server-side; the secret is never received or rendered here.
|
||||||
|
*/
|
||||||
|
export function LinkAccountCard({ link }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { openLinkModal } = useUI();
|
||||||
|
const linking = link.phase === "linking";
|
||||||
|
const linked = link.status?.linked ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="loose" className="portal-link__card">
|
||||||
|
<div className="portal-link__card-head">
|
||||||
|
<div>
|
||||||
|
<span className="portal-link__eyebrow">
|
||||||
|
{t("accountLink.card.eyebrow")}
|
||||||
|
</span>
|
||||||
|
<h2 className="portal-link__title">{t("accountLink.card.title")}</h2>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={linked ? "success" : "neutral"} size="sm">
|
||||||
|
{linked
|
||||||
|
? t("accountLink.card.linked")
|
||||||
|
: t("accountLink.card.notLinked")}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!link.loginConfigured && (
|
||||||
|
<Banner
|
||||||
|
tone="neutral"
|
||||||
|
title={t("accountLink.card.loginNotConfigured.title")}
|
||||||
|
>
|
||||||
|
{t("accountLink.card.loginNotConfigured.before")}{" "}
|
||||||
|
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||||
|
{t("accountLink.card.loginNotConfigured.after")}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{link.error && (
|
||||||
|
<Banner tone="danger" title={t("accountLink.card.error.title")}>
|
||||||
|
{link.error}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linked ? (
|
||||||
|
<div className="portal-link__actions">
|
||||||
|
<span className="portal-link__muted">
|
||||||
|
{link.status?.name
|
||||||
|
? t("accountLink.card.linkedAs", { name: link.status.name })
|
||||||
|
: t("accountLink.card.linkedGeneric")}{" "}
|
||||||
|
{t("accountLink.card.billingNote")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
accent="red"
|
||||||
|
loading={linking}
|
||||||
|
onClick={link.unlink}
|
||||||
|
>
|
||||||
|
{t("accountLink.card.unlink")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="portal-link__actions">
|
||||||
|
<Button loading={linking} onClick={() => openLinkModal()}>
|
||||||
|
{t("accountLink.card.linkButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Banner, Button, Modal } from "@shared/components";
|
||||||
|
import SupabaseLoginForm from "@shared/auth/ui/SupabaseLoginForm";
|
||||||
|
import {
|
||||||
|
useSupabaseLogin,
|
||||||
|
type SupabaseLoginSession,
|
||||||
|
} from "@shared/auth/ui/useSupabaseLogin";
|
||||||
|
import "@shared/auth/ui/auth-theme.css";
|
||||||
|
import {
|
||||||
|
ensureSaasSupabase,
|
||||||
|
isSaasSupabaseConfigured,
|
||||||
|
PENDING_LINK_KEY,
|
||||||
|
SAAS_OAUTH_PROVIDERS,
|
||||||
|
} from "@portal/auth/saasSupabase";
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
mode?: "link" | "reauth";
|
||||||
|
/** Called with the SaaS session after a successful sign-in. */
|
||||||
|
onLinked: (session: SupabaseLoginSession) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function LinkAccountModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
mode = "link",
|
||||||
|
onLinked,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) ensureSaasSupabase();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const reauth = mode === "reauth";
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width="md"
|
||||||
|
title={
|
||||||
|
reauth
|
||||||
|
? t("accountLink.modal.reauthTitle")
|
||||||
|
: t("accountLink.modal.linkTitle")
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
reauth
|
||||||
|
? t("accountLink.modal.reauthSubtitle")
|
||||||
|
: t("accountLink.modal.linkSubtitle")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSaasSupabaseConfigured ? (
|
||||||
|
<SupabaseLoginForm state={login} />
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
<Banner
|
||||||
|
tone="neutral"
|
||||||
|
title={t("accountLink.modal.loginNotConfigured.title")}
|
||||||
|
>
|
||||||
|
{t("accountLink.modal.loginNotConfigured.before")}{" "}
|
||||||
|
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||||
|
{t("accountLink.modal.loginNotConfigured.and")}{" "}
|
||||||
|
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
|
||||||
|
{t("accountLink.modal.loginNotConfigured.after")}
|
||||||
|
</Banner>
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
await onLinked({ access_token: "dev-stub-jwt" });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("accountLink.modal.simulateSignIn")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Card } from "@shared/components";
|
||||||
|
import { LinkGate } from "@portal/components/account-link/LinkGate";
|
||||||
|
|
||||||
|
const meta: Meta<typeof LinkGate> = {
|
||||||
|
title: "Portal/AccountLink/LinkGate",
|
||||||
|
component: LinkGate,
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof LinkGate>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gating follows the Link toolbar global: "Unlinked" shows the lock prompt,
|
||||||
|
* any linked state renders the feature.
|
||||||
|
*/
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
feature: "AI extraction",
|
||||||
|
children: (
|
||||||
|
<Card padding="loose">A billable feature, unlocked once linked.</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Banner, Button } from "@shared/components";
|
||||||
|
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("accountLink.gate.titleFeature", { feature })
|
||||||
|
: t("accountLink.gate.title")
|
||||||
|
}
|
||||||
|
description={t("accountLink.gate.description")}
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={() => openLinkModal()}>
|
||||||
|
{t("accountLink.gate.action")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user