From cd7264a76a139ce9eb0a07348880e89d23ed678e Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:12:05 +0100 Subject: [PATCH] refactor(fe): share the SaaS PAYG experience with desktop via a cloud/ layer (#6649) Co-authored-by: James Brunton --- .taskfiles/frontend.yml | 7 + AGENTS.md | 24 +- LICENSE | 2 + .../saas/security/SupabaseSecurityConfig.java | 14 +- .../public/locales/en-GB/translation.toml | 63 +- .../public/locales/en-US/translation.toml | 61 -- frontend/editor/src/cloud/LICENSE | 51 ++ frontend/editor/src/cloud/_probe.ts | 1 + frontend/editor/src/cloud/auth/session.ts | 16 + frontend/editor/src/cloud/auth/teamSession.ts | 37 ++ .../components/UsageLimitModalHost.tsx | 0 .../onboarding/SaasOnboardingModal.tsx | 6 + .../components/onboarding/renderButtons.tsx | 0 .../components/onboarding/saasFlowResolver.ts | 13 +- .../onboarding/saasOnboardingFlowConfig.ts | 0 .../onboarding/slides/FreeEditorSlide.tsx | 0 .../slides/SaasOnboardingSlides.module.css | 0 .../onboarding/slides/TeamSlide.tsx | 0 .../onboarding/slides/UsageSnapshotSlide.tsx | 0 .../onboarding/useSaasOnboardingState.ts | 18 +- .../shared/FreeLimitReachedModal.tsx | 0 .../shared/SpendCapReachedModal.tsx | 0 .../shared/TeamInvitationBanner.tsx | 0 .../shared/config/cloudConfigNavSections.tsx | 45 ++ .../shared/config/configSections/Payg.css | 0 .../shared/config/configSections/Payg.tsx | 0 .../shared/config/configSections/PaygFree.css | 0 .../shared/config/configSections/PaygFree.tsx | 0 .../shared/config/configSections/Plan.tsx | 0 .../config/configSections/SpendCapControl.css | 0 .../config/configSections/SpendCapControl.tsx | 0 .../configSections/StripeCheckoutPanel.tsx | 129 ++-- .../config/configSections/TeamSection.tsx | 0 .../config/configSections/UpgradeModal.css | 0 .../config/configSections/UpgradeModal.tsx | 5 +- .../config/configSections/usageMeters.tsx | 0 .../components/usageLimitModals.ts | 0 .../contexts/SaaSTeamContext.tsx | 21 +- .../src/{saas => cloud}/hooks/useWallet.ts | 168 ++--- .../src/cloud/hooks/walletDevPreview.ts | 46 ++ .../editor/src/cloud/platform/openExternal.ts | 28 + frontend/editor/src/cloud/services/billing.ts | 80 +++ .../services/paygErrorInterceptor.ts | 0 frontend/editor/src/cloud/tsconfig.json | 21 + .../configSections/SaaSTeamsSection.tsx | 9 - .../config/configSections/SaasPlanSection.tsx | 8 - .../shared/modals/CreditExhaustedModal.tsx | 13 - .../modals/InsufficientCreditsModal.tsx | 17 - .../src/core/contexts/SaasBillingContext.tsx | 14 - .../editor/src/core/hooks/useCreditCheck.ts | 7 +- .../{saas => core}/hooks/useRenderCount.ts | 0 frontend/editor/src/core/styles/zIndex.ts | 6 + frontend/editor/src/desktop/auth/session.ts | 15 + .../src/desktop/auth/teamSession.test.ts | 79 +++ .../editor/src/desktop/auth/teamSession.ts | 48 ++ .../src/desktop/components/AppProviders.tsx | 38 +- .../DesktopSaasOnboardingBootstrap.tsx | 74 +++ .../components/policies/PoliciesSidebar.tsx | 25 + .../QuickAccessBarFooterExtensions.tsx | 90 --- .../desktop/components/shared/CloudBadge.tsx | 2 +- .../shared/TeamInvitationBanner.tsx | 161 ----- .../shared/billing/SaaSStripeCheckout.tsx | 199 ------ .../shared/config/configNavSections.tsx | 83 +-- .../configSections/SaaSTeamsSection.tsx | 542 ----------------- .../config/configSections/SaasPlanSection.tsx | 260 -------- .../plan/ActiveSubscriptionCard.tsx | 257 -------- .../configSections/plan/PlanUpgradeCard.tsx | 102 ---- .../plan/SaaSAvailablePlansSection.tsx | 79 --- .../configSections/plan/SaasPlanCard.tsx | 218 ------- .../configSections/plan/UsageDisplay.tsx | 127 ---- .../shared/modals/CreditExhaustedModal.tsx | 573 ------------------ .../shared/modals/CreditModalBootstrap.tsx | 107 ---- .../shared/modals/CreditUsageBanner.tsx | 53 -- .../shared/modals/FeatureListItem.tsx | 59 -- .../modals/InsufficientCreditsModal.tsx | 162 ----- frontend/editor/src/desktop/config/billing.ts | 73 --- .../src/desktop/constants/creditEvents.ts | 13 - .../desktop/contexts/SaaSCheckoutContext.tsx | 86 --- .../src/desktop/contexts/SaaSTeamContext.tsx | 388 ------------ .../desktop/contexts/SaasBillingContext.tsx | 440 -------------- .../hooks/useConfirmedSaaSMode.test.ts | 62 ++ .../src/desktop/hooks/useConfirmedSaaSMode.ts | 30 + .../src/desktop/hooks/useCreditCheck.ts | 71 --- .../src/desktop/hooks/useCreditEvents.ts | 35 -- .../desktop/hooks/useEnableMeteredBilling.ts | 69 --- .../editor/src/desktop/hooks/useSaaSPlans.ts | 158 ----- .../src/desktop/platform/openExternal.ts | 16 + .../src/desktop/services/apiClientSetup.ts | 5 +- .../editor/src/desktop/services/billing.ts | 126 ++++ .../desktop/services/httpErrorHandler.test.ts | 51 ++ .../src/desktop/services/httpErrorHandler.ts | 31 + .../desktop/services/operationRouter.test.ts | 61 ++ .../src/desktop/services/operationRouter.ts | 15 +- .../desktop/services/saasBillingService.ts | 419 ------------- .../desktop/services/tauriHttpClient.test.ts | 58 ++ .../src/desktop/services/tauriHttpClient.ts | 9 + frontend/editor/src/desktop/tsconfig.json | 8 +- .../shared/config/configNavSections.tsx | 246 -------- .../src/proprietary/services/policyApi.ts | 9 +- frontend/editor/src/saas/App.tsx | 8 +- frontend/editor/src/saas/auth/UseSession.tsx | 98 +-- frontend/editor/src/saas/auth/session.ts | 22 + frontend/editor/src/saas/auth/teamSession.ts | 35 ++ .../saas/components/OnboardingBootstrap.tsx | 56 +- .../components/SignupRequiredBootstrap.tsx | 3 +- .../saas/components/TrialExpiredBootstrap.tsx | 131 ---- .../onboarding/saasFlowResolver.test.ts | 66 ++ .../shared/FreeLimitReachedModal.test.tsx | 96 +++ .../components/shared/ManageBillingButton.tsx | 70 --- .../components/shared/StripeCheckoutSaas.tsx | 273 --------- .../components/shared/TrialExpiredModal.tsx | 201 ------ .../shared/config/saasConfigNavSections.tsx | 31 +- .../editor/src/saas/hooks/walletDevPreview.ts | 123 ++++ .../editor/src/saas/platform/openExternal.ts | 18 + .../src/saas/services/apiClientSetup.ts | 16 +- frontend/editor/src/saas/services/billing.ts | 93 +++ frontend/editor/src/saas/styles/zIndex.ts | 3 +- frontend/editor/src/saas/tsconfig.json | 8 +- frontend/editor/tsconfig.desktop.vite.json | 12 +- frontend/editor/tsconfig.saas.vite.json | 10 +- frontend/editor/vite.config.ts | 4 + frontend/eslint.config.mjs | 62 ++ 122 files changed, 1836 insertions(+), 6365 deletions(-) create mode 100644 frontend/editor/src/cloud/LICENSE create mode 100644 frontend/editor/src/cloud/_probe.ts create mode 100644 frontend/editor/src/cloud/auth/session.ts create mode 100644 frontend/editor/src/cloud/auth/teamSession.ts rename frontend/editor/src/{saas => cloud}/components/UsageLimitModalHost.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/onboarding/SaasOnboardingModal.tsx (94%) rename frontend/editor/src/{saas => cloud}/components/onboarding/renderButtons.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/onboarding/saasFlowResolver.ts (55%) rename frontend/editor/src/{saas => cloud}/components/onboarding/saasOnboardingFlowConfig.ts (100%) rename frontend/editor/src/{saas => cloud}/components/onboarding/slides/FreeEditorSlide.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/onboarding/slides/SaasOnboardingSlides.module.css (100%) rename frontend/editor/src/{saas => cloud}/components/onboarding/slides/TeamSlide.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/onboarding/slides/UsageSnapshotSlide.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/onboarding/useSaasOnboardingState.ts (88%) rename frontend/editor/src/{saas => cloud}/components/shared/FreeLimitReachedModal.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/shared/SpendCapReachedModal.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/shared/TeamInvitationBanner.tsx (100%) create mode 100644 frontend/editor/src/cloud/components/shared/config/cloudConfigNavSections.tsx rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/Payg.css (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/Payg.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/PaygFree.css (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/PaygFree.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/Plan.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/SpendCapControl.css (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/SpendCapControl.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/StripeCheckoutPanel.tsx (69%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/TeamSection.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/UpgradeModal.css (100%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/UpgradeModal.tsx (99%) rename frontend/editor/src/{saas => cloud}/components/shared/config/configSections/usageMeters.tsx (100%) rename frontend/editor/src/{saas => cloud}/components/usageLimitModals.ts (100%) rename frontend/editor/src/{saas => cloud}/contexts/SaaSTeamContext.tsx (93%) rename frontend/editor/src/{saas => cloud}/hooks/useWallet.ts (71%) create mode 100644 frontend/editor/src/cloud/hooks/walletDevPreview.ts create mode 100644 frontend/editor/src/cloud/platform/openExternal.ts create mode 100644 frontend/editor/src/cloud/services/billing.ts rename frontend/editor/src/{saas => cloud}/services/paygErrorInterceptor.ts (100%) create mode 100644 frontend/editor/src/cloud/tsconfig.json delete mode 100644 frontend/editor/src/core/components/shared/config/configSections/SaaSTeamsSection.tsx delete mode 100644 frontend/editor/src/core/components/shared/config/configSections/SaasPlanSection.tsx delete mode 100644 frontend/editor/src/core/components/shared/modals/CreditExhaustedModal.tsx delete mode 100644 frontend/editor/src/core/components/shared/modals/InsufficientCreditsModal.tsx delete mode 100644 frontend/editor/src/core/contexts/SaasBillingContext.tsx rename frontend/editor/src/{saas => core}/hooks/useRenderCount.ts (100%) create mode 100644 frontend/editor/src/desktop/auth/session.ts create mode 100644 frontend/editor/src/desktop/auth/teamSession.test.ts create mode 100644 frontend/editor/src/desktop/auth/teamSession.ts create mode 100644 frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx create mode 100644 frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx delete mode 100644 frontend/editor/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/TeamInvitationBanner.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/billing/SaaSStripeCheckout.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/config/configSections/plan/ActiveSubscriptionCard.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/config/configSections/plan/PlanUpgradeCard.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/config/configSections/plan/SaaSAvailablePlansSection.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/config/configSections/plan/SaasPlanCard.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/config/configSections/plan/UsageDisplay.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/modals/CreditExhaustedModal.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/modals/CreditModalBootstrap.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/modals/CreditUsageBanner.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/modals/FeatureListItem.tsx delete mode 100644 frontend/editor/src/desktop/components/shared/modals/InsufficientCreditsModal.tsx delete mode 100644 frontend/editor/src/desktop/config/billing.ts delete mode 100644 frontend/editor/src/desktop/constants/creditEvents.ts delete mode 100644 frontend/editor/src/desktop/contexts/SaaSCheckoutContext.tsx delete mode 100644 frontend/editor/src/desktop/contexts/SaaSTeamContext.tsx delete mode 100644 frontend/editor/src/desktop/contexts/SaasBillingContext.tsx create mode 100644 frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.test.ts create mode 100644 frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.ts delete mode 100644 frontend/editor/src/desktop/hooks/useCreditCheck.ts delete mode 100644 frontend/editor/src/desktop/hooks/useCreditEvents.ts delete mode 100644 frontend/editor/src/desktop/hooks/useEnableMeteredBilling.ts delete mode 100644 frontend/editor/src/desktop/hooks/useSaaSPlans.ts create mode 100644 frontend/editor/src/desktop/platform/openExternal.ts create mode 100644 frontend/editor/src/desktop/services/billing.ts create mode 100644 frontend/editor/src/desktop/services/httpErrorHandler.test.ts create mode 100644 frontend/editor/src/desktop/services/operationRouter.test.ts delete mode 100644 frontend/editor/src/desktop/services/saasBillingService.ts create mode 100644 frontend/editor/src/desktop/services/tauriHttpClient.test.ts create mode 100644 frontend/editor/src/saas/auth/session.ts create mode 100644 frontend/editor/src/saas/auth/teamSession.ts delete mode 100644 frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx create mode 100644 frontend/editor/src/saas/components/onboarding/saasFlowResolver.test.ts create mode 100644 frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx delete mode 100644 frontend/editor/src/saas/components/shared/ManageBillingButton.tsx delete mode 100644 frontend/editor/src/saas/components/shared/StripeCheckoutSaas.tsx delete mode 100644 frontend/editor/src/saas/components/shared/TrialExpiredModal.tsx create mode 100644 frontend/editor/src/saas/hooks/walletDevPreview.ts create mode 100644 frontend/editor/src/saas/platform/openExternal.ts create mode 100644 frontend/editor/src/saas/services/billing.ts diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 4ce9418a3..c355cbd65 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -262,6 +262,12 @@ tasks: cmds: - npx tsc --noEmit --project editor/src/desktop/tsconfig.json + typecheck:cloud: + desc: "Typecheck cloud shared layer (standalone)" + deps: [prepare] + cmds: + - npx tsc --noEmit --project editor/src/cloud/tsconfig.json + typecheck:scripts: desc: "Typecheck scripts" deps: [prepare] @@ -293,6 +299,7 @@ tasks: - task: typecheck:proprietary - task: typecheck:saas - task: typecheck:desktop + - task: typecheck:cloud - task: typecheck:scripts - task: typecheck:prototypes - task: typecheck:portal diff --git a/AGENTS.md b/AGENTS.md index 6e4e67510..8dbb78824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,29 @@ import { useFileContext } from "@proprietary/contexts/FileContext"; - Building layer-specific override that wraps a lower layer's component - Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version -The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade. +The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order. + +#### Frontend `cloud/` Layer + +`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override): + +- **core** → core +- **proprietary** → proprietary → core +- **saas** → saas → cloud → proprietary → core +- **desktop** → desktop → cloud → proprietary → core +- **cloud** → cloud → proprietary → core + +What goes where: + +- **core** — OSS base. +- **proprietary** — licensed / offline features. +- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding. +- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`. +- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing. + +`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`. + +Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`). #### Component Override Pattern (Stub/Shadow) Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals. diff --git a/LICENSE b/LICENSE index efc31d3a4..2b2f7fc08 100644 --- a/LICENSE +++ b/LICENSE @@ -16,6 +16,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE". * All content that resides under the "frontend/editor/src/saas/" directory of this repository, if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE". +* All content that resides under the "frontend/editor/src/cloud/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE". * All content that resides under the "frontend/editor/src/prototypes/" directory of this repository, if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE". * All content that resides under the "frontend/portal/" directory of this repository, diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 2c44a15c9..0bccbf0b7 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -260,7 +260,7 @@ public class SupabaseSecurityConfig { applicationProperties.getSystem() != null && applicationProperties.getSystem().getCorsAllowedOrigins() != null && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty(); - List origins = + List configuredOrigins = operatorOverride ? applicationProperties.getSystem().getCorsAllowedOrigins() : List.of( @@ -270,6 +270,18 @@ public class SupabaseSecurityConfig { "https://stirling.com", "https://app.stirling.com", "https://api.stirling.com"); + // Always allow the desktop (Tauri) app's webview origins so the bundled + // desktop client can reach the cloud backend regardless of the operator's + // configured web origins. A browser can never present a tauri:// (or + // tauri.localhost) origin, so these are desktop-app identities — safe to + // allow alongside allowCredentials=true. Mirrors core WebMvcConfig. + List origins = new ArrayList<>(configuredOrigins); + for (String desktopOrigin : + List.of("tauri://localhost", "http://tauri.localhost", "https://tauri.localhost")) { + if (!origins.contains(desktopOrigin)) { + origins.add(desktopOrigin); + } + } if (origins.stream().anyMatch(o -> o.contains("*"))) { log.warn( "CORS origins contain a wildcard paired with allowCredentials=true: {}." diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 5cf1de401..0e9b5957b 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2747,7 +2747,7 @@ summary = "Ran {{count}} tools" unknownTool = "Unknown tool" [cloudBadge] -tooltip = "This operation will use your cloud credits" +tooltip = "Runs in the cloud (included, no extra charge)" [color.eyeDropper] tooltip = "Pick colour from screen" @@ -2769,7 +2769,6 @@ error = "Error" expand = "Expand" loading = "Loading..." next = "Next" -operation = "this operation" preview = "Preview" previous = "Previous" refresh = "Refresh" @@ -3222,68 +3221,25 @@ posthog = "PostHog Analytics" scarf = "Scarf Pixel" [credits] -enableOverageBilling = "Enable Overage Billing" -maybeLater = "Maybe later" -upgrade = "Upgrade" [credits.insufficient] -brief = "Insufficient credits. You need {{required}} credits but have {{current}}." -freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing." -managedMember = "Please contact your team leader for assistance." -message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits." -messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}." -teamMember = "Enable overage billing to never run out of credits." -title = "Insufficient Credits" [credits.modal] advancedMonitoring = "Advanced monitoring" allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)" apiSandbox = "API sandbox" -contactSales = "Contact Sales" -creditsRemaining = "{{current}} of {{total}} remaining" -creditsThisMonth = "Monthly credits" -current = "Current Plan" -customPricing = "Custom" dedicatedSupportSlas = "Dedicated support & SLAs" -enterpriseSubscription = "Enterprise" -everythingInCredits = "Everything in Credits, plus:" -everythingInFree = "Everything in Free, plus:" fasterThroughput = "10x faster throughput" -forRegularWork = "For regular PDF work:" -freeTier = "Free Tier" fullyPrivateFiles = "Fully private files" largeFileProcessing = "Large file processing" -managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader." -managedMemberTitle = "Unlimited Credits" -meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings." -meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working." -meteringIncluded = "{{credits}} credits/month included with Team" -meteringNeverRunOut = "Never run out of credits" -meteringNoCommitment = "No commitment, cancel anytime" -meteringPayAsYouGo = "Only pay for what you use" -meteringPrice = "Additional credits at {{price}}/credit" -meteringTitle = "Pay-What-You-Use Overage Billing" -monthlyCredits = "monthly credits" orgWideAccess = "Org-wide access controls" -overage = "overage" -perMonth = "/month" -popular = "Popular" premiumAiModels = "Premium AI models" prioritySupport = "Priority support" privateDocCloud = "Private Document Cloud" ragFineTuning = "RAG + fine-tuning" secureApiAccess = "Secure API access" -selfHostLink = "Review the docs and plans" -selfHostPrompt = "Want to self host?" standardThroughput = "Standard throughput" -subtitle = "Upgrade to Team for 10x the credits and faster processing." -subtitlePro = "Enable automatic overage billing to never run out of credits." -teamLeaderOnly = "Only team leaders can enable overage billing" -teamSubscription = "Team" -titleExhausted = "You've used your free credits" -titleExhaustedPro = "You have run out of credits" unlimitedApiAccess = "Unlimited API access" -unlimitedMonthlyCredits = "Site Licence" unlimitedSeats = "Unlimited seats" [crop] @@ -5394,11 +5350,7 @@ resetsTomorrow = "Resets tomorrow" thisPeriod = "This billing period" [payment] -autoClose = "This window will close automatically..." canCloseWindow = "You can now close this window." -checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information." -checkoutOpened = "Checkout Opened in Browser" -closeLater = "I'll Do This Later" error = "Payment Error" generatingLicense = "Generating your licence key..." licenseActivated = "Licence activated! Your licence key has been saved. A confirmation email has been sent to your registered email address." @@ -5414,7 +5366,6 @@ paymentCanceled = "Payment was cancelled. No charges were made." perMonth = "/month" preparing = "Preparing your checkout..." redirecting = "Redirecting to secure checkout..." -refreshBilling = "I've Completed Payment - Refresh Billing" success = "Payment Successful!" successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly." syncError = "Payment successful but licence sync failed. Your licence will be updated shortly. Please contact support if issues persist." @@ -5704,13 +5655,10 @@ title = "Pipeline" [plan] contact = "Contact Us" -currency = "Currency" current = "Current Plan" -customPricing = "Custom" featureComparison = "Feature Comparison" from = "From" hideComparison = "Hide Feature Comparison" -included = "Included" licensedSeats = "Licensed: {{count}} seats" manage = "Manage" perMonth = "/month" @@ -5728,7 +5676,6 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] -loadError = "Unable to load plan pricing. Using default values." subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -5739,7 +5686,6 @@ highlight3 = "Latest features" name = "Enterprise" requiresServer = "Requires Server" requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise." -siteLicense = "Site Licence" [plan.feature] api = "API Access" @@ -5846,16 +5792,9 @@ success = "Licence Activated!" successMessage = "Your licence has been successfully activated. You can now close this window." [plan.team] -name = "Team" [plan.trial] -continueWithFree = "Continue with Free" -expired = "Your Trial Has Ended" -expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier." -freeTierLimitations = "Free tier includes basic PDF tools with usage limits." message = "" -subscribe = "Subscribe to Pro" -subscribeToPro = "Subscribe to Pro" [policies] deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 5b22c3064..01a9eceac 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -2743,7 +2743,6 @@ error = "Error" expand = "Expand" loading = "Loading..." next = "Next" -operation = "this operation" preview = "Preview" previous = "Previous" refresh = "Refresh" @@ -3196,68 +3195,25 @@ posthog = "PostHog Analytics" scarf = "Scarf Pixel" [credits] -enableOverageBilling = "Enable Overage Billing" -maybeLater = "Maybe later" -upgrade = "Upgrade" [credits.insufficient] -brief = "Insufficient credits. You need {{required}} credits but have {{current}}." -freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing." -managedMember = "Please contact your team leader for assistance." -message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits." -messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}." -teamMember = "Enable overage billing to never run out of credits." -title = "Insufficient Credits" [credits.modal] advancedMonitoring = "Advanced monitoring" allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)" apiSandbox = "API sandbox" -contactSales = "Contact Sales" -creditsRemaining = "{{current}} of {{total}} remaining" -creditsThisMonth = "Monthly credits" -current = "Current Plan" -customPricing = "Custom" dedicatedSupportSlas = "Dedicated support & SLAs" -enterpriseSubscription = "Enterprise" -everythingInCredits = "Everything in Credits, plus:" -everythingInFree = "Everything in Free, plus:" fasterThroughput = "10x faster throughput" -forRegularWork = "For regular PDF work:" -freeTier = "Free Tier" fullyPrivateFiles = "Fully private files" largeFileProcessing = "Large file processing" -managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader." -managedMemberTitle = "Unlimited Credits" -meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings." -meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working." -meteringIncluded = "{{credits}} credits/month included with Team" -meteringNeverRunOut = "Never run out of credits" -meteringNoCommitment = "No commitment, cancel anytime" -meteringPayAsYouGo = "Only pay for what you use" -meteringPrice = "Additional credits at {{price}}/credit" -meteringTitle = "Pay-What-You-Use Overage Billing" -monthlyCredits = "monthly credits" orgWideAccess = "Org-wide access controls" -overage = "overage" -perMonth = "/month" -popular = "Popular" premiumAiModels = "Premium AI models" prioritySupport = "Priority support" privateDocCloud = "Private Document Cloud" ragFineTuning = "RAG + fine-tuning" secureApiAccess = "Secure API access" -selfHostLink = "Review the docs and plans" -selfHostPrompt = "Want to self host?" standardThroughput = "Standard throughput" -subtitle = "Upgrade to Team for 10x the credits and faster processing." -subtitlePro = "Enable automatic overage billing to never run out of credits." -teamLeaderOnly = "Only team leaders can enable overage billing" -teamSubscription = "Team" -titleExhausted = "You've used your free credits" -titleExhaustedPro = "You have run out of credits" unlimitedApiAccess = "Unlimited API access" -unlimitedMonthlyCredits = "Site License" unlimitedSeats = "Unlimited seats" [crop] @@ -5363,11 +5319,7 @@ resetsTomorrow = "Resets tomorrow" thisPeriod = "This billing period" [payment] -autoClose = "This window will close automatically..." canCloseWindow = "You can now close this window." -checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information." -checkoutOpened = "Checkout Opened in Browser" -closeLater = "I'll Do This Later" error = "Payment Error" generatingLicense = "Generating your license key..." licenseActivated = "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address." @@ -5383,7 +5335,6 @@ paymentCanceled = "Payment was canceled. No charges were made." perMonth = "/month" preparing = "Preparing your checkout..." redirecting = "Redirecting to secure checkout..." -refreshBilling = "I've Completed Payment - Refresh Billing" success = "Payment Successful!" successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly." syncError = "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist." @@ -5673,13 +5624,10 @@ title = "Pipeline" [plan] contact = "Contact Us" -currency = "Currency" current = "Current Plan" -customPricing = "Custom" featureComparison = "Feature Comparison" from = "From" hideComparison = "Hide Feature Comparison" -included = "Included" licensedSeats = "Licensed: {{count}} seats" manage = "Manage" perMonth = "/month" @@ -5697,7 +5645,6 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] -loadError = "Unable to load plan pricing. Using default values." subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -5708,7 +5655,6 @@ highlight3 = "Latest features" name = "Enterprise" requiresServer = "Requires Server" requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise." -siteLicense = "Site License" [plan.feature] api = "API Access" @@ -5815,15 +5761,8 @@ success = "License Activated!" successMessage = "Your license has been successfully activated. You can now close this window." [plan.team] -name = "Team" [plan.trial] -continueWithFree = "Continue with Free" -expired = "Your Trial Has Ended" -expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier." -freeTierLimitations = "Free tier includes basic PDF tools with usage limits." -subscribe = "Subscribe to Pro" -subscribeToPro = "Subscribe to Pro" [policies] deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." diff --git a/frontend/editor/src/cloud/LICENSE b/frontend/editor/src/cloud/LICENSE new file mode 100644 index 000000000..d26855680 --- /dev/null +++ b/frontend/editor/src/cloud/LICENSE @@ -0,0 +1,51 @@ +Stirling PDF User License + +Copyright (c) 2025 Stirling PDF Inc. + +License Scope & Usage Rights + +Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License. + +For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files +provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical +processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service +(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription +covering the appropriate number of licensed users. + +Trial and Minimal Use + +You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that: +* Use is limited to the capabilities and restrictions defined by the Software itself; +* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts. + +Continued use beyond this scope requires a valid Stirling PDF User License. + +Modifications and Derivative Works + +You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works: + +* May not be deployed in production environments without a valid User License; +* May not be distributed or sublicensed; +* Remain the intellectual property of Stirling PDF and/or its licensors; +* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription. + +Prohibited Actions + +Unless explicitly permitted by a paid license or separate agreement, you may not: + +* Use the Software in production environments; +* Copy, merge, distribute, sublicense, or sell the Software; +* Remove or alter any licensing or copyright notices; +* Circumvent access restrictions or licensing requirements. + +Third-Party Components + +The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by +their original license terms as provided by their respective owners. + +Disclaimer + +THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/frontend/editor/src/cloud/_probe.ts b/frontend/editor/src/cloud/_probe.ts new file mode 100644 index 000000000..b40e59d86 --- /dev/null +++ b/frontend/editor/src/cloud/_probe.ts @@ -0,0 +1 @@ +export const CLOUD_LAYER_PROBE = "cloud" as const; diff --git a/frontend/editor/src/cloud/auth/session.ts b/frontend/editor/src/cloud/auth/session.ts new file mode 100644 index 000000000..2dc8da30f --- /dev/null +++ b/frontend/editor/src/cloud/auth/session.ts @@ -0,0 +1,16 @@ +/** + * Auth/session seam (@app/auth/session). saas keeps a Supabase web session; + * desktop keeps a JWT in the Tauri secure store — cloud code reads the token + * through this seam instead. Default no-op; saas/ and desktop/ shadow it. + */ + +/** Minimal session shape shared across platforms. */ +export interface AppSession { + /** Bearer access token for authenticated API calls, or null when signed out. */ + accessToken: string | null; +} + +/** The current access token for authenticated API calls, or null when signed out. */ +export async function getAccessToken(): Promise { + return null; +} diff --git a/frontend/editor/src/cloud/auth/teamSession.ts b/frontend/editor/src/cloud/auth/teamSession.ts new file mode 100644 index 000000000..1de09774b --- /dev/null +++ b/frontend/editor/src/cloud/auth/teamSession.ts @@ -0,0 +1,37 @@ +/** + * Team-session auth seam (@app/auth/teamSession). {@link SaaSTeamContext} needs + * just two things from auth (can-use-teams + a post-membership refresh), so it + * consumes them through this narrow seam rather than reaching a platform auth + * surface directly. Default reports no access; saas/ and desktop/ shadow it. + */ + +/** + * The minimal auth surface {@link SaaSTeamContext} depends on. + * + * Each platform supplies its own implementation: + * - {@link canUseTeams} is {@code true} only for a signed-in, non-anonymous + * user (web: Supabase non-anonymous session; desktop: a valid authService + * token). When {@code false} the context stays empty and makes no API calls. + * - {@link refreshAfterMembershipChange} is invoked after a membership change + * (accepting an invite, leaving a team) so the platform can refresh any + * derived auth-tier state. On web this refreshes credits + the Supabase + * session; on desktop it is a no-op (no such derived state). + */ +export interface TeamAuth { + /** Whether the current session may load/manage teams (signed in, not anonymous). */ + canUseTeams: boolean; + /** Refresh derived auth state after a team membership change. */ + refreshAfterMembershipChange: () => Promise; +} + +/** + * Resolve the team-relevant auth surface for the current session. Cloud default + * reports no access + a no-op refresh; saas/desktop shadow it. Implemented as a + * hook so the saas/desktop impls can subscribe to live auth state. + */ +export function useTeamAuth(): TeamAuth { + return { + canUseTeams: false, + refreshAfterMembershipChange: async () => {}, + }; +} diff --git a/frontend/editor/src/saas/components/UsageLimitModalHost.tsx b/frontend/editor/src/cloud/components/UsageLimitModalHost.tsx similarity index 100% rename from frontend/editor/src/saas/components/UsageLimitModalHost.tsx rename to frontend/editor/src/cloud/components/UsageLimitModalHost.tsx diff --git a/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx similarity index 94% rename from frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx rename to frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx index 491aad7dc..5aa52034a 100644 --- a/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx +++ b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx @@ -14,6 +14,12 @@ import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; interface SaasOnboardingModalProps { opened: boolean; onClose: () => void; + /** + * Drop the closing "desktop-install" slide. Set by the desktop app, which + * reuses this flow but has no reason to pitch its own download. Defaults to + * false (slide shown) so the web (saas) flow is unchanged. + */ + hideDesktopInstall?: boolean; } export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { diff --git a/frontend/editor/src/saas/components/onboarding/renderButtons.tsx b/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx similarity index 100% rename from frontend/editor/src/saas/components/onboarding/renderButtons.tsx rename to frontend/editor/src/cloud/components/onboarding/renderButtons.tsx diff --git a/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts similarity index 55% rename from frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts rename to frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts index ed723852c..b6fb19973 100644 --- a/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts +++ b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts @@ -5,21 +5,30 @@ export interface SaasFlowInputs { showUsageSlide: boolean; /** Team leaders only — invited members and anonymous guests skip the team slide. */ showTeamSlide: boolean; + /** + * Drop the closing "desktop-install" slide. The web (saas) flow pitches the + * desktop download, but the desktop app reuses this same flow and is already + * the desktop app, so it omits that slide. Defaults to false (slide shown). + */ + hideDesktopInstall?: boolean; } /** * Resolves the SaaS onboarding slide sequence. The free-editor pitch and * desktop install bookend the flow; the usage meter and team slides slot in - * when their conditions hold. + * when their conditions hold. When {@link SaasFlowInputs.hideDesktopInstall} is + * set, the closing desktop-install slide is dropped (used by the desktop app, + * which has no reason to pitch its own download). */ export function resolveSaasFlow({ showUsageSlide, showTeamSlide, + hideDesktopInstall = false, }: SaasFlowInputs): SlideId[] { return [ "free-editor", ...(showUsageSlide ? (["usage"] as const) : []), ...(showTeamSlide ? (["team"] as const) : []), - "desktop-install", + ...(hideDesktopInstall ? [] : (["desktop-install"] as const)), ]; } diff --git a/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts similarity index 100% rename from frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts rename to frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts diff --git a/frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx b/frontend/editor/src/cloud/components/onboarding/slides/FreeEditorSlide.tsx similarity index 100% rename from frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx rename to frontend/editor/src/cloud/components/onboarding/slides/FreeEditorSlide.tsx diff --git a/frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css b/frontend/editor/src/cloud/components/onboarding/slides/SaasOnboardingSlides.module.css similarity index 100% rename from frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css rename to frontend/editor/src/cloud/components/onboarding/slides/SaasOnboardingSlides.module.css diff --git a/frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx b/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx similarity index 100% rename from frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx rename to frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx diff --git a/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx b/frontend/editor/src/cloud/components/onboarding/slides/UsageSnapshotSlide.tsx similarity index 100% rename from frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx rename to frontend/editor/src/cloud/components/onboarding/slides/UsageSnapshotSlide.tsx diff --git a/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts similarity index 88% rename from frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts rename to frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts index 7ef384e16..0b76c38a8 100644 --- a/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts +++ b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts @@ -11,6 +11,7 @@ import { } from "@app/components/onboarding/saasOnboardingFlowConfig"; import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver"; import { DOWNLOAD_URLS } from "@app/constants/downloads"; +import { openExternal } from "@app/platform/openExternal"; interface UseSaasOnboardingStateResult { currentStep: number; @@ -24,11 +25,18 @@ interface UseSaasOnboardingStateResult { interface UseSaasOnboardingStateProps { opened: boolean; onClose: () => void; + /** + * Drop the closing "desktop-install" slide. The desktop app reuses this + * flow but has no reason to pitch its own download. Defaults to false + * (slide shown) so the web (saas) flow is unchanged. + */ + hideDesktopInstall?: boolean; } export function useSaasOnboardingState({ opened, onClose, + hideDesktopInstall = false, }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { const { loading } = useAuth(); const { wallet } = useWallet(); @@ -80,8 +88,9 @@ export function useSaasOnboardingState({ const showTeamSlide = isTeamLeader; const flowSlideIds = useMemo( - () => resolveSaasFlow({ showUsageSlide, showTeamSlide }), - [showUsageSlide, showTeamSlide], + () => + resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }), + [showUsageSlide, showTeamSlide, hideDesktopInstall], ); const totalSteps = flowSlideIds.length; const maxIndex = Math.max(totalSteps - 1, 0); @@ -136,10 +145,11 @@ export function useSaasOnboardingState({ onClose(); return; case "download-selected": { - // Open download URL in new tab + // Open the download URL in the user's browser via the platform seam + // (saas opens a new tab, desktop hands off to the OS shell). const downloadUrl = selectedDownloadUrlRef.current || os.url; if (downloadUrl) { - window.open(downloadUrl, "_blank", "noopener,noreferrer"); + void openExternal(downloadUrl); } // Then advance to next slide or close if last if (currentStep === maxIndex) { diff --git a/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx b/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx rename to frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx diff --git a/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx b/frontend/editor/src/cloud/components/shared/SpendCapReachedModal.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx rename to frontend/editor/src/cloud/components/shared/SpendCapReachedModal.tsx diff --git a/frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx rename to frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx diff --git a/frontend/editor/src/cloud/components/shared/config/cloudConfigNavSections.tsx b/frontend/editor/src/cloud/components/shared/config/cloudConfigNavSections.tsx new file mode 100644 index 000000000..6890fd4fa --- /dev/null +++ b/frontend/editor/src/cloud/components/shared/config/cloudConfigNavSections.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { type TFunction } from "i18next"; +import { + type ConfigNavSection, + type ConfigNavItem, +} from "@core/components/shared/config/configNavSections"; +import Plan from "@app/components/shared/config/configSections/Plan"; +import TeamSection from "@app/components/shared/config/configSections/TeamSection"; + +/** + * Shared cloud config nav-section builders, composed by both the saas (web) and + * desktop (Tauri) nav wrappers. The Plan (wallet-driven PAYG dashboard + spend + * cap) and Team sections are identical across platforms; each leaf owns its own + * modal chrome and appends its leaf-only sections around these. + */ + +type Translate = TFunction<"translation", undefined>; + +/** The Plan (billing) nav item — wallet-driven PAYG dashboard + spend cap. */ +export function createCloudPlanNavItem(t: Translate): ConfigNavItem { + return { + key: "plan", + label: t("config.plan", "Plan"), + icon: "credit-card", + component: , + }; +} + +/** The Team nav item — shared SaaS team management (invite/rename/members). */ +export function createCloudTeamNavItem(t: Translate): ConfigNavItem { + return { + key: "teams", + label: t("config.team", "Team"), + icon: "groups-rounded", + component: , + }; +} + +/** Billing nav section wrapping the Plan item, for leaves that group it (saas). */ +export function createCloudBillingSection(t: Translate): ConfigNavSection { + return { + title: "Billing", + items: [createCloudPlanNavItem(t)], + }; +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.css b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/Payg.css rename to frontend/editor/src/cloud/components/shared/config/configSections/Payg.css diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/Payg.tsx diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css rename to frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/Plan.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/Plan.tsx diff --git a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css rename to frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css diff --git a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx diff --git a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx similarity index 69% rename from frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx index 3dd602fbe..1ffc9f4ee 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx @@ -9,7 +9,7 @@ *
  * // In UpgradeModal.tsx — only when the user advances to step 2:
  * const StripeCheckoutPanel = React.lazy(
- *   () => import("./StripeCheckoutPanel"),
+ *   () => import("@app/components/shared/config/configSections/StripeCheckoutPanel"),
  * );
  * 
* @@ -27,11 +27,12 @@ * *

Architecture

* - * Stripe-touching code lives in Supabase edge functions, not the Java - * backend. This panel invokes {@code create-checkout-session} - * directly via {@code supabase.functions.invoke()} - same pattern {@link - * usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is - * attached automatically by the Supabase client. + * Stripe-touching code lives in Supabase edge functions, not the Java backend. + * This panel no longer talks to Supabase directly — it mints the PAYG checkout + * session through the {@code @app/services/billing} seam + * ({@link createCheckoutSession} with a {@code teamId}), which each platform + * implements (web supabase client vs Tauri fetch). The seam drives the + * {@code create-checkout-session} edge function. * *

The edge function is the canonical place Stripe Checkout * Sessions get created - it uses the Stripe Sync Engine tables, has dedicated @@ -42,16 +43,19 @@ *

Behaviour

* *
    - *
  1. On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id, - * currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is - * NOT set here — it's an application-layer setting applied via {@code PATCH /payg/cap} after - * the subscription lands. - *
  2. If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge - * function isn't deployed yet (errors out / returns a {@code cs_mock_} - * sentinel), render a clearly-labelled placeholder + "Continue with - * mock" button so the post-completion path stays testable. + *
  3. On mount: calls {@link createCheckoutSession} with the {@code teamId}, + * {@code currency} and billing email to obtain a {@code clientSecret}. The + * spending cap is NOT set here — it's an application-layer setting applied + * via {@code PATCH /payg/cap} after the subscription lands. + *
  4. If no Stripe publishable key is configured OR the edge function isn't + * deployed yet (errors out / returns a {@code cs_mock_} sentinel), render + * a clearly-labelled placeholder + "Continue with mock" button so the + * post-completion path stays testable. + *
  5. If only a hosted {@code url} comes back (the redirect fallback), hand it + * to the system browser via {@link openExternal}. *
  6. Otherwise render the real {@code } + - * {@code } iframe. + * {@code } iframe. The Tauri webview has no CSP, so + * embedded checkout works on desktop too. *
* * The parent {@link UpgradeModal} passes {@code onComplete} which fires when @@ -60,8 +64,13 @@ */ import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { supabase } from "@app/auth/supabase"; import { useAuth } from "@app/auth/UseSession"; +import { + createCheckoutSession, + getStripePublishableKey, +} from "@app/services/billing"; +import { openExternal } from "@app/platform/openExternal"; +import { getWalletDevPreview } from "@app/hooks/walletDevPreview"; // Eager static imports here are OK because this whole module is itself lazy- // imported by the modal. They land in the same lazy chunk. @@ -89,21 +98,6 @@ export interface StripeCheckoutPanelProps { onError?: (message: string) => void; } -/** - * Response shape from the {@code create-checkout-session} Supabase edge - * function. Mirrors what the function returns. - */ -interface CheckoutResponse { - /** Stripe Checkout Session client_secret. */ - client_secret: string; - /** - * Optional sentinel: edge functions in non-prod environments may return a - * stubbed secret prefixed {@code cs_mock_} so the FE knows to render the - * placeholder rather than try to mount a real iframe with a bad secret. - */ - mock?: boolean; -} - // Singleton Stripe promise — created on first use and reused for the lifetime // of the tab. {@code loadStripe} is dynamically imported so the actual SDK // chunk is only pulled when this code path runs. @@ -148,16 +142,14 @@ const StripeCheckoutPanel: React.FC = ({ const tRef = useRef(t); tRef.current = t; - const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; + const publishableKey = getStripePublishableKey(); // Dev preview route has no backend — skip the API call and go straight // to the mock placeholder so the design + completion path stay testable. - // Both checks required so a production tenant with a /dev/ URL prefix - // can't accidentally trigger the placeholder. - const devPreview = - import.meta.env.DEV && - typeof window !== "undefined" && - window.location.pathname.startsWith("/dev/"); + // The dev-preview detection (import.meta.env.DEV + a /dev/ path) lives behind + // the walletDevPreview seam since cloud may not read either directly; it is + // a saas-only affordance and resolves to null on desktop / prod. + const devPreview = getWalletDevPreview() !== null; useEffect(() => { if (devPreview) { @@ -177,44 +169,37 @@ const StripeCheckoutPanel: React.FC = ({ let cancelled = false; async function createSession() { try { - // Direct Supabase edge function invocation. We call - // {@code create-checkout-session} (not {@code create-payg-team-subscription} — - // that one creates a subscription directly without going through the - // hosted Stripe Embedded Checkout iframe and doesn't return a - // client_secret). team_id is required because the edge fn runs - // outside our Spring Security context and can't resolve it from the - // JWT alone. The cap is *not* set during checkout — it's an - // application-layer setting, applied via PATCH /payg/cap after the - // subscription lands. window.location.href is the success_url so the - // user comes back to the Plan tab after Stripe finishes the redirect. - const returnUrl = window.location.href; - const { data, error: invokeError } = - await supabase.functions.invoke( - "create-checkout-session", - { - body: { - team_id: teamId, - currency, - success_url: returnUrl, - cancel_url: returnUrl, - // Maps to Stripe's customer_email when the team has no Stripe - // customer yet — prefills + locks the email field in Checkout. - // Teams with an existing customer get the email locked from the - // customer record instead; this field is ignored for them. - ...(billingEmail ? { billing_owner_email: billingEmail } : {}), - }, - }, - ); - if (invokeError) { - throw invokeError; + // Mint the PAYG checkout session through the billing seam. Passing a + // teamId routes it to the {@code create-checkout-session} edge function + // (not {@code create-payg-team-subscription} — that one subscribes + // directly without the embedded iframe and returns no client_secret). + // team_id is required because the edge fn runs outside our Spring + // Security context and can't resolve it from the JWT alone. The cap is + // *not* set during checkout — it's applied via PATCH /payg/cap after + // the subscription lands. The platform impl supplies the success/cancel + // return URL (browser origin on web, deep link on desktop). + const session = await createCheckoutSession({ + teamId, + currency, + // Maps to Stripe's customer_email when the team has no Stripe + // customer yet — prefills + locks the email field in Checkout. Teams + // with an existing customer get the email locked from the customer + // record instead; this field is ignored for them. + billingOwnerEmail: billingEmail, + }); + if (cancelled) return; + // Hosted-url fallback: no embedded iframe, hand the URL to the system + // browser. The deep-link / origin return URL brings the user back. + if (session.url && !session.clientSecret) { + await openExternal(session.url); + return; } - if (!data?.client_secret) { + if (!session.clientSecret) { throw new Error("Edge function returned no client_secret"); } - if (cancelled) return; - setClientSecret(data.client_secret); + setClientSecret(session.clientSecret); setIsMock( - Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"), + Boolean(session.mock) || session.clientSecret.startsWith("cs_mock_"), ); } catch (e: unknown) { if (cancelled) return; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css b/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.css similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css rename to frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.css diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.tsx similarity index 99% rename from frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.tsx index 42139f109..0ca290611 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.tsx @@ -43,7 +43,10 @@ function dispatchOverlay(open: boolean) { // Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user // reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the // chunk-graph reasoning. -const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel")); +const StripeCheckoutPanel = React.lazy( + () => + import("@app/components/shared/config/configSections/StripeCheckoutPanel"), +); interface UpgradeModalProps { open: boolean; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx similarity index 100% rename from frontend/editor/src/saas/components/shared/config/configSections/usageMeters.tsx rename to frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx diff --git a/frontend/editor/src/saas/components/usageLimitModals.ts b/frontend/editor/src/cloud/components/usageLimitModals.ts similarity index 100% rename from frontend/editor/src/saas/components/usageLimitModals.ts rename to frontend/editor/src/cloud/components/usageLimitModals.ts diff --git a/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx b/frontend/editor/src/cloud/contexts/SaaSTeamContext.tsx similarity index 93% rename from frontend/editor/src/saas/contexts/SaaSTeamContext.tsx rename to frontend/editor/src/cloud/contexts/SaaSTeamContext.tsx index 18df1ed0d..3e762556b 100644 --- a/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx +++ b/frontend/editor/src/cloud/contexts/SaaSTeamContext.tsx @@ -7,12 +7,16 @@ import { useCallback, } from "react"; import apiClient from "@app/services/apiClient"; -import { useAuth } from "@app/auth/UseSession"; -import { isUserAnonymous } from "@app/auth/supabase"; +import { useTeamAuth } from "@app/auth/teamSession"; /** - * SaaS web implementation of SaaS Team Context - * Provides team management for authenticated (non-anonymous) users + * Shared (cloud) SaaS Team Context. + * + * Provides team management for authenticated (non-anonymous) users. The + * platform-specific auth bits — whether teams may be used at all, and how to + * refresh derived auth state after a membership change — come from the + * {@code @app/auth/teamSession} seam (Supabase web session on saas, authService + * on desktop), keeping this context free of any platform auth coupling. */ interface Team { @@ -92,8 +96,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) { >([]); const [loading, setLoading] = useState(true); - const { user, refreshCredits, refreshSession } = useAuth(); - const canUseTeams = !!user && !isUserAnonymous(user); + const { canUseTeams, refreshAfterMembershipChange } = useTeamAuth(); const fetchMyTeams = useCallback(async () => { if (!canUseTeams) return null; @@ -223,8 +226,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) { await apiClient.post(`/api/v1/team/invitations/${token}/accept`); await fetchReceivedInvitations(); await refreshTeams(); - await refreshCredits(); - await refreshSession(); + await refreshAfterMembershipChange(); }; const rejectInvitation = async (token: string) => { @@ -255,8 +257,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) { await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`); await refreshTeams(); - await refreshCredits(); - await refreshSession(); + await refreshAfterMembershipChange(); }; const isTeamLeader = currentTeam?.isLeader ?? false; diff --git a/frontend/editor/src/saas/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts similarity index 71% rename from frontend/editor/src/saas/hooks/useWallet.ts rename to frontend/editor/src/cloud/hooks/useWallet.ts index 5daf04242..0222cc77c 100644 --- a/frontend/editor/src/saas/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -37,15 +37,19 @@ * When the hook is rendered outside the saas app (e.g. on {@code * /dev/payg-preview} during local design work) the {@code AppConfigContext} * provider is not mounted and no backend is available. The hook detects that - * via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to - * a synthesised snapshot whose subscription state is read from - * {@code localStorage} (key {@code stirling.payg.devSubscription}). Both - * conditions are required so a production tenant whose URL happens to start - * with {@code /dev/} can't trigger the fallback. + * via the {@code @app/hooks/walletDevPreview} seam and, when it returns a live + * channel, falls back to a synthesised snapshot whose subscription state is + * read from {@code localStorage}. The detection + synthesis (which read + * {@code import.meta.env}, {@code window.location} and web storage — all banned + * in cloud/) live in the saas leaf's impl of that seam; this hook just consults + * it. Desktop's cascade falls through to the cloud default (no dev preview), so + * it always fetches the real wallet. */ import { useCallback, useEffect, useRef, useState } from "react"; import apiClient from "@app/services/apiClient"; -import { supabase } from "@app/auth/supabase"; +import { createPortalSession } from "@app/services/billing"; +import { openExternal } from "@app/platform/openExternal"; +import { getWalletDevPreview } from "@app/hooks/walletDevPreview"; // ─── Public types ─────────────────────────────────────────────────────── @@ -185,24 +189,20 @@ export interface UseWalletResult { */ updateCap: (capUsd: number | null) => Promise; /** - * Mint a Stripe Customer Portal session and navigate to it. Calls - * the {@code create-customer-portal-session} Supabase edge function - * directly with the user's JWT (same pattern as checkout — no backend - * proxy; the function's RPC enforces team membership) and assigns - * {@code window.location} to the returned URL — same-tab redirect. - * We do not use {@code window.open(...,"_blank")} after an {@code await} - * because browsers treat it as non-user-gesture and silently popup-block - * it; the portal is a full-page experience anyway and Stripe redirects - * back to {@code return_url} on close. Throws on error so the caller can - * show a friendly toast — notably 404 {@code team_not_subscribed}. + * Mint a Stripe Customer Portal session and send the user to it. Mints the + * session via the {@code @app/services/billing} seam (passing the caller's + * {@code teamId}, which the PAYG portal edge function needs to resolve the + * team outside Spring Security) and opens the returned URL via the + * {@code @app/platform/openExternal} seam — so web and desktop each route it + * the platform-appropriate way (new tab on web, system browser on desktop). + * Throws on error so the caller can show a friendly toast — notably 404 + * {@code team_not_subscribed}. */ openPortal: () => Promise; } // ─── Implementation ───────────────────────────────────────────────────── -const STORAGE_KEY = "stirling.payg.devSubscription"; - /** * Stable reference reuse — if the new payload deep-equals the previous one, * return the previous object so React's reference check short-circuits child @@ -216,6 +216,7 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { if (!prev) return next; if ( prev.status !== next.status || + prev.teamId !== next.teamId || prev.role !== next.role || prev.billingPeriodStart !== next.billingPeriodStart || prev.billingPeriodEnd !== next.billingPeriodEnd || @@ -263,87 +264,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { return prev; } -/** - * Synthesise a wallet snapshot for the dev preview route. Mirrors the same - * shape the backend returns. Subscription state comes from localStorage so - * the modal's "mark subscribed" action survives a reload. - */ -function buildDevPreviewWallet(role: WalletRole): Wallet { - const subscribed = - typeof window !== "undefined" && - (() => { - try { - return window.localStorage.getItem(STORAGE_KEY) === "subscribed"; - } catch { - return false; - } - })(); - - const now = new Date(); - const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); - const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); - const isoDay = (d: Date) => d.toISOString().slice(0, 10); - - return { - teamId: null, - status: subscribed ? "subscribed" : "free", - role, - billingPeriodStart: isoDay(periodStart), - billingPeriodEnd: isoDay(periodEnd), - billableUsed: 62, - billableLimit: subscribed ? 1250 : 500, - freeAllowance: 500, - // One-time grant: a free team has used 62 of 500 (438 left); the dev - // subscribed team is shown with its grant fully spent (kept across the - // subscribe — it just no longer gates them). - freeRemaining: subscribed ? 0 : 438, - // Free teams also carry a rate now — the backend resolves it from the - // default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid - // PDFs/month") can render before subscribing. Mirror that here. - pricePerDocMinor: 2, - currency: "usd", - estimatedBillMinor: subscribed ? 0 : null, - capUsd: subscribed ? 25 : null, - noCap: false, - stripeSubscriptionId: subscribed ? "sub_devpreview" : null, - spendUnitsThisPeriod: 62, - // Wave 1 backend (PR #6574) returns a per-category breakdown so the - // hero panel can split AI / automation / API. Use realistic but - // tier-distinguishable mock values so the dev preview shows a - // different visual when the localStorage flip toggles subscribed. - categoryBreakdown: subscribed - ? { api: 12, ai: 35, automation: 15 } - : { api: 5, ai: 40, automation: 17 }, - // Members are populated in the leader view by the real backend - // (joining team_memberships); the dev preview returns an empty - // array — Plan.tsx + PaygLeader still resolve role via wallet.role, - // so empty members just hides the sub-caps card. - members: [], - // Activity feed is V1 = [], the backend ships this in Wave 2 once - // payg_meter_event_log is read-accessible from the wallet endpoint. - recent: [], - }; -} - -/** True when we're rendered outside the real saas app (e.g. dev preview route). */ -function isDevPreviewContext(): boolean { - // Both checks required: production builds drop the path check, so a real - // tenant whose URL begins with /dev/ can't accidentally hit the synthesised - // fallback. - if (!import.meta.env.DEV) return false; - if (typeof window === "undefined") return false; - return window.location.pathname.startsWith("/dev/"); -} - -/** Best-effort role read for dev preview — flips per query string ?role=member. */ -function devPreviewRole(): WalletRole { - if (typeof window === "undefined") return "leader"; - const url = new URL(window.location.href); - return url.searchParams.get("role") === "member" ? "member" : "leader"; -} - export function useWallet(): UseWalletResult { - const devPreview = useRef(isDevPreviewContext()).current; + // Resolved once: the dev-preview side-channel when rendered outside the real + // app (saas /dev/payg-preview route), else null (every real build + desktop). + // The detection + synthesis live behind the @app/hooks/walletDevPreview seam + // because they read import.meta.env / window.location / localStorage, which + // cloud/ may not touch directly. + const devPreview = useRef(getWalletDevPreview()).current; const [wallet, setWallet] = useState(null); const [loading, setLoading] = useState(true); @@ -369,7 +296,7 @@ export function useWallet(): UseWalletResult { setError(null); if (devPreview) { - const synth = buildDevPreviewWallet(devPreviewRole()); + const synth = devPreview.buildWallet(devPreview.role()); if (cancelled || reqId !== latestReqId.current) return; setWallet((prev) => reuseIfEqual(prev, synth)); setLoading(false); @@ -417,11 +344,7 @@ export function useWallet(): UseWalletResult { const markSubscribed = useCallback( async (capUsd: number | null) => { if (devPreview) { - try { - window.localStorage.setItem(STORAGE_KEY, "subscribed"); - } catch { - /* storage unavailable */ - } + devPreview.markSubscribed(); await refetch(); return; } @@ -479,39 +402,22 @@ export function useWallet(): UseWalletResult { const openPortal = useCallback(async () => { if (devPreview) { - // No real Stripe in dev preview — navigate to a placeholder so the - // click still feels alive. Same-tab to match the real-flow behaviour. - window.location.assign("https://billing.stripe.com/p/login/mock"); + // No real Stripe in dev preview — open a placeholder so the click still + // feels alive. Routed through the openExternal seam to stay portable. + await openExternal("https://billing.stripe.com/p/login/mock"); return; } - // Direct edge-fn invocation with the user's JWT — same pattern as - // create-checkout-session. The payg_get_checkout_context RPC inside the - // fn enforces team membership, so no backend proxy is needed; team_id - // must be a NUMBER (the fn type-checks and rejects strings). + // Mint the portal session through the billing seam, passing teamId: the + // PAYG portal edge function needs it to resolve the caller's team outside + // Spring Security (its RPC enforces team membership). Then hand the URL to + // the openExternal seam so each platform routes it appropriately. The seam + // throws on error (e.g. 404 team_not_subscribed) so callers can toast. const teamId = wallet?.teamId; if (teamId == null) { throw new Error("No team resolved yet"); } - const { data, error: invokeError } = await supabase.functions.invoke<{ - url?: string; - error?: string; - }>("create-customer-portal-session", { - body: { team_id: teamId, return_url: window.location.href }, - }); - if (invokeError) { - // FunctionsHttpError etc. — the StripePortalLink caller catches and - // shows a friendly toast (404 team_not_subscribed, 403, outage). - throw invokeError; - } - if (!data?.url) { - throw new Error(data?.error ?? "Portal session response missing url"); - } - // Same-tab navigation rather than window.open(...,"_blank"): Stripe's - // customer portal is a full-page experience and brings the user back - // via return_url, so a popup buys us nothing — and window.open after - // an awaited promise is treated as non-user-gesture and silently - // popup-blocked by most browsers. - window.location.assign(data.url); + const { url } = await createPortalSession({ teamId }); + await openExternal(url); }, [devPreview, wallet?.teamId]); return { diff --git a/frontend/editor/src/cloud/hooks/walletDevPreview.ts b/frontend/editor/src/cloud/hooks/walletDevPreview.ts new file mode 100644 index 000000000..7a1477cb4 --- /dev/null +++ b/frontend/editor/src/cloud/hooks/walletDevPreview.ts @@ -0,0 +1,46 @@ +/** + * Wallet dev-preview seam (@app/hooks/walletDevPreview). + * + * The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas + * (web) and desktop (Tauri) leaves, so it must stay platform-portable: it can't + * read {@code import.meta.env}, {@code window.location} or web storage directly + * (the cloud ESLint guardrail enforces this). The PAYG dev-preview route + * ({@code /dev/payg-preview}) is a saas-only local-design affordance that + * synthesises a wallet from {@code localStorage} when the real backend isn't + * mounted — all three of those banned reads. {@link useWallet} reaches that + * affordance through this seam instead. + * + * This module is the DEFAULT + the shared TypeScript contract: it reports + * "not a dev preview" so the real backend fetch always runs. The saas leaf + * shadows it with saas/hooks/walletDevPreview.ts, which supplies the synthesis; + * desktop has no dev-preview route, so the cascade falls through to this + * default. Returning {@code null} from {@link getWalletDevPreview} is the + * canonical "no dev preview active" signal. + */ +import type { Wallet, WalletRole } from "@app/hooks/useWallet"; + +/** + * The dev-preview side-channel {@link useWallet} consumes when rendered outside + * the real app (the saas {@code /dev/payg-preview} route). When active it stands + * in for the backend: {@link buildWallet} synthesises the snapshot and + * {@link markSubscribed} flips the simulated subscription state. {@code null} + * (the cloud default + every desktop build) means "no dev preview — fetch the + * real wallet". + */ +export interface WalletDevPreview { + /** Synthesise the dev-preview wallet snapshot (subscription state from storage). */ + buildWallet: (role: WalletRole) => Wallet; + /** Best-effort role read for the preview — flips per {@code ?role=member}. */ + role: () => WalletRole; + /** Flip the simulated subscription state to subscribed (persisted across reload). */ + markSubscribed: () => void; +} + +/** + * Resolve the active dev-preview side-channel, or {@code null} when we're in a + * real build / on a real route. The cloud default + desktop always return + * {@code null}; the saas leaf returns a live channel only on the dev route. + */ +export function getWalletDevPreview(): WalletDevPreview | null { + return null; +} diff --git a/frontend/editor/src/cloud/platform/openExternal.ts b/frontend/editor/src/cloud/platform/openExternal.ts new file mode 100644 index 000000000..d4c06a37d --- /dev/null +++ b/frontend/editor/src/cloud/platform/openExternal.ts @@ -0,0 +1,28 @@ +/** + * Open-external-URL seam (@app/platform/openExternal). + * + * The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas + * (web) and desktop (Tauri) leaves. Opening a URL in the user's real browser + * differs per platform — saas hands it to the browser via window.open / + * location.assign, desktop hands it to the OS via the Tauri shell plugin so it + * escapes the embedded webview. Cloud code must not reach either of those + * directly, so it opens external URLs through this seam. + * + * This module is the DEFAULT + the shared TypeScript contract. Real builds + * shadow it with saas/platform/openExternal.ts and desktop/platform/ + * openExternal.ts; this default body is only reached by the cloud-standalone + * typecheck, so it throws to make an accidental real-build resolution loud. + */ + +/** Opens an external URL in the user's system browser. */ +export type OpenExternal = (url: string) => Promise; + +/** + * Opens an external URL in the user's system browser. Each platform supplies + * its own implementation; this default is never reached in a real build. + */ +export const openExternal: OpenExternal = async ( + _url: string, +): Promise => { + throw new Error("openExternal: platform impl required"); +}; diff --git a/frontend/editor/src/cloud/services/billing.ts b/frontend/editor/src/cloud/services/billing.ts new file mode 100644 index 000000000..7f049cd54 --- /dev/null +++ b/frontend/editor/src/cloud/services/billing.ts @@ -0,0 +1,80 @@ +/** + * Billing data seam (@app/services/billing). + * + * Creating Stripe Checkout / Customer Portal sessions touches platform-specific + * transport (saas: supabase-js web client; desktop: Tauri native HTTP with an + * explicit bearer + deep-link return URL). Cloud code can't reach those + * directly, so it mints sessions through this seam. This module is the DEFAULT + + * shared contract; saas/services/billing.ts and desktop/services/billing.ts + * shadow it. The default bodies throw so an accidental real-build resolution is + * loud (only the cloud-standalone typecheck reaches them). + */ + +/** + * Parameters for {@link createCheckoutSession}, which drives the PAYG + * {@code create-checkout-session} edge function (see StripeCheckoutPanel). The + * function runs outside Spring Security, so it needs the caller's {@link teamId} + * (it can't resolve the team from the JWT alone). The platform impl supplies the + * return URL itself (browser origin on web, deep-link scheme on desktop), so it + * is intentionally NOT part of this shape. + */ +export interface CheckoutParams { + /** The caller's team id. Required — scopes the PAYG subscription. */ + teamId: number; + /** Lower-case 3-letter ISO currency (e.g. {@code "gbp"}). Selects the Stripe Price. */ + currency?: string; + /** Billing email for the Checkout Session; maps to Stripe {@code customer_email} when the team has no customer yet. */ + billingOwnerEmail?: string | null; +} + +/** + * Result of {@link createCheckoutSession}. Embedded checkout yields a + * {@code clientSecret}; hosted checkout yields a {@code url}. Exactly one is set. + */ +export interface CheckoutSession { + /** Stripe Checkout Session client secret for embedded mode. */ + clientSecret?: string; + /** Hosted Stripe Checkout URL for redirect mode. */ + url?: string; + /** Non-prod sentinel: a stubbed secret (prefixed {@code cs_mock_}) renders a placeholder instead of a real iframe. */ + mock?: boolean; +} + +/** Result of {@link createPortalSession}. */ +export interface PortalSession { + /** Stripe Customer Portal URL to send the user to. */ + url: string; +} + +/** + * Parameters for {@link createPortalSession}. The PAYG portal edge function + * needs the caller's {@code teamId} (runs outside Spring Security). + */ +export interface PortalParams { + /** The caller's team id; required by the PAYG portal edge function. */ + teamId: number; +} + +/** Create a Stripe Checkout Session via the SaaS billing backend (platform impl required). */ +export async function createCheckoutSession( + _params: CheckoutParams, +): Promise { + throw new Error("billing: platform impl required"); +} + +/** Mint a Stripe Customer Portal session via the SaaS billing backend (platform impl required). */ +export async function createPortalSession( + _params: PortalParams, +): Promise { + throw new Error("billing: platform impl required"); +} + +/** + * The Stripe publishable key used to initialise {@code loadStripe()} for + * embedded checkout. Sourced through the seam because cloud code may not read + * {@code import.meta.env} directly. The cloud default returns "" so the checkout + * component falls back to its mock placeholder rather than throwing. + */ +export function getStripePublishableKey(): string { + return ""; +} diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.ts b/frontend/editor/src/cloud/services/paygErrorInterceptor.ts similarity index 100% rename from frontend/editor/src/saas/services/paygErrorInterceptor.ts rename to frontend/editor/src/cloud/services/paygErrorInterceptor.ts diff --git a/frontend/editor/src/cloud/tsconfig.json b/frontend/editor/src/cloud/tsconfig.json new file mode 100644 index 000000000..bddc25d1b --- /dev/null +++ b/frontend/editor/src/cloud/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../../", + "paths": { + "@app/*": ["src/cloud/*", "src/proprietary/*", "src/core/*"], + "@cloud/*": ["src/cloud/*"], + "@proprietary/*": ["src/proprietary/*"], + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] + } + }, + "include": [ + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + "../core/setupTests.ts", + "." + ] +} diff --git a/frontend/editor/src/core/components/shared/config/configSections/SaaSTeamsSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/SaaSTeamsSection.tsx deleted file mode 100644 index e27cfffe3..000000000 --- a/frontend/editor/src/core/components/shared/config/configSections/SaaSTeamsSection.tsx +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Core stub for SaaS Teams Section - * Desktop layer provides the real implementation - * This component only appears in SaaS mode - */ -export function SaaSTeamsSection() { - // Core stub - return null (no team management in web builds) - return null; -} diff --git a/frontend/editor/src/core/components/shared/config/configSections/SaasPlanSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/SaasPlanSection.tsx deleted file mode 100644 index a11c93f02..000000000 --- a/frontend/editor/src/core/components/shared/config/configSections/SaasPlanSection.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Core stub for SaasPlanSection - * This component returns null in non-desktop builds - * The desktop layer shadows this with the real implementation - */ -export function SaasPlanSection() { - return null; -} diff --git a/frontend/editor/src/core/components/shared/modals/CreditExhaustedModal.tsx b/frontend/editor/src/core/components/shared/modals/CreditExhaustedModal.tsx deleted file mode 100644 index 5c3953d5c..000000000 --- a/frontend/editor/src/core/components/shared/modals/CreditExhaustedModal.tsx +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Core stub for Credit Exhausted Modal - * Desktop build overrides this with actual modal implementation - */ - -interface CreditExhaustedModalProps { - opened: boolean; - onClose: () => void; -} - -export function CreditExhaustedModal(_props: CreditExhaustedModalProps) { - return null; -} diff --git a/frontend/editor/src/core/components/shared/modals/InsufficientCreditsModal.tsx b/frontend/editor/src/core/components/shared/modals/InsufficientCreditsModal.tsx deleted file mode 100644 index 5666001a4..000000000 --- a/frontend/editor/src/core/components/shared/modals/InsufficientCreditsModal.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Core stub for Insufficient Credits Modal - * Desktop build overrides this with actual modal implementation - */ - -interface InsufficientCreditsModalProps { - opened: boolean; - onClose: () => void; - toolId?: string; - requiredCredits?: number; -} - -export function InsufficientCreditsModal( - _props: InsufficientCreditsModalProps, -) { - return null; -} diff --git a/frontend/editor/src/core/contexts/SaasBillingContext.tsx b/frontend/editor/src/core/contexts/SaasBillingContext.tsx deleted file mode 100644 index 37fc956af..000000000 --- a/frontend/editor/src/core/contexts/SaasBillingContext.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Core stub for SaasBillingContext. - * Returns null in web/core builds — desktop layer shadows this with the real implementation. - * See: frontend/src/desktop/contexts/SaasBillingContext.tsx - */ -export const useSaaSBilling = (): null => null; - -export function SaasBillingProvider({ - children, -}: { - children: React.ReactNode; -}) { - return <>{children}; -} diff --git a/frontend/editor/src/core/hooks/useCreditCheck.ts b/frontend/editor/src/core/hooks/useCreditCheck.ts index ba96fab47..6863fb697 100644 --- a/frontend/editor/src/core/hooks/useCreditCheck.ts +++ b/frontend/editor/src/core/hooks/useCreditCheck.ts @@ -1,7 +1,8 @@ /** - * Core stub for credit checking before cloud operations - * Desktop layer shadows this with the real implementation - * In web builds, always allows the operation (no credit system) + * No-op stub kept only to satisfy useToolOperation's @app/hooks/useCreditCheck + * import. There is no pre-flight credit/usage check anymore — PAYG is enforced + * server-side (the BE returns 402 FEATURE_DEGRADED / PAYG_LIMIT_REACHED, which + * paygErrorInterceptor turns into the usage-limit modal). Always allows. */ export function useCreditCheck(_operationType?: string, _endpoint?: string) { return { diff --git a/frontend/editor/src/saas/hooks/useRenderCount.ts b/frontend/editor/src/core/hooks/useRenderCount.ts similarity index 100% rename from frontend/editor/src/saas/hooks/useRenderCount.ts rename to frontend/editor/src/core/hooks/useRenderCount.ts diff --git a/frontend/editor/src/core/styles/zIndex.ts b/frontend/editor/src/core/styles/zIndex.ts index 8d7052397..b77128356 100644 --- a/frontend/editor/src/core/styles/zIndex.ts +++ b/frontend/editor/src/core/styles/zIndex.ts @@ -6,6 +6,12 @@ export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300; export const Z_ANALYTICS_MODAL = 1301; // Config/Settings modal - should appear above analytics modal when navigating from onboarding export const Z_INDEX_CONFIG_MODAL = 1400; +// Modal layered directly over the settings/config modal (e.g. the Stripe +// checkout modal). Consumed by the shared cloud/ checkout component, so it +// lives in the core base both the saas and cloud cascades resolve. +// Must be strictly ABOVE the config modal (1400); 1450 is taken by the cookie +// preferences modal, so sit above the whole settings cluster (below 2000). +export const Z_INDEX_OVER_SETTINGS_MODAL = 1500; export const Z_INDEX_FILE_MANAGER_MODAL = 1200; diff --git a/frontend/editor/src/desktop/auth/session.ts b/frontend/editor/src/desktop/auth/session.ts new file mode 100644 index 000000000..666090c97 --- /dev/null +++ b/frontend/editor/src/desktop/auth/session.ts @@ -0,0 +1,15 @@ +import { authService } from "@app/services/authService"; +import type { AppSession } from "@cloud/auth/session"; + +export type { AppSession }; + +/** + * Desktop (Tauri) implementation of the @app/auth/session seam. + * + * Delegates to authService, which manages the JWT in the Tauri secure store + * (with a localStorage fallback) and validates/caches it. Behaviour of + * authService is unchanged — this only exposes its existing accessor. + */ +export async function getAccessToken(): Promise { + return authService.getAuthToken(); +} diff --git a/frontend/editor/src/desktop/auth/teamSession.test.ts b/frontend/editor/src/desktop/auth/teamSession.test.ts new file mode 100644 index 000000000..a593302a4 --- /dev/null +++ b/frontend/editor/src/desktop/auth/teamSession.test.ts @@ -0,0 +1,79 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ─────────────────────────────────────────────────────────────────── +const { subscribeToAuthMock, getCurrentModeMock, subscribeToModeChangesMock } = + vi.hoisted(() => ({ + subscribeToAuthMock: vi.fn(), + getCurrentModeMock: vi.fn(), + subscribeToModeChangesMock: vi.fn(), + })); + +vi.mock("@app/services/authService", () => ({ + authService: { subscribeToAuth: subscribeToAuthMock }, +})); +vi.mock("@app/services/connectionModeService", () => ({ + connectionModeService: { + getCurrentMode: getCurrentModeMock, + subscribeToModeChanges: subscribeToModeChangesMock, + }, +})); + +import { useTeamAuth } from "@app/auth/teamSession"; + +/** Wire authService.subscribeToAuth to immediately report `status`. */ +function withAuthStatus(status: "authenticated" | "unauthenticated") { + subscribeToAuthMock.mockImplementation( + (listener: (s: string, u: unknown) => void) => { + listener(status, null); + return () => {}; + }, + ); +} + +describe("useTeamAuth (desktop)", () => { + beforeEach(() => { + subscribeToAuthMock.mockReset(); + getCurrentModeMock.mockReset(); + subscribeToModeChangesMock.mockReset(); + subscribeToModeChangesMock.mockReturnValue(() => {}); + }); + afterEach(() => vi.clearAllMocks()); + + it("withholds team access until the SaaS mode is known (pessimistic default)", () => { + withAuthStatus("authenticated"); + // getCurrentMode never resolves within this synchronous render. + getCurrentModeMock.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useTeamAuth()); + expect(result.current.canUseTeams).toBe(false); + }); + + it("grants team access only when authenticated AND in SaaS mode", async () => { + withAuthStatus("authenticated"); + getCurrentModeMock.mockResolvedValue("saas"); + + const { result } = renderHook(() => useTeamAuth()); + await waitFor(() => expect(result.current.canUseTeams).toBe(true)); + }); + + it("denies team access in self-hosted mode even when authenticated", async () => { + withAuthStatus("authenticated"); + getCurrentModeMock.mockResolvedValue("selfhosted"); + + const { result } = renderHook(() => useTeamAuth()); + // Flush the resolved mode promise (+ its state update) inside act, then + // assert canUseTeams stayed false. + await act(async () => {}); + expect(result.current.canUseTeams).toBe(false); + }); + + it("denies team access when in SaaS mode but unauthenticated", async () => { + withAuthStatus("unauthenticated"); + getCurrentModeMock.mockResolvedValue("saas"); + + const { result } = renderHook(() => useTeamAuth()); + await act(async () => {}); + expect(result.current.canUseTeams).toBe(false); + }); +}); diff --git a/frontend/editor/src/desktop/auth/teamSession.ts b/frontend/editor/src/desktop/auth/teamSession.ts new file mode 100644 index 000000000..7311cb0b4 --- /dev/null +++ b/frontend/editor/src/desktop/auth/teamSession.ts @@ -0,0 +1,48 @@ +/** + * Desktop (Tauri) implementation of the @app/auth/teamSession seam. + * + * Wires the cloud {@link SaaSTeamContext} to the desktop auth surface. Desktop + * keeps its JWT in the Tauri secure store via authService, so team access is + * gated on a live "authenticated" status from authService rather than a + * Supabase session. + * + * Teams are a cloud-only surface: the endpoints (/api/v1/team/**) live on the + * SaaS backend, not the local bundled backend or a self-hosted server. The + * SaaSTeamProvider is mounted unconditionally in AppProviders, so canUseTeams + * is the ONLY thing stopping its mount effect from fetching teams. It must + * therefore also require SaaS connection mode — otherwise an authenticated user + * in self-hosted or local ("disconnected") mode triggers /api/v1/team/my + + * /api/v1/team/invitations/pending against a backend that 404s them. + * + * The SaaS-mode flag starts pessimistically false (NOT useSaaSMode()'s + * optimistic true): authService and connectionModeService resolve + * independently, and an optimistic default would let one team fetch slip + * through on cold start before the mode is known. Desktop has no credit/session + * refreshers, so the post-membership refresh is a no-op. + */ +import { useEffect, useState } from "react"; +import { authService } from "@app/services/authService"; +import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode"; +import type { TeamAuth } from "@cloud/auth/teamSession"; + +export type { TeamAuth } from "@cloud/auth/teamSession"; + +export function useTeamAuth(): TeamAuth { + const [isAuthenticated, setIsAuthenticated] = useState(false); + // Pessimistic SaaS-mode check (starts false) so no team fetch slips through on + // cold start before the mode resolves. Shared with the Policies gate. + const isSaaSMode = useConfirmedSaaSMode(); + + useEffect(() => { + // subscribeToAuth immediately notifies the listener of the current state, + // so no separate initial fetch is needed. + return authService.subscribeToAuth((status) => { + setIsAuthenticated(status === "authenticated"); + }); + }, []); + + return { + canUseTeams: isAuthenticated && isSaaSMode, + refreshAfterMembershipChange: async () => {}, + }; +} diff --git a/frontend/editor/src/desktop/components/AppProviders.tsx b/frontend/editor/src/desktop/components/AppProviders.tsx index c60b67a4c..69c258b4f 100644 --- a/frontend/editor/src/desktop/components/AppProviders.tsx +++ b/frontend/editor/src/desktop/components/AppProviders.tsx @@ -4,6 +4,8 @@ import { DesktopConfigSync } from "@app/components/DesktopConfigSync"; import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer"; import { SaveShortcutListener } from "@app/components/SaveShortcutListener"; import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal"; +import { DesktopSaasOnboardingBootstrap } from "@app/components/DesktopSaasOnboardingBootstrap"; +import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; import { SignInModal } from "@app/components/SignInModal"; import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents"; import { ToolActionsContext } from "@app/contexts/ToolActionsContext"; @@ -22,9 +24,6 @@ import { endpointAvailabilityService } from "@app/services/endpointAvailabilityS import { getCurrentWindow } from "@tauri-apps/api/window"; import { isTauri } from "@tauri-apps/api/core"; import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext"; -import { SaasBillingProvider } from "@app/contexts/SaasBillingContext"; -import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext"; -import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap"; import UpdateModal from "@core/components/shared/UpdateModal"; import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup"; @@ -338,21 +337,24 @@ export function AppProviders({ children }: { children: ReactNode }) { }} > - - - - - - - {children} - {/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */} - - {/* Global sign-in modal, opened via stirling:open-sign-in event */} - - {/* Desktop auto-update popup */} - {updatePopupModal} - - + + + + {children} + {/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */} + + {/* SaaS product onboarding (cloud flow, minus the desktop-download slide), + shown once after a SaaS sign-in. Mirrors saas's OnboardingBootstrap. */} + + {/* Always-mounted host for the PAYG usage-limit modals (free-limit / + spend-cap). Resolves to the cloud implementation via @app; listens + for both the imperative open events (direct-call 402s) and the + usageLimitBridge event (server-side policy/AI run 402s). */} + + {/* Global sign-in modal, opened via stirling:open-sign-in event */} + + {/* Desktop auto-update popup */} + {updatePopupModal} diff --git a/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx new file mode 100644 index 000000000..eef035bbd --- /dev/null +++ b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "@app/auth/UseSession"; +import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; + +/** + * Desktop bootstrap for the SaaS product onboarding. + * + * Mirrors saas's {@code OnboardingBootstrap}: once a SaaS user has signed in we + * show the shared cloud {@link SaasOnboardingModal} (free-editor pitch → usage + * meter → team), reusing the exact same flow as the web app. The closing + * "download desktop" slide is dropped via {@code hideDesktopInstall} — this IS + * the desktop app, so pitching its own download makes no sense. + * + * Differences from the saas bootstrap: + * - The desktop {@code useAuth} (proprietary) exposes no pro/wallet fields; the + * cloud flow reads the live wallet itself to decide which slides to show, so + * we just wait for a non-anonymous signed-in user. + * - Gated on {@code connectionMode === "saas"} so it never fires in local or + * self-hosted mode (where there is no SaaS wallet/team to onboard). + * + * Shown once per device, gated by localStorage (same key the saas web flow uses + * so a user who onboarded on the web isn't re-onboarded — they are independent + * stores, but the key/intent is shared). + */ +const STORAGE_KEY = "saas_onboarding_seen"; + +interface DesktopSaasOnboardingBootstrapProps { + connectionMode: "saas" | "selfhosted" | "local" | null; +} + +export function DesktopSaasOnboardingBootstrap({ + connectionMode, +}: DesktopSaasOnboardingBootstrapProps) { + const { user, loading } = useAuth(); + const [showModal, setShowModal] = useState(false); + + const isSignedInSaasUser = + connectionMode === "saas" && + !loading && + !!user && + user.is_anonymous !== true; + + useEffect(() => { + if (!isSignedInSaasUser) return; + let seen = false; + try { + seen = localStorage.getItem(STORAGE_KEY) === "true"; + } catch { + // localStorage unavailable — fail open and show onboarding. + } + if (!seen) { + setShowModal(true); + } + }, [isSignedInSaasUser]); + + const handleClose = () => { + try { + localStorage.setItem(STORAGE_KEY, "true"); + } catch { + // localStorage unavailable — best-effort; the in-memory flag still hides it. + } + setShowModal(false); + }; + + if (!showModal) return null; + + return ( + + ); +} diff --git a/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx new file mode 100644 index 000000000..c757b0d26 --- /dev/null +++ b/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx @@ -0,0 +1,25 @@ +/** + * Desktop shadow of the proprietary Policies right-rail. + * + * Re-exports the proprietary module unchanged EXCEPT `usePoliciesEnabled`, which + * additionally requires an active SaaS connection. Policy runs execute + bill via + * the cloud (POST /api/v1/policies/.../run hits the SaaS backend), so on desktop + * the feature must be hidden in local ("disconnected") and self-hosted modes — + * otherwise the panel + auto-run controller would fire policy runs against a + * backend that doesn't serve them. On web the build flavor already gates it. + * + * `usePoliciesEnabled` is the single gate RightSidebar uses for BOTH the rail + * section and mounting PolicyAutoRunController, so this one override covers both. + */ +import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode"; + +export * from "@proprietary/components/policies/PoliciesSidebar"; + +export function usePoliciesEnabled(): boolean { + // Pessimistic SaaS-mode check (starts false): this gate also controls whether + // PolicyAutoRunController mounts, and that fires GET /api/v1/policies on mount. + // useSaaSMode()'s optimistic-true default would leak that request against the + // local/self-hosted backend on cold start before the mode resolves. + return POLICIES_ENABLED && useConfirmedSaaSMode(); +} diff --git a/frontend/editor/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx b/frontend/editor/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx deleted file mode 100644 index ab597f038..000000000 --- a/frontend/editor/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState } from "react"; -import { Box, Text, Stack } from "@mantine/core"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; -import { BILLING_CONFIG } from "@app/config/billing"; -import { connectionModeService } from "@app/services/connectionModeService"; -import { authService } from "@app/services/authService"; -import { CREDIT_EVENTS } from "@app/constants/creditEvents"; - -/** - * Desktop credit counter displayed in QuickAccessBar footer - * Shows when user is in SaaS mode with low credits (<20) - */ - -interface QuickAccessBarFooterExtensionsProps { - className?: string; -} - -export function QuickAccessBarFooterExtensions({ - className, -}: QuickAccessBarFooterExtensionsProps) { - const { creditBalance, loading, isManagedTeamMember } = useSaaSBilling(); - const [isSaasMode, setIsSaasMode] = useState(false); - const [isAuthenticated, setIsAuthenticated] = useState(false); - - // Check connection mode and authentication status - useEffect(() => { - const checkMode = async () => { - const mode = await connectionModeService.getCurrentMode(); - const auth = await authService.isAuthenticated(); - setIsSaasMode(mode === "saas"); - setIsAuthenticated(auth); - }; - - checkMode(); - - // Subscribe to mode changes - const unsubscribe = connectionModeService.subscribeToModeChanges(checkMode); - return unsubscribe; - }, []); - - // Subscribe to auth changes - useEffect(() => { - const unsubscribe = authService.subscribeToAuth((status) => { - setIsAuthenticated(status === "authenticated"); - }); - return unsubscribe; - }, []); - - // Don't show credit counter if: - // - Not in SaaS mode - // - Not authenticated - // - Still loading billing data - // - User is a managed team member (unlimited credits) - // - Credits >= 20 (only show when low) - if ( - !isSaasMode || - !isAuthenticated || - loading || - isManagedTeamMember || - creditBalance >= BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD - ) { - return null; - } - - const handleClick = () => { - // Dispatch low credits event to open upgrade modal - window.dispatchEvent( - new CustomEvent(CREDIT_EVENTS.EXHAUSTED, { - detail: { source: "quickAccessBar" }, - }), - ); - }; - - return ( - - - - {creditBalance} {creditBalance === 1 ? "credit" : "credits"} - - - Upgrade - - - - ); -} diff --git a/frontend/editor/src/desktop/components/shared/CloudBadge.tsx b/frontend/editor/src/desktop/components/shared/CloudBadge.tsx index 1f5717eb1..e0d13d293 100644 --- a/frontend/editor/src/desktop/components/shared/CloudBadge.tsx +++ b/frontend/editor/src/desktop/components/shared/CloudBadge.tsx @@ -18,7 +18,7 @@ export function CloudBadge({ className }: CloudBadgeProps) { (null); - - // Load connection mode on mount - useEffect(() => { - connectionModeService - .getCurrentMode() - .then((mode) => setConnectionMode(mode)); - }, []); - - // Accept invitation handler - const handleAccept = async () => { - const invitation = receivedInvitations[0]; - if (!invitation) return; - - setProcessing(true); - - try { - await acceptInvitation(invitation.invitationToken); - console.log( - "[TeamInvitationBanner] Invitation accepted successfully:", - invitation.teamName, - ); - - // Wait briefly for backend to process team membership update - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Refresh billing after joining team (tier may have changed) - console.log( - "[TeamInvitationBanner] Refreshing billing after team join...", - ); - await refreshBilling(); - - setDismissed(true); - } catch (error) { - console.error( - "[TeamInvitationBanner] Failed to accept invitation:", - error, - ); - } finally { - setProcessing(false); - } - }; - - // Reject invitation handler - const handleReject = async () => { - const invitation = receivedInvitations[0]; - if (!invitation) return; - - setProcessing(true); - - try { - await rejectInvitation(invitation.invitationToken); - console.log("[TeamInvitationBanner] Invitation rejected"); - setDismissed(true); - } catch (error) { - console.error( - "[TeamInvitationBanner] Failed to reject invitation:", - error, - ); - } finally { - setProcessing(false); - } - }; - - // Visibility logic - const shouldShow = - connectionMode === "saas" && !dismissed && receivedInvitations.length > 0; - - if (!shouldShow) return null; - - const invitation = receivedInvitations[0]; // Show first invitation - - const message = ( - - {invitation.inviterEmail}{" "} - {t("team.invitationBanner.message", "has invited you to join")}{" "} - {invitation.teamName} - - ); - - const actionButtons = ( - - - - - ); - - return ( - - {message} - {actionButtons} - - } - show={shouldShow} - dismissible={false} - background="var(--mantine-color-dark-7)" - borderColor="var(--mantine-color-dark-5)" - textColor="rgba(255, 255, 255, 0.95)" - iconColor="rgba(255, 255, 255, 0.95)" - /> - ); -} diff --git a/frontend/editor/src/desktop/components/shared/billing/SaaSStripeCheckout.tsx b/frontend/editor/src/desktop/components/shared/billing/SaaSStripeCheckout.tsx deleted file mode 100644 index cf5918564..000000000 --- a/frontend/editor/src/desktop/components/shared/billing/SaaSStripeCheckout.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { saasBillingService } from "@app/services/saasBillingService"; -import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import OpenInBrowserIcon from "@mui/icons-material/OpenInBrowser"; - -type CheckoutState = { - status: "idle" | "loading" | "opened" | "refreshing" | "error"; - error?: string; - sessionPlanId?: string; -}; - -interface SaaSStripeCheckoutProps { - opened: boolean; - onClose: () => void; - planId: string | null; - onSuccess?: () => void; -} - -export const SaaSStripeCheckout: React.FC = ({ - opened, - onClose, - planId, - onSuccess, -}) => { - const { t } = useTranslation(); - const [state, setState] = useState({ status: "idle" }); - - const createCheckoutSession = async () => { - if (!planId) { - setState({ status: "error", error: "No plan selected" }); - return; - } - - try { - setState({ status: "loading" }); - - // Map UI plan IDs to Stripe plan IDs - const stripePlanId = planId === "team" ? "pro" : planId; - - // Open checkout in browser (returns void, opens browser window) - await saasBillingService.openCheckout( - stripePlanId as "pro", - window.location.origin, - ); - - setState({ - status: "opened", - sessionPlanId: planId, - }); - } catch (err) { - const errorMessage = - err instanceof Error - ? err.message - : "Failed to create checkout session"; - console.error("[SaaSStripeCheckout] Error creating checkout:", err); - setState({ - status: "error", - error: errorMessage, - }); - } - }; - - const handleRefreshClick = async () => { - console.log("[SaaSStripeCheckout] User requested refresh after checkout"); - setState({ ...state, status: "refreshing" }); - - // Give Stripe webhooks a moment to process (2-3 seconds) - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Trigger the refresh - if (onSuccess) { - await onSuccess(); - } - - // Close modal after refresh - onClose(); - }; - - const handleClose = () => { - // Reset state to idle to clean up the session - setState({ status: "idle", error: undefined, sessionPlanId: undefined }); - onClose(); - }; - - // Initialize checkout when modal opens or plan changes - useEffect(() => { - if (opened) { - // Check if we need a new session (first time or plan changed) - const needsNewSession = - state.status === "idle" || - !state.sessionPlanId || - state.sessionPlanId !== planId; - - if (needsNewSession) { - console.log( - "[SaaSStripeCheckout] Opening checkout in browser for plan:", - planId, - ); - createCheckoutSession(); - } - } else if (!opened) { - // Clean up state when modal closes - setState({ status: "idle", error: undefined, sessionPlanId: undefined }); - } - }, [opened, planId]); - - const renderContent = () => { - switch (state.status) { - case "loading": - return ( -
- - - {t("payment.preparing", "Preparing your checkout...")} - -
- ); - - case "opened": - return ( - } - > - - - {t( - "payment.checkoutInstructions", - "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information.", - )} - - - - - - ); - - case "error": - return ( - - - {state.error} - - - - ); - - default: - return null; - } - }; - - const getPlanName = () => { - if (planId === "team") return t("plan.team.name", "Team"); - if (planId === "enterprise") return t("plan.enterprise.name", "Enterprise"); - return t("plan.free.name", "Free"); - }; - - return ( - - - {t("payment.upgradeTitle", "Upgrade to {{planName}}", { - planName: getPlanName(), - })} - - - } - size="md" - centered - withCloseButton={true} - closeOnEscape={true} - closeOnClickOutside={false} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > - {renderContent()} - - ); -}; diff --git a/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx b/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx index 1004ffc7a..07afc1659 100644 --- a/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx @@ -1,13 +1,12 @@ import { useTranslation } from "react-i18next"; import { useState, useEffect } from "react"; -import { - useConfigNavSections as useProprietaryConfigNavSections, - createConfigNavSections as createProprietaryConfigNavSections, -} from "@proprietary/components/shared/config/configNavSections"; +import { useConfigNavSections as useProprietaryConfigNavSections } from "@proprietary/components/shared/config/configNavSections"; import { ConfigNavSection } from "@core/components/shared/config/configNavSections"; import { ConnectionSettings } from "@app/components/ConnectionSettings"; -import { SaasPlanSection } from "@app/components/shared/config/configSections/SaasPlanSection"; -import { SaaSTeamsSection } from "@app/components/shared/config/configSections/SaaSTeamsSection"; +import { + createCloudPlanNavItem, + createCloudTeamNavItem, +} from "@app/components/shared/config/cloudConfigNavSections"; import { connectionModeService } from "@app/services/connectionModeService"; import { authService } from "@app/services/authService"; @@ -100,29 +99,18 @@ export const useConfigNavSections = ( // Connection Mode always sits immediately after Preferences result.push(connectionModeSection); - // Plan & Billing and Team sections only when authenticated in SaaS mode + // Plan & Billing and Team sections only when authenticated in SaaS mode. + // These are the SHARED cloud sections — the wallet-driven PAYG Plan + // (dashboard + spend cap) and the team management UI — so a desktop SaaS + // user sees the same Plan / billing / team experience as the web app. if (isSaasMode && isAuthenticated) { result.push({ title: t("settings.planBilling.title", "Plan & Billing"), - items: [ - { - key: "planBilling", - label: t("settings.planBilling.title", "Plan & Billing"), - icon: "credit-card", - component: , - }, - ], + items: [createCloudPlanNavItem(t)], }); result.push({ title: t("settings.team.title", "Team"), - items: [ - { - key: "teams", - label: t("settings.team.title", "Team"), - icon: "groups-rounded", - component: , - }, - ], + items: [createCloudTeamNavItem(t)], }); } @@ -146,52 +134,3 @@ export const useConfigNavSections = ( return result; }; - -/** - * Deprecated: Use useConfigNavSections hook instead - * Desktop extension of createConfigNavSections that adds connection settings - */ -export const createConfigNavSections = ( - isAdmin: boolean = false, - runningEE: boolean = false, - loginEnabled: boolean = false, -): ConfigNavSection[] => { - console.warn( - "createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.", - ); - - // Get the proprietary sections (includes core Preferences + admin sections) - const sections = createProprietaryConfigNavSections( - isAdmin, - runningEE, - loginEnabled, - ); - - // Add Connection section at the beginning (after Preferences) - sections.splice(1, 0, { - title: "Connection", - items: [ - { - key: "connectionMode", - label: "Connection Mode", - icon: "desktop-cloud-rounded", - component: , - }, - ], - }); - - // Add Plan & Billing section (after Connection Mode) - sections.splice(2, 0, { - title: "Plan & Billing", - items: [ - { - key: "planBilling", - label: "Plan & Billing", - icon: "credit-card", - component: , - }, - ], - }); - - return sections; -}; diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx deleted file mode 100644 index 74f03b51a..000000000 --- a/frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx +++ /dev/null @@ -1,542 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Button, - TextInput, - Group, - Text, - Stack, - Alert, - Table, - Badge, - ActionIcon, - Menu, -} from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; -import LocalIcon from "@app/components/shared/LocalIcon"; -import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import apiClient from "@app/services/apiClient"; - -/** - * Desktop SaaS Teams Section - * Allows team management for users connected to SaaS backend - * CRITICAL: Only shown when in SaaS mode (enforced by navigation) - */ -export function SaaSTeamsSection() { - const { t } = useTranslation(); - const { - currentTeam, - teamMembers, - teamInvitations, - isTeamLeader, - isPersonalTeam, - inviteUser, - cancelInvitation, - removeMember, - leaveTeam, - refreshTeams, - } = useSaaSTeam(); - - const [inviteEmail, setInviteEmail] = useState(""); - const [inviting, setInviting] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - // Team rename state - const [isEditingName, setIsEditingName] = useState(false); - const [newTeamName, setNewTeamName] = useState(""); - const [renamingTeam, setRenamingTeam] = useState(false); - - // Refresh team data on mount and every 10 seconds - useEffect(() => { - // Refresh immediately on mount - refreshTeams(); - - // Then refresh every 10 seconds - const interval = setInterval(() => { - refreshTeams(); - }, 10000); - - return () => clearInterval(interval); - }, []); // Only run on mount/unmount - - const handleInvite = async (e: React.FormEvent) => { - e.preventDefault(); - if (!inviteEmail.trim()) return; - - setInviting(true); - setError(null); - setSuccess(null); - - try { - await inviteUser(inviteEmail); - setSuccess( - t("team.inviteSent", "Invitation sent to {{email}}", { - email: inviteEmail, - }), - ); - setInviteEmail(""); - } catch (err) { - const error = err as { response?: { data?: { error?: string } } }; - setError( - error.response?.data?.error || - t("team.inviteError", "Failed to send invitation"), - ); - } finally { - setInviting(false); - } - }; - - const handleRemove = async (memberId: number, memberEmail: string) => { - if ( - !window.confirm( - t("team.confirmRemove", "Remove {{email}} from the team?", { - email: memberEmail, - }), - ) - ) - return; - - try { - await removeMember(memberId); - setSuccess(t("team.memberRemoved", "Member removed successfully")); - } catch (err) { - const error = err as { response?: { data?: { error?: string } } }; - setError( - error.response?.data?.error || - t("team.removeError", "Failed to remove member"), - ); - } - }; - - const handleCancelInvitation = async ( - invitationId: number, - email: string, - ) => { - if ( - !window.confirm( - t("team.confirmCancelInvite", "Cancel invitation for {{email}}?", { - email, - }), - ) - ) - return; - - try { - await cancelInvitation(invitationId); - setSuccess( - t("team.inviteCancelled", "Invitation for {{email}} cancelled", { - email, - }), - ); - } catch (err) { - const error = err as { response?: { data?: { error?: string } } }; - setError( - error.response?.data?.error || - t("team.cancelInviteError", "Failed to cancel invitation"), - ); - } - }; - - const handleStartRename = () => { - if (currentTeam) { - setNewTeamName(currentTeam.name); - setIsEditingName(true); - } - }; - - const handleCancelRename = () => { - setIsEditingName(false); - setNewTeamName(""); - }; - - const handleRenameSubmit = async () => { - if (!currentTeam || !newTeamName.trim()) return; - - setRenamingTeam(true); - setError(null); - - try { - await apiClient.post(`/api/v1/team/${currentTeam.teamId}/rename`, { - newName: newTeamName.trim(), - }); - - setSuccess(t("team.renameSuccess", "Team renamed successfully")); - setIsEditingName(false); - await refreshTeams(); - } catch (err) { - const error = err as { - response?: { data?: { error?: string } }; - message?: string; - }; - setError( - error.response?.data?.error || - error.message || - t("team.renameError", "Failed to rename team"), - ); - } finally { - setRenamingTeam(false); - } - }; - - const handleLeaveTeam = async () => { - if (!currentTeam || isPersonalTeam) return; - - const confirmMessage = isTeamLeader - ? t( - "team.confirmLeaveLeader", - 'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.', - { name: currentTeam.name }, - ) - : t("team.confirmLeave", 'Are you sure you want to leave "{{name}}"?', { - name: currentTeam.name, - }); - - if (!window.confirm(confirmMessage)) return; - - try { - await leaveTeam(); - setSuccess(t("team.leaveSuccess", "Successfully left team")); - } catch (err) { - const error = err as { - response?: { data?: { error?: string } }; - message?: string; - }; - setError( - error.response?.data?.error || - error.message || - t("team.leaveError", "Failed to leave team"), - ); - } - }; - - if (!currentTeam) { - return ( - - {t("team.loading", "Loading team information...")} - - ); - } - - return ( - - {/* Header */} -
- -
- {isEditingName ? ( - - setNewTeamName(e.target.value)} - placeholder={t("team.namePlaceholder", "Team name")} - style={{ flex: 1, maxWidth: 300 }} - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") handleRenameSubmit(); - if (e.key === "Escape") handleCancelRename(); - }} - /> - - - - - - - - ) : ( - - - {currentTeam.name} - - {isTeamLeader && !isPersonalTeam && ( - - - - )} - {isTeamLeader && ( - {t("team.leader", "LEADER")} - )} - {isPersonalTeam && ( - - {t("team.personal", "Personal")} - - )} - - )} - {!isEditingName && !isPersonalTeam && ( - - {t("team.memberCount", "{{count}} team members", { - count: currentTeam.seatsUsed, - })} - - )} -
- {!isPersonalTeam && !isTeamLeader && !isEditingName && ( - - )} -
-
- - {/* Error/Success Messages */} - {error && ( - setError(null)} withCloseButton> - {error} - - )} - - {success && ( - setSuccess(null)} withCloseButton> - {success} - - )} - - {/* Invite Members */} - {isTeamLeader && ( -
- - {t("team.invite.title", "Invite Team Member")} - -
- - setInviteEmail(e.target.value)} - style={{ flex: 1 }} - required - error={ - inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) - ? t("team.invite.invalidEmail", "Invalid email format") - : undefined - } - /> - - -
-
- )} - - {/* Team Members Table */} -
- - {t("team.members.title", "Team Members")} - - - - - - {t("team.members.nameColumn", "Name")} - - - {t("team.members.emailColumn", "Email")} - - - {t("team.members.roleColumn", "Role")} - - {isTeamLeader && !isPersonalTeam && ( - - )} - - - - {teamMembers.length === 0 && teamInvitations.length === 0 ? ( - - - - {t("team.members.empty", "No team members yet.")} - - - - ) : ( - <> - {/* Active Members */} - {teamMembers.map((member) => ( - - - - {member.username} - - - - - {member.email} - - - - - {member.role} - - - {isTeamLeader && !isPersonalTeam && ( - - {member.role !== "LEADER" && ( - - - - - - - - - } - onClick={() => - handleRemove(member.id, member.email) - } - > - {t("team.members.remove", "Remove from Team")} - - - - )} - - )} - - ))} - - {/* Pending Invitations */} - {teamInvitations - .filter((inv) => inv.status === "PENDING") - .map((invitation) => ( - - - - {invitation.inviteeEmail.split("@")[0]} - - - - - {invitation.inviteeEmail} - - - - - {t("team.members.pending", "PENDING")} - - - {isTeamLeader && !isPersonalTeam && ( - - - handleCancelInvitation( - invitation.invitationId, - invitation.inviteeEmail, - ) - } - aria-label={t( - "team.invite.cancelLabel", - "Cancel invitation", - )} - > - - - - )} - - ))} - - )} - -
-
-
- ); -} diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx deleted file mode 100644 index 43232dc1b..000000000 --- a/frontend/editor/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { useEffect, useState } from "react"; -import { - Stack, - Loader, - Alert, - Button, - Center, - Text, - Flex, -} from "@mantine/core"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; -import AccessTimeIcon from "@mui/icons-material/AccessTime"; -import { useTranslation } from "react-i18next"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; -import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; -import { useSaaSPlans } from "@app/hooks/useSaaSPlans"; -import { connectionModeService } from "@app/services/connectionModeService"; -import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext"; -import { ActiveSubscriptionCard } from "@app/components/shared/config/configSections/plan/ActiveSubscriptionCard"; -import { SaaSAvailablePlansSection } from "@app/components/shared/config/configSections/plan/SaaSAvailablePlansSection"; - -/** - * SaaS Plan & Billing section - * Shows subscription status, billing information, and usage metrics - * Only visible when connected to SaaS - */ -export function SaasPlanSection() { - const { t } = useTranslation(); - const [isSaasMode, setIsSaasMode] = useState(null); - const [isOpeningPortal, setIsOpeningPortal] = useState(false); - - // Billing context - const { - subscription, - usage, - tier, - isTrialing, - trialDaysRemaining, - loading, - error, - refreshBilling, - price, - currency, - isManagedTeamMember, - openBillingPortal, - } = useSaaSBilling(); - - // Team data for ActiveSubscriptionCard - const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam(); - - // Plans data - const { plans, loading: plansLoading, error: plansError } = useSaaSPlans(); - - // Check connection mode on mount - useEffect(() => { - const checkMode = async () => { - const mode = await connectionModeService.getCurrentMode(); - setIsSaasMode(mode === "saas"); - }; - - checkMode(); - - // Subscribe to mode changes - const unsubscribe = connectionModeService.subscribeToModeChanges( - async (config) => { - setIsSaasMode(config.mode === "saas"); - }, - ); - - return unsubscribe; - }, []); - - // Handle "Manage Billing" button click - const handleManageBilling = async () => { - setIsOpeningPortal(true); - - try { - // Context handles opening portal and auto-refresh - await openBillingPortal(); - } catch (error) { - console.error("[SaasPlanSection] Failed to open billing portal:", error); - } finally { - setIsOpeningPortal(false); - } - }; - - // Format date for trial end - const formatDate = (timestamp: number): string => { - return new Date(timestamp * 1000).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }); - }; - - // Don't render anything if not in SaaS mode - if (isSaasMode === false) { - return ( -
- } - > - - {t( - "settings.planBilling.notAvailable", - "Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).", - )} - - -
- ); - } - - // Loading state while checking mode - if (isSaasMode === null) { - return ( -
- -
- ); - } - - // Loading state while fetching billing/team data - // Note: loading already includes teamLoading from billing context - if (loading) { - return ( -
- - - - {t( - "settings.planBilling.loading", - "Loading billing information...", - )} - - -
- ); - } - - // Error state - if (error) { - return ( -
- } - title={t( - "settings.planBilling.errors.fetchFailed", - "Unable to fetch billing data", - )} - > - - {error} - - - -
- ); - } - - // Main content - return ( - -
- {/* Header with title and Manage Billing button */} - -

- {t("settings.planBilling.currentPlan", "Active Plan")} -

- {tier !== "free" && !isManagedTeamMember && ( - - )} -
- - {/* Trial Status Alert */} - {isTrialing && - trialDaysRemaining !== undefined && - subscription?.currentPeriodEnd && ( - } - mt="md" - mb="md" - title={t("settings.planBilling.trial.title", "Free Trial Active")} - > - - {t( - "settings.planBilling.trial.daysRemainingFull", - "Your trial ends in {{days}} days", - { - days: trialDaysRemaining, - defaultValue: `Your trial ends in ${trialDaysRemaining} days`, - }, - )} - - - {t("settings.planBilling.trial.endDate", "Expires: {{date}}", { - date: formatDate(subscription.currentPeriodEnd), - defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`, - })} - - - )} - - {/* Plan cards */} - - {/* Current subscription card */} - - - {/* Available plans grid */} - - -
-
- ); -} diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/plan/ActiveSubscriptionCard.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/plan/ActiveSubscriptionCard.tsx deleted file mode 100644 index 454858d47..000000000 --- a/frontend/editor/src/desktop/components/shared/config/configSections/plan/ActiveSubscriptionCard.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - Card, - Text, - Group, - Badge, - Stack, - Tooltip, - ActionIcon, -} from "@mantine/core"; -import GroupIcon from "@mui/icons-material/Group"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import { useTranslation } from "react-i18next"; -import type { BillingStatus } from "@app/services/saasBillingService"; -import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing"; -import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; - -interface TeamData { - teamId: number; - name: string; - isPersonal: boolean; - isLeader: boolean; - seatsUsed: number; -} - -interface ActiveSubscriptionCardProps { - tier: BillingStatus["tier"]; - subscription: BillingStatus["subscription"]; - usage: BillingStatus["meterUsage"]; - isTrialing: boolean; - price?: number; - currency?: string; - currentTeam?: TeamData | null; - isTeamLeader?: boolean; - isPersonalTeam?: boolean; -} - -export function ActiveSubscriptionCard({ - tier, - subscription, - usage, - isTrialing, - price, - currency, - currentTeam, - isTeamLeader = false, - isPersonalTeam = true, -}: ActiveSubscriptionCardProps) { - const { t } = useTranslation(); - - // Format timestamp to readable date - const formatDate = (timestamp: number): string => { - return new Date(timestamp * 1000).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }); - }; - - // Get tier display name - const getTierName = (): string => { - switch (tier) { - case "free": - return t("settings.planBilling.tier.free", "Free Plan"); - case "team": - return t("settings.planBilling.tier.team", "Team Plan"); - case "enterprise": - return t("settings.planBilling.tier.enterprise", "Enterprise Plan"); - default: - return tier; - } - }; - - // Get price display - const getPriceDisplay = (): string => { - if (tier === "free") { - return "$0/month"; - } - // Use actual price from Stripe if available - if (price !== undefined && currency) { - return `${currency}${price}/month`; - } - // Fallback to default pricing - return "$10/month"; - }; - - // Get description - const getDescription = (): string => { - if (tier === "free") { - return t( - "settings.planBilling.tier.freeDescription", - "50 credits per month", - ); - } - return t( - "settings.planBilling.tier.teamDescription", - "500 credits/month included, automatic overage billing for uninterrupted service", - ); - }; - - // Format overage cost - const formatOverageCost = (cents: number, credits: number): string => { - return t("settings.planBilling.billing.overageCost", { - amount: `$${(cents / 100).toFixed(2)}`, - credits, - defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`, - }); - }; - - // Pro/Team card - if (tier === "team" || tier === "enterprise") { - return ( - - - - {/* Left side: Name, badges, description */} -
- - - {!isPersonalTeam && isTeamLeader - ? t("settings.planBilling.tier.team", "Team Plan") - : getTierName()} - - {!isPersonalTeam && ( - } - > - {t("settings.planBilling.tier.teamBadge", "Team")} - - )} - - - {t("settings.planBilling.tier.teamTooltipCredits", { - credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH, - defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`, - })} - - - {t("settings.planBilling.tier.teamTooltipOverage", { - price: getFormattedOveragePrice(), - defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`, - })} - - - {t( - "settings.planBilling.tier.teamTooltipFineprint", - "Only pay for what you use beyond included credits.", - )} - -
- } - multiline - withArrow - position="right" - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > - - - -
- {isTrialing && ( - - {t("settings.planBilling.status.trial", "Trial")} - - )} - - {!isPersonalTeam && !isTeamLeader && ( - - {t( - "settings.planBilling.team.managedByTeam", - "Managed by team", - )} - - )} - {!isPersonalTeam && isTeamLeader && currentTeam && ( - - {t( - "settings.planBilling.team.memberCount", - "{{count}} team members", - { count: currentTeam.seatsUsed }, - )} - - )} - - {getDescription()} - - {/* Show overage cost if applicable */} - {usage && usage.currentPeriodCredits > 0 && ( - - {formatOverageCost( - usage.estimatedCost, - usage.currentPeriodCredits, - )} - - )} - - - {/* Right side: Price */} -
- {!isPersonalTeam && !isTeamLeader ? ( - - {t( - "settings.planBilling.team.managedByTeam", - "Managed by team", - )} - - ) : ( - - {getPriceDisplay()} - - )} -
- - - {/* Next billing date at bottom */} - {subscription?.currentPeriodEnd && ( - - - {t( - "settings.planBilling.billing.nextBillingDate", - "Next billing date:", - )}{" "} - {formatDate(subscription.currentPeriodEnd)} - - - )} - - - ); - } - - // Free plan card - return ( - - -
- - - {getTierName()} - - - - {getDescription()} - -
-
- - {getPriceDisplay()} - -
-
-
- ); -} diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/plan/PlanUpgradeCard.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/plan/PlanUpgradeCard.tsx deleted file mode 100644 index 323b7fa9f..000000000 --- a/frontend/editor/src/desktop/components/shared/config/configSections/plan/PlanUpgradeCard.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from "react"; -import { Card, Text, Button, Stack, List, ThemeIcon } from "@mantine/core"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import { useTranslation } from "react-i18next"; -import { open as shellOpen } from "@tauri-apps/plugin-shell"; -import { STIRLING_SAAS_URL } from "@app/constants/connection"; -import { BILLING_CONFIG } from "@app/config/billing"; -import type { TierLevel } from "@app/types/billing"; - -interface PlanUpgradeCardProps { - currentTier: TierLevel; -} - -export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) { - const { t } = useTranslation(); - - // Don't show upgrade card if already on Team or Enterprise - if (currentTier !== "free") { - return null; - } - - const handleUpgrade = async () => { - // For MVP, direct to web SaaS for upgrades - const upgradeUrl = `${STIRLING_SAAS_URL}/account?tab=plan`; - - try { - await shellOpen(upgradeUrl); - } catch (error) { - console.error("[PlanUpgradeCard] Failed to open upgrade URL:", error); - } - }; - - return ( - - - {/* Header */} - - {t("settings.planBilling.upgrade.title", "Upgrade Your Plan")} - - - {/* Team plan benefits */} - - {t("settings.planBilling.upgrade.subtitle", "Upgrade to Team for:")} - - - - - - } - > - - {t("settings.planBilling.upgrade.featureCredits", { - teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH, - freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH, - defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month (vs ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} on Free)`, - })} - - - {t( - "settings.planBilling.upgrade.featureMembers", - "Unlimited team members", - )} - - - {t( - "settings.planBilling.upgrade.featureThroughput", - "Faster processing throughput", - )} - - - {t( - "settings.planBilling.upgrade.featureApi", - "API access for automation", - )} - - - {t( - "settings.planBilling.upgrade.featureSupport", - "Priority support", - )} - - - - {/* Upgrade button */} - - - - {t( - "settings.planBilling.upgrade.opensInBrowser", - "Opens in browser to complete upgrade", - )} - - - - ); -} diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/plan/SaaSAvailablePlansSection.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/plan/SaaSAvailablePlansSection.tsx deleted file mode 100644 index 28e8de48c..000000000 --- a/frontend/editor/src/desktop/components/shared/config/configSections/plan/SaaSAvailablePlansSection.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react"; -import { Text, SimpleGrid, Loader, Alert, Center } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { PlanTier } from "@app/hooks/useSaaSPlans"; -import { SaasPlanCard } from "@app/components/shared/config/configSections/plan/SaasPlanCard"; -import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext"; -import type { TierLevel } from "@app/types/billing"; - -interface SaaSAvailablePlansSectionProps { - plans: PlanTier[]; - currentTier?: TierLevel; - loading?: boolean; - error?: string | null; -} - -export const SaaSAvailablePlansSection: React.FC< - SaaSAvailablePlansSectionProps -> = ({ plans, currentTier, loading, error }) => { - const { t } = useTranslation(); - const { openCheckout } = useSaaSCheckout(); - - const handleUpgradeClick = (plan: PlanTier) => { - if (plan.isContactOnly) { - // Handled by mailto link in the card - return; - } - - if (plan.id === currentTier) { - // Already on this plan - return; - } - - console.log( - "[SaaSAvailablePlansSection] Upgrade clicked for plan:", - plan.id, - ); - openCheckout(plan.id); - }; - - if (loading) { - return ( -
- -
- ); - } - - if (error) { - return ( - - - {t( - "plan.availablePlans.loadError", - "Unable to load plan pricing. Using default values.", - )} - - - ); - } - - return ( -
- - {t("plan.availablePlans.title", "Available Plans")} - - - {plans.map((plan) => ( - - ))} - -
- ); -}; diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/plan/SaasPlanCard.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/plan/SaasPlanCard.tsx deleted file mode 100644 index 1c9330086..000000000 --- a/frontend/editor/src/desktop/components/shared/config/configSections/plan/SaasPlanCard.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React from "react"; -import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { PlanTier } from "@app/hooks/useSaaSPlans"; -import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem"; -import type { TierLevel } from "@app/types/billing"; - -interface SaasPlanCardProps { - plan: PlanTier; - isCurrentPlan?: boolean; - currentTier?: TierLevel; - onUpgradeClick?: (plan: PlanTier) => void; -} - -export const SaasPlanCard: React.FC = ({ - plan, - isCurrentPlan, - currentTier, - onUpgradeClick, -}) => { - const { t } = useTranslation(); - - // Free plan is included if user has Team or Enterprise tier - const isIncluded = - plan.id === "free" && - (currentTier === "team" || currentTier === "enterprise"); - - // Determine card styling based on plan type - const getCardStyle = () => { - const baseStyle: React.CSSProperties = { - backgroundColor: "light-dark(#FFFFFF, #1A1A1E)", - borderWidth: 1, - position: "relative", - overflow: "visible", - }; - - if (plan.id === "free" && isCurrentPlan) { - return { - ...baseStyle, - borderColor: "var(--border-default)", - opacity: 0.85, - }; - } - - if (plan.popular) { - return { - ...baseStyle, - borderColor: "rgb(59, 130, 246)", - borderWidth: 2, - cursor: "pointer", - transition: "all 0.2s ease", - boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)", - }; - } - - return baseStyle; - }; - - const handleMouseEnter = (e: React.MouseEvent) => { - if (plan.popular && !isCurrentPlan) { - e.currentTarget.style.transform = "translateY(-4px)"; - e.currentTarget.style.boxShadow = "0 12px 48px rgba(59, 130, 246, 0.3)"; - } - }; - - const handleMouseLeave = (e: React.MouseEvent) => { - if (plan.popular && !isCurrentPlan) { - e.currentTarget.style.transform = "translateY(0)"; - e.currentTarget.style.boxShadow = "0 2px 8px rgba(59, 130, 246, 0.1)"; - } - }; - - const handleClick = () => { - if (plan.popular && !isCurrentPlan && onUpgradeClick) { - onUpgradeClick(plan); - } - }; - - return ( - - {plan.popular && ( - - {t("plan.popular", "Popular")} - - )} - - -
- - {plan.name} - - - - {plan.isContactOnly - ? t("plan.customPricing", "Custom") - : `${plan.currency}${plan.price}`} - - {!plan.isContactOnly && ( - - {plan.period} - - )} - - - {plan.isContactOnly - ? t("plan.enterprise.siteLicense", "Site License") - : plan.id === "free" - ? `50 ${t("credits.modal.monthlyCredits", "monthly credits")}` - : plan.overagePrice - ? `500 ${t("credits.modal.monthlyCredits", "monthly credits")} + ${plan.currency}${plan.overagePrice.toFixed(2)}/${t("credits.modal.overage", "overage")}` - : `500 ${t("credits.modal.monthlyCredits", "monthly credits")}`} - -
- - - - {plan.id === "free" - ? t("credits.modal.forRegularWork", "For regular PDF work:") - : plan.id === "enterprise" - ? t( - "credits.modal.everythingInCredits", - "Everything in Credits, plus:", - ) - : t( - "credits.modal.everythingInFree", - "Everything in Free, plus:", - )} - - {plan.highlights.map((highlight: string, index: number) => ( - - {highlight} - - ))} - - -
- - - - - ); -}; diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/plan/UsageDisplay.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/plan/UsageDisplay.tsx deleted file mode 100644 index 8088c18cf..000000000 --- a/frontend/editor/src/desktop/components/shared/config/configSections/plan/UsageDisplay.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React from "react"; -import { Card, Text, Stack, Group, Progress, Alert } from "@mantine/core"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import { useTranslation } from "react-i18next"; -import type { BillingStatus } from "@app/services/saasBillingService"; -import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing"; - -interface UsageDisplayProps { - tier: BillingStatus["tier"]; - usage: BillingStatus["meterUsage"]; -} - -export function UsageDisplay({ tier, usage }: UsageDisplayProps) { - const { t } = useTranslation(); - - // Credits per month based on tier - const getMonthlyCredits = (): number => { - switch (tier) { - case "free": - return BILLING_CONFIG.FREE_CREDITS_PER_MONTH; - case "team": - return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH; - case "enterprise": - return 1000; // Placeholder — enterprise credits are custom - default: - return BILLING_CONFIG.FREE_CREDITS_PER_MONTH; - } - }; - - const monthlyCredits = getMonthlyCredits(); - - // Format currency - const formatCurrency = (cents: number): string => { - return `$${(cents / 100).toFixed(2)}`; - }; - - return ( - - - {/* Header */} - - {t("settings.planBilling.credits.title", "Credit Usage")} - - - {/* Monthly credits info */} - - - {t("settings.planBilling.credits.included", { - count: monthlyCredits, - defaultValue: `${monthlyCredits} credits/month (included)`, - })} - - - - {/* Overage credits (if metered billing enabled) */} - {usage && usage.currentPeriodCredits > 0 && ( - <> - - - - {t("settings.planBilling.credits.overage", { - count: usage.currentPeriodCredits, - defaultValue: `+ ${usage.currentPeriodCredits} overage`, - })} - - - {t("settings.planBilling.credits.estimatedCost", { - amount: formatCurrency(usage.estimatedCost), - defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`, - })} - - - - {/* Progress bar for overage usage */} - - - - } - > - - {t("settings.planBilling.credits.overageInfo", { - price: getFormattedOveragePrice(), - defaultValue: `Overage credits are billed at ${getFormattedOveragePrice()} per credit. You'll only pay for what you use beyond your monthly allowance.`, - })} - - - - )} - - {/* No overage message */} - {(!usage || usage.currentPeriodCredits === 0) && tier !== "free" && ( - - - {t("settings.planBilling.credits.noOverage", { - count: monthlyCredits, - defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`, - })} - - - )} - - {/* Free tier message */} - {tier === "free" && ( - - - {t("settings.planBilling.credits.freeTierInfo", { - freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH, - teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH, - defaultValue: `Free plan includes ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} credits per month. Upgrade to Team for ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month and pay-as-you-go overage billing.`, - })} - - - )} - - - ); -} diff --git a/frontend/editor/src/desktop/components/shared/modals/CreditExhaustedModal.tsx b/frontend/editor/src/desktop/components/shared/modals/CreditExhaustedModal.tsx deleted file mode 100644 index 51597e2bf..000000000 --- a/frontend/editor/src/desktop/components/shared/modals/CreditExhaustedModal.tsx +++ /dev/null @@ -1,573 +0,0 @@ -import { - Modal, - Stack, - Card, - Text, - Group, - Badge, - Button, - Alert, -} from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import TrendingUpIcon from "@mui/icons-material/TrendingUp"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; -import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; -import { - BILLING_CONFIG, - getCurrencySymbol, - getFormattedOveragePrice, -} from "@app/config/billing"; -import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { CreditUsageBanner } from "@app/components/shared/modals/CreditUsageBanner"; -import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem"; -import { - FREE_PLAN_FEATURES, - TEAM_PLAN_FEATURES, - ENTERPRISE_PLAN_FEATURES, -} from "@app/config/planFeatures"; -import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext"; -import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling"; - -interface CreditExhaustedModalProps { - opened: boolean; - onClose: () => void; -} - -/** - * Desktop Credit Exhausted Modal - * Shows upgrade options when user runs out of credits - * Routes to different UI based on user status (free/team/managed member) - */ -export function CreditExhaustedModal({ - opened, - onClose, -}: CreditExhaustedModalProps) { - const { t } = useTranslation(); - const { creditBalance, tier, plans, refreshBilling, isManagedTeamMember } = - useSaaSBilling(); - const { isTeamLeader } = useSaaSTeam(); - const { openCheckout } = useSaaSCheckout(); - - const { enablingMetering, meteringError, handleEnableMetering } = - useEnableMeteredBilling(refreshBilling, onClose, "CreditExhaustedModal"); - - // Managed team members have unlimited credits via team - if (isManagedTeamMember) { - return ( - - - - {t( - "credits.modal.managedMemberMessage", - "You have unlimited access to credits through your team. If you need assistance, please contact your team leader.", - )} - - - - - ); - } - - // Team users should enable overage billing - // Only team leaders can enable metered billing, members see different UI - if (tier === "team") { - const teamPlan = plans.get("team"); - const teamCurrency = teamPlan?.currency ?? "$"; - const overagePrice = - teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT; - const formattedOveragePrice = getFormattedOveragePrice( - teamCurrency, - overagePrice, - ); - - return ( - - - {t( - "credits.modal.titleExhaustedPro", - "You have run out of credits", - )} - - - {t( - "credits.modal.subtitlePro", - "Enable automatic overage billing to never run out of credits.", - )} - - - } - styles={{ - body: { padding: "0rem 0rem 0.5rem 0rem" }, - content: { - backgroundColor: "light-dark(#FFFFFF, #1A1A1E)", - }, - header: { - backgroundColor: "light-dark(#FFFFFF, #1A1A1E)", - }, - overlay: { - backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))", - }, - }} - > - - - - {meteringError && ( - - {meteringError} - - )} - - {/* Explanation Card */} - - - - - - {t( - "credits.modal.meteringTitle", - "Pay-What-You-Use Overage Billing", - )} - - - - - {t("credits.modal.meteringExplanation", { - credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH, - defaultValue: `Your Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working.`, - })} - - - - - {t("credits.modal.meteringIncluded", { - credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH, - defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`, - })} - - - {t( - "credits.modal.meteringPrice", - "Additional credits at {{price}}/credit", - { - price: formattedOveragePrice, - }, - )} - - - {t( - "credits.modal.meteringPayAsYouGo", - "Only pay for what you use", - )} - - - {t( - "credits.modal.meteringNoCommitment", - "No commitment, cancel anytime", - )} - - - {t( - "credits.modal.meteringNeverRunOut", - "Never run out of credits", - )} - - - - - - {t( - "credits.modal.meteringBillingNote", - "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.", - )} - - - - - - {/* Action Buttons */} - - - {!isTeamLeader && ( - - {t( - "credits.modal.teamLeaderOnly", - "Only team leaders can enable overage billing", - )} - - )} - - - - - ); - } - - // Free tier users - show upgrade modal - const teamPlan = plans.get("team"); - const teamPrice = teamPlan?.price ?? 20; - const teamCurrency = teamPlan?.currency ?? "$"; - const overagePrice = - teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT; - - const currencySymbol = getCurrencySymbol(teamCurrency); - const formattedOveragePrice = `${currencySymbol}${overagePrice.toFixed(2)}`; - - return ( - - - {t("credits.modal.titleExhausted", "You've used your free credits")} - - - {t( - "credits.modal.subtitle", - "Upgrade to Team for 10x the credits and faster processing.", - )} - - - } - styles={{ - body: { padding: "0rem 0rem 0.5rem 0rem" }, - content: { - backgroundColor: "light-dark(#FFFFFF, #1A1A1E)", - }, - header: { - backgroundColor: "light-dark(#FFFFFF, #1A1A1E)", - }, - overlay: { - backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))", - }, - }} - > - - - - - {/* Free Plan Card */} - - -
- - {t("credits.modal.freeTier", "Free Tier")} - - - - {currencySymbol}0 - - - {t("credits.modal.perMonth", "/month")} - - - - {BILLING_CONFIG.FREE_CREDITS_PER_MONTH}{" "} - {t("credits.modal.monthlyCredits", "monthly credits")} - -
- - - - {t("credits.modal.forRegularWork", "For regular PDF work:")} - - {FREE_PLAN_FEATURES.map((feature, index) => ( - - {t(feature.translationKey, feature.defaultText)} - - ))} - - - -
-
- - {/* Team Plan Card */} - openCheckout("pro")} - onMouseEnter={(e) => { - e.currentTarget.style.transform = "translateY(-4px)"; - e.currentTarget.style.boxShadow = - "0 12px 48px rgba(59, 130, 246, 0.3)"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = "translateY(0)"; - e.currentTarget.style.boxShadow = - "0 2px 8px rgba(59, 130, 246, 0.1)"; - }} - > - - {t("credits.modal.popular", "Popular")} - - -
- - {t("credits.modal.teamSubscription", "Team")} - - - - {currencySymbol} - {teamPrice} - - - {t("credits.modal.perMonth", "/month")} - - - - {BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}{" "} - {t("credits.modal.monthlyCredits", "monthly credits")} +{" "} - {formattedOveragePrice}/ - {t("credits.modal.overage", "overage")} - -
- - - - {t( - "credits.modal.everythingInFree", - "Everything in Free, plus:", - )} - - {TEAM_PLAN_FEATURES.map((feature, index) => ( - - {t(feature.translationKey, feature.defaultText)} - - ))} - - - -
-
- - {/* Enterprise Plan Card */} - - -
- - {t("credits.modal.enterpriseSubscription", "Enterprise")} - - - {t("credits.modal.customPricing", "Custom")} - - - {t("credits.modal.unlimitedMonthlyCredits", "Site License")} - -
- - - - {t( - "credits.modal.everythingInCredits", - "Everything in Credits, plus:", - )} - - {ENTERPRISE_PLAN_FEATURES.map((feature, index) => ( - - {t(feature.translationKey, feature.defaultText)} - - ))} - - - -
-
-
- - - {t("credits.modal.selfHostPrompt", "Want to self host?")}{" "} - { - e.currentTarget.style.textDecoration = "underline"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.textDecoration = "none"; - }} - > - {t("credits.modal.selfHostLink", "Review the docs and plans")} - - -
-
- ); -} diff --git a/frontend/editor/src/desktop/components/shared/modals/CreditModalBootstrap.tsx b/frontend/editor/src/desktop/components/shared/modals/CreditModalBootstrap.tsx deleted file mode 100644 index 2b7fce228..000000000 --- a/frontend/editor/src/desktop/components/shared/modals/CreditModalBootstrap.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect, useState } from "react"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; -import { useSaaSMode } from "@app/hooks/useSaaSMode"; -import { BILLING_CONFIG } from "@app/config/billing"; -import { CreditExhaustedModal } from "@app/components/shared/modals/CreditExhaustedModal"; -import { InsufficientCreditsModal } from "@app/components/shared/modals/InsufficientCreditsModal"; -import { useCreditEvents } from "@app/hooks/useCreditEvents"; -import { CREDIT_EVENTS } from "@app/constants/creditEvents"; - -/** - * Desktop Credit Modal Bootstrap - * Listens to credit events and shows appropriate modals - * Orchestrates credit exhausted and insufficient credits modals - */ -export function CreditModalBootstrap() { - const [exhaustedOpen, setExhaustedOpen] = useState(false); - const [insufficientOpen, setInsufficientOpen] = useState(false); - const [insufficientDetails, setInsufficientDetails] = useState<{ - toolId?: string; - requiredCredits?: number; - }>({}); - - const isSaaSMode = useSaaSMode(); - const { - creditBalance, - isManagedTeamMember, - lastFetchTime, - plansLastFetchTime, - refreshPlans, - } = useSaaSBilling(); - - // Preload plan pricing when billing confirms credits are low. - // Fires once: only when in SaaS mode, billing has loaded (lastFetchTime set) and plans haven't been - // fetched yet (plansLastFetchTime null). This way the modal shows real prices instantly. - useEffect(() => { - if ( - isSaaSMode && - lastFetchTime !== null && - plansLastFetchTime === null && - creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD && - !isManagedTeamMember - ) { - refreshPlans(); - } - }, [ - isSaaSMode, - lastFetchTime, - plansLastFetchTime, - creditBalance, - isManagedTeamMember, - refreshPlans, - ]); - - // Monitor credit balance and dispatch events - useCreditEvents(); - - useEffect(() => { - const handleExhausted = () => { - // Don't show modal for managed team members - if (isManagedTeamMember) { - return; - } - setExhaustedOpen(true); - }; - - const handleInsufficient = (e: Event) => { - // Don't show modal for managed team members - if (isManagedTeamMember) { - return; - } - const customEvent = e as CustomEvent; - setInsufficientDetails({ - toolId: customEvent.detail?.operationType, - requiredCredits: customEvent.detail?.requiredCredits, - }); - // Show the plans banner (CreditExhaustedModal) instead of the simpler - // InsufficientCreditsModal — same experience as clicking the upgrade button. - setExhaustedOpen(true); - }; - - window.addEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted); - window.addEventListener(CREDIT_EVENTS.INSUFFICIENT, handleInsufficient); - - return () => { - window.removeEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted); - window.removeEventListener( - CREDIT_EVENTS.INSUFFICIENT, - handleInsufficient, - ); - }; - }, [isManagedTeamMember, creditBalance]); - - return ( - <> - setExhaustedOpen(false)} - /> - setInsufficientOpen(false)} - toolId={insufficientDetails.toolId} - requiredCredits={insufficientDetails.requiredCredits} - /> - - ); -} diff --git a/frontend/editor/src/desktop/components/shared/modals/CreditUsageBanner.tsx b/frontend/editor/src/desktop/components/shared/modals/CreditUsageBanner.tsx deleted file mode 100644 index deaea0a9e..000000000 --- a/frontend/editor/src/desktop/components/shared/modals/CreditUsageBanner.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Divider, Group, Text, Progress, Stack } from "@mantine/core"; -import { useTranslation } from "react-i18next"; - -interface CreditUsageBannerProps { - currentCredits: number; - totalCredits: number; -} - -/** - * Credit usage banner showing remaining credits with progress bar - * Used in credit exhausted and upgrade modals - */ -export function CreditUsageBanner({ - currentCredits, - totalCredits, -}: CreditUsageBannerProps) { - const { t } = useTranslation(); - const percentageRemaining = - totalCredits > 0 ? (currentCredits / totalCredits) * 100 : 0; - - return ( - - - - - - {t("credits.modal.creditsThisMonth", "Monthly credits")} - - - {t( - "credits.modal.creditsRemaining", - "{{current}} of {{total}} remaining", - { - current: currentCredits, - total: totalCredits, - }, - )} - - - - - - - ); -} diff --git a/frontend/editor/src/desktop/components/shared/modals/FeatureListItem.tsx b/frontend/editor/src/desktop/components/shared/modals/FeatureListItem.tsx deleted file mode 100644 index 8b3a5a077..000000000 --- a/frontend/editor/src/desktop/components/shared/modals/FeatureListItem.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Group, Text } from "@mantine/core"; -import CheckCircleIcon from "@mui/icons-material/Check"; -import CloseIcon from "@mui/icons-material/Close"; - -interface FeatureListItemProps { - children: React.ReactNode; - included: boolean; - color?: string; - dimmed?: boolean; - fw?: number; - size?: "xs" | "sm" | "md" | "lg" | string; -} - -export function FeatureListItem({ - children, - included, - color = "var(--color-primary-600)", - dimmed = false, - fw = 400, - size = "sm", -}: FeatureListItemProps) { - const Icon = included ? CheckCircleIcon : CloseIcon; - const iconColor = included ? color : "var(--color-red-600)"; - - // Map Mantine sizes to icon font sizes - const iconSizeMap: Record = { - xs: 14, - sm: 16, - md: 18, - lg: 20, - }; - - // Determine icon size - use mapped value if it exists, otherwise use the string directly - const iconSize = iconSizeMap[size] || size; - - // For Text component, only use Mantine sizes if the size is a predefined key - const textSize = iconSizeMap[size] ? size : undefined; - - return ( - - - - {children} - - - ); -} diff --git a/frontend/editor/src/desktop/components/shared/modals/InsufficientCreditsModal.tsx b/frontend/editor/src/desktop/components/shared/modals/InsufficientCreditsModal.tsx deleted file mode 100644 index 51b6aba30..000000000 --- a/frontend/editor/src/desktop/components/shared/modals/InsufficientCreditsModal.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Modal, Stack, Text, Button, Alert, Group } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; -import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; -import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext"; -import WarningIcon from "@mui/icons-material/Warning"; -import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling"; - -interface InsufficientCreditsModalProps { - opened: boolean; - onClose: () => void; - toolId?: string; - requiredCredits?: number; -} - -/** - * Desktop Insufficient Credits Modal - * Shows when user attempts operation without enough credits - */ -export function InsufficientCreditsModal({ - opened, - onClose, - toolId, - requiredCredits, -}: InsufficientCreditsModalProps) { - const { t } = useTranslation(); - const { creditBalance, tier, refreshBilling, isManagedTeamMember } = - useSaaSBilling(); - const { isTeamLeader } = useSaaSTeam(); - const { openCheckout } = useSaaSCheckout(); - - const { enablingMetering, meteringError, handleEnableMetering } = - useEnableMeteredBilling( - refreshBilling, - onClose, - "InsufficientCreditsModal", - ); - - const toolName = toolId - ? t(`tool.${toolId}.name`, toolId) - : t("common.operation", "this operation"); - - return ( - - - - {t("credits.insufficient.title", "Insufficient Credits")} - - - } - > - - }> - - {requiredCredits - ? t( - "credits.insufficient.messageWithAmount", - "You need {{required}} credits to run {{tool}}, but you only have {{current}}.", - { - required: requiredCredits, - tool: toolName, - current: creditBalance, - }, - ) - : t( - "credits.insufficient.message", - "You do not have enough credits to run {{tool}}. You currently have {{current}} credits.", - { - tool: toolName, - current: creditBalance, - }, - )} - - - - {isManagedTeamMember ? ( - <> - - {t( - "credits.insufficient.managedMember", - "Please contact your team leader for assistance.", - )} - - - - ) : tier === "team" ? ( - <> - - {t( - "credits.insufficient.teamMember", - "Enable overage billing to never run out of credits.", - )} - - {meteringError && {meteringError}} - - {!isTeamLeader && ( - - {t( - "credits.modal.teamLeaderOnly", - "Only team leaders can enable overage billing", - )} - - )} - - - ) : ( - <> - - {t( - "credits.insufficient.freeTier", - "Upgrade to Team for 10x more credits and unlimited overage billing.", - )} - - - - - )} - - - ); -} diff --git a/frontend/editor/src/desktop/config/billing.ts b/frontend/editor/src/desktop/config/billing.ts deleted file mode 100644 index d7d3d2c21..000000000 --- a/frontend/editor/src/desktop/config/billing.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Billing Configuration for Desktop - * Single source of truth for billing-related constants - */ - -export const BILLING_CONFIG = { - // Credits included in Free plan (per month) - FREE_CREDITS_PER_MONTH: 50, - - // Credits included in Pro plan (per month) - INCLUDED_CREDITS_PER_MONTH: 500, - - // Overage pricing (per credit) - also fetched dynamically from Stripe - OVERAGE_PRICE_PER_CREDIT: 0.05, - - // Credit warning threshold (shows low-balance indicator) - LOW_CREDIT_THRESHOLD: 10, - - // Threshold at which plan pricing is preloaded (higher than LOW_CREDIT_THRESHOLD - // so prices are ready before the warning UI appears) - PLAN_PRICING_PRELOAD_THRESHOLD: 20, - - // Stripe lookup keys - PRO_PLAN_LOOKUP_KEY: "plan:pro", - METER_LOOKUP_KEY: "meter:overage", - - // Display formats - CURRENCY_SYMBOLS: { - gbp: "£", - usd: "$", - eur: "€", - cny: "¥", - inr: "₹", - brl: "R$", - idr: "Rp", - jpy: "¥", - } as const, -} as const; - -/** - * Get current billing configuration - */ -export function getBillingConfig() { - return BILLING_CONFIG; -} - -/** - * Format overage price with currency symbol - * @param currency Currency code (e.g., 'usd', 'gbp') - * @param price Optional price override (defaults to BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT) - */ -export function getFormattedOveragePrice( - currency: string = "usd", - price?: number, -): string { - const symbol = - BILLING_CONFIG.CURRENCY_SYMBOLS[ - currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS - ] || "$"; - const amount = price ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT; - return `${symbol}${amount.toFixed(2)}`; -} - -/** - * Get currency symbol from currency code - */ -export function getCurrencySymbol(currency: string): string { - return ( - BILLING_CONFIG.CURRENCY_SYMBOLS[ - currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS - ] || currency.toUpperCase() - ); -} diff --git a/frontend/editor/src/desktop/constants/creditEvents.ts b/frontend/editor/src/desktop/constants/creditEvents.ts deleted file mode 100644 index d500debd2..000000000 --- a/frontend/editor/src/desktop/constants/creditEvents.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Credit event constants for desktop credit system - * Used for communication between credit monitoring, UI, and operations - */ - -export const CREDIT_EVENTS = { - EXHAUSTED: "credits:exhausted", - INSUFFICIENT: "credits:insufficient", - REFRESH_NEEDED: "credits:refresh-needed", -} as const; - -export type CreditEventType = - (typeof CREDIT_EVENTS)[keyof typeof CREDIT_EVENTS]; diff --git a/frontend/editor/src/desktop/contexts/SaaSCheckoutContext.tsx b/frontend/editor/src/desktop/contexts/SaaSCheckoutContext.tsx deleted file mode 100644 index 936de0f47..000000000 --- a/frontend/editor/src/desktop/contexts/SaaSCheckoutContext.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { - createContext, - useContext, - useState, - ReactNode, - useCallback, -} from "react"; -import { SaaSStripeCheckout } from "@app/components/shared/billing/SaaSStripeCheckout"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; - -interface SaaSCheckoutContextType { - opened: boolean; - selectedPlan: string | null; - openCheckout: (planId: string) => void; - closeCheckout: () => void; -} - -const SaaSCheckoutContext = createContext( - undefined, -); - -export const useSaaSCheckout = () => { - const context = useContext(SaaSCheckoutContext); - if (!context) { - throw new Error("useSaaSCheckout must be used within SaaSCheckoutProvider"); - } - return context; -}; - -interface SaaSCheckoutProviderProps { - children: ReactNode; -} - -export const SaaSCheckoutProvider: React.FC = ({ - children, -}) => { - const [opened, setOpened] = useState(false); - const [selectedPlan, setSelectedPlan] = useState(null); - - // Access billing context for auto-refresh after checkout - const { refreshBilling } = useSaaSBilling(); - - const openCheckout = (planId: string) => { - setSelectedPlan(planId); - setOpened(true); - }; - - const closeCheckout = () => { - setOpened(false); - // Don't reset selectedPlan immediately to allow for cleanup - setTimeout(() => setSelectedPlan(null), 300); - }; - - // Internal success handler - automatically refreshes billing context - const handleCheckoutSuccess = useCallback(async () => { - // Wait for webhooks to process (2 seconds) - await new Promise((resolve) => setTimeout(resolve, 2000)); - try { - await refreshBilling(); - } catch (error) { - console.error( - "[SaaSCheckoutContext] Failed to refresh billing after checkout:", - error, - ); - } - }, [refreshBilling]); - - return ( - - {children} - - - ); -}; diff --git a/frontend/editor/src/desktop/contexts/SaaSTeamContext.tsx b/frontend/editor/src/desktop/contexts/SaaSTeamContext.tsx deleted file mode 100644 index 70f1bc9db..000000000 --- a/frontend/editor/src/desktop/contexts/SaaSTeamContext.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { - createContext, - useContext, - useEffect, - useState, - ReactNode, - useCallback, -} from "react"; -import apiClient from "@app/services/apiClient"; -import { authService } from "@app/services/authService"; -import { connectionModeService } from "@app/services/connectionModeService"; - -/** - * Desktop implementation of SaaS Team Context - * Provides team management for users connected to SaaS backend - * CRITICAL: Only active when in SaaS mode - all API calls check connection mode first - */ - -interface Team { - teamId: number; - name: string; - teamType: string; - isPersonal: boolean; - memberCount: number; - seatCount: number; - seatsUsed: number; - maxSeats: number; - isLeader: boolean; -} - -interface TeamMember { - id: number; - username: string; - email: string; - role: string; - joinedAt: string; -} - -interface TeamInvitation { - invitationId: number; - teamName: string; - inviterEmail: string; - inviteeEmail: string; - invitationToken: string; - status: string; - expiresAt: string; -} - -interface SaaSTeamContextType { - currentTeam: Team | null; - teams: Team[]; - teamMembers: TeamMember[]; - teamInvitations: TeamInvitation[]; - receivedInvitations: TeamInvitation[]; - isTeamLeader: boolean; - isPersonalTeam: boolean; - loading: boolean; - - inviteUser: (email: string) => Promise; - acceptInvitation: (token: string) => Promise; - rejectInvitation: (token: string) => Promise; - cancelInvitation: (invitationId: number) => Promise; - removeMember: (memberId: number) => Promise; - leaveTeam: () => Promise; - refreshTeams: () => Promise; -} - -const SaaSTeamContext = createContext({ - currentTeam: null, - teams: [], - teamMembers: [], - teamInvitations: [], - receivedInvitations: [], - isTeamLeader: false, - isPersonalTeam: true, - loading: true, - inviteUser: async () => {}, - acceptInvitation: async () => {}, - rejectInvitation: async () => {}, - cancelInvitation: async () => {}, - removeMember: async () => {}, - leaveTeam: async () => {}, - refreshTeams: async () => {}, -}); - -export function SaaSTeamProvider({ children }: { children: ReactNode }) { - const [currentTeam, setCurrentTeam] = useState(null); - const [teams, setTeams] = useState([]); - const [teamMembers, setTeamMembers] = useState([]); - const [teamInvitations, setTeamInvitations] = useState([]); - const [receivedInvitations, setReceivedInvitations] = useState< - TeamInvitation[] - >([]); - const [loading, setLoading] = useState(true); - const [isSaasMode, setIsSaasMode] = useState(false); - const [isAuthenticated, setIsAuthenticated] = useState(false); - - // Check if in SaaS mode and authenticated - useEffect(() => { - const checkAccess = async () => { - const mode = await connectionModeService.getCurrentMode(); - const auth = await authService.isAuthenticated(); - setIsSaasMode(mode === "saas"); - setIsAuthenticated(auth); - }; - - checkAccess(); - - // Subscribe to connection mode changes - const unsubscribe = - connectionModeService.subscribeToModeChanges(checkAccess); - return unsubscribe; - }, []); - - // Subscribe to auth changes - useEffect(() => { - const unsubscribe = authService.subscribeToAuth((status) => { - setIsAuthenticated(status === "authenticated"); - }); - return unsubscribe; - }, []); - - const fetchMyTeams = useCallback(async () => { - // CRITICAL: Only fetch if in SaaS mode and authenticated - if (!isSaasMode || !isAuthenticated) { - console.log( - "[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated", - ); - return null; - } - - try { - const response = await apiClient.get("/api/v1/team/my", { - suppressErrorToast: true, - }); - setTeams(response.data); - - const activeTeam = response.data[0]; - console.log("[SaaSTeamContext] Current team set:", { - teamId: activeTeam?.teamId, - name: activeTeam?.name, - isPersonal: activeTeam?.isPersonal, - isLeader: activeTeam?.isLeader, - }); - setCurrentTeam(activeTeam || null); - return activeTeam || null; - } catch (error) { - console.error("[SaaSTeamContext] Failed to fetch teams:", error); - return null; - } - }, [isSaasMode, isAuthenticated]); - - const fetchTeamMembers = useCallback( - async (teamId: number) => { - // CRITICAL: Only fetch if in SaaS mode and authenticated - if (!isSaasMode || !isAuthenticated) { - console.log( - "[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated", - ); - return; - } - - try { - const response = await apiClient.get( - `/api/v1/team/${teamId}/members`, - { suppressErrorToast: true }, - ); - setTeamMembers(response.data); - } catch (error) { - console.error("[SaaSTeamContext] Failed to fetch team members:", error); - } - }, - [isSaasMode, isAuthenticated], - ); - - const fetchTeamInvitations = useCallback( - async (teamId?: number) => { - // CRITICAL: Only fetch if in SaaS mode and authenticated - if (!isSaasMode || !isAuthenticated || !teamId) { - return; - } - - try { - const response = await apiClient.get( - `/api/v1/team/${teamId}/invitations`, - { - suppressErrorToast: true, - }, - ); - setTeamInvitations(response.data); - } catch (error) { - console.error( - "[SaaSTeamContext] Failed to fetch team invitations:", - error, - ); - } - }, - [isSaasMode, isAuthenticated], - ); - - const fetchReceivedInvitations = useCallback(async () => { - // CRITICAL: Only fetch if in SaaS mode and authenticated - if (!isSaasMode || !isAuthenticated) { - return; - } - - console.log("[SaaSTeamContext] Fetching received team invitations"); - - try { - const response = await apiClient.get( - "/api/v1/team/invitations/pending", - { suppressErrorToast: true }, - ); - console.log( - "[SaaSTeamContext] Received invitations response:", - response.data, - ); - setReceivedInvitations(response.data); - } catch (error) { - console.error( - "[SaaSTeamContext] Failed to fetch received invitations:", - error, - ); - } - }, [isSaasMode, isAuthenticated]); - - useEffect(() => { - if (isSaasMode && isAuthenticated) { - fetchMyTeams(); - fetchReceivedInvitations(); - } else { - // Clear state when not in SaaS mode or not authenticated - setTeams([]); - setCurrentTeam(null); - setTeamMembers([]); - setTeamInvitations([]); - setReceivedInvitations([]); - setLoading(false); - } - }, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations]); - - useEffect(() => { - if ( - currentTeam && - !currentTeam.isPersonal && - isSaasMode && - isAuthenticated - ) { - fetchTeamMembers(currentTeam.teamId); - // Only fetch invitations if user is team leader - if (currentTeam.isLeader) { - fetchTeamInvitations(currentTeam.teamId); - } else { - setTeamInvitations([]); - } - } else { - setTeamMembers([]); - setTeamInvitations([]); - } - setLoading(false); - }, [ - currentTeam, - isSaasMode, - isAuthenticated, - fetchTeamMembers, - fetchTeamInvitations, - ]); - - const inviteUser = async (email: string) => { - if (!currentTeam) throw new Error("No current team"); - if (!isSaasMode) throw new Error("Not in SaaS mode"); - - await apiClient.post("/api/v1/team/invite", { - teamId: currentTeam.teamId, - email, - }); - await fetchTeamInvitations(currentTeam.teamId); - }; - - const acceptInvitation = async (token: string) => { - if (!isSaasMode) throw new Error("Not in SaaS mode"); - - await apiClient.post(`/api/v1/team/invitations/${token}/accept`); - await fetchReceivedInvitations(); - await refreshTeams(); - // Note: Desktop doesn't have refreshCredits/refreshSession like SaaS - }; - - const rejectInvitation = async (token: string) => { - if (!isSaasMode) throw new Error("Not in SaaS mode"); - - await apiClient.post(`/api/v1/team/invitations/${token}/reject`); - await fetchReceivedInvitations(); - }; - - const cancelInvitation = async (invitationId: number) => { - if (!isSaasMode) throw new Error("Not in SaaS mode"); - - await apiClient.delete(`/api/v1/team/invitations/${invitationId}`); - if (currentTeam) { - await fetchTeamInvitations(currentTeam.teamId); - } - }; - - const removeMember = async (memberId: number) => { - if (!currentTeam) throw new Error("No current team"); - if (!isSaasMode) throw new Error("Not in SaaS mode"); - - await apiClient.delete( - `/api/v1/team/${currentTeam.teamId}/members/${memberId}`, - ); - await refreshTeams(); - await fetchTeamMembers(currentTeam.teamId); - }; - - const leaveTeam = async () => { - if (!currentTeam) throw new Error("No current team"); - if (!isSaasMode) throw new Error("Not in SaaS mode"); - - await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`); - await refreshTeams(); - // Note: Desktop doesn't have refreshCredits/refreshSession like SaaS - }; - - const refreshTeams = useCallback(async () => { - if (!isSaasMode || !isAuthenticated) { - console.log( - "[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated", - ); - return; - } - - const newCurrentTeam = await fetchMyTeams(); - await fetchReceivedInvitations(); - if (newCurrentTeam && !newCurrentTeam.isPersonal) { - await fetchTeamMembers(newCurrentTeam.teamId); - // Only fetch invitations if user is team leader - if (newCurrentTeam.isLeader) { - await fetchTeamInvitations(newCurrentTeam.teamId); - } - } - }, [ - isSaasMode, - isAuthenticated, - fetchMyTeams, - fetchReceivedInvitations, - fetchTeamMembers, - fetchTeamInvitations, - ]); - - const isTeamLeader = currentTeam?.isLeader ?? false; - const isPersonalTeam = currentTeam?.isPersonal ?? true; - - return ( - - {children} - - ); -} - -export function useSaaSTeam() { - const context = useContext(SaaSTeamContext); - if (context === undefined) { - throw new Error("useSaaSTeam must be used within a SaaSTeamProvider"); - } - return context; -} - -export { SaaSTeamContext }; -export type { Team, TeamMember, TeamInvitation }; diff --git a/frontend/editor/src/desktop/contexts/SaasBillingContext.tsx b/frontend/editor/src/desktop/contexts/SaasBillingContext.tsx deleted file mode 100644 index d6a5f6b4e..000000000 --- a/frontend/editor/src/desktop/contexts/SaasBillingContext.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import { - createContext, - useContext, - useEffect, - useState, - ReactNode, - useCallback, - useRef, - useMemo, -} from "react"; -import { - saasBillingService, - BillingStatus, - PlanPrice, -} from "@app/services/saasBillingService"; -import { authService } from "@app/services/authService"; -import { connectionModeService } from "@app/services/connectionModeService"; -import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; -import type { TierLevel } from "@app/types/billing"; - -// How long plan pricing is considered fresh. Lives at module level so both the -// provider and usePlanPricing (the consumer hook) can reference it. -const PLANS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour - -/** - * Desktop implementation of SaaS Billing Context - * Provides billing and plan management for users connected to SaaS backend - * CRITICAL: Only active when in SaaS mode - all API calls check connection mode first - * - * Features: - * - Centralized billing state management - * - Automatic caching (5-minute TTL) - * - Lazy loading (fetches on first access) - * - Auto-refresh after checkout/portal - * - No flickering (preserves data during refresh) - */ - -interface SaasBillingContextType { - // Billing Status - billingStatus: BillingStatus | null; - tier: TierLevel; - subscription: BillingStatus["subscription"]; - usage: BillingStatus["meterUsage"]; - isTrialing: boolean; - trialDaysRemaining?: number; - price?: number; - currency?: string; - creditBalance: number; // Real-time remaining credits - - // Available Plans - plans: Map; - plansLoading: boolean; - plansError: string | null; - plansLastFetchTime: number | null; - - // Derived State - isManagedTeamMember: boolean; - - // State Flags - loading: boolean; - error: string | null; - lastFetchTime: number | null; - - // Actions - refreshBilling: () => Promise; - refreshCredits: () => Promise; // Alias for refreshBilling (for clarity) - refreshPlans: () => Promise; - openBillingPortal: () => Promise; -} - -const SaasBillingContext = createContext({ - billingStatus: null, - tier: "free", - subscription: null, - usage: null, - isTrialing: false, - trialDaysRemaining: undefined, - price: undefined, - currency: undefined, - creditBalance: 0, - plans: new Map(), - plansLoading: false, - plansError: null, - plansLastFetchTime: null, - isManagedTeamMember: false, - loading: false, - error: null, - lastFetchTime: null, - refreshBilling: async () => {}, - refreshCredits: async () => {}, - refreshPlans: async () => {}, - openBillingPortal: async () => {}, -}); - -export function SaasBillingProvider({ children }: { children: ReactNode }) { - const [billingStatus, setBillingStatus] = useState( - null, - ); - const [plans, setPlans] = useState>(new Map()); - const [plansLoading, setPlansLoading] = useState(false); - const [plansError, setPlansError] = useState(null); - const [loading, setLoading] = useState(false); // Start false (lazy load) - const [error, setError] = useState(null); - // lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure). - // lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context. - const lastFetchTimeRef = useRef(null); - const [lastFetchTimeValue, setLastFetchTimeValue] = useState( - null, - ); - // billingStatusRef mirrors billingStatus so fetchBillingData can read the current value - // without needing billingStatus in its useCallback dep array. - const billingStatusRef = useRef(null); - // plansLastFetchTimeRef is the source of truth for timing; plansLastFetchTimeValue - // is the state mirror exposed via context so consumers can react to it. - const plansLastFetchTimeRef = useRef(null); - const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState< - number | null - >(null); - // In-flight deduplication — prevents concurrent duplicate network requests. - const plansFetchInProgressRef = useRef(false); - const [isSaasMode, setIsSaasMode] = useState(false); - const [isAuthenticated, setIsAuthenticated] = useState(false); - - // Access team context for derived state - const { - currentTeam, - isPersonalTeam, - isTeamLeader, - loading: teamLoading, - } = useSaaSTeam(); - - // Compute derived state: user is managed member if in non-personal team but not leader - const isManagedTeamMember = currentTeam - ? !isPersonalTeam && !isTeamLeader - : false; - - // Check if in SaaS mode and authenticated (same pattern as SaaSTeamContext) - useEffect(() => { - const checkAccess = async () => { - const mode = await connectionModeService.getCurrentMode(); - const auth = await authService.isAuthenticated(); - setIsSaasMode(mode === "saas"); - setIsAuthenticated(auth); - }; - - checkAccess(); - - // Subscribe to connection mode changes - const unsubscribe = - connectionModeService.subscribeToModeChanges(checkAccess); - return unsubscribe; - }, []); - - // Subscribe to auth changes - useEffect(() => { - const unsubscribe = authService.subscribeToAuth((status) => { - setIsAuthenticated(status === "authenticated"); - }); - return unsubscribe; - }, []); - - // Fetch billing status with caching - const fetchBillingData = useCallback(async () => { - // Guard: Skip if not in SaaS mode or not authenticated - if (!isSaasMode || !isAuthenticated) { - return; - } - - // Guard: Wait for team context to load before determining managed status - if (teamLoading) { - return; - } - - // Guard: Skip if managed team member (billing managed by leader) - if (isManagedTeamMember) { - return; - } - - // Cache check: Skip if fresh data exists (<5 min old) - const now = Date.now(); - if ( - billingStatusRef.current && - lastFetchTimeRef.current && - now - lastFetchTimeRef.current < 300000 - ) { - return; - } - - // Only set loading if no existing data (prevents flicker on refresh) - if (!billingStatusRef.current) { - setLoading(true); - } - - try { - const status = await saasBillingService.getBillingStatus(); - billingStatusRef.current = status; - setBillingStatus(status); - lastFetchTimeRef.current = now; - setLastFetchTimeValue(now); - setError(null); - } catch (err) { - console.error("[SaasBillingContext] Failed to fetch billing:", err); - setError(err instanceof Error ? err.message : "Failed to fetch billing"); - // Don't clear billing status on error (preserve existing data) - } finally { - setLoading(false); - } - }, [isSaasMode, isAuthenticated, isManagedTeamMember, teamLoading]); - - // Raw plan fetch — auth guard + in-flight deduplication only. - // TTL cache logic lives in usePlanPricing so the consumer hook controls staleness policy. - const fetchPlansData = useCallback(async () => { - if (!isSaasMode || !isAuthenticated) { - return; - } - - if (plansFetchInProgressRef.current) { - return; - } - - plansFetchInProgressRef.current = true; - setPlansLoading(true); - setPlansError(null); - - try { - const priceMap = await saasBillingService.getAvailablePlans("usd"); - setPlans(priceMap); - const now = Date.now(); - plansLastFetchTimeRef.current = now; - setPlansLastFetchTimeValue(now); - } catch (err) { - console.error("[SaasBillingContext] Failed to fetch plans:", err); - setPlansError( - err instanceof Error ? err.message : "Failed to fetch plans", - ); - } finally { - plansFetchInProgressRef.current = false; - setPlansLoading(false); - } - }, [isSaasMode, isAuthenticated]); - - // Clear data when leaving SaaS mode or logging out - useEffect(() => { - if (!isSaasMode || !isAuthenticated) { - // Clear state when not in SaaS mode or not authenticated - billingStatusRef.current = null; - setBillingStatus(null); - setPlans(new Map()); - lastFetchTimeRef.current = null; - setLastFetchTimeValue(null); - plansLastFetchTimeRef.current = null; - setPlansLastFetchTimeValue(null); - plansFetchInProgressRef.current = false; - setLoading(false); - setError(null); - setPlansError(null); - } - }, [isSaasMode, isAuthenticated]); - - // Auto-fetch billing when team context finishes loading - useEffect(() => { - // Only fetch if: in SaaS mode, authenticated, team finished loading, and haven't fetched yet - if ( - isSaasMode && - isAuthenticated && - !teamLoading && - !isManagedTeamMember && - billingStatusRef.current === null && - lastFetchTimeRef.current === null - ) { - fetchBillingData(); - } - }, [ - isSaasMode, - isAuthenticated, - teamLoading, - isManagedTeamMember, - fetchBillingData, - ]); - - // Public refresh methods - const refreshBilling = useCallback(async () => { - if (!isSaasMode || !isAuthenticated) { - return; - } - - // Force cache invalidation — write to ref synchronously so fetchBillingData - // reads null immediately (not a stale closure value from the previous render). - lastFetchTimeRef.current = null; - setLastFetchTimeValue(null); - await fetchBillingData(); - }, [isSaasMode, isAuthenticated, fetchBillingData]); - - const refreshPlans = useCallback(async () => { - await fetchPlansData(); - }, [fetchPlansData]); - - const openBillingPortal = useCallback(async () => { - const returnUrl = window.location.href; - await saasBillingService.openBillingPortal(returnUrl); - - // Auto-refresh after portal (delayed for webhook processing) - setTimeout(() => { - refreshBilling(); - }, 3000); - }, [refreshBilling]); - - // Listen for credit updates from API response headers (immediate, no fetch) - useEffect(() => { - const handleCreditsUpdated = (e: Event) => { - const customEvent = e as CustomEvent<{ creditsRemaining: number }>; - const newBalance = customEvent.detail?.creditsRemaining; - - if (typeof newBalance === "number" && billingStatus) { - // Update credit balance in billing status without full refresh - const updated = { ...billingStatus, creditBalance: newBalance }; - billingStatusRef.current = updated; - setBillingStatus(updated); - - // Dispatch exhausted event if credits hit 0 - if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) { - window.dispatchEvent( - new CustomEvent("credits:exhausted", { - detail: { - previousBalance: billingStatus.creditBalance ?? 0, - currentBalance: newBalance, - }, - }), - ); - } - } - }; - - window.addEventListener("credits:updated", handleCreditsUpdated); - return () => { - window.removeEventListener("credits:updated", handleCreditsUpdated); - }; - }, [billingStatus]); - - const contextValue = useMemo( - () => ({ - billingStatus, - tier: isManagedTeamMember ? "team" : (billingStatus?.tier ?? "free"), - subscription: billingStatus?.subscription ?? null, - usage: billingStatus?.meterUsage ?? null, - isTrialing: billingStatus?.isTrialing ?? false, - trialDaysRemaining: billingStatus?.trialDaysRemaining, - price: plans.get("team")?.price, - currency: plans.get("team")?.currency, - creditBalance: billingStatus?.creditBalance ?? 0, - plans, - plansLoading, - plansError, - plansLastFetchTime: plansLastFetchTimeValue, - isManagedTeamMember, - loading: loading || teamLoading, - error, - lastFetchTime: lastFetchTimeValue, - refreshBilling, - refreshCredits: refreshBilling, - refreshPlans, - openBillingPortal, - }), - [ - billingStatus, - isManagedTeamMember, - plans, - plansLoading, - plansError, - plansLastFetchTimeValue, - loading, - teamLoading, - error, - lastFetchTimeValue, - refreshBilling, - refreshPlans, - openBillingPortal, - ], - ); - - return ( - - {children} - - ); -} - -export function useSaaSBilling() { - const context = useContext(SaasBillingContext); - if (context === undefined) { - throw new Error("useSaaSBilling must be used within SaasBillingProvider"); - } - - // Lazy fetch: Trigger fetch on first access (after team context loads) - // Note: context.loading includes teamLoading, so this waits for team to load - useEffect(() => { - const needsFetch = - context.billingStatus === null && - context.lastFetchTime === null && - !context.loading && - !context.isManagedTeamMember; // Managed members don't need billing data - - if (needsFetch) { - context.refreshBilling(); - } - }, [ - context.billingStatus, - context.lastFetchTime, - context.loading, - context.isManagedTeamMember, - context.refreshBilling, - ]); - - return context; -} - -/** - * Hook for components that display plan pricing data. - * Fetches on first use; re-fetches only after PLANS_CACHE_TTL_MS (1 hour). - * Safe to call from multiple components — in-flight deduplication is handled by fetchPlansData. - */ -export function usePlanPricing() { - const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } = - useContext(SaasBillingContext); - - useEffect(() => { - const isFresh = - plansLastFetchTime !== null && - Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS; - - if (!isFresh) { - refreshPlans(); - } - }, [plansLastFetchTime, refreshPlans]); - - return { plans, plansLoading, plansError }; -} - -export { SaasBillingContext }; -export type { SaasBillingContextType }; diff --git a/frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.test.ts b/frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.test.ts new file mode 100644 index 000000000..e85806c3c --- /dev/null +++ b/frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { getCurrentModeMock, subscribeToModeChangesMock } = vi.hoisted(() => ({ + getCurrentModeMock: vi.fn(), + subscribeToModeChangesMock: vi.fn(), +})); + +vi.mock("@app/services/connectionModeService", () => ({ + connectionModeService: { + getCurrentMode: getCurrentModeMock, + subscribeToModeChanges: subscribeToModeChangesMock, + }, +})); + +import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode"; + +describe("useConfirmedSaaSMode", () => { + beforeEach(() => { + getCurrentModeMock.mockReset(); + subscribeToModeChangesMock.mockReset(); + subscribeToModeChangesMock.mockReturnValue(() => {}); + }); + afterEach(() => vi.clearAllMocks()); + + it("starts false before the mode resolves (pessimistic)", () => { + getCurrentModeMock.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useConfirmedSaaSMode()); + expect(result.current).toBe(false); + }); + + it("becomes true once the mode is confirmed saas", async () => { + getCurrentModeMock.mockResolvedValue("saas"); + const { result } = renderHook(() => useConfirmedSaaSMode()); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("stays false in self-hosted mode", async () => { + getCurrentModeMock.mockResolvedValue("selfhosted"); + const { result } = renderHook(() => useConfirmedSaaSMode()); + await act(async () => {}); + expect(result.current).toBe(false); + }); + + it("reacts to a later mode change", async () => { + getCurrentModeMock.mockResolvedValue("selfhosted"); + let notify: ((cfg: { mode: string }) => void) | undefined; + subscribeToModeChangesMock.mockImplementation( + (cb: (cfg: { mode: string }) => void) => { + notify = cb; + return () => {}; + }, + ); + + const { result } = renderHook(() => useConfirmedSaaSMode()); + await act(async () => {}); + expect(result.current).toBe(false); + + await act(async () => notify?.({ mode: "saas" })); + expect(result.current).toBe(true); + }); +}); diff --git a/frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.ts b/frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.ts new file mode 100644 index 000000000..53cfd3ab4 --- /dev/null +++ b/frontend/editor/src/desktop/hooks/useConfirmedSaaSMode.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import { connectionModeService } from "@app/services/connectionModeService"; + +/** + * Like {@link useSaaSMode}, but starts pessimistically FALSE: it returns true + * only once the connection mode has been CONFIRMED to be "saas". + * + * Use this to gate mounting components that fire a network call on mount — the + * cloud team context, the Policies rail + auto-run controller. useSaaSMode() + * starts optimistically true (right for tool-availability UX, where a flash of + * "unavailable" is worse than a flash of "available"), but for a network- + * triggering gate that optimism leaks a request: the gated surface mounts on + * cold start, fires its mount fetch against the local/self-hosted backend, and + * only then unmounts when the real mode resolves. Starting false closes that + * window — nothing mounts (and nothing fetches) until we KNOW we're in SaaS. + */ +export function useConfirmedSaaSMode(): boolean { + const [isSaaSMode, setIsSaaSMode] = useState(false); + + useEffect(() => { + void connectionModeService + .getCurrentMode() + .then((mode) => setIsSaaSMode(mode === "saas")); + return connectionModeService.subscribeToModeChanges((cfg) => + setIsSaaSMode(cfg.mode === "saas"), + ); + }, []); + + return isSaaSMode; +} diff --git a/frontend/editor/src/desktop/hooks/useCreditCheck.ts b/frontend/editor/src/desktop/hooks/useCreditCheck.ts deleted file mode 100644 index 656b1e947..000000000 --- a/frontend/editor/src/desktop/hooks/useCreditCheck.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; -import { useSaaSMode } from "@app/hooks/useSaaSMode"; -import { getToolCreditCost } from "@app/utils/creditCosts"; -import { CREDIT_EVENTS } from "@app/constants/creditEvents"; -import { operationRouter } from "@app/services/operationRouter"; -import type { ToolId } from "@app/types/toolId"; - -/** - * Desktop implementation of credit checking for cloud operations. - * Hooks are called at render time; the returned checkCredits callback - * closes over the billing state so it can be called safely inside - * async operation handlers. - * - * Returns null when the operation is allowed, or an error message string - * when it should be blocked. - */ -export function useCreditCheck(operationType?: string, endpoint?: string) { - const billing = useSaaSBilling(); - const isSaaSMode = useSaaSMode(); - const { t } = useTranslation(); - - const checkCredits = useCallback( - async (runtimeEndpoint?: string): Promise => { - if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted - if (!billing) return null; - - // If the operation routes to the local backend, no credits are consumed — skip check. - // runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint. - const ep = runtimeEndpoint ?? endpoint; - if (ep) { - try { - const willUseSaaS = await operationRouter.willRouteToSaaS(ep); - if (!willUseSaaS) return null; - } catch { - // If routing check fails, fall through to credit check as a safe default. - } - } - - const { creditBalance, loading } = billing; - const requiredCredits = getToolCreditCost(operationType as ToolId); - - if (!loading && creditBalance < requiredCredits) { - window.dispatchEvent( - new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, { - detail: { - operationType, - requiredCredits, - currentBalance: creditBalance, - }, - }), - ); - - return t( - "credits.insufficient.brief", - "Insufficient credits. You need {{required}} credits but have {{current}}.", - { - required: requiredCredits, - current: creditBalance, - }, - ); - } - - return null; - }, - [billing, isSaaSMode, operationType, endpoint, t], - ); - - return { checkCredits }; -} diff --git a/frontend/editor/src/desktop/hooks/useCreditEvents.ts b/frontend/editor/src/desktop/hooks/useCreditEvents.ts deleted file mode 100644 index 626a7161e..000000000 --- a/frontend/editor/src/desktop/hooks/useCreditEvents.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; -import { useSaaSMode } from "@app/hooks/useSaaSMode"; -import { CREDIT_EVENTS } from "@app/constants/creditEvents"; - -/** - * Desktop hook that monitors credit balance and dispatches events - * when credits are exhausted or low. - * Only active in SaaS mode — self-hosted users have no credit balance. - */ -export function useCreditEvents() { - const isSaaSMode = useSaaSMode(); - const { creditBalance } = useSaaSBilling(); - const prevBalanceRef = useRef(creditBalance); - - useEffect(() => { - const prevBalance = prevBalanceRef.current; - - // Dispatch exhausted event when credits reach 0 from positive balance. - // Skip entirely in self-hosted mode — creditBalance defaults to 0 there. - if (isSaaSMode && creditBalance <= 0 && prevBalance > 0) { - window.dispatchEvent( - new CustomEvent(CREDIT_EVENTS.EXHAUSTED, { - detail: { - previousBalance: prevBalance, - currentBalance: creditBalance, - }, - }), - ); - } - - // Update ref for next comparison - prevBalanceRef.current = creditBalance; - }, [isSaaSMode, creditBalance]); -} diff --git a/frontend/editor/src/desktop/hooks/useEnableMeteredBilling.ts b/frontend/editor/src/desktop/hooks/useEnableMeteredBilling.ts deleted file mode 100644 index 7df3aa48b..000000000 --- a/frontend/editor/src/desktop/hooks/useEnableMeteredBilling.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from "react"; -import { supabase } from "@app/auth/supabase"; -import { authService } from "@app/services/authService"; - -/** - * Shared hook for enabling metered (overage) billing via Supabase edge function. - * Used by CreditExhaustedModal and InsufficientCreditsModal to avoid duplicate logic. - * - * @param refreshBilling - Callback to refresh billing state after success - * @param onSuccess - Callback invoked after billing is enabled and refreshed - * @param logPrefix - Label used in console messages for easier tracing - */ -export function useEnableMeteredBilling( - refreshBilling: () => Promise, - onSuccess: () => void, - logPrefix: string, -): { - enablingMetering: boolean; - meteringError: string | null; - handleEnableMetering: () => Promise; -} { - const [enablingMetering, setEnablingMetering] = useState(false); - const [meteringError, setMeteringError] = useState(null); - - const handleEnableMetering = async () => { - console.debug(`[${logPrefix}] Enabling metered billing`); - setEnablingMetering(true); - setMeteringError(null); - - try { - const token = await authService.getAuthToken(); - if (!token) { - throw new Error("Not authenticated"); - } - - const { data, error } = await supabase.functions.invoke( - "create-meter-subscription", - { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }, - ); - - if (error) { - throw new Error(error.message || "Failed to enable metered billing"); - } - - if (!data?.success) { - throw new Error( - data?.error || data?.message || "Failed to enable metered billing", - ); - } - - console.debug(`[${logPrefix}] Metered billing enabled successfully`); - - await refreshBilling(); - onSuccess(); - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "Failed to enable metered billing"; - console.error(`[${logPrefix}] Failed to enable metered billing:`, err); - setMeteringError(message); - } finally { - setEnablingMetering(false); - } - }; - - return { enablingMetering, meteringError, handleEnableMetering }; -} diff --git a/frontend/editor/src/desktop/hooks/useSaaSPlans.ts b/frontend/editor/src/desktop/hooks/useSaaSPlans.ts deleted file mode 100644 index e3ea8163f..000000000 --- a/frontend/editor/src/desktop/hooks/useSaaSPlans.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { - useSaaSBilling, - usePlanPricing, -} from "@app/contexts/SaasBillingContext"; -import { - FREE_PLAN_FEATURES, - TEAM_PLAN_FEATURES, - ENTERPRISE_PLAN_FEATURES, -} from "@app/config/planFeatures"; -import type { TierLevel } from "@app/types/billing"; - -export interface PlanFeature { - name: string; - included: boolean; -} - -export interface PlanTier { - id: TierLevel; - name: string; - price: number; - currency: string; - period: string; - popular?: boolean; - features: PlanFeature[]; - highlights: string[]; - isContactOnly?: boolean; - overagePrice?: number; -} - -export const useSaaSPlans = () => { - const { t } = useTranslation(); - const { refreshPlans } = useSaaSBilling(); - const { plans, plansLoading, plansError } = usePlanPricing(); - - const computedPlans = useMemo(() => { - const teamPlan = plans.get("team"); - - return [ - { - id: "free", - name: t("plan.free.name", "Free"), - price: 0, - currency: "$", - period: t("plan.period.month", "/month"), - highlights: FREE_PLAN_FEATURES.map((f) => - t(f.translationKey, f.defaultText), - ), - features: [ - { - name: t("plan.feature.pdfTools", "Basic PDF Tools"), - included: true, - }, - { - name: t("plan.feature.fileSize", "File Size Limit"), - included: false, - }, - { - name: t("plan.feature.automation", "Automate tool workflows"), - included: false, - }, - { name: t("plan.feature.api", "API Access"), included: false }, - { - name: t("plan.feature.priority", "Priority Support"), - included: false, - }, - { - name: t("plan.feature.customPricing", "Custom Pricing"), - included: false, - }, - ], - }, - { - id: "team", - name: t("plan.team.name", "Team"), - price: teamPlan?.price || 10, - currency: teamPlan?.currency || "$", - period: t("plan.period.month", "/month"), - popular: true, - overagePrice: teamPlan?.overagePrice || 0.05, - highlights: TEAM_PLAN_FEATURES.map((f) => - t(f.translationKey, f.defaultText), - ), - features: [ - { - name: t("plan.feature.pdfTools", "Basic PDF Tools"), - included: true, - }, - { - name: t("plan.feature.fileSize", "File Size Limit"), - included: true, - }, - { - name: t("plan.feature.automation", "Automate tool workflows"), - included: true, - }, - { - name: t("plan.feature.api", "Monthly API Credits"), - included: true, - }, - { - name: t("plan.feature.priority", "Priority Support"), - included: false, - }, - { - name: t("plan.feature.customPricing", "Custom Pricing"), - included: false, - }, - ], - }, - { - id: "enterprise", - name: t("plan.enterprise.name", "Enterprise"), - price: 0, - currency: "$", - period: "", - isContactOnly: true, - highlights: ENTERPRISE_PLAN_FEATURES.map((f) => - t(f.translationKey, f.defaultText), - ), - features: [ - { - name: t("plan.feature.pdfTools", "Basic PDF Tools"), - included: true, - }, - { - name: t("plan.feature.fileSize", "File Size Limit"), - included: true, - }, - { - name: t("plan.feature.automation", "Automate tool workflows"), - included: true, - }, - { - name: t("plan.feature.api", "Monthly API Credits"), - included: true, - }, - { - name: t("plan.feature.priority", "Priority Support"), - included: true, - }, - { - name: t("plan.feature.customPricing", "Custom Pricing"), - included: true, - }, - ], - }, - ]; - }, [t, plans]); - - return { - plans: computedPlans, - loading: plansLoading, - error: plansError, - refetch: refreshPlans, - }; -}; diff --git a/frontend/editor/src/desktop/platform/openExternal.ts b/frontend/editor/src/desktop/platform/openExternal.ts new file mode 100644 index 000000000..3666d799c --- /dev/null +++ b/frontend/editor/src/desktop/platform/openExternal.ts @@ -0,0 +1,16 @@ +/** + * desktop (Tauri) implementation of the @app/platform/openExternal seam. + * + * The app runs inside a Tauri webview, so window.open would trap the URL in our + * own window. We hand it to the OS via the Tauri shell plugin's open() instead + * — the same mechanism authService.openInSystemBrowser already uses — so the + * link lands in the user's real browser. + */ +import { open as shellOpen } from "@tauri-apps/plugin-shell"; +import type { OpenExternal } from "@cloud/platform/openExternal"; + +export const openExternal: OpenExternal = async ( + url: string, +): Promise => { + await shellOpen(url); +}; diff --git a/frontend/editor/src/desktop/services/apiClientSetup.ts b/frontend/editor/src/desktop/services/apiClientSetup.ts index 16d340b49..f94b226a6 100644 --- a/frontend/editor/src/desktop/services/apiClientSetup.ts +++ b/frontend/editor/src/desktop/services/apiClientSetup.ts @@ -10,6 +10,7 @@ import { tauriBackendService } from "@app/services/tauriBackendService"; import { createBackendNotReadyError } from "@app/constants/backendErrors"; import { operationRouter } from "@app/services/operationRouter"; import { authService } from "@app/services/authService"; +import { getAccessToken } from "@app/auth/session"; import { connectionModeService } from "@app/services/connectionModeService"; import { STIRLING_SAAS_URL, @@ -86,7 +87,7 @@ export function setupApiInterceptors(client: AxiosInstance): void { // If another request is already refreshing, wait before attaching token await authService.awaitRefreshIfInProgress(); - const token = await authService.getAuthToken(); + const token = await getAccessToken(); if (token) { extendedConfig.headers.Authorization = `Bearer ${token}`; @@ -224,7 +225,7 @@ export function setupApiInterceptors(client: AxiosInstance): void { if (refreshed) { // Retry the original request with new token - const token = await authService.getAuthToken(); + const token = await getAccessToken(); console.debug( `[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`, ); diff --git a/frontend/editor/src/desktop/services/billing.ts b/frontend/editor/src/desktop/services/billing.ts new file mode 100644 index 000000000..5ff0cb0e4 --- /dev/null +++ b/frontend/editor/src/desktop/services/billing.ts @@ -0,0 +1,126 @@ +/** + * Desktop (Tauri) implementation of the @app/services/billing seam. + * + * Routes Stripe session/portal creation through the desktop supabase-js client's + * functions.invoke with an EXPLICIT Authorization: Bearer header carrying the + * authService token (the desktop client is configured persistSession:false / + * autoRefreshToken:false, so it never auto-attaches a JWT). The request is + * fulfilled by the webview fetch against the Supabase edge functions. + * + * The one substantive difference from the web impl is the return URL: web uses + * window.location.origin, but in the desktop webview that is a localhost dev + * server / tauri:// origin Stripe can't return to. We pass the app's deep-link + * scheme (stirlingpdf://) so Stripe can bring the user back into the app. + */ +import { supabase } from "@app/auth/supabase"; +import { authService } from "@app/services/authService"; +import type { + CheckoutParams, + CheckoutSession, + PortalParams, + PortalSession, +} from "@cloud/services/billing"; + +export type { + CheckoutParams, + CheckoutSession, + PortalParams, + PortalSession, +} from "@cloud/services/billing"; + +/** + * Deep-link the SaaS billing backend uses as Stripe's success/cancel/return + * URL on desktop. The OS routes it back to the running app, which the deep-link + * handler picks up to refresh the wallet after checkout/portal. + */ +const DESKTOP_BILLING_RETURN_URL = "stirlingpdf://billing/return"; + +/** Resolve the desktop JWT, throwing a friendly error when signed out. */ +async function requireToken(): Promise { + const token = await authService.getAuthToken(); + if (!token) { + throw new Error("No authentication token available"); + } + return token; +} + +/** + * Create a Stripe Checkout Session for the PAYG subscription via the + * {@code create-checkout-session} edge function (see StripeCheckoutPanel), + * routed through Tauri (explicit bearer, deep-link callback). The Tauri webview + * has no CSP, so the component mounts the embedded Stripe iframe from the + * returned clientSecret (falling back to the hosted url otherwise). + */ +export async function createCheckoutSession( + params: CheckoutParams, +): Promise { + const token = await requireToken(); + + const { data, error } = await supabase.functions.invoke<{ + client_secret?: string; + url?: string; + mock?: boolean; + }>("create-checkout-session", { + headers: { Authorization: `Bearer ${token}` }, + body: { + team_id: params.teamId, + currency: params.currency ?? "gbp", + success_url: DESKTOP_BILLING_RETURN_URL, + cancel_url: DESKTOP_BILLING_RETURN_URL, + ...(params.billingOwnerEmail + ? { billing_owner_email: params.billingOwnerEmail } + : {}), + }, + }); + + if (error) { + throw error; + } + if (data?.client_secret) { + return { + clientSecret: data.client_secret, + mock: Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"), + }; + } + if (data?.url) { + return { url: data.url }; + } + throw new Error("Edge function returned no client_secret"); +} + +/** + * Mint a Stripe Customer Portal session via the PAYG + * {@code create-customer-portal-session} edge function (its RPC enforces team + * membership), through the desktop supabase client with an explicit bearer and + * the deep-link return URL. + */ +export async function createPortalSession( + params: PortalParams, +): Promise { + const token = await requireToken(); + + const { data, error } = await supabase.functions.invoke<{ + url?: string; + error?: string; + }>("create-customer-portal-session", { + headers: { Authorization: `Bearer ${token}` }, + body: { team_id: params.teamId, return_url: DESKTOP_BILLING_RETURN_URL }, + }); + if (error) { + throw error; + } + if (!data?.url) { + throw new Error(data?.error ?? "Portal session response missing url"); + } + return { url: data.url }; +} + +/** + * The Stripe publishable key for embedded checkout. Desktop reads the same + * build-time {@code VITE_STRIPE_PUBLISHABLE_KEY} the web build uses (baked into + * the Tauri bundle). import.meta.env is permitted in the desktop leaf (the ban + * only applies to cloud/). + */ +export function getStripePublishableKey(): string { + return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; +} diff --git a/frontend/editor/src/desktop/services/httpErrorHandler.test.ts b/frontend/editor/src/desktop/services/httpErrorHandler.test.ts new file mode 100644 index 000000000..7ecb4f31a --- /dev/null +++ b/frontend/editor/src/desktop/services/httpErrorHandler.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents"; +import { FREE_LIMIT_MODAL_EVENT } from "@app/components/usageLimitModals"; +import { handleHttpError } from "@app/services/httpErrorHandler"; + +// The non-PAYG path delegates to core; stub it so these cases stay isolated. +vi.mock("@core/services/httpErrorHandler", () => ({ + handleHttpError: vi.fn().mockResolvedValue(false), +})); + +function axiosErr( + status: number, + sentinel: string, + extra: Record = {}, +) { + return { + isAxiosError: true, + response: { status, data: { error: sentinel, ...extra } }, + }; +} + +describe("desktop handleHttpError — PAYG sentinels", () => { + let events: string[]; + let listener: (e: Event) => void; + + beforeEach(() => { + events = []; + listener = (e: Event) => events.push(e.type); + window.addEventListener(OPEN_SIGN_IN_EVENT, listener); + window.addEventListener(FREE_LIMIT_MODAL_EVENT, listener); + }); + afterEach(() => { + window.removeEventListener(OPEN_SIGN_IN_EVENT, listener); + window.removeEventListener(FREE_LIMIT_MODAL_EVENT, listener); + }); + + test("401 SIGNUP_REQUIRED opens the desktop sign-in modal", async () => { + const handled = await handleHttpError(axiosErr(401, "SIGNUP_REQUIRED")); + expect(handled).toBe(true); + expect(events).toContain(OPEN_SIGN_IN_EVENT); + }); + + test("402 FEATURE_DEGRADED (free) pops the limit modal, not sign-in", async () => { + const handled = await handleHttpError( + axiosErr(402, "FEATURE_DEGRADED", { subscribed: false }), + ); + expect(handled).toBe(true); + expect(events).toContain(FREE_LIMIT_MODAL_EVENT); + expect(events).not.toContain(OPEN_SIGN_IN_EVENT); + }); +}); diff --git a/frontend/editor/src/desktop/services/httpErrorHandler.ts b/frontend/editor/src/desktop/services/httpErrorHandler.ts index e2deed091..e084101f2 100644 --- a/frontend/editor/src/desktop/services/httpErrorHandler.ts +++ b/frontend/editor/src/desktop/services/httpErrorHandler.ts @@ -1,5 +1,10 @@ import { isAxiosError } from "axios"; import { handleHttpError as coreHandleHttpError } from "@core/services/httpErrorHandler"; +import { + classifyPaygError, + handlePaygError, +} from "@app/services/paygErrorInterceptor"; +import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents"; /** * Desktop override of handleHttpError. @@ -10,6 +15,32 @@ import { handleHttpError as coreHandleHttpError } from "@core/services/httpError export async function handleHttpError(error: unknown): Promise { const status = isAxiosError(error) ? error.response?.status : undefined; + // PAYG entitlement sentinels (402 FEATURE_DEGRADED / PAYG_LIMIT_REACHED, + // 401 SIGNUP_REQUIRED) come from the backend EntitlementGuard on DIRECT API + // calls. Mirror saas's paygErrorInterceptor: pop the matching usage-limit + // modal (free vs spend-cap, keyed on `subscribed`) and suppress the generic + // toast — the modal is the actionable surface. Classified strictly so we + // don't hijack the session-expired 401 flow below. Server-side run paths + // (policy auto-run, AI agent) broadcast the usageLimitBridge event instead, + // which the mounted UsageLimitModalHost handles. + const paygKind = classifyPaygError(error); + if (paygKind !== null) { + if (paygKind === "SIGNUP_REQUIRED") { + // Desktop has no web signup-page bootstrap (handlePaygError's + // payg:signupRequired event has no desktop listener). The desktop account + // flow is the SignInModal, so open that directly for an anonymous user who + // hit a billable endpoint. + try { + window.dispatchEvent(new Event(OPEN_SIGN_IN_EVENT)); + } catch { + // non-browser env (tests / SSR) — no-op. + } + } else { + handlePaygError(paygKind, error); + } + return true; // Suppress generic toast — modal handles it. + } + if (status === 401) { // In desktop builds, 401s are handled by the auth service (token refresh + toast // shown by apiClientSetup). Authentication is done via the onboarding modal or diff --git a/frontend/editor/src/desktop/services/operationRouter.test.ts b/frontend/editor/src/desktop/services/operationRouter.test.ts new file mode 100644 index 000000000..2580a5395 --- /dev/null +++ b/frontend/editor/src/desktop/services/operationRouter.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test, vi } from "vitest"; + +// Verifies operationRouter.getBaseUrl host selection in SaaS mode: cloud-only +// feature endpoints (payg/team/policies) must hit the SaaS backend, NOT the local +// bundled backend (which doesn't serve them — regression that returned 500 for +// /api/v1/payg/wallet). A plain non-cloud, non-tool endpoint still defaults local. + +const SAAS_URL = "https://api.saas.test"; + +// NB: vi.mock factories are hoisted above top-level consts, so they must use +// literals (not SAAS_URL/LOCAL_URL) to avoid a TDZ ReferenceError. +vi.mock("@app/services/connectionModeService", () => ({ + connectionModeService: { + getCurrentMode: vi.fn().mockResolvedValue("saas"), + getServerConfig: vi + .fn() + .mockResolvedValue({ url: "https://api.saas.test" }), + }, +})); +vi.mock("@app/constants/connection", () => ({ + STIRLING_SAAS_BACKEND_API_URL: "https://api.saas.test", +})); +vi.mock("@app/services/tauriBackendService", () => ({ + tauriBackendService: { + isOnline: true, + getBackendUrl: () => "http://localhost:62994", + }, +})); +vi.mock("@app/services/endpointAvailabilityService", () => ({ + endpointAvailabilityService: { + isEndpointSupportedLocally: vi.fn().mockResolvedValue(true), + isEndpointSupportedOnSaaS: vi.fn().mockResolvedValue(true), + }, +})); +vi.mock("@app/services/selfHostedServerMonitor", () => ({ + selfHostedServerMonitor: { getSnapshot: () => ({ status: "online" }) }, +})); +vi.mock("@app/i18n", () => ({ + default: { t: (_k: string, fallback: string) => fallback || _k }, +})); + +import { operationRouter } from "@app/services/operationRouter"; + +describe("operationRouter.getBaseUrl — SaaS mode cloud-only routing", () => { + test.each([ + "/api/v1/payg/wallet", + "/api/v1/payg/cap", + "/api/v1/payg/dev/mark-subscribed", + "/api/v1/team/my", + "/api/v1/policies", + "/api/v1/policies/run", + ])("%s routes to the SaaS backend (not local)", async (endpoint) => { + await expect(operationRouter.getBaseUrl(endpoint)).resolves.toBe(SAAS_URL); + }); + + test("willRouteToSaaS is true for cloud-only endpoints", async () => { + await expect( + operationRouter.willRouteToSaaS("/api/v1/payg/wallet"), + ).resolves.toBe(true); + }); +}); diff --git a/frontend/editor/src/desktop/services/operationRouter.ts b/frontend/editor/src/desktop/services/operationRouter.ts index 268e3b9e7..f588f12f2 100644 --- a/frontend/editor/src/desktop/services/operationRouter.ts +++ b/frontend/editor/src/desktop/services/operationRouter.ts @@ -45,17 +45,22 @@ export class OperationRouter { } /** - * Check if endpoint should route to SaaS backend (not local) + * Cloud-only endpoints: features the local bundled backend does NOT serve, so + * in SaaS mode they must ALWAYS hit the SaaS cloud backend (never local). + * These are not tool (PDF-processing) endpoints — they're account/billing/ + * automation platform features — so they bypass the local-first tool routing. * @param endpoint - The endpoint path to check - * @returns true if endpoint should route to SaaS backend + * @returns true if endpoint must route to the SaaS backend */ private isSaaSBackendEndpoint(endpoint?: string): boolean { if (!endpoint) return false; const saasBackendPatterns = [ - /^\/api\/v1\/team\//, // Team endpoints - /^\/api\/v1\/auth\//, // Auth endpoints (Supabase auth in SaaS mode) - // Add more SaaS-specific patterns here as needed + /^\/api\/v1\/team\//, // Team management + /^\/api\/v1\/auth\//, // Supabase auth (SaaS mode) + /^\/api\/v1\/payg\//, // PAYG wallet / spend-cap / billing + /^\/api\/v1\/policies(?:\/|$)/, // Policy runs — must bill via the cloud + // Add more cloud-only feature prefixes here as they land. ]; return saasBackendPatterns.some((pattern) => pattern.test(endpoint)); diff --git a/frontend/editor/src/desktop/services/saasBillingService.ts b/frontend/editor/src/desktop/services/saasBillingService.ts deleted file mode 100644 index 61c0ee99e..000000000 --- a/frontend/editor/src/desktop/services/saasBillingService.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { open as shellOpen } from "@tauri-apps/plugin-shell"; -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; -import { supabase } from "@app/auth/supabase"; -import { authService } from "@app/services/authService"; -import { connectionModeService } from "@app/services/connectionModeService"; -import { - STIRLING_SAAS_URL, - STIRLING_SAAS_BACKEND_API_URL, - SUPABASE_KEY, -} from "@app/constants/connection"; -import type { - TierLevel, - SubscriptionStatus, - StripePlanId, -} from "@app/types/billing"; -import { getCurrencySymbol } from "@app/config/billing"; - -/** - * Billing status returned from Supabase edge function - */ -export interface BillingStatus { - subscription: { - id: string; - status: SubscriptionStatus; - currentPeriodStart: number; // Unix timestamp - currentPeriodEnd: number; // Unix timestamp - } | null; - meterUsage: { - currentPeriodCredits: number; // Overage credits used - estimatedCost: number; // In cents - } | null; - tier: TierLevel; - isTrialing: boolean; - trialDaysRemaining?: number; - creditBalance?: number; // Real-time remaining credits -} - -/** - * Response from manage-billing edge function - */ -interface ManageBillingResponse { - url: string; -} - -/** - * Plan pricing information - */ -export interface PlanPrice { - price: number; - currency: string; - overagePrice?: number; -} - -/** - * Service for managing SaaS billing operations (Stripe + Supabase) - * Desktop-layer implementation using Tauri APIs for browser integration - */ -export class SaasBillingService { - private static instance: SaasBillingService; - - static getInstance(): SaasBillingService { - if (!SaasBillingService.instance) { - SaasBillingService.instance = new SaasBillingService(); - } - return SaasBillingService.instance; - } - - /** - * Check if billing features are available (SaaS mode only) - */ - async isBillingAvailable(): Promise { - try { - const mode = await connectionModeService.getCurrentMode(); - const isAuthenticated = await authService.isAuthenticated(); - return mode === "saas" && isAuthenticated; - } catch (error) { - console.error( - "[Desktop Billing] Failed to check billing availability:", - error, - ); - return false; - } - } - - /** - * Fetch billing status from Supabase edge function - * Calls get-usage-billing which returns subscription + meter usage data - */ - async getBillingStatus(): Promise { - // Check if in SaaS mode - const isAvailable = await this.isBillingAvailable(); - if (!isAvailable) { - throw new Error("Billing is only available in SaaS mode"); - } - - // Get JWT token for authentication - const token = await authService.getAuthToken(); - if (!token) { - throw new Error("No authentication token available"); - } - - try { - // Call RPC via REST API using Tauri fetch (Supabase client RPC may not work in Tauri) - const rpcUrl = `${STIRLING_SAAS_URL}/rest/v1/rpc/get_user_billing_status`; - - const response = await tauriFetch(rpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - apikey: SUPABASE_KEY || "", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({}), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("[Desktop Billing] RPC error response:", errorText); - throw new Error( - `RPC call failed: ${response.status} ${response.statusText}`, - ); - } - - // RPC may return an array or a single object — normalise to array then take first element - const raw = (await response.json()) as unknown; - const billingDataArray = Array.isArray(raw) ? raw : raw ? [raw] : []; - const billingData = billingDataArray[0] as - | { - user_id: string; - has_metered_billing_enabled: boolean; - is_pro: boolean; - } - | undefined; - - // Determine tier based on pro status - const isPro = billingData?.is_pro || false; - const tier: BillingStatus["tier"] = isPro ? "team" : "free"; - - // Fetch additional subscription details if pro - let subscription: BillingStatus["subscription"] = null; - let meterUsage: BillingStatus["meterUsage"] = null; - let isTrialing = false; - let trialDaysRemaining: number | undefined; - - if (isPro) { - // Fetch usage details - try { - const { data: usageData, error: usageError } = - await supabase.functions.invoke<{ - subscription: BillingStatus["subscription"]; - meterUsage: BillingStatus["meterUsage"]; - }>("get-usage-billing", { - headers: { - Authorization: `Bearer ${token}`, - }, - body: {}, - }); - - if (!usageError && usageData) { - subscription = usageData.subscription; - meterUsage = usageData.meterUsage; - - if (subscription?.status === "trialing") { - isTrialing = true; - const trialEnd = subscription.currentPeriodEnd; - const now = Math.floor(Date.now() / 1000); - trialDaysRemaining = Math.ceil((trialEnd - now) / (24 * 60 * 60)); - } - } - } catch (usageError) { - console.warn( - "[Desktop Billing] Failed to fetch usage data:", - usageError, - ); - } - } - - // Fetch credit balance for all authenticated users (both Pro and Free) - // Use backend API endpoint /api/v1/credits (same as SaaS web) - let creditBalance: number | undefined; - try { - const creditsEndpoint = `${STIRLING_SAAS_BACKEND_API_URL}/api/v1/credits`; - const creditResponse = await tauriFetch(creditsEndpoint, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (creditResponse.ok) { - const creditData = await creditResponse.json(); - // Backend returns { totalAvailableCredits: number, ... } - const credits = creditData?.totalAvailableCredits; - creditBalance = typeof credits === "number" ? credits : 0; - } else { - const errorText = await creditResponse.text(); - console.warn( - "[Desktop Billing] Failed to fetch credit balance:", - creditResponse.status, - errorText, - ); - creditBalance = 0; - } - } catch (error) { - console.error( - "[Desktop Billing] Error fetching credit balance:", - error, - ); - creditBalance = 0; - } - - const billingStatus: BillingStatus = { - subscription, - meterUsage, - tier, - isTrialing, - trialDaysRemaining, - creditBalance, - }; - - return billingStatus; - } catch (error) { - console.error("[Desktop Billing] Failed to fetch billing status:", error); - - if (error instanceof Error) { - throw error; - } - - throw new Error("Failed to fetch billing status", { cause: error }); - } - } - - /** - * Open Stripe billing portal in system browser - * Calls manage-billing edge function to get portal URL - */ - async openBillingPortal(returnUrl: string): Promise { - // Check if in SaaS mode - const isAvailable = await this.isBillingAvailable(); - if (!isAvailable) { - throw new Error("Billing portal is only available in SaaS mode"); - } - - // Get JWT token for authentication - const token = await authService.getAuthToken(); - if (!token) { - throw new Error("No authentication token available"); - } - - try { - // Call Supabase edge function to get Stripe portal URL - const { data, error } = - await supabase.functions.invoke( - "manage-billing", - { - headers: { - Authorization: `Bearer ${token}`, - }, - body: { - return_url: returnUrl, - }, - }, - ); - - if (error) { - console.error( - "[Desktop Billing] Error creating billing portal session:", - error, - ); - throw new Error( - error.message || "Failed to create billing portal session", - ); - } - - if (!data || !data.url) { - throw new Error("No portal URL returned from manage-billing"); - } - - // Open in system browser (same pattern as OAuth) - await shellOpen(data.url); - } catch (error) { - console.error("[Desktop Billing] Failed to open billing portal:", error); - - if (error instanceof Error) { - throw error; - } - - throw new Error("Failed to open billing portal", { cause: error }); - } - } - - /** - * Fetch available plan pricing from Stripe - * Calls stripe-price-lookup edge function to get current pricing for all plans - */ - async getAvailablePlans( - currencyCode: string = "usd", - ): Promise> { - // Check if in SaaS mode - const isAvailable = await this.isBillingAvailable(); - if (!isAvailable) { - throw new Error("Billing is only available in SaaS mode"); - } - - // Get JWT token for authentication - const token = await authService.getAuthToken(); - if (!token) { - throw new Error("No authentication token available"); - } - - try { - const { data, error } = await supabase.functions.invoke<{ - prices: Record; - missing: string[]; - }>("stripe-price-lookup", { - headers: { - Authorization: `Bearer ${token}`, - }, - body: { - lookup_keys: ["plan:pro", "meter:overage"], - currency: currencyCode, - }, - }); - - if (error) { - throw new Error(error.message || "Failed to fetch plan pricing"); - } - - if (!data || !data.prices) { - throw new Error("No pricing data returned"); - } - - // Map prices with currency symbols - const plans = new Map(); - const proPrice = data.prices["plan:pro"]; - const overagePrice = data.prices["meter:overage"]; - - if (proPrice) { - plans.set("team", { - price: proPrice.unit_amount / 100, - currency: getCurrencySymbol(proPrice.currency), - overagePrice: overagePrice ? overagePrice.unit_amount / 100 : 0.05, - }); - } - - return plans; - } catch (error) { - console.error("[Desktop Billing] Error fetching available plans:", error); - throw error; - } - } - - /** - * Open Stripe checkout for plan upgrades in system browser - * Creates hosted checkout session and opens in browser - */ - async openCheckout(planId: StripePlanId, returnUrl: string): Promise { - // Check if in SaaS mode - const isAvailable = await this.isBillingAvailable(); - if (!isAvailable) { - throw new Error("Checkout is only available in SaaS mode"); - } - - // Get JWT token for authentication - const token = await authService.getAuthToken(); - if (!token) { - throw new Error("No authentication token available"); - } - - try { - // Call Supabase edge function to create checkout session - // Use 'hosted' mode for browser redirect instead of 'embedded' - const { data, error } = await supabase.functions.invoke<{ url: string }>( - "create-checkout", - { - headers: { - Authorization: `Bearer ${token}`, - }, - body: { - ui_mode: "hosted", - success_url: `${returnUrl}/checkout/success`, - cancel_url: `${returnUrl}/checkout/cancel`, - purchase_type: "subscription", - plan: planId, - }, - }, - ); - - if (error) { - console.error( - "[Desktop Billing] Error creating checkout session:", - error, - ); - throw new Error(error.message || "Failed to create checkout session"); - } - - if (!data || !data.url) { - console.error("[Desktop Billing] Invalid response data:", data); - throw new Error("No checkout URL returned from create-checkout"); - } - - // Open in system browser (same pattern as billing portal) - await shellOpen(data.url); - } catch (error) { - console.error( - "[Desktop Billing] Failed to create checkout session:", - error, - ); - - if (error instanceof Error) { - throw error; - } - - throw new Error("Failed to create checkout session", { cause: error }); - } - } -} - -export const saasBillingService = SaasBillingService.getInstance(); diff --git a/frontend/editor/src/desktop/services/tauriHttpClient.test.ts b/frontend/editor/src/desktop/services/tauriHttpClient.test.ts new file mode 100644 index 000000000..ae59d7e91 --- /dev/null +++ b/frontend/editor/src/desktop/services/tauriHttpClient.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; + +// Regression: a caller-set "Content-Type: multipart/form-data" (no boundary) on a +// FormData POST must NOT reach the server, or Jetty rejects it with +// "No multipart boundary parameter in Content-Type". The client must drop it so the +// native fetch generates the boundary (axios does this for FormData). + +const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() })); +vi.mock("@tauri-apps/plugin-http", () => ({ fetch: fetchMock })); + +import { create } from "@app/services/tauriHttpClient"; + +function okJson() { + return { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "application/json" }), + text: async () => "{}", + }; +} + +function lastFetchHeaders(): Record { + const opts = fetchMock.mock.calls[0]?.[1] ?? {}; + return (opts.headers ?? {}) as Record; +} + +describe("tauriHttpClient — Content-Type handling", () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(okJson()); + }); + + test("strips a caller-set Content-Type on FormData so the boundary is generated", async () => { + const client = create({ baseURL: "https://api.test" }); + const form = new FormData(); + form.append("fileInput", new Blob(["x"]), "f.pdf"); + + await client.post("/api/v1/policies/abc/run", form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const keys = Object.keys(lastFetchHeaders()).map((k) => k.toLowerCase()); + expect(keys).not.toContain("content-type"); + expect(fetchMock.mock.calls[0][1].body).toBeInstanceOf(FormData); + }); + + test("keeps application/json for plain object bodies", async () => { + const client = create({ baseURL: "https://api.test" }); + await client.post("/api/v1/x", { a: 1 }); + + const ct = Object.entries(lastFetchHeaders()).find( + ([k]) => k.toLowerCase() === "content-type", + )?.[1]; + expect(ct).toBe("application/json"); + }); +}); diff --git a/frontend/editor/src/desktop/services/tauriHttpClient.ts b/frontend/editor/src/desktop/services/tauriHttpClient.ts index c214f1330..3a39866c4 100644 --- a/frontend/editor/src/desktop/services/tauriHttpClient.ts +++ b/frontend/editor/src/desktop/services/tauriHttpClient.ts @@ -213,6 +213,15 @@ class TauriHttpClient { if (finalConfig.data instanceof FormData) { // FormData can be passed directly body = finalConfig.data; + // Drop any caller-supplied Content-Type so the native fetch generates + // multipart/form-data WITH its boundary (matches axios's FormData + // handling). A boundary-less "multipart/form-data" header makes the + // server reject the body ("No multipart boundary parameter"). + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === "content-type") { + delete headers[key]; + } + } } else if (typeof finalConfig.data === "object") { // Serialize as JSON body = JSON.stringify(finalConfig.data); diff --git a/frontend/editor/src/desktop/tsconfig.json b/frontend/editor/src/desktop/tsconfig.json index f50dc35a6..b210c8a15 100644 --- a/frontend/editor/src/desktop/tsconfig.json +++ b/frontend/editor/src/desktop/tsconfig.json @@ -3,7 +3,13 @@ "compilerOptions": { "baseUrl": "../../", "paths": { - "@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"], + "@app/*": [ + "src/desktop/*", + "src/cloud/*", + "src/proprietary/*", + "src/core/*" + ], + "@cloud/*": ["src/cloud/*"], "@proprietary/*": ["src/proprietary/*"], "@core/*": ["src/core/*"], "@shared/*": ["../shared/*"] diff --git a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx index 7b9d0cfea..23856929a 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx @@ -2,7 +2,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useConfigNavSections as useCoreConfigNavSections, - createConfigNavSections as createCoreConfigNavSections, ConfigNavSection, } from "@core/components/shared/config/configNavSections"; import PeopleSection from "@app/components/shared/config/configSections/PeopleSection"; @@ -270,251 +269,6 @@ export const useConfigNavSections = ( return sections; }; -/** - * Deprecated: Use useConfigNavSections hook instead - * Proprietary extension of createConfigNavSections that adds all admin and workspace sections - */ -export const createConfigNavSections = ( - isAdmin: boolean = false, - runningEE: boolean = false, - loginEnabled: boolean = false, -): ConfigNavSection[] => { - console.warn( - "createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.", - ); - - // Get the core sections (just Preferences) - const sections = createCoreConfigNavSections( - isAdmin, - runningEE, - loginEnabled, - ); - - // Add account management under Preferences - const preferencesSection = sections.find((section) => - section.items.some((item) => item.key === "general"), - ); - if (preferencesSection) { - preferencesSection.items = preferencesSection.items.map((item) => - item.key === "general" - ? { ...item, component: } - : item, - ); - - if (loginEnabled) { - preferencesSection.items.push({ - key: "account", - label: "Account", - icon: "person-rounded", - component: , - }); - } - } - - // Add Admin sections if user is admin OR if login is disabled (but mark as disabled) - if (isAdmin || !loginEnabled) { - const requiresLogin = !loginEnabled; - - // Workspace - sections.push({ - title: "Workspace", - items: [ - { - key: "people", - label: "People", - icon: "group-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "teams", - label: "Teams", - icon: "groups-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - ], - }); - - // Configuration - sections.push({ - title: "Configuration", - items: [ - { - key: "adminGeneral", - label: "System Settings", - icon: "settings-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminFeatures", - label: "Features", - icon: "extension-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminEndpoints", - label: "Endpoints", - icon: "api-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminDatabase", - label: "Database", - icon: "storage-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminAdvanced", - label: "Advanced", - icon: "tune-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - ], - }); - - // Security & Authentication - sections.push({ - title: "Security & Authentication", - items: [ - { - key: "adminSecurity", - label: "Security", - icon: "shield-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminConnections", - label: "Connections", - icon: "link-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - ], - }); - - // Licensing & Analytics - sections.push({ - title: "Licensing & Analytics", - items: [ - { - key: "adminPlan", - label: "Plan", - icon: "star-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminAudit", - label: "Audit", - icon: "fact-check-rounded", - component: , - // Non-Enterprise users can click in to see the demo preview. - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminUsage", - label: "Usage Analytics", - icon: "analytics-rounded", - component: , - // Non-Enterprise users can click in to see the demo preview. - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - ], - }); - - // Policies & Privacy - sections.push({ - title: "Policies & Privacy", - items: [ - { - key: "adminLegal", - label: "Legal", - icon: "gavel-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - { - key: "adminPrivacy", - label: "Privacy", - icon: "visibility-rounded", - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin - ? "Enable login mode first" - : undefined, - }, - ], - }); - } - - // Add Developer section if login is enabled - if (loginEnabled) { - const developerSection: ConfigNavSection = { - title: "Developer", - items: [ - { - key: "api-keys", - label: "API Keys", - icon: "key-rounded", - component: , - }, - ], - }; - - // Add Developer section after Preferences (or Workspace if it exists) - const insertIndex = isAdmin ? 2 : 1; - sections.splice(insertIndex, 0, developerSection); - } - - return sections; -}; - // Re-export types for convenience export type { ConfigNavSection, diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index 8f7315c4f..c7e6a8b6c 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -56,10 +56,12 @@ export async function runStoredPolicy( ): Promise { const form = new FormData(); for (const file of files) form.append("fileInput", file); + // Don't set Content-Type: the HTTP client must generate multipart/form-data + // WITH its boundary from the FormData body. A manual boundary-less header makes + // the server reject the request ("no multipart boundary parameter"). const res = await apiClient.post( `/api/v1/policies/${encodeURIComponent(id)}/run`, form, - { headers: { "Content-Type": "multipart/form-data" } }, ); return res.data.jobId; } @@ -81,9 +83,8 @@ export async function runPolicyPipeline( "json", new Blob([JSON.stringify(definition)], { type: "application/json" }), ); - const res = await apiClient.post("/api/v1/policies/run", form, { - headers: { "Content-Type": "multipart/form-data" }, - }); + // No Content-Type: let the client set multipart/form-data with its boundary. + const res = await apiClient.post("/api/v1/policies/run", form); return res.data.jobId; } diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index c706c964d..7825816a5 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -18,7 +18,6 @@ import OAuthConsent from "@app/routes/OAuthConsent"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import MobileScannerPage from "@app/pages/MobileScannerPage"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; -import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; @@ -47,9 +46,9 @@ function PublicRouteProviders({ children }: { children: ReactNode }) { } /** - * Onboarding and trial-expired modals must never cover auth-flow pages - * (login, signup, OAuth consent): they steal focus from the task the user - * was sent there to complete. Unmounting also stops their background polling. + * Onboarding / sign-up modals must never cover auth-flow pages (login, signup, + * OAuth consent): they steal focus from the task the user was sent there to + * complete. */ function NonAuthBootstraps() { const location = useLocation(); @@ -59,7 +58,6 @@ function NonAuthBootstraps() { return ( <> - diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index fd7ee93c1..04dea19c7 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -54,15 +54,6 @@ export function deriveDisplayName( ); } -export interface TrialStatus { - isTrialing: boolean; - trialEnd: string; - daysRemaining: number; - hasPaymentMethod: boolean; - hasScheduledSub: boolean; - status: string; -} - interface AuthContextType { session: Session | null; user: User | null; @@ -83,7 +74,6 @@ interface AuthContextType { subscription: SubscriptionInfo | null; creditSummary: CreditSummary | null; isPro: boolean | null; - trialStatus: TrialStatus | null; profilePictureUrl: string | null; profilePictureMetadata: ProfilePictureMetadata | null; signOut: () => Promise; @@ -92,7 +82,6 @@ interface AuthContextType { updateCredits: (newBalance: number) => void; refreshCredits: () => Promise; refreshProStatus: () => Promise; - refreshTrialStatus: () => Promise; refreshProfilePicture: () => Promise; refreshProfilePictureMetadata: () => Promise; } @@ -108,7 +97,6 @@ const AuthContext = createContext({ subscription: null, creditSummary: null, isPro: null, - trialStatus: null, profilePictureUrl: null, profilePictureMetadata: null, signOut: async () => {}, @@ -121,7 +109,6 @@ const AuthContext = createContext({ updateCredits: () => {}, refreshCredits: async () => {}, refreshProStatus: async () => {}, - refreshTrialStatus: async () => {}, refreshProfilePicture: async () => {}, refreshProfilePictureMetadata: async () => {}, }); @@ -138,7 +125,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { null, ); const [isPro, setIsPro] = useState(null); - const [trialStatus, setTrialStatus] = useState(null); const [profilePictureUrl, setProfilePictureUrl] = useState( null, ); @@ -197,75 +183,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetchProStatus(); }, [fetchProStatus]); - const fetchTrialStatus = useCallback( - async (sessionToUse?: Session | null) => { - const currentSession = sessionToUse ?? session; - - if (!currentSession?.user) { - console.debug( - "[Auth Debug] No user session, skipping trial status fetch", - ); - setTrialStatus(null); - return; - } - - try { - console.debug( - "[Auth Debug] Fetching trial status for user:", - currentSession.user.id, - ); - const { data, error } = await supabase - .from("billing_subscriptions") - .select( - "status, trial_end, has_payment_method, scheduled_subscription_id", - ) - .in("status", ["trialing", "incomplete_expired", "canceled"]) - .order("created_at", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (error) { - console.error("[Auth Debug] Error fetching trial status:", error); - setTrialStatus(null); - return; - } - - if (data?.trial_end) { - const trialEnd = new Date(data.trial_end); - const now = new Date(); - const daysRemaining = Math.ceil( - (trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), - ); - - setTrialStatus({ - isTrialing: data.status === "trialing" && daysRemaining > 0, - trialEnd: data.trial_end, - daysRemaining: Math.max(0, daysRemaining), - hasPaymentMethod: data.has_payment_method || false, - hasScheduledSub: !!data.scheduled_subscription_id, - status: data.status, - }); - console.debug("[Auth Debug] Trial status fetched:", { - status: data.status, - daysRemaining: Math.max(0, daysRemaining), - hasPaymentMethod: data.has_payment_method, - isTrialing: data.status === "trialing" && daysRemaining > 0, - }); - } else { - setTrialStatus(null); - } - } catch (error: unknown) { - console.debug("[Auth Debug] Failed to fetch trial status:", error); - setTrialStatus(null); - } - }, - [session], - ); - - const refreshTrialStatus = useCallback(async () => { - await fetchTrialStatus(); - }, [fetchTrialStatus]); - // Provider photo as interim fallback when the bucket copy is missing — // skipped when the user explicitly chose upload/removal (source "upload"). const providerAvatarFallback = useCallback( @@ -482,7 +399,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setSession(data.session); - // Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response + // Fetch credits, pro status, profile picture metadata, and profile picture using the session from the response if (data.session?.user) { // Sync OAuth avatar in background; fetch the picture once the // sync settles instead of guessing with a fixed delay. @@ -498,7 +415,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetchCredits(data.session); await fetchProStatus(data.session); - await fetchTrialStatus(data.session); await fetchProfilePictureMetadata(data.session); } } @@ -542,12 +458,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Additional handling for specific events if (event === "SIGNED_OUT") { console.debug("[Auth Debug] User signed out, clearing session"); - // Clear credit data, pro status, trial status, profile picture, and metadata on sign out + // Clear credit data, pro status, profile picture, and metadata on sign out setCreditBalance(null); setCreditSummary(null); setSubscription(null); setIsPro(null); - setTrialStatus(null); setProfilePictureUrl(null); setProfilePictureMetadata(null); } else if (event === "SIGNED_IN") { @@ -577,7 +492,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { Promise.all([ fetchCredits(newSession), fetchProStatus(newSession), - fetchTrialStatus(newSession), fetchProfilePictureMetadata(newSession), ]).then(() => { // Fetch the picture once the avatar sync settles. @@ -592,12 +506,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } else if (event === "TOKEN_REFRESHED") { console.debug("[Auth Debug] Token refreshed"); - // Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh + // Optionally refresh credits, pro status, profile picture metadata, and profile picture on token refresh if (newSession?.user) { Promise.all([ fetchCredits(newSession), fetchProStatus(newSession), - fetchTrialStatus(newSession), fetchProfilePictureMetadata(newSession), fetchProfilePicture(newSession), ]).then(() => { @@ -634,12 +547,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] User upgrade synchronized successfully", ); - // Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade + // Refresh credits, pro status, profile picture metadata, and profile picture after upgrade if (newSession?.user) { return Promise.all([ fetchCredits(newSession), fetchProStatus(newSession), - fetchTrialStatus(newSession), fetchProfilePictureMetadata(newSession), fetchProfilePicture(newSession), ]); @@ -681,7 +593,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { subscription, creditSummary, isPro, - trialStatus, profilePictureUrl, profilePictureMetadata, signOut, @@ -690,7 +601,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { updateCredits, refreshCredits, refreshProStatus, - refreshTrialStatus, refreshProfilePicture, refreshProfilePictureMetadata, }; diff --git a/frontend/editor/src/saas/auth/session.ts b/frontend/editor/src/saas/auth/session.ts new file mode 100644 index 000000000..a72fd81f6 --- /dev/null +++ b/frontend/editor/src/saas/auth/session.ts @@ -0,0 +1,22 @@ +import { supabase } from "@app/auth/supabase"; +import type { AppSession } from "@cloud/auth/session"; + +export type { AppSession }; + +/** + * SaaS (web) implementation of the @app/auth/session seam. + * + * Reads the Supabase web session and returns its access token. Mirrors the + * token lookup that apiClientSetup.getAuthHeaders performed previously. + */ +export async function getAccessToken(): Promise { + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session?.access_token ?? null; + } catch (e) { + console.warn("[auth/session] Failed to read Supabase session", e); + return null; + } +} diff --git a/frontend/editor/src/saas/auth/teamSession.ts b/frontend/editor/src/saas/auth/teamSession.ts new file mode 100644 index 000000000..16a71069f --- /dev/null +++ b/frontend/editor/src/saas/auth/teamSession.ts @@ -0,0 +1,35 @@ +/** + * saas (web) implementation of the @app/auth/teamSession seam. + * + * Wires the cloud {@link SaaSTeamContext} to the SaaS-only auth surface it used + * to reach directly before the move to cloud/: the Supabase web session + * ({@code useAuth()}), the anonymous check ({@code isUserAnonymous}), and the + * credit + session refreshers. Those imports are banned in cloud (Supabase web + * client + the SaaS-only {@code useAuth} shape), so cloud reaches them through + * {@link useTeamAuth} instead. Behaviour is preserved verbatim from the + * pre-move saas SaaSTeamContext. + */ +import { useCallback } from "react"; +import { useAuth } from "@app/auth/UseSession"; +import { isUserAnonymous } from "@app/auth/supabase"; +import type { TeamAuth } from "@cloud/auth/teamSession"; + +export type { TeamAuth } from "@cloud/auth/teamSession"; + +export function useTeamAuth(): TeamAuth { + const { user, refreshCredits, refreshSession } = useAuth(); + + // Teams require a signed-in, non-anonymous user — anonymous (guest) sessions + // never load or manage teams. + const canUseTeams = !!user && !isUserAnonymous(user); + + // After a membership change the user's billing tier may have changed, so + // refresh credits + the Supabase session (mirrors the pre-move accept/leave + // flow in saas SaaSTeamContext). + const refreshAfterMembershipChange = useCallback(async () => { + await refreshCredits(); + await refreshSession(); + }, [refreshCredits, refreshSession]); + + return { canUseTeams, refreshAfterMembershipChange }; +} diff --git a/frontend/editor/src/saas/components/OnboardingBootstrap.tsx b/frontend/editor/src/saas/components/OnboardingBootstrap.tsx index 89dffddf4..27ae92b16 100644 --- a/frontend/editor/src/saas/components/OnboardingBootstrap.tsx +++ b/frontend/editor/src/saas/components/OnboardingBootstrap.tsx @@ -15,64 +15,16 @@ export default function OnboardingBootstrap() { const { preferences, updatePreference } = usePreferences(); const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding(); - const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth(); + const { user, loading } = useAuth(); const [showModal, setShowModal] = useState(false); - const [isPolling, setIsPolling] = useState(false); - const [pollAttempts, setPollAttempts] = useState(0); - // Start polling when user logs in + // Show the onboarding modal once on first login, after the user has loaded. useEffect(() => { const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true"; - - if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) { - console.debug("[Onboarding] Starting poll for trial data"); - setIsPolling(true); - setPollAttempts(0); - } - }, [user, loading, isPolling, showModal]); - - // Poll for trial data - useEffect(() => { - if (!isPolling) return; - - const pollInterval = 500; // Check every 500ms - - const timer = setTimeout(async () => { - const newAttempts = pollAttempts + 1; - console.debug( - "[Onboarding] Polling for trial data, attempt:", - newAttempts, - ); - - await refreshTrialStatus(); - setPollAttempts(newAttempts); - - // Check will happen in the next effect - }, pollInterval); - - return () => clearTimeout(timer); - }, [isPolling, pollAttempts, refreshTrialStatus]); - - // Stop polling when data arrives or timeout - useEffect(() => { - if (!isPolling) return; - - const hasData = trialStatus !== undefined && trialStatus !== null; - const hasProStatus = isPro !== null; - const maxAttempts = 10; - - if (hasData || pollAttempts >= maxAttempts) { - console.debug("[Onboarding] Trial data ready or timeout, showing modal", { - hasData, - hasProStatus, - attempts: pollAttempts, - trialStatus, - isPro, - }); - setIsPolling(false); + if (user && !hasSeenOnboarding && !loading && !showModal) { setShowModal(true); } - }, [isPolling, trialStatus, isPro, pollAttempts]); + }, [user, loading, showModal]); const handleClose = () => { localStorage.setItem(STORAGE_KEY, "true"); diff --git a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx index 6c28156f3..43e388587 100644 --- a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx +++ b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx @@ -17,8 +17,7 @@ import type { PaygSignupRequiredDetail } from "@app/services/paygErrorIntercepto * The {@code apiClient} module is created at app boot, outside the React * tree, and can't import JSX. We bridge with a {@code CustomEvent}: the * interceptor dispatches, this bootstrap (mounted near the app root) - * listens and renders. Same pattern as {@code TrialExpiredBootstrap}, - * just driven by a request-side trigger rather than auth state. + * listens and renders, driven by a request-side trigger. * *

De-duping

* If the user fires multiple billable requests in quick succession (e.g. diff --git a/frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx b/frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx deleted file mode 100644 index 40826f190..000000000 --- a/frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { lazy, Suspense, useEffect, useState } from "react"; -import { useAuth } from "@app/auth/UseSession"; -import { TrialExpiredModal } from "@app/components/shared/TrialExpiredModal"; - -const StripeCheckout = lazy( - () => import("@app/components/shared/StripeCheckoutSaas"), -); - -/** - * Bootstrap component that shows the trial expired modal when a user's trial has ended - * and they haven't added a payment method. Shows once per user per expired trial. - */ -export default function TrialExpiredBootstrap() { - const { user, trialStatus, isPro } = useAuth(); - const [showModal, setShowModal] = useState(false); - const [checkoutOpened, setCheckoutOpened] = useState(false); - - useEffect(() => { - // Close modal if user logs out or session expires - if (!user) { - if (showModal) { - console.debug("[TrialExpired] User logged out, closing modal"); - setShowModal(false); - } - if (checkoutOpened) { - setCheckoutOpened(false); - } - return; - } - - // Only check conditions when auth is fully loaded - if (trialStatus === null || isPro === null) { - return; - } - - // Build localStorage key unique to this user - const storageKey = `trialExpiredModalShown_${user.id}`; - const hasSeenModal = localStorage.getItem(storageKey) === "true"; - - // If user is currently trialing, clear any previous "seen" flag - // This handles the edge case where a user might re-enter a trial - if (trialStatus.isTrialing) { - if (hasSeenModal) { - console.debug("[TrialExpired] User is trialing, clearing seen flag"); - localStorage.removeItem(storageKey); - } - return; - } - - // Check if all conditions are met to show the modal - const isExpired = - trialStatus.status === "incomplete_expired" || - trialStatus.status === "canceled"; - const hasNoPayment = - !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub; - const wasDowngraded = !isPro; - const trialEndedRecently = trialStatus.daysRemaining === 0; - - const shouldShowModal = - isExpired && - hasNoPayment && - wasDowngraded && - trialEndedRecently && - !hasSeenModal; - - if (shouldShowModal) { - console.debug("[TrialExpired] Showing trial expired modal", { - status: trialStatus.status, - daysRemaining: trialStatus.daysRemaining, - hasPaymentMethod: trialStatus.hasPaymentMethod, - hasScheduledSub: trialStatus.hasScheduledSub, - isPro, - }); - setShowModal(true); - } - }, [user, trialStatus, isPro, showModal, checkoutOpened]); - - const handleClose = () => { - if (user) { - const storageKey = `trialExpiredModalShown_${user.id}`; - localStorage.setItem(storageKey, "true"); - console.debug("[TrialExpired] Modal dismissed, marking as seen"); - } - setShowModal(false); - }; - - const handleSubscribe = () => { - console.debug("[TrialExpired] User clicked Subscribe to Pro"); - setCheckoutOpened(true); - }; - - const handleCheckoutSuccess = () => { - console.debug("[TrialExpired] Subscription successful, refreshing page"); - // Close modal and refresh to update subscription status - handleClose(); - window.location.reload(); - }; - - const handleCheckoutClose = () => { - console.debug("[TrialExpired] Checkout closed"); - setCheckoutOpened(false); - }; - - return ( - <> - - - {user && checkoutOpened && ( - - - console.error("[TrialExpired] Checkout error:", error) - } - isTrialConversion={false} // Trial already ended, so this is not a conversion - /> - - )} - - ); -} diff --git a/frontend/editor/src/saas/components/onboarding/saasFlowResolver.test.ts b/frontend/editor/src/saas/components/onboarding/saasFlowResolver.test.ts new file mode 100644 index 000000000..3af525ca1 --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/saasFlowResolver.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; + +// Resolves through the saas cascade to the cloud impl +// (src/cloud/components/onboarding/saasFlowResolver.ts). No saas-level shadow +// exists, so this exercises the migrated cloud pure-function directly. +import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver"; +import type { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig"; + +describe("resolveSaasFlow — hideDesktopInstall", () => { + // The four-slide superset (all conditions on) makes the desktop-install + // toggle the only moving part, so we can assert on a stable slide order. + const allOn = { showUsageSlide: true, showTeamSlide: true }; + + it("KEEPS the desktop-install slide by default (flag omitted)", () => { + expect(resolveSaasFlow(allOn)).toEqual([ + "free-editor", + "usage", + "team", + "desktop-install", + ]); + }); + + it("KEEPS the desktop-install slide when hideDesktopInstall is explicitly false", () => { + expect(resolveSaasFlow({ ...allOn, hideDesktopInstall: false })).toEqual< + SlideId[] + >(["free-editor", "usage", "team", "desktop-install"]); + }); + + it("OMITS the desktop-install slide when hideDesktopInstall is true", () => { + expect(resolveSaasFlow({ ...allOn, hideDesktopInstall: true })).toEqual< + SlideId[] + >(["free-editor", "usage", "team"]); + }); + + it("leaves the rest of the flow order unchanged — only the trailing slide differs", () => { + const shown = resolveSaasFlow({ ...allOn, hideDesktopInstall: false }); + const hidden = resolveSaasFlow({ ...allOn, hideDesktopInstall: true }); + + // The hidden flow is exactly the shown flow minus its final + // desktop-install entry — no reordering, no other slides dropped. + expect(shown[shown.length - 1]).toBe("desktop-install"); + expect(hidden).toEqual(shown.slice(0, -1)); + }); + + it("free-editor bookends the flow and hideDesktopInstall is independent of the optional middle slides", () => { + // Minimal flow: optional slides off, desktop-install hidden → just the + // free-editor pitch. Confirms the flag composes with the conditionals + // rather than depending on them. + expect( + resolveSaasFlow({ + showUsageSlide: false, + showTeamSlide: false, + hideDesktopInstall: true, + }), + ).toEqual(["free-editor"]); + + // Same conditions but desktop-install shown → free-editor + desktop-install. + expect( + resolveSaasFlow({ + showUsageSlide: false, + showTeamSlide: false, + hideDesktopInstall: false, + }), + ).toEqual(["free-editor", "desktop-install"]); + }); +}); diff --git a/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx new file mode 100644 index 000000000..7eac7b4d8 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { Wallet } from "@app/hooks/useWallet"; + +// The modal resolves through the saas cascade to the cloud impl +// (src/cloud/components/shared/FreeLimitReachedModal.tsx). It calls +// @app/hooks/useWallet directly and bails out (renders null) until a wallet +// resolves, so the only mock the smoke test needs is the wallet hook — the +// rest of the subtree (AnimatedSlideBackground, FreeMeterPanel, MUI icon) is +// pure CSS/SVG/divs and renders fine under jsdom. +const wallet: Wallet = { + teamId: 1, + status: "free", + role: "leader", + billingPeriodStart: "2026-06-01", + billingPeriodEnd: "2026-06-30", + billableUsed: 500, + billableLimit: 500, + freeAllowance: 500, + freeRemaining: 0, + pricePerDocMinor: 2, + currency: "usd", + estimatedBillMinor: 0, + capUsd: null, + noCap: false, + stripeSubscriptionId: null, + spendUnitsThisPeriod: 0, + categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + members: [], + recent: [], +}; + +const useWalletMock = vi.fn(); + +vi.mock("@app/hooks/useWallet", () => ({ + useWallet: () => useWalletMock(), +})); + +// navigateToSettings is only invoked on CTA click; stub it so the smoke test +// never touches real settings/router plumbing. +vi.mock("@app/utils/settingsNavigation", () => ({ + navigateToSettings: vi.fn(), +})); + +import { FreeLimitReachedModal } from "@app/components/shared/FreeLimitReachedModal"; + +const renderModal = () => + render( + + + , + ); + +describe("FreeLimitReachedModal — render smoke", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders its primary CTA when the wallet has resolved", () => { + useWalletMock.mockReturnValue({ + wallet, + loading: false, + error: null, + refetch: vi.fn(), + markSubscribed: vi.fn(), + updateCap: vi.fn(), + openPortal: vi.fn(), + }); + + renderModal(); + + // i18n is stubbed in setupTests to echo the key, so the CTA shows as its + // translation key. Asserting on it confirms the modal mounted through the + // Mantine portal without throwing. + expect(screen.getByText("plan.freeLimit.cta")).toBeInTheDocument(); + expect(screen.getByText("plan.freeLimit.dismiss")).toBeInTheDocument(); + }); + + it("renders nothing while the wallet is still loading", () => { + useWalletMock.mockReturnValue({ + wallet: null, + loading: true, + error: null, + refetch: vi.fn(), + markSubscribed: vi.fn(), + updateCap: vi.fn(), + openPortal: vi.fn(), + }); + + renderModal(); + + // The modal holds back until the wallet lands, so no CTA is in the DOM. + expect(screen.queryByText("plan.freeLimit.cta")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx b/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx deleted file mode 100644 index f5bf270cc..000000000 --- a/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useState } from "react"; -import { supabase } from "@app/auth/supabase"; -import { Button } from "@mantine/core"; -import { usePlans } from "@app/hooks/usePlans"; - -interface TrialStatus { - isTrialing: boolean; - trialEnd: string; - daysRemaining: number; - hasPaymentMethod: boolean; - hasScheduledSub: boolean; -} - -export function ManageBillingButton({ - returnUrl = typeof window !== "undefined" ? window.location.href : "/", - children = "Manage billing", - trialStatus, -}: { - returnUrl?: string; - children?: React.ReactNode; - trialStatus?: TrialStatus; -}) { - const [loading, setLoading] = useState(false); - const [err, setErr] = useState(null); - const { data } = usePlans(); - - // Hide for free plan users - if (!data || data.currentPlan.id === "free") { - return null; - } - - // Hide for trial users who haven't scheduled a subscription yet - if (trialStatus?.isTrialing && !trialStatus.hasScheduledSub) { - return null; - } - - const onClick = async () => { - setLoading(true); - setErr(null); - try { - const { data, error } = await supabase.functions.invoke<{ - url: string; - error?: string; - }>("manage-billing", { - body: { return_url: returnUrl }, - }); - if (error) throw error; - if (!data || "error" in data) - throw new Error(data?.error ?? "No portal URL"); - window.location.href = data.url; - } catch (e: unknown) { - setErr(e instanceof Error ? e.message : "Could not open billing portal"); - } finally { - setLoading(false); - } - }; - - return ( -
- - {err &&
{err}
} -
- ); -} diff --git a/frontend/editor/src/saas/components/shared/StripeCheckoutSaas.tsx b/frontend/editor/src/saas/components/shared/StripeCheckoutSaas.tsx deleted file mode 100644 index 43c96a9b3..000000000 --- a/frontend/editor/src/saas/components/shared/StripeCheckoutSaas.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { loadStripe } from "@stripe/stripe-js"; -import { - EmbeddedCheckoutProvider, - EmbeddedCheckout, -} from "@stripe/react-stripe-js"; -import { supabase } from "@app/auth/supabase"; -import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex"; - -const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; - -export type PurchaseType = "subscription" | "credits"; -export type CreditsPack = "xsmall" | "small" | "medium" | "large" | null; -export type PlanID = "pro" | null; - -interface StripeCheckoutProps { - opened: boolean; - onClose: () => void; - // Saas-specific props - planId?: PlanID; - purchaseType?: PurchaseType; - creditsPack?: CreditsPack; - planName?: string; - planPrice?: number; - currency?: string; - isTrialConversion?: boolean; - // Proprietary-specific props (for compatibility) - planGroup?: unknown; - minimumSeats?: number; - onLicenseActivated?: (licenseInfo: { - licenseType: string; - enabled: boolean; - maxUsers: number; - hasKey: boolean; - }) => void; - hostedCheckoutSuccess?: { - isUpgrade: boolean; - licenseKey?: string; - } | null; - // Common props - onSuccess?: (sessionId: string) => void; - onError?: (error: string) => void; -} - -type CheckoutState = { - status: "idle" | "loading" | "ready" | "success" | "error"; - clientSecret?: string; - error?: string; - sessionParams?: { - purchaseType: PurchaseType; - planId: PlanID; - creditsPack: CreditsPack; - }; -}; - -const StripeCheckout: React.FC = ({ - opened, - onClose, - planId, - purchaseType, - creditsPack, - planName, - isTrialConversion, - onSuccess, - onError, -}) => { - const { t } = useTranslation(); - const [state, setState] = useState({ status: "idle" }); - // Load Stripe.js lazily, only when this checkout component mounts. Loading - // at module scope pulled the Stripe script into every page that imports - // this file, which triggered the dev "HTTPS required" warning on every - // non-payment route. - const stripePromise = useMemo(() => loadStripe(STRIPE_KEY), []); - - const createCheckoutSession = async () => { - try { - setState({ status: "loading" }); - - const { data, error } = await supabase.functions.invoke( - "create-checkout", - { - body: { - purchase_type: purchaseType, - ui_mode: "embedded", - plan: planId, - credits_pack: creditsPack, - callback_base_url: window.location.origin, - trial_conversion: isTrialConversion || false, - }, - }, - ); - - if (error) { - throw new Error(error.message || "Failed to create checkout session"); - } - - if (!data) { - throw new Error("No data received from server"); - } - - const jsonData = typeof data === "string" ? JSON.parse(data) : data; - - if (!jsonData?.clientSecret) { - throw new Error("No client secret received from server"); - } - - setState({ - status: "ready", - clientSecret: jsonData.clientSecret, - sessionParams: { - purchaseType: purchaseType!, - planId: planId!, - creditsPack: creditsPack!, - }, - }); - } catch (err) { - const errorMessage = - err instanceof Error - ? err.message - : "Failed to create checkout session"; - setState({ - status: "error", - error: errorMessage, - }); - onError?.(errorMessage); - } - }; - - const handlePaymentComplete = () => { - setState({ status: "success" }); - - // Call success callback immediately - parent will handle timing - onSuccess?.(""); - - // Note: Parent (Plan.tsx) now handles the delay and modal closing - }; - - const handleClose = () => { - // Reset state to idle to clean up the session - setState({ - status: "idle", - clientSecret: undefined, - error: undefined, - sessionParams: undefined, - }); - onClose(); - }; - - // Initialize checkout when modal opens or parameters change - useEffect(() => { - if (opened) { - // Check if we need a new session (first time or parameters changed) - const needsNewSession = - state.status === "idle" || - !state.sessionParams || - state.sessionParams.purchaseType !== purchaseType || - state.sessionParams.planId !== planId || - state.sessionParams.creditsPack !== creditsPack; - - if (needsNewSession) { - console.log("Creating new checkout session:", { - purchaseType, - planId, - creditsPack, - }); - createCheckoutSession(); - } - } else if (!opened) { - // Clean up state when modal closes - setState({ - status: "idle", - clientSecret: undefined, - error: undefined, - sessionParams: undefined, - }); - } - }, [opened, purchaseType, planId, creditsPack]); - - const renderContent = () => { - switch (state.status) { - case "loading": - return ( -
- - - {t("payment.preparing", "Preparing your checkout...")} - -
- ); - - case "ready": - if (!state.clientSecret) return null; - - return ( - - - - ); - - case "success": - return ( - - - - {t( - "payment.successMessage", - "Your plan has been upgraded successfully. You will receive a confirmation email shortly.", - )} - - - {t( - "payment.autoClose", - "This window will close automatically...", - )} - - - - ); - - case "error": - return ( - - - {state.error} - - - - ); - - default: - return null; - } - }; - - return ( - - - {t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName })} - -
- } - size="xl" - centered - withCloseButton={true} - closeOnEscape={true} - closeOnClickOutside={false} - zIndex={Z_INDEX_OVER_SETTINGS_MODAL} - > - {renderContent()} - - ); -}; - -export default StripeCheckout; -export { StripeCheckout }; diff --git a/frontend/editor/src/saas/components/shared/TrialExpiredModal.tsx b/frontend/editor/src/saas/components/shared/TrialExpiredModal.tsx deleted file mode 100644 index 4f80a696b..000000000 --- a/frontend/editor/src/saas/components/shared/TrialExpiredModal.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { Modal, Stack, Button } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; -import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; -import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; - -interface TrialExpiredModalProps { - opened: boolean; - onClose: () => void; - onSubscribe: () => void; -} - -export function TrialExpiredModal({ - opened, - onClose, - onSubscribe, -}: TrialExpiredModalProps) { - const { t } = useTranslation(); - - // Use CSS variables for theme colors - const amberColor = - getComputedStyle(document.documentElement) - .getPropertyValue("--color-amber-500") - .trim() || "#f59e0b"; - const redColor = - getComputedStyle(document.documentElement) - .getPropertyValue("--color-red-500") - .trim() || "#ef4444"; - const gradientStops: [string, string] = [amberColor, redColor]; - - const circles = [ - { - position: "bottom-left" as const, - size: 270, // 16.875rem - color: "rgba(255, 255, 255, 0.25)", - opacity: 0.9, - amplitude: 24, // 1.5rem - duration: 4.5, - offsetX: 18, // 1.125rem - offsetY: 14, // 0.875rem - }, - { - position: "top-right" as const, - size: 300, // 18.75rem - color: "rgba(255, 255, 255, 0.2)", - opacity: 0.9, - amplitude: 28, // 1.75rem - duration: 4.5, - delay: 0.5, - offsetX: 24, // 1.5rem - offsetY: 18, // 1.125rem - }, - ]; - - return ( - {}} // Prevent closing by clicking outside or ESC - withCloseButton={false} - closeOnClickOutside={false} - closeOnEscape={false} - centered - size="lg" - radius="lg" - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} - styles={{ - body: { padding: 0 }, - content: { - overflow: "hidden", - border: "none", - background: "var(--bg-surface)", - maxHeight: "90vh", - display: "flex", - flexDirection: "column", - }, - }} - > - -
- -
-
- -
-
-
- -
- -
- {t("plan.trial.expired", "Your Trial Has Ended")} -
- -
-
- {t( - "plan.trial.expiredMessage", - "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.", - )} -
-
- -
-
- {t( - "plan.trial.freeTierLimitations", - "Free tier includes basic PDF tools with usage limits.", - )} -
-
- -
- -
- - - -
-
-
-
-
-
- ); -} diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index dbe40e7d8..deea98d2b 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -8,10 +8,12 @@ import HotkeysSection from "@app/components/shared/config/configSections/Hotkeys import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; -import Plan from "@app/components/shared/config/configSections/Plan"; import McpSection from "@app/components/shared/config/configSections/McpSection"; import LegalSection from "@app/components/shared/config/configSections/LegalSection"; -import TeamSection from "@app/components/shared/config/configSections/TeamSection"; +import { + createCloudBillingSection, + createCloudTeamNavItem, +} from "@app/components/shared/config/cloudConfigNavSections"; type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>; @@ -95,20 +97,9 @@ function appendBillingSection( return sections; } - return [ - ...sections, - { - title: "Billing", - items: [ - { - key: "plan", - label: t("config.plan", "Plan"), - icon: "credit-card", - component: , - }, - ], - }, - ]; + // The Plan/Billing section is the shared cloud surface (wallet-driven PAYG + // dashboard + spend cap), so both saas and desktop reference one source. + return [...sections, createCloudBillingSection(t)]; } // Add an "MCP Server" tab in the Developer section. Always shown in SaaS; @@ -206,12 +197,8 @@ export function createSaasConfigNavSections( }; if (!isAnonymous) { - accountSection.items.push({ - key: "teams", - label: t("config.team", "Team"), - icon: "groups-rounded", - component: , - }); + // Shared cloud team item — same management UI on saas and desktop. + accountSection.items.push(createCloudTeamNavItem(t)); } let sections = [accountSection, ...baseSections]; diff --git a/frontend/editor/src/saas/hooks/walletDevPreview.ts b/frontend/editor/src/saas/hooks/walletDevPreview.ts new file mode 100644 index 000000000..e62ec333d --- /dev/null +++ b/frontend/editor/src/saas/hooks/walletDevPreview.ts @@ -0,0 +1,123 @@ +/** + * saas (web) implementation of the @app/hooks/walletDevPreview seam. + * + * Houses the PAYG dev-preview side-channel that {@code useWallet} used to carry + * inline. It synthesises a wallet snapshot from {@code localStorage} when the + * hook is rendered outside the real saas app (the {@code /dev/payg-preview} + * route during local design work), where {@code AppConfigContext} is not mounted + * and no backend is available. This is the only place the banned-in-cloud reads + * ({@code import.meta.env.DEV}, {@code window.location}, {@code localStorage}) + * live — cloud reaches them through {@link getWalletDevPreview}. + * + * Behaviour preserved verbatim from the pre-move saas useWallet: + * - both {@code import.meta.env.DEV} AND a {@code /dev/} path are required, so a + * production tenant whose URL happens to start with {@code /dev/} can't hit + * the fallback; + * - subscription state is read from / written to {@code localStorage} so the + * modal's "mark subscribed" action survives a reload. + */ +import type { Wallet, WalletRole } from "@app/hooks/useWallet"; +import type { WalletDevPreview } from "@cloud/hooks/walletDevPreview"; + +export type { WalletDevPreview } from "@cloud/hooks/walletDevPreview"; + +const STORAGE_KEY = "stirling.payg.devSubscription"; + +/** + * Synthesise a wallet snapshot for the dev preview route. Mirrors the same + * shape the backend returns. Subscription state comes from localStorage so + * the modal's "mark subscribed" action survives a reload. + */ +function buildDevPreviewWallet(role: WalletRole): Wallet { + const subscribed = + typeof window !== "undefined" && + (() => { + try { + return window.localStorage.getItem(STORAGE_KEY) === "subscribed"; + } catch { + return false; + } + })(); + + const now = new Date(); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const isoDay = (d: Date) => d.toISOString().slice(0, 10); + + return { + teamId: null, + status: subscribed ? "subscribed" : "free", + role, + billingPeriodStart: isoDay(periodStart), + billingPeriodEnd: isoDay(periodEnd), + billableUsed: 62, + billableLimit: subscribed ? 1250 : 500, + freeAllowance: 500, + // One-time grant: a free team has used 62 of 500 (438 left); the dev + // subscribed team is shown with its grant fully spent (kept across the + // subscribe — it just no longer gates them). + freeRemaining: subscribed ? 0 : 438, + // Free teams also carry a rate now — the backend resolves it from the + // default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid + // PDFs/month") can render before subscribing. Mirror that here. + pricePerDocMinor: 2, + currency: "usd", + estimatedBillMinor: subscribed ? 0 : null, + capUsd: subscribed ? 25 : null, + noCap: false, + stripeSubscriptionId: subscribed ? "sub_devpreview" : null, + spendUnitsThisPeriod: 62, + // Wave 1 backend (PR #6574) returns a per-category breakdown so the + // hero panel can split AI / automation / API. Use realistic but + // tier-distinguishable mock values so the dev preview shows a + // different visual when the localStorage flip toggles subscribed. + categoryBreakdown: subscribed + ? { api: 12, ai: 35, automation: 15 } + : { api: 5, ai: 40, automation: 17 }, + // Members are populated in the leader view by the real backend + // (joining team_memberships); the dev preview returns an empty + // array — Plan.tsx + PaygLeader still resolve role via wallet.role, + // so empty members just hides the sub-caps card. + members: [], + // Activity feed is V1 = [], the backend ships this in Wave 2 once + // payg_meter_event_log is read-accessible from the wallet endpoint. + recent: [], + }; +} + +/** True when we're rendered outside the real saas app (e.g. dev preview route). */ +function isDevPreviewContext(): boolean { + // Both checks required: production builds drop the path check, so a real + // tenant whose URL begins with /dev/ can't accidentally hit the synthesised + // fallback. + if (!import.meta.env.DEV) return false; + if (typeof window === "undefined") return false; + return window.location.pathname.startsWith("/dev/"); +} + +/** Best-effort role read for dev preview — flips per query string ?role=member. */ +function devPreviewRole(): WalletRole { + if (typeof window === "undefined") return "leader"; + const url = new URL(window.location.href); + return url.searchParams.get("role") === "member" ? "member" : "leader"; +} + +/** + * Resolve the active dev-preview side-channel, or {@code null} when we're in a + * real build / on a real route (the common case). Both {@code import.meta.env.DEV} + * and a {@code /dev/} path must hold. + */ +export function getWalletDevPreview(): WalletDevPreview | null { + if (!isDevPreviewContext()) return null; + return { + buildWallet: buildDevPreviewWallet, + role: devPreviewRole, + markSubscribed: () => { + try { + window.localStorage.setItem(STORAGE_KEY, "subscribed"); + } catch { + /* storage unavailable */ + } + }, + }; +} diff --git a/frontend/editor/src/saas/platform/openExternal.ts b/frontend/editor/src/saas/platform/openExternal.ts new file mode 100644 index 000000000..7030f0e96 --- /dev/null +++ b/frontend/editor/src/saas/platform/openExternal.ts @@ -0,0 +1,18 @@ +/** + * saas (web) implementation of the @app/platform/openExternal seam. + * + * The seam's consumers are cloud-layer "leave-and-return" redirects — Stripe + * Checkout / customer portal — which mint a session with a return_url and expect + * the user to come back to the app afterwards. On the web that means a SAME-TAB + * full-page navigation (window.location.assign): the return_url then lands the + * user back in the originating tab. (A post-await window.open would also be + * popup-blocked as a non-user-gesture.) The desktop impl instead hands the URL + * to the system browser and the app is re-entered via its deep-link return. + */ +import type { OpenExternal } from "@cloud/platform/openExternal"; + +export const openExternal: OpenExternal = async ( + url: string, +): Promise => { + window.location.assign(url); +}; diff --git a/frontend/editor/src/saas/services/apiClientSetup.ts b/frontend/editor/src/saas/services/apiClientSetup.ts index eaed3ec19..8ab38ced7 100644 --- a/frontend/editor/src/saas/services/apiClientSetup.ts +++ b/frontend/editor/src/saas/services/apiClientSetup.ts @@ -1,18 +1,12 @@ import type { AxiosInstance } from "axios"; -import { supabase } from "@app/auth/supabase"; +import { getAccessToken } from "@app/auth/session"; -/** SaaS auth headers for raw fetch() calls — Supabase access token from current session. */ +/** SaaS auth headers for raw fetch() calls — access token from current session. */ export async function getAuthHeaders(): Promise> { const headers: Record = {}; - try { - const { - data: { session }, - } = await supabase.auth.getSession(); - if (session?.access_token) { - headers["Authorization"] = `Bearer ${session.access_token}`; - } - } catch (e) { - console.warn("[apiClientSetup] Failed to read Supabase session", e); + const token = await getAccessToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; } return headers; } diff --git a/frontend/editor/src/saas/services/billing.ts b/frontend/editor/src/saas/services/billing.ts new file mode 100644 index 000000000..ae7245d56 --- /dev/null +++ b/frontend/editor/src/saas/services/billing.ts @@ -0,0 +1,93 @@ +/** + * SaaS (web) implementation of the @app/services/billing seam. + * + * Stripe-touching work lives in Supabase edge functions, invoked through the web + * supabase-js client (which attaches the signed-in user's session JWT + * automatically). The return URL is the browser origin. + */ +import { supabase } from "@app/auth/supabase"; +import type { + CheckoutParams, + CheckoutSession, + PortalParams, + PortalSession, +} from "@cloud/services/billing"; + +export type { + CheckoutParams, + CheckoutSession, + PortalParams, + PortalSession, +} from "@cloud/services/billing"; + +/** + * Create a Stripe Checkout Session for the PAYG subscription via the + * {@code create-checkout-session} edge function (see StripeCheckoutPanel). The + * function runs outside Spring Security, so it takes the team id directly. + */ +export async function createCheckoutSession( + params: CheckoutParams, +): Promise { + const returnUrl = window.location.href; + const { data, error } = await supabase.functions.invoke<{ + client_secret?: string; + url?: string; + mock?: boolean; + }>("create-checkout-session", { + body: { + team_id: params.teamId, + currency: params.currency ?? "gbp", + success_url: returnUrl, + cancel_url: returnUrl, + ...(params.billingOwnerEmail + ? { billing_owner_email: params.billingOwnerEmail } + : {}), + }, + }); + + if (error) { + throw error; + } + if (data?.client_secret) { + return { + clientSecret: data.client_secret, + mock: Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"), + }; + } + if (data?.url) { + return { url: data.url }; + } + throw new Error("Edge function returned no client_secret"); +} + +/** + * The Stripe publishable key for embedded checkout. On the web this is the + * build-time {@code VITE_STRIPE_PUBLISHABLE_KEY}. + */ +export function getStripePublishableKey(): string { + return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; +} + +/** + * Mint a Stripe Customer Portal session via the PAYG + * {@code create-customer-portal-session} edge function (its RPC enforces team + * membership). return_url is the current location so Stripe brings the user + * back to this page on close. + */ +export async function createPortalSession( + params: PortalParams, +): Promise { + const { data, error } = await supabase.functions.invoke<{ + url?: string; + error?: string; + }>("create-customer-portal-session", { + body: { team_id: params.teamId, return_url: window.location.href }, + }); + if (error) { + throw error; + } + if (!data?.url) { + throw new Error(data?.error ?? "Portal session response missing url"); + } + return { url: data.url }; +} diff --git a/frontend/editor/src/saas/styles/zIndex.ts b/frontend/editor/src/saas/styles/zIndex.ts index 58ec70ca0..7cd14f1fa 100644 --- a/frontend/editor/src/saas/styles/zIndex.ts +++ b/frontend/editor/src/saas/styles/zIndex.ts @@ -6,4 +6,5 @@ export * from "@core/styles/zIndex"; // SaaS-specific z-index constants export const Z_ANALYTICS_MODAL = 1301; -export const Z_INDEX_OVER_SETTINGS_MODAL = 1400; +// Z_INDEX_OVER_SETTINGS_MODAL now lives in core (re-exported above) so the +// shared cloud/ checkout component can resolve it too. diff --git a/frontend/editor/src/saas/tsconfig.json b/frontend/editor/src/saas/tsconfig.json index 1e59f4590..adf7337be 100644 --- a/frontend/editor/src/saas/tsconfig.json +++ b/frontend/editor/src/saas/tsconfig.json @@ -3,7 +3,13 @@ "compilerOptions": { "baseUrl": "../../", "paths": { - "@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"], + "@app/*": [ + "src/saas/*", + "src/cloud/*", + "src/proprietary/*", + "src/core/*" + ], + "@cloud/*": ["src/cloud/*"], "@proprietary/*": ["src/proprietary/*"], "@core/*": ["src/core/*"], "@shared/*": ["../shared/*"] diff --git a/frontend/editor/tsconfig.desktop.vite.json b/frontend/editor/tsconfig.desktop.vite.json index ee4556ade..f0c8cd171 100644 --- a/frontend/editor/tsconfig.desktop.vite.json +++ b/frontend/editor/tsconfig.desktop.vite.json @@ -2,7 +2,13 @@ "extends": "./tsconfig.proprietary.vite.json", "compilerOptions": { "paths": { - "@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"], + "@app/*": [ + "src/desktop/*", + "src/cloud/*", + "src/proprietary/*", + "src/core/*" + ], + "@cloud/*": ["src/cloud/*"], "@proprietary/*": ["src/proprietary/*"], "@core/*": ["src/core/*"], "@shared/*": ["../shared/*"] @@ -12,6 +18,8 @@ "src/core/**/*.test.ts*", "src/core/**/*.spec.ts*", "src/proprietary/**/*.test.ts*", - "src/proprietary/**/*.spec.ts*" + "src/proprietary/**/*.spec.ts*", + "src/cloud/**/*.test.ts*", + "src/cloud/**/*.spec.ts*" ] } diff --git a/frontend/editor/tsconfig.saas.vite.json b/frontend/editor/tsconfig.saas.vite.json index 0ab501bf9..b4d2f57d0 100644 --- a/frontend/editor/tsconfig.saas.vite.json +++ b/frontend/editor/tsconfig.saas.vite.json @@ -2,7 +2,13 @@ "extends": "./tsconfig.json", "compilerOptions": { "paths": { - "@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"], + "@app/*": [ + "src/saas/*", + "src/cloud/*", + "src/proprietary/*", + "src/core/*" + ], + "@cloud/*": ["src/cloud/*"], "@proprietary/*": ["src/proprietary/*"], "@core/*": ["src/core/*"], "@shared/*": ["../shared/*"] @@ -13,6 +19,8 @@ "src/core/**/*.spec.ts*", "src/proprietary/**/*.test.ts*", "src/proprietary/**/*.spec.ts*", + "src/cloud/**/*.test.ts*", + "src/cloud/**/*.spec.ts*", "src/desktop" ] } diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index a52337e82..c8904f907 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -74,6 +74,10 @@ function compressStaticCopyPlugin(): PluginOption { }; } +// NOTE: cloud/ is a SHARED layer, not a runnable build flavor — it's compiled +// into the saas and desktop builds. It has no entry here and no vite tsconfig; +// it is only typechecked standalone via editor/src/cloud/tsconfig.json +// (task frontend:typecheck:cloud) to prove it carries no saas/desktop-only deps. const VALID_MODES = [ "core", "proprietary", diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 11071641d..55950ad1b 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -104,6 +104,68 @@ export default defineConfig( ], }, }, + // The cloud/ layer is the SHARED hosted/SaaS experience consumed by BOTH the + // saas and desktop leaves, so it must stay platform-portable. It must not + // reach platform-specific things directly (Supabase, Tauri, raw fetch, + // window.location, web storage, or import.meta.env.VITE_*) — those arrive via + // @app/* seams (services/apiClient, auth/session, platform/openExternal, ...) + // that each leaf provides for its own platform. + { + files: ["editor/src/cloud/**/*.{js,mjs,jsx,ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + ...baseRestrictedImportPatterns, + { + regex: "^@supabase/", + message: + "cloud/ must stay platform-portable. Reach Supabase via an @app/* seam (e.g. @app/auth/supabase, @app/auth/session) provided per-platform in saas/ and desktop/.", + }, + { + regex: "^@tauri-apps/", + message: + "cloud/ must stay platform-portable. Tauri APIs are desktop-only — reach native features via an @app/* seam (e.g. @app/platform/openExternal).", + }, + ], + }, + ], + "no-restricted-globals": [ + "error", + { + name: "fetch", + message: + "cloud/ must not call raw fetch — use @app/services/apiClient so each platform supplies its own transport.", + }, + { + name: "localStorage", + message: + "cloud/ must not touch localStorage — use an @app/* storage seam so desktop/web can differ.", + }, + { + name: "sessionStorage", + message: + "cloud/ must not touch sessionStorage — use an @app/* storage seam so desktop/web can differ.", + }, + ], + "no-restricted-syntax": [ + "error", + { + selector: + "MemberExpression[object.name='window'][property.name='location']", + message: + "cloud/ must not touch window.location — use an @app/* seam (e.g. @app/platform/openExternal) so desktop/web can differ.", + }, + { + selector: + "MemberExpression[property.name='env'][object.type='MetaProperty'][object.meta.name='import'][object.property.name='meta']", + message: + "cloud/ must not read import.meta.env — use @app/constants/app / @app/platform seams so config is supplied per-platform.", + }, + ], + }, + }, // The shared/ layer is the seed of a future packages/shared-ui — it must // only depend on third-party packages and on itself. If it ever imports // from editor or portal layers, extraction to a standalone package later