refactor(fe): share the SaaS PAYG experience with desktop via a cloud/ layer (#6649)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-06-17 11:12:05 +01:00
committed by GitHub
co-authored by James Brunton
parent ef0deef4f2
commit cd7264a76a
122 changed files with 1836 additions and 6365 deletions
+7
View File
@@ -262,6 +262,12 @@ tasks:
cmds: cmds:
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json - 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: typecheck:scripts:
desc: "Typecheck scripts" desc: "Typecheck scripts"
deps: [prepare] deps: [prepare]
@@ -293,6 +299,7 @@ tasks:
- task: typecheck:proprietary - task: typecheck:proprietary
- task: typecheck:saas - task: typecheck:saas
- task: typecheck:desktop - task: typecheck:desktop
- task: typecheck:cloud
- task: typecheck:scripts - task: typecheck:scripts
- task: typecheck:prototypes - task: typecheck:prototypes
- task: typecheck:portal - task: typecheck:portal
+23 -1
View File
@@ -169,7 +169,29 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
- Building layer-specific override that wraps a lower layer's component - 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 - 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) #### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals. Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
+2
View File
@@ -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". 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, * 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". 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, * 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". 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, * All content that resides under the "frontend/portal/" directory of this repository,
@@ -260,7 +260,7 @@ public class SupabaseSecurityConfig {
applicationProperties.getSystem() != null applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null && applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty(); && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty();
List<String> origins = List<String> configuredOrigins =
operatorOverride operatorOverride
? applicationProperties.getSystem().getCorsAllowedOrigins() ? applicationProperties.getSystem().getCorsAllowedOrigins()
: List.of( : List.of(
@@ -270,6 +270,18 @@ public class SupabaseSecurityConfig {
"https://stirling.com", "https://stirling.com",
"https://app.stirling.com", "https://app.stirling.com",
"https://api.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<String> 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("*"))) { if (origins.stream().anyMatch(o -> o.contains("*"))) {
log.warn( log.warn(
"CORS origins contain a wildcard paired with allowCredentials=true: {}." "CORS origins contain a wildcard paired with allowCredentials=true: {}."
@@ -2747,7 +2747,7 @@ summary = "Ran {{count}} tools"
unknownTool = "Unknown tool" unknownTool = "Unknown tool"
[cloudBadge] [cloudBadge]
tooltip = "This operation will use your cloud credits" tooltip = "Runs in the cloud (included, no extra charge)"
[color.eyeDropper] [color.eyeDropper]
tooltip = "Pick colour from screen" tooltip = "Pick colour from screen"
@@ -2769,7 +2769,6 @@ error = "Error"
expand = "Expand" expand = "Expand"
loading = "Loading..." loading = "Loading..."
next = "Next" next = "Next"
operation = "this operation"
preview = "Preview" preview = "Preview"
previous = "Previous" previous = "Previous"
refresh = "Refresh" refresh = "Refresh"
@@ -3222,68 +3221,25 @@ posthog = "PostHog Analytics"
scarf = "Scarf Pixel" scarf = "Scarf Pixel"
[credits] [credits]
enableOverageBilling = "Enable Overage Billing"
maybeLater = "Maybe later"
upgrade = "Upgrade"
[credits.insufficient] [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] [credits.modal]
advancedMonitoring = "Advanced monitoring" advancedMonitoring = "Advanced monitoring"
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)" allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
apiSandbox = "API sandbox" apiSandbox = "API sandbox"
contactSales = "Contact Sales"
creditsRemaining = "{{current}} of {{total}} remaining"
creditsThisMonth = "Monthly credits"
current = "Current Plan"
customPricing = "Custom"
dedicatedSupportSlas = "Dedicated support & SLAs" dedicatedSupportSlas = "Dedicated support & SLAs"
enterpriseSubscription = "Enterprise"
everythingInCredits = "Everything in Credits, plus:"
everythingInFree = "Everything in Free, plus:"
fasterThroughput = "10x faster throughput" fasterThroughput = "10x faster throughput"
forRegularWork = "For regular PDF work:"
freeTier = "Free Tier"
fullyPrivateFiles = "Fully private files" fullyPrivateFiles = "Fully private files"
largeFileProcessing = "Large file processing" 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" orgWideAccess = "Org-wide access controls"
overage = "overage"
perMonth = "/month"
popular = "Popular"
premiumAiModels = "Premium AI models" premiumAiModels = "Premium AI models"
prioritySupport = "Priority support" prioritySupport = "Priority support"
privateDocCloud = "Private Document Cloud" privateDocCloud = "Private Document Cloud"
ragFineTuning = "RAG + fine-tuning" ragFineTuning = "RAG + fine-tuning"
secureApiAccess = "Secure API access" secureApiAccess = "Secure API access"
selfHostLink = "Review the docs and plans"
selfHostPrompt = "Want to self host?"
standardThroughput = "Standard throughput" 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" unlimitedApiAccess = "Unlimited API access"
unlimitedMonthlyCredits = "Site Licence"
unlimitedSeats = "Unlimited seats" unlimitedSeats = "Unlimited seats"
[crop] [crop]
@@ -5394,11 +5350,7 @@ resetsTomorrow = "Resets tomorrow"
thisPeriod = "This billing period" thisPeriod = "This billing period"
[payment] [payment]
autoClose = "This window will close automatically..."
canCloseWindow = "You can now close this window." 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" error = "Payment Error"
generatingLicense = "Generating your licence key..." 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." 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" perMonth = "/month"
preparing = "Preparing your checkout..." preparing = "Preparing your checkout..."
redirecting = "Redirecting to secure checkout..." redirecting = "Redirecting to secure checkout..."
refreshBilling = "I've Completed Payment - Refresh Billing"
success = "Payment Successful!" success = "Payment Successful!"
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly." 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." 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] [plan]
contact = "Contact Us" contact = "Contact Us"
currency = "Currency"
current = "Current Plan" current = "Current Plan"
customPricing = "Custom"
featureComparison = "Feature Comparison" featureComparison = "Feature Comparison"
from = "From" from = "From"
hideComparison = "Hide Feature Comparison" hideComparison = "Hide Feature Comparison"
included = "Included"
licensedSeats = "Licensed: {{count}} seats" licensedSeats = "Licensed: {{count}} seats"
manage = "Manage" manage = "Manage"
perMonth = "/month" perMonth = "/month"
@@ -5728,7 +5676,6 @@ small = "500 Credits"
xsmall = "100 Credits" xsmall = "100 Credits"
[plan.availablePlans] [plan.availablePlans]
loadError = "Unable to load plan pricing. Using default values."
subtitle = "Choose the plan that fits your needs" subtitle = "Choose the plan that fits your needs"
title = "Available Plans" title = "Available Plans"
@@ -5739,7 +5686,6 @@ highlight3 = "Latest features"
name = "Enterprise" name = "Enterprise"
requiresServer = "Requires Server" requiresServer = "Requires Server"
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise." requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
siteLicense = "Site Licence"
[plan.feature] [plan.feature]
api = "API Access" api = "API Access"
@@ -5846,16 +5792,9 @@ success = "Licence Activated!"
successMessage = "Your licence has been successfully activated. You can now close this window." successMessage = "Your licence has been successfully activated. You can now close this window."
[plan.team] [plan.team]
name = "Team"
[plan.trial] [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 = "" message = ""
subscribe = "Subscribe to Pro"
subscribeToPro = "Subscribe to Pro"
[policies] [policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
@@ -2743,7 +2743,6 @@ error = "Error"
expand = "Expand" expand = "Expand"
loading = "Loading..." loading = "Loading..."
next = "Next" next = "Next"
operation = "this operation"
preview = "Preview" preview = "Preview"
previous = "Previous" previous = "Previous"
refresh = "Refresh" refresh = "Refresh"
@@ -3196,68 +3195,25 @@ posthog = "PostHog Analytics"
scarf = "Scarf Pixel" scarf = "Scarf Pixel"
[credits] [credits]
enableOverageBilling = "Enable Overage Billing"
maybeLater = "Maybe later"
upgrade = "Upgrade"
[credits.insufficient] [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] [credits.modal]
advancedMonitoring = "Advanced monitoring" advancedMonitoring = "Advanced monitoring"
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)" allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
apiSandbox = "API sandbox" apiSandbox = "API sandbox"
contactSales = "Contact Sales"
creditsRemaining = "{{current}} of {{total}} remaining"
creditsThisMonth = "Monthly credits"
current = "Current Plan"
customPricing = "Custom"
dedicatedSupportSlas = "Dedicated support & SLAs" dedicatedSupportSlas = "Dedicated support & SLAs"
enterpriseSubscription = "Enterprise"
everythingInCredits = "Everything in Credits, plus:"
everythingInFree = "Everything in Free, plus:"
fasterThroughput = "10x faster throughput" fasterThroughput = "10x faster throughput"
forRegularWork = "For regular PDF work:"
freeTier = "Free Tier"
fullyPrivateFiles = "Fully private files" fullyPrivateFiles = "Fully private files"
largeFileProcessing = "Large file processing" 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" orgWideAccess = "Org-wide access controls"
overage = "overage"
perMonth = "/month"
popular = "Popular"
premiumAiModels = "Premium AI models" premiumAiModels = "Premium AI models"
prioritySupport = "Priority support" prioritySupport = "Priority support"
privateDocCloud = "Private Document Cloud" privateDocCloud = "Private Document Cloud"
ragFineTuning = "RAG + fine-tuning" ragFineTuning = "RAG + fine-tuning"
secureApiAccess = "Secure API access" secureApiAccess = "Secure API access"
selfHostLink = "Review the docs and plans"
selfHostPrompt = "Want to self host?"
standardThroughput = "Standard throughput" 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" unlimitedApiAccess = "Unlimited API access"
unlimitedMonthlyCredits = "Site License"
unlimitedSeats = "Unlimited seats" unlimitedSeats = "Unlimited seats"
[crop] [crop]
@@ -5363,11 +5319,7 @@ resetsTomorrow = "Resets tomorrow"
thisPeriod = "This billing period" thisPeriod = "This billing period"
[payment] [payment]
autoClose = "This window will close automatically..."
canCloseWindow = "You can now close this window." 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" error = "Payment Error"
generatingLicense = "Generating your license key..." 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." 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" perMonth = "/month"
preparing = "Preparing your checkout..." preparing = "Preparing your checkout..."
redirecting = "Redirecting to secure checkout..." redirecting = "Redirecting to secure checkout..."
refreshBilling = "I've Completed Payment - Refresh Billing"
success = "Payment Successful!" success = "Payment Successful!"
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly." 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." 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] [plan]
contact = "Contact Us" contact = "Contact Us"
currency = "Currency"
current = "Current Plan" current = "Current Plan"
customPricing = "Custom"
featureComparison = "Feature Comparison" featureComparison = "Feature Comparison"
from = "From" from = "From"
hideComparison = "Hide Feature Comparison" hideComparison = "Hide Feature Comparison"
included = "Included"
licensedSeats = "Licensed: {{count}} seats" licensedSeats = "Licensed: {{count}} seats"
manage = "Manage" manage = "Manage"
perMonth = "/month" perMonth = "/month"
@@ -5697,7 +5645,6 @@ small = "500 Credits"
xsmall = "100 Credits" xsmall = "100 Credits"
[plan.availablePlans] [plan.availablePlans]
loadError = "Unable to load plan pricing. Using default values."
subtitle = "Choose the plan that fits your needs" subtitle = "Choose the plan that fits your needs"
title = "Available Plans" title = "Available Plans"
@@ -5708,7 +5655,6 @@ highlight3 = "Latest features"
name = "Enterprise" name = "Enterprise"
requiresServer = "Requires Server" requiresServer = "Requires Server"
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise." requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
siteLicense = "Site License"
[plan.feature] [plan.feature]
api = "API Access" api = "API Access"
@@ -5815,15 +5761,8 @@ success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window." successMessage = "Your license has been successfully activated. You can now close this window."
[plan.team] [plan.team]
name = "Team"
[plan.trial] [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] [policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
+51
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
export const CLOUD_LAYER_PROBE = "cloud" as const;
+16
View File
@@ -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<string | null> {
return null;
}
@@ -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<void>;
}
/**
* 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 () => {},
};
}
@@ -14,6 +14,12 @@ import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface SaasOnboardingModalProps { interface SaasOnboardingModalProps {
opened: boolean; opened: boolean;
onClose: () => void; 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) { export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
@@ -5,21 +5,30 @@ export interface SaasFlowInputs {
showUsageSlide: boolean; showUsageSlide: boolean;
/** Team leaders only — invited members and anonymous guests skip the team slide. */ /** Team leaders only — invited members and anonymous guests skip the team slide. */
showTeamSlide: boolean; 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 * Resolves the SaaS onboarding slide sequence. The free-editor pitch and
* desktop install bookend the flow; the usage meter and team slides slot in * 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({ export function resolveSaasFlow({
showUsageSlide, showUsageSlide,
showTeamSlide, showTeamSlide,
hideDesktopInstall = false,
}: SaasFlowInputs): SlideId[] { }: SaasFlowInputs): SlideId[] {
return [ return [
"free-editor", "free-editor",
...(showUsageSlide ? (["usage"] as const) : []), ...(showUsageSlide ? (["usage"] as const) : []),
...(showTeamSlide ? (["team"] as const) : []), ...(showTeamSlide ? (["team"] as const) : []),
"desktop-install", ...(hideDesktopInstall ? [] : (["desktop-install"] as const)),
]; ];
} }
@@ -11,6 +11,7 @@ import {
} from "@app/components/onboarding/saasOnboardingFlowConfig"; } from "@app/components/onboarding/saasOnboardingFlowConfig";
import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver"; import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver";
import { DOWNLOAD_URLS } from "@app/constants/downloads"; import { DOWNLOAD_URLS } from "@app/constants/downloads";
import { openExternal } from "@app/platform/openExternal";
interface UseSaasOnboardingStateResult { interface UseSaasOnboardingStateResult {
currentStep: number; currentStep: number;
@@ -24,11 +25,18 @@ interface UseSaasOnboardingStateResult {
interface UseSaasOnboardingStateProps { interface UseSaasOnboardingStateProps {
opened: boolean; opened: boolean;
onClose: () => void; 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({ export function useSaasOnboardingState({
opened, opened,
onClose, onClose,
hideDesktopInstall = false,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { loading } = useAuth(); const { loading } = useAuth();
const { wallet } = useWallet(); const { wallet } = useWallet();
@@ -80,8 +88,9 @@ export function useSaasOnboardingState({
const showTeamSlide = isTeamLeader; const showTeamSlide = isTeamLeader;
const flowSlideIds = useMemo( const flowSlideIds = useMemo(
() => resolveSaasFlow({ showUsageSlide, showTeamSlide }), () =>
[showUsageSlide, showTeamSlide], resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }),
[showUsageSlide, showTeamSlide, hideDesktopInstall],
); );
const totalSteps = flowSlideIds.length; const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0); const maxIndex = Math.max(totalSteps - 1, 0);
@@ -136,10 +145,11 @@ export function useSaasOnboardingState({
onClose(); onClose();
return; return;
case "download-selected": { 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; const downloadUrl = selectedDownloadUrlRef.current || os.url;
if (downloadUrl) { if (downloadUrl) {
window.open(downloadUrl, "_blank", "noopener,noreferrer"); void openExternal(downloadUrl);
} }
// Then advance to next slide or close if last // Then advance to next slide or close if last
if (currentStep === maxIndex) { if (currentStep === maxIndex) {
@@ -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: <Plan />,
};
}
/** 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: <TeamSection />,
};
}
/** 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)],
};
}
@@ -9,7 +9,7 @@
* <pre> * <pre>
* // In UpgradeModal.tsx — only when the user advances to step 2: * // In UpgradeModal.tsx — only when the user advances to step 2:
* const StripeCheckoutPanel = React.lazy( * const StripeCheckoutPanel = React.lazy(
* () => import("./StripeCheckoutPanel"), * () => import("@app/components/shared/config/configSections/StripeCheckoutPanel"),
* ); * );
* </pre> * </pre>
* *
@@ -27,11 +27,12 @@
* *
* <h2>Architecture</h2> * <h2>Architecture</h2>
* *
* Stripe-touching code lives in Supabase edge functions, not the Java * Stripe-touching code lives in Supabase edge functions, not the Java backend.
* backend. This panel invokes {@code create-checkout-session} * This panel no longer talks to Supabase directly it mints the PAYG checkout
* directly via {@code supabase.functions.invoke()} - same pattern {@link * session through the {@code @app/services/billing} seam
* usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is * ({@link createCheckoutSession} with a {@code teamId}), which each platform
* attached automatically by the Supabase client. * implements (web supabase client vs Tauri fetch). The seam drives the
* {@code create-checkout-session} edge function.
* *
* <p>The edge function is the canonical place Stripe Checkout * <p>The edge function is the canonical place Stripe Checkout
* Sessions get created - it uses the Stripe Sync Engine tables, has dedicated * Sessions get created - it uses the Stripe Sync Engine tables, has dedicated
@@ -42,16 +43,19 @@
* <h2>Behaviour</h2> * <h2>Behaviour</h2>
* *
* <ol> * <ol>
* <li>On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id, * <li>On mount: calls {@link createCheckoutSession} with the {@code teamId},
* currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is * {@code currency} and billing email to obtain a {@code clientSecret}. The
* NOT set here it's an application-layer setting applied via {@code PATCH /payg/cap} after * spending cap is NOT set here it's an application-layer setting applied
* the subscription lands. * via {@code PATCH /payg/cap} after the subscription lands.
* <li>If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge * <li>If no Stripe publishable key is configured OR the edge function isn't
* function isn't deployed yet (errors out / returns a {@code cs_mock_} * deployed yet (errors out / returns a {@code cs_mock_} sentinel), render
* sentinel), render a clearly-labelled placeholder + "Continue with * a clearly-labelled placeholder + "Continue with mock" button so the
* mock" button so the post-completion path stays testable. * post-completion path stays testable.
* <li>If only a hosted {@code url} comes back (the redirect fallback), hand it
* to the system browser via {@link openExternal}.
* <li>Otherwise render the real {@code <EmbeddedCheckoutProvider>} + * <li>Otherwise render the real {@code <EmbeddedCheckoutProvider>} +
* {@code <EmbeddedCheckout>} iframe. * {@code <EmbeddedCheckout>} iframe. The Tauri webview has no CSP, so
* embedded checkout works on desktop too.
* </ol> * </ol>
* *
* The parent {@link UpgradeModal} passes {@code onComplete} which fires when * The parent {@link UpgradeModal} passes {@code onComplete} which fires when
@@ -60,8 +64,13 @@
*/ */
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { supabase } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession"; 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- // Eager static imports here are OK because this whole module is itself lazy-
// imported by the modal. They land in the same lazy chunk. // imported by the modal. They land in the same lazy chunk.
@@ -89,21 +98,6 @@ export interface StripeCheckoutPanelProps {
onError?: (message: string) => void; 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 // Singleton Stripe promise — created on first use and reused for the lifetime
// of the tab. {@code loadStripe} is dynamically imported so the actual SDK // of the tab. {@code loadStripe} is dynamically imported so the actual SDK
// chunk is only pulled when this code path runs. // chunk is only pulled when this code path runs.
@@ -148,16 +142,14 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
const tRef = useRef(t); const tRef = useRef(t);
tRef.current = 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 // Dev preview route has no backend — skip the API call and go straight
// to the mock placeholder so the design + completion path stay testable. // to the mock placeholder so the design + completion path stay testable.
// Both checks required so a production tenant with a /dev/ URL prefix // The dev-preview detection (import.meta.env.DEV + a /dev/ path) lives behind
// can't accidentally trigger the placeholder. // the walletDevPreview seam since cloud may not read either directly; it is
const devPreview = // a saas-only affordance and resolves to null on desktop / prod.
import.meta.env.DEV && const devPreview = getWalletDevPreview() !== null;
typeof window !== "undefined" &&
window.location.pathname.startsWith("/dev/");
useEffect(() => { useEffect(() => {
if (devPreview) { if (devPreview) {
@@ -177,44 +169,37 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
let cancelled = false; let cancelled = false;
async function createSession() { async function createSession() {
try { try {
// Direct Supabase edge function invocation. We call // Mint the PAYG checkout session through the billing seam. Passing a
// {@code create-checkout-session} (not {@code create-payg-team-subscription} — // teamId routes it to the {@code create-checkout-session} edge function
// that one creates a subscription directly without going through the // (not {@code create-payg-team-subscription} — that one subscribes
// hosted Stripe Embedded Checkout iframe and doesn't return a // directly without the embedded iframe and returns no client_secret).
// client_secret). team_id is required because the edge fn runs // team_id is required because the edge fn runs outside our Spring
// outside our Spring Security context and can't resolve it from the // Security context and can't resolve it from the JWT alone. The cap is
// JWT alone. The cap is *not* set during checkout — it's an // *not* set during checkout — it's applied via PATCH /payg/cap after
// application-layer setting, applied via PATCH /payg/cap after the // the subscription lands. The platform impl supplies the success/cancel
// subscription lands. window.location.href is the success_url so the // return URL (browser origin on web, deep link on desktop).
// user comes back to the Plan tab after Stripe finishes the redirect. const session = await createCheckoutSession({
const returnUrl = window.location.href; teamId,
const { data, error: invokeError } =
await supabase.functions.invoke<CheckoutResponse>(
"create-checkout-session",
{
body: {
team_id: teamId,
currency, currency,
success_url: returnUrl,
cancel_url: returnUrl,
// Maps to Stripe's customer_email when the team has no Stripe // Maps to Stripe's customer_email when the team has no Stripe
// customer yet — prefills + locks the email field in Checkout. // customer yet — prefills + locks the email field in Checkout. Teams
// Teams with an existing customer get the email locked from the // with an existing customer get the email locked from the customer
// customer record instead; this field is ignored for them. // record instead; this field is ignored for them.
...(billingEmail ? { billing_owner_email: billingEmail } : {}), billingOwnerEmail: billingEmail,
}, });
}, if (cancelled) return;
); // Hosted-url fallback: no embedded iframe, hand the URL to the system
if (invokeError) { // browser. The deep-link / origin return URL brings the user back.
throw invokeError; 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"); throw new Error("Edge function returned no client_secret");
} }
if (cancelled) return; setClientSecret(session.clientSecret);
setClientSecret(data.client_secret);
setIsMock( setIsMock(
Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"), Boolean(session.mock) || session.clientSecret.startsWith("cs_mock_"),
); );
} catch (e: unknown) { } catch (e: unknown) {
if (cancelled) return; if (cancelled) return;
@@ -43,7 +43,10 @@ function dispatchOverlay(open: boolean) {
// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user // Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user
// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the // reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the
// chunk-graph reasoning. // chunk-graph reasoning.
const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel")); const StripeCheckoutPanel = React.lazy(
() =>
import("@app/components/shared/config/configSections/StripeCheckoutPanel"),
);
interface UpgradeModalProps { interface UpgradeModalProps {
open: boolean; open: boolean;
@@ -7,12 +7,16 @@ import {
useCallback, useCallback,
} from "react"; } from "react";
import apiClient from "@app/services/apiClient"; import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession"; import { useTeamAuth } from "@app/auth/teamSession";
import { isUserAnonymous } from "@app/auth/supabase";
/** /**
* SaaS web implementation of SaaS Team Context * Shared (cloud) SaaS Team Context.
* Provides team management for authenticated (non-anonymous) users *
* 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 { interface Team {
@@ -92,8 +96,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
>([]); >([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { user, refreshCredits, refreshSession } = useAuth(); const { canUseTeams, refreshAfterMembershipChange } = useTeamAuth();
const canUseTeams = !!user && !isUserAnonymous(user);
const fetchMyTeams = useCallback(async () => { const fetchMyTeams = useCallback(async () => {
if (!canUseTeams) return null; if (!canUseTeams) return null;
@@ -223,8 +226,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
await apiClient.post(`/api/v1/team/invitations/${token}/accept`); await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
await fetchReceivedInvitations(); await fetchReceivedInvitations();
await refreshTeams(); await refreshTeams();
await refreshCredits(); await refreshAfterMembershipChange();
await refreshSession();
}; };
const rejectInvitation = async (token: string) => { 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 apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
await refreshTeams(); await refreshTeams();
await refreshCredits(); await refreshAfterMembershipChange();
await refreshSession();
}; };
const isTeamLeader = currentTeam?.isLeader ?? false; const isTeamLeader = currentTeam?.isLeader ?? false;
@@ -37,15 +37,19 @@
* When the hook is rendered outside the saas app (e.g. on {@code * When the hook is rendered outside the saas app (e.g. on {@code
* /dev/payg-preview} during local design work) the {@code AppConfigContext} * /dev/payg-preview} during local design work) the {@code AppConfigContext}
* provider is not mounted and no backend is available. The hook detects that * 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 * via the {@code @app/hooks/walletDevPreview} seam and, when it returns a live
* a synthesised snapshot whose subscription state is read from * channel, falls back to a synthesised snapshot whose subscription state is
* {@code localStorage} (key {@code stirling.payg.devSubscription}). Both * read from {@code localStorage}. The detection + synthesis (which read
* conditions are required so a production tenant whose URL happens to start * {@code import.meta.env}, {@code window.location} and web storage all banned
* with {@code /dev/} can't trigger the fallback. * 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 { useCallback, useEffect, useRef, useState } from "react";
import apiClient from "@app/services/apiClient"; 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 ─────────────────────────────────────────────────────── // ─── Public types ───────────────────────────────────────────────────────
@@ -185,24 +189,20 @@ export interface UseWalletResult {
*/ */
updateCap: (capUsd: number | null) => Promise<void>; updateCap: (capUsd: number | null) => Promise<void>;
/** /**
* Mint a Stripe Customer Portal session and navigate to it. Calls * Mint a Stripe Customer Portal session and send the user to it. Mints the
* the {@code create-customer-portal-session} Supabase edge function * session via the {@code @app/services/billing} seam (passing the caller's
* directly with the user's JWT (same pattern as checkout no backend * {@code teamId}, which the PAYG portal edge function needs to resolve the
* proxy; the function's RPC enforces team membership) and assigns * team outside Spring Security) and opens the returned URL via the
* {@code window.location} to the returned URL same-tab redirect. * {@code @app/platform/openExternal} seam so web and desktop each route it
* We do not use {@code window.open(...,"_blank")} after an {@code await} * the platform-appropriate way (new tab on web, system browser on desktop).
* because browsers treat it as non-user-gesture and silently popup-block * Throws on error so the caller can show a friendly toast notably 404
* it; the portal is a full-page experience anyway and Stripe redirects * {@code team_not_subscribed}.
* back to {@code return_url} on close. Throws on error so the caller can
* show a friendly toast notably 404 {@code team_not_subscribed}.
*/ */
openPortal: () => Promise<void>; openPortal: () => Promise<void>;
} }
// ─── Implementation ───────────────────────────────────────────────────── // ─── Implementation ─────────────────────────────────────────────────────
const STORAGE_KEY = "stirling.payg.devSubscription";
/** /**
* Stable reference reuse if the new payload deep-equals the previous one, * Stable reference reuse if the new payload deep-equals the previous one,
* return the previous object so React's reference check short-circuits child * 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) return next;
if ( if (
prev.status !== next.status || prev.status !== next.status ||
prev.teamId !== next.teamId ||
prev.role !== next.role || prev.role !== next.role ||
prev.billingPeriodStart !== next.billingPeriodStart || prev.billingPeriodStart !== next.billingPeriodStart ||
prev.billingPeriodEnd !== next.billingPeriodEnd || prev.billingPeriodEnd !== next.billingPeriodEnd ||
@@ -263,87 +264,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
return prev; 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 { export function useWallet(): UseWalletResult {
const devPreview = useRef<boolean>(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<Wallet | null>(null); const [wallet, setWallet] = useState<Wallet | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
@@ -369,7 +296,7 @@ export function useWallet(): UseWalletResult {
setError(null); setError(null);
if (devPreview) { if (devPreview) {
const synth = buildDevPreviewWallet(devPreviewRole()); const synth = devPreview.buildWallet(devPreview.role());
if (cancelled || reqId !== latestReqId.current) return; if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, synth)); setWallet((prev) => reuseIfEqual(prev, synth));
setLoading(false); setLoading(false);
@@ -417,11 +344,7 @@ export function useWallet(): UseWalletResult {
const markSubscribed = useCallback( const markSubscribed = useCallback(
async (capUsd: number | null) => { async (capUsd: number | null) => {
if (devPreview) { if (devPreview) {
try { devPreview.markSubscribed();
window.localStorage.setItem(STORAGE_KEY, "subscribed");
} catch {
/* storage unavailable */
}
await refetch(); await refetch();
return; return;
} }
@@ -479,39 +402,22 @@ export function useWallet(): UseWalletResult {
const openPortal = useCallback(async () => { const openPortal = useCallback(async () => {
if (devPreview) { if (devPreview) {
// No real Stripe in dev preview — navigate to a placeholder so the // No real Stripe in dev preview — open a placeholder so the click still
// click still feels alive. Same-tab to match the real-flow behaviour. // feels alive. Routed through the openExternal seam to stay portable.
window.location.assign("https://billing.stripe.com/p/login/mock"); await openExternal("https://billing.stripe.com/p/login/mock");
return; return;
} }
// Direct edge-fn invocation with the user's JWT — same pattern as // Mint the portal session through the billing seam, passing teamId: the
// create-checkout-session. The payg_get_checkout_context RPC inside the // PAYG portal edge function needs it to resolve the caller's team outside
// fn enforces team membership, so no backend proxy is needed; team_id // Spring Security (its RPC enforces team membership). Then hand the URL to
// must be a NUMBER (the fn type-checks and rejects strings). // 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; const teamId = wallet?.teamId;
if (teamId == null) { if (teamId == null) {
throw new Error("No team resolved yet"); throw new Error("No team resolved yet");
} }
const { data, error: invokeError } = await supabase.functions.invoke<{ const { url } = await createPortalSession({ teamId });
url?: string; await openExternal(url);
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);
}, [devPreview, wallet?.teamId]); }, [devPreview, wallet?.teamId]);
return { return {
@@ -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;
}
@@ -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<void>;
/**
* 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<void> => {
throw new Error("openExternal: platform impl required");
};
@@ -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<CheckoutSession> {
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<PortalSession> {
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 "";
}
+21
View File
@@ -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",
"."
]
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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}</>;
}
@@ -1,7 +1,8 @@
/** /**
* Core stub for credit checking before cloud operations * No-op stub kept only to satisfy useToolOperation's @app/hooks/useCreditCheck
* Desktop layer shadows this with the real implementation * import. There is no pre-flight credit/usage check anymore — PAYG is enforced
* In web builds, always allows the operation (no credit system) * 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) { export function useCreditCheck(_operationType?: string, _endpoint?: string) {
return { return {
@@ -6,6 +6,12 @@ export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
export const Z_ANALYTICS_MODAL = 1301; export const Z_ANALYTICS_MODAL = 1301;
// Config/Settings modal - should appear above analytics modal when navigating from onboarding // Config/Settings modal - should appear above analytics modal when navigating from onboarding
export const Z_INDEX_CONFIG_MODAL = 1400; 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; export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
@@ -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<string | null> {
return authService.getAuthToken();
}
@@ -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<never>(() => {}));
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);
});
});
@@ -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 () => {},
};
}
@@ -4,6 +4,8 @@ import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer"; import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
import { SaveShortcutListener } from "@app/components/SaveShortcutListener"; import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal"; 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 { SignInModal } from "@app/components/SignInModal";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents"; import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import { ToolActionsContext } from "@app/contexts/ToolActionsContext"; import { ToolActionsContext } from "@app/contexts/ToolActionsContext";
@@ -22,9 +24,6 @@ import { endpointAvailabilityService } from "@app/services/endpointAvailabilityS
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext"; 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 UpdateModal from "@core/components/shared/UpdateModal";
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup"; import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
@@ -338,21 +337,24 @@ export function AppProviders({ children }: { children: ReactNode }) {
}} }}
> >
<SaaSTeamProvider key={appKey}> <SaaSTeamProvider key={appKey}>
<SaasBillingProvider>
<SaaSCheckoutProvider>
<DesktopConfigSync /> <DesktopConfigSync />
<DesktopBannerInitializer /> <DesktopBannerInitializer />
<SaveShortcutListener /> <SaveShortcutListener />
<CreditModalBootstrap />
{children} {children}
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */} {/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
<DesktopOnboardingModal /> <DesktopOnboardingModal />
{/* SaaS product onboarding (cloud flow, minus the desktop-download slide),
shown once after a SaaS sign-in. Mirrors saas's OnboardingBootstrap. */}
<DesktopSaasOnboardingBootstrap connectionMode={connectionMode} />
{/* 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). */}
<UsageLimitModalHost />
{/* Global sign-in modal, opened via stirling:open-sign-in event */} {/* Global sign-in modal, opened via stirling:open-sign-in event */}
<SignInModal /> <SignInModal />
{/* Desktop auto-update popup */} {/* Desktop auto-update popup */}
{updatePopupModal} {updatePopupModal}
</SaaSCheckoutProvider>
</SaasBillingProvider>
</SaaSTeamProvider> </SaaSTeamProvider>
</ToolActionsContext.Provider> </ToolActionsContext.Provider>
</ProprietaryAppProviders> </ProprietaryAppProviders>
@@ -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 (
<SaasOnboardingModal
opened={showModal}
onClose={handleClose}
hideDesktopInstall
/>
);
}
@@ -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();
}
@@ -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 (
<Box
className={className}
style={{ padding: "0.5rem", cursor: "pointer" }}
onClick={handleClick}
>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed" fw={500}>
{creditBalance} {creditBalance === 1 ? "credit" : "credits"}
</Text>
<Text size="xs" c="dimmed" style={{ textDecoration: "underline" }}>
Upgrade
</Text>
</Stack>
</Box>
);
}
@@ -18,7 +18,7 @@ export function CloudBadge({ className }: CloudBadgeProps) {
<Tooltip <Tooltip
label={t( label={t(
"cloudBadge.tooltip", "cloudBadge.tooltip",
"This operation will use your cloud credits", "Runs in the cloud (included, no extra charge)",
)} )}
position="top" position="top"
withArrow withArrow
@@ -1,161 +0,0 @@
import { useState, useEffect } from "react";
import { Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import { connectionModeService } from "@app/services/connectionModeService";
export function TeamInvitationBanner() {
const { t } = useTranslation();
const { receivedInvitations, acceptInvitation, rejectInvitation } =
useSaaSTeam();
const { refreshBilling } = useSaaSBilling();
const [processing, setProcessing] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [connectionMode, setConnectionMode] = useState<string | null>(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 = (
<Text
component="span"
size="sm"
fw={500}
style={{ color: "rgba(255, 255, 255, 0.95)" }}
>
<strong>{invitation.inviterEmail}</strong>{" "}
{t("team.invitationBanner.message", "has invited you to join")}{" "}
<strong>{invitation.teamName}</strong>
</Text>
);
const actionButtons = (
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
color="gray"
size="xs"
onClick={handleAccept}
loading={processing}
leftSection={
<LocalIcon
icon="check"
width="0.9rem"
height="0.9rem"
style={{ color: "var(--mantine-color-dark-9)" }}
/>
}
styles={{
label: {
color: "var(--mantine-color-dark-9)",
},
}}
>
{t("team.invitationBanner.acceptButton", "Accept")}
</Button>
<Button
variant="subtle"
size="xs"
onClick={handleReject}
loading={processing}
style={{ color: "rgba(255, 255, 255, 0.7)" }}
>
{t("team.invitationBanner.rejectButton", "Decline")}
</Button>
</Group>
);
return (
<InfoBanner
icon="mail"
message={
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{ width: "100%" }}
>
{message}
{actionButtons}
</Group>
}
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)"
/>
);
}
@@ -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<SaaSStripeCheckoutProps> = ({
opened,
onClose,
planId,
onSuccess,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ 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 (
<div className="flex flex-col items-center justify-center py-8">
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</div>
);
case "opened":
return (
<Alert
color="blue"
title={t("payment.checkoutOpened", "Checkout Opened in Browser")}
icon={<OpenInBrowserIcon />}
>
<Stack gap="md">
<Text size="sm">
{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.",
)}
</Text>
<Button
variant="filled"
color="blue"
onClick={handleRefreshClick}
fullWidth
>
{t(
"payment.refreshBilling",
"I've Completed Payment - Refresh Billing",
)}
</Button>
<Button variant="subtle" onClick={handleClose} fullWidth>
{t("payment.closeLater", "I'll Do This Later")}
</Button>
</Stack>
</Alert>
);
case "error":
return (
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
);
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 (
<Modal
opened={opened}
onClose={handleClose}
title={
<div>
<Text fw={600} size="lg">
{t("payment.upgradeTitle", "Upgrade to {{planName}}", {
planName: getPlanName(),
})}
</Text>
</div>
}
size="md"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
@@ -1,13 +1,12 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { useConfigNavSections as useProprietaryConfigNavSections } from "@proprietary/components/shared/config/configNavSections";
useConfigNavSections as useProprietaryConfigNavSections,
createConfigNavSections as createProprietaryConfigNavSections,
} from "@proprietary/components/shared/config/configNavSections";
import { ConfigNavSection } from "@core/components/shared/config/configNavSections"; import { ConfigNavSection } from "@core/components/shared/config/configNavSections";
import { ConnectionSettings } from "@app/components/ConnectionSettings"; import { ConnectionSettings } from "@app/components/ConnectionSettings";
import { SaasPlanSection } from "@app/components/shared/config/configSections/SaasPlanSection"; import {
import { SaaSTeamsSection } from "@app/components/shared/config/configSections/SaaSTeamsSection"; createCloudPlanNavItem,
createCloudTeamNavItem,
} from "@app/components/shared/config/cloudConfigNavSections";
import { connectionModeService } from "@app/services/connectionModeService"; import { connectionModeService } from "@app/services/connectionModeService";
import { authService } from "@app/services/authService"; import { authService } from "@app/services/authService";
@@ -100,29 +99,18 @@ export const useConfigNavSections = (
// Connection Mode always sits immediately after Preferences // Connection Mode always sits immediately after Preferences
result.push(connectionModeSection); 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) { if (isSaasMode && isAuthenticated) {
result.push({ result.push({
title: t("settings.planBilling.title", "Plan & Billing"), title: t("settings.planBilling.title", "Plan & Billing"),
items: [ items: [createCloudPlanNavItem(t)],
{
key: "planBilling",
label: t("settings.planBilling.title", "Plan & Billing"),
icon: "credit-card",
component: <SaasPlanSection />,
},
],
}); });
result.push({ result.push({
title: t("settings.team.title", "Team"), title: t("settings.team.title", "Team"),
items: [ items: [createCloudTeamNavItem(t)],
{
key: "teams",
label: t("settings.team.title", "Team"),
icon: "groups-rounded",
component: <SaaSTeamsSection />,
},
],
}); });
} }
@@ -146,52 +134,3 @@ export const useConfigNavSections = (
return result; 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: <ConnectionSettings />,
},
],
});
// Add Plan & Billing section (after Connection Mode)
sections.splice(2, 0, {
title: "Plan & Billing",
items: [
{
key: "planBilling",
label: "Plan & Billing",
icon: "credit-card",
component: <SaasPlanSection />,
},
],
});
return sections;
};
@@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<Alert color="gray">
<Text>{t("team.loading", "Loading team information...")}</Text>
</Alert>
);
}
return (
<Stack gap="lg">
{/* Header */}
<div>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
{isEditingName ? (
<Group gap="xs" align="center">
<TextInput
value={newTeamName}
onChange={(e) => 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();
}}
/>
<ActionIcon
variant="filled"
color="blue"
onClick={handleRenameSubmit}
loading={renamingTeam}
disabled={!newTeamName.trim()}
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleCancelRename}
disabled={renamingTeam}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
) : (
<Group gap="xs" align="center">
<Text fw={600} size="lg">
{currentTeam.name}
</Text>
{isTeamLeader && !isPersonalTeam && (
<ActionIcon
variant="subtle"
size="sm"
onClick={handleStartRename}
aria-label={t("team.editName", "Edit team name")}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
)}
{isTeamLeader && (
<Badge color="blue">{t("team.leader", "LEADER")}</Badge>
)}
{isPersonalTeam && (
<Badge color="gray" variant="light" size="xs">
{t("team.personal", "Personal")}
</Badge>
)}
</Group>
)}
{!isEditingName && !isPersonalTeam && (
<Text size="sm" c="dimmed" mt={4}>
{t("team.memberCount", "{{count}} team members", {
count: currentTeam.seatsUsed,
})}
</Text>
)}
</div>
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
<Button
color="red"
variant="outline"
size="xs"
onClick={handleLeaveTeam}
leftSection={
<LocalIcon icon="logout" width="1rem" height="1rem" />
}
>
{t("team.leaveButton", "Leave Team")}
</Button>
)}
</Group>
</div>
{/* Error/Success Messages */}
{error && (
<Alert color="red" onClose={() => setError(null)} withCloseButton>
{error}
</Alert>
)}
{success && (
<Alert color="green" onClose={() => setSuccess(null)} withCloseButton>
{success}
</Alert>
)}
{/* Invite Members */}
{isTeamLeader && (
<div>
<Text fw={600} size="md" mb="sm">
{t("team.invite.title", "Invite Team Member")}
</Text>
<form onSubmit={handleInvite}>
<Group>
<TextInput
type="email"
placeholder={t("team.invite.placeholder", "[email protected]")}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
style={{ flex: 1 }}
required
error={
inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
? t("team.invite.invalidEmail", "Invalid email format")
: undefined
}
/>
<Button
type="submit"
loading={inviting}
disabled={
!inviteEmail.trim() ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
}
>
{t("team.invite.sendButton", "Send Invite")}
</Button>
</Group>
</form>
</div>
)}
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">
{t("team.members.title", "Team Members")}
</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr
style={{ backgroundColor: "var(--mantine-color-gray-0)" }}
>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.nameColumn", "Name")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.emailColumn", "Email")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.roleColumn", "Role")}
</Table.Th>
{isTeamLeader && !isPersonalTeam && (
<Table.Th style={{ width: 50 }}></Table.Th>
)}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamMembers.length === 0 && teamInvitations.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
<Text ta="center" c="dimmed" py="xl">
{t("team.members.empty", "No team members yet.")}
</Text>
</Table.Td>
</Table.Tr>
) : (
<>
{/* Active Members */}
{teamMembers.map((member) => (
<Table.Tr key={`member-${member.id}`}>
<Table.Td>
<Text size="sm" fw={500}>
{member.username}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{member.email}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={member.role === "LEADER" ? "blue" : undefined}
style={
member.role !== "LEADER"
? {
backgroundColor: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
}
: undefined
}
>
{member.role}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
{member.role !== "LEADER" && (
<Menu
position="bottom-end"
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Menu.Target>
<ActionIcon variant="subtle">
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
leftSection={
<LocalIcon
icon="person-remove"
width="1rem"
height="1rem"
/>
}
onClick={() =>
handleRemove(member.id, member.email)
}
>
{t("team.members.remove", "Remove from Team")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
)}
</Table.Tr>
))}
{/* Pending Invitations */}
{teamInvitations
.filter((inv) => inv.status === "PENDING")
.map((invitation) => (
<Table.Tr key={`invitation-${invitation.invitationId}`}>
<Table.Td>
<Text size="sm" fw={500} c="dimmed" fs="italic">
{invitation.inviteeEmail.split("@")[0]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{invitation.inviteeEmail}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color="yellow" variant="light">
{t("team.members.pending", "PENDING")}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
onClick={() =>
handleCancelInvitation(
invitation.invitationId,
invitation.inviteeEmail,
)
}
aria-label={t(
"team.invite.cancelLabel",
"Cancel invitation",
)}
>
<LocalIcon
icon="close"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Table.Td>
)}
</Table.Tr>
))}
</>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
);
}
@@ -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<boolean | null>(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 (
<Center p="xl">
<Alert
color="blue"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
>
<Text size="sm">
{t(
"settings.planBilling.notAvailable",
"Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).",
)}
</Text>
</Alert>
</Center>
);
}
// Loading state while checking mode
if (isSaasMode === null) {
return (
<Center p="xl">
<Loader size="sm" />
</Center>
);
}
// Loading state while fetching billing/team data
// Note: loading already includes teamLoading from billing context
if (loading) {
return (
<Center p="xl">
<Stack align="center" gap="md">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t(
"settings.planBilling.loading",
"Loading billing information...",
)}
</Text>
</Stack>
</Center>
);
}
// Error state
if (error) {
return (
<Center p="xl">
<Alert
color="red"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
title={t(
"settings.planBilling.errors.fetchFailed",
"Unable to fetch billing data",
)}
>
<Stack gap="sm">
<Text size="sm">{error}</Text>
<Button
variant="light"
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
onClick={refreshBilling}
size="xs"
>
{t("settings.planBilling.errors.retry", "Retry")}
</Button>
</Stack>
</Alert>
</Center>
);
}
// Main content
return (
<SaaSCheckoutProvider>
<div>
{/* Header with title and Manage Billing button */}
<Flex justify="space-between" align="center" mb="md">
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("settings.planBilling.currentPlan", "Active Plan")}
</h3>
{tier !== "free" && !isManagedTeamMember && (
<Button
variant="light"
size="sm"
onClick={handleManageBilling}
loading={isOpeningPortal}
disabled={isOpeningPortal}
>
{t(
"settings.planBilling.billing.manageBilling",
"Manage Billing",
)}
</Button>
)}
</Flex>
{/* Trial Status Alert */}
{isTrialing &&
trialDaysRemaining !== undefined &&
subscription?.currentPeriodEnd && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t("settings.planBilling.trial.title", "Free Trial Active")}
>
<Text size="sm">
{t(
"settings.planBilling.trial.daysRemainingFull",
"Your trial ends in {{days}} days",
{
days: trialDaysRemaining,
defaultValue: `Your trial ends in ${trialDaysRemaining} days`,
},
)}
</Text>
<Text size="xs" c="dimmed">
{t("settings.planBilling.trial.endDate", "Expires: {{date}}", {
date: formatDate(subscription.currentPeriodEnd),
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
})}
</Text>
</Alert>
)}
{/* Plan cards */}
<Stack gap="lg">
{/* Current subscription card */}
<ActiveSubscriptionCard
tier={tier}
subscription={subscription}
usage={usage}
isTrialing={isTrialing}
price={price}
currency={currency}
currentTeam={currentTeam}
isTeamLeader={isTeamLeader}
isPersonalTeam={isPersonalTeam}
/>
{/* Available plans grid */}
<SaaSAvailablePlansSection
plans={plans}
currentTier={tier}
loading={plansLoading}
error={plansError}
/>
</Stack>
</div>
</SaaSCheckoutProvider>
);
}
@@ -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 (
<Card padding="lg" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
{/* Left side: Name, badges, description */}
<div style={{ flex: 1 }}>
<Group gap="xs" mb="xs">
<Text size="lg" fw={600}>
{!isPersonalTeam && isTeamLeader
? t("settings.planBilling.tier.team", "Team Plan")
: getTierName()}
</Text>
{!isPersonalTeam && (
<Badge
color="violet"
variant="light"
leftSection={<GroupIcon sx={{ fontSize: 12 }} />}
>
{t("settings.planBilling.tier.teamBadge", "Team")}
</Badge>
)}
<Tooltip
label={
<div style={{ maxWidth: 300 }}>
<Text size="sm" mb="xs">
{t("settings.planBilling.tier.teamTooltipCredits", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`,
})}
</Text>
<Text size="sm" mb="xs">
{t("settings.planBilling.tier.teamTooltipOverage", {
price: getFormattedOveragePrice(),
defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`,
})}
</Text>
<Text size="sm">
{t(
"settings.planBilling.tier.teamTooltipFineprint",
"Only pay for what you use beyond included credits.",
)}
</Text>
</div>
}
multiline
withArrow
position="right"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<InfoOutlinedIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
{isTrialing && (
<Badge color="blue" variant="light">
{t("settings.planBilling.status.trial", "Trial")}
</Badge>
)}
</Group>
{!isPersonalTeam && !isTeamLeader && (
<Text size="sm" c="dimmed" mb="xs">
{t(
"settings.planBilling.team.managedByTeam",
"Managed by team",
)}
</Text>
)}
{!isPersonalTeam && isTeamLeader && currentTeam && (
<Text size="sm" c="dimmed" mb="xs">
{t(
"settings.planBilling.team.memberCount",
"{{count}} team members",
{ count: currentTeam.seatsUsed },
)}
</Text>
)}
<Text size="sm" c="dimmed" mb="xs">
{getDescription()}
</Text>
{/* Show overage cost if applicable */}
{usage && usage.currentPeriodCredits > 0 && (
<Text size="sm" c="orange" fw={500}>
{formatOverageCost(
usage.estimatedCost,
usage.currentPeriodCredits,
)}
</Text>
)}
</div>
{/* Right side: Price */}
<div style={{ textAlign: "right" }}>
{!isPersonalTeam && !isTeamLeader ? (
<Text size="lg" c="dimmed">
{t(
"settings.planBilling.team.managedByTeam",
"Managed by team",
)}
</Text>
) : (
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
)}
</div>
</Group>
{/* Next billing date at bottom */}
{subscription?.currentPeriodEnd && (
<Group gap="xs" mt="xs">
<Text size="sm" c="dimmed">
{t(
"settings.planBilling.billing.nextBillingDate",
"Next billing date:",
)}{" "}
{formatDate(subscription.currentPeriodEnd)}
</Text>
</Group>
)}
</Stack>
</Card>
);
}
// Free plan card
return (
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{getTierName()}
</Text>
</Group>
<Text size="sm" c="dimmed">
{getDescription()}
</Text>
</div>
<div style={{ textAlign: "right" }}>
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
</div>
</Group>
</Card>
);
}
@@ -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 (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t("settings.planBilling.upgrade.title", "Upgrade Your Plan")}
</Text>
{/* Team plan benefits */}
<Text size="sm" c="dimmed">
{t("settings.planBilling.upgrade.subtitle", "Upgrade to Team for:")}
</Text>
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon color="blue" size={20} radius="xl">
<CheckCircleIcon sx={{ fontSize: 12 }} />
</ThemeIcon>
}
>
<List.Item>
{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)`,
})}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureMembers",
"Unlimited team members",
)}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureThroughput",
"Faster processing throughput",
)}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureApi",
"API access for automation",
)}
</List.Item>
<List.Item>
{t(
"settings.planBilling.upgrade.featureSupport",
"Priority support",
)}
</List.Item>
</List>
{/* Upgrade button */}
<Button variant="filled" fullWidth onClick={handleUpgrade}>
{t("settings.planBilling.upgrade.cta", "Upgrade to Team")}
</Button>
<Text size="xs" c="dimmed" ta="center">
{t(
"settings.planBilling.upgrade.opensInBrowser",
"Opens in browser to complete upgrade",
)}
</Text>
</Stack>
</Card>
);
}
@@ -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 (
<Center py="xl">
<Loader size="md" />
</Center>
);
}
if (error) {
return (
<Alert color="orange" variant="light" mt="md">
<Text size="sm">
{t(
"plan.availablePlans.loadError",
"Unable to load plan pricing. Using default values.",
)}
</Text>
</Alert>
);
}
return (
<div>
<Text size="lg" fw={600} mb="md">
{t("plan.availablePlans.title", "Available Plans")}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{plans.map((plan) => (
<SaasPlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentTier}
currentTier={currentTier}
onUpgradeClick={handleUpgradeClick}
/>
))}
</SimpleGrid>
</div>
);
};
@@ -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<SaasPlanCardProps> = ({
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<Card
key={plan.id}
padding="md"
radius="md"
withBorder
style={getCardStyle()}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{plan.popular && (
<Badge
size="sm"
style={{
position: "absolute",
top: -10,
left: "50%",
transform: "translateX(-50%)",
background: "rgb(59, 130, 246)",
color: "white",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.5px",
paddingLeft: "12px",
paddingRight: "12px",
}}
>
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="sm" className="h-full">
<div>
<Text size="md" fw={600} mb="xs">
{plan.name}
</Text>
<Group gap="xs" align="baseline">
<Text size="xl" fw={700}>
{plan.isContactOnly
? t("plan.customPricing", "Custom")
: `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" fw={400} c="dimmed">
{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")}`}
</Text>
</div>
<Stack gap="xs">
<Text size="xs" fw={500} mb="xs">
{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:",
)}
</Text>
{plan.highlights.map((highlight: string, index: number) => (
<FeatureListItem
key={index}
included
color={
plan.id === "free"
? "var(--mantine-color-gray-6)"
: "var(--color-primary-600)"
}
size="xs"
>
{highlight}
</FeatureListItem>
))}
</Stack>
<div className="flex-grow" />
<Button
variant={
isCurrentPlan || isIncluded
? "subtle"
: plan.isContactOnly
? "outline"
: "filled"
}
color={plan.isContactOnly ? undefined : "blue"}
disabled={isCurrentPlan || isIncluded}
fullWidth
size="sm"
radius="lg"
onClick={(e) => {
e.stopPropagation();
onUpgradeClick?.(plan);
}}
style={{
fontWeight: 600,
...((isCurrentPlan || isIncluded) && {
background: "transparent",
border: "none",
cursor: "default",
}),
...(plan.isContactOnly && {
borderColor: "var(--text-primary)",
color: "var(--text-primary)",
}),
}}
component={plan.isContactOnly ? "a" : undefined}
href={
plan.isContactOnly
? `mailto:[email protected]?subject=${plan.name} Plan Inquiry`
: undefined
}
>
{isCurrentPlan
? t("plan.current", "Current Plan")
: isIncluded
? t("plan.included", "Included")
: plan.isContactOnly
? t("plan.contact", "Contact Sales")
: t("plan.upgrade", "Upgrade")}
</Button>
</Stack>
</Card>
);
};
@@ -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 (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t("settings.planBilling.credits.title", "Credit Usage")}
</Text>
{/* Monthly credits info */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("settings.planBilling.credits.included", {
count: monthlyCredits,
defaultValue: `${monthlyCredits} credits/month (included)`,
})}
</Text>
</Group>
{/* Overage credits (if metered billing enabled) */}
{usage && usage.currentPeriodCredits > 0 && (
<>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("settings.planBilling.credits.overage", {
count: usage.currentPeriodCredits,
defaultValue: `+ ${usage.currentPeriodCredits} overage`,
})}
</Text>
<Text size="sm" fw={500} c="orange">
{t("settings.planBilling.credits.estimatedCost", {
amount: formatCurrency(usage.estimatedCost),
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
})}
</Text>
</Group>
{/* Progress bar for overage usage */}
<Progress
value={100}
color="orange"
size="sm"
radius="xl"
striped
animated
/>
</Stack>
<Alert
color="blue"
variant="light"
icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}
>
<Text size="xs">
{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.`,
})}
</Text>
</Alert>
</>
)}
{/* No overage message */}
{(!usage || usage.currentPeriodCredits === 0) && tier !== "free" && (
<Alert color="green" variant="light">
<Text size="sm">
{t("settings.planBilling.credits.noOverage", {
count: monthlyCredits,
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
})}
</Text>
</Alert>
)}
{/* Free tier message */}
{tier === "free" && (
<Alert color="blue" variant="light">
<Text size="sm">
{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.`,
})}
</Text>
</Alert>
)}
</Stack>
</Card>
);
}
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={t("credits.modal.managedMemberTitle", "Unlimited Credits")}
>
<Stack gap="md">
<Text size="sm">
{t(
"credits.modal.managedMemberMessage",
"You have unlimited access to credits through your team. If you need assistance, please contact your team leader.",
)}
</Text>
<Button onClick={onClose} fullWidth>
{t("common.close", "Close")}
</Button>
</Stack>
</Modal>
);
}
// 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 (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
closeOnClickOutside={!enablingMetering}
closeOnEscape={!enablingMetering}
centered
size="lg"
radius="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{t(
"credits.modal.titleExhaustedPro",
"You have run out of credits",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
"credits.modal.subtitlePro",
"Enable automatic overage billing to never run out of credits.",
)}
</Text>
</Stack>
}
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))",
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}
/>
{meteringError && (
<Alert color="red" ml="lg" mr="lg">
{meteringError}
</Alert>
)}
{/* Explanation Card */}
<Card
padding="xl"
radius="md"
withBorder
ml="lg"
mr="lg"
style={{
borderColor: "var(--color-primary-600)",
borderWidth: 2,
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md">
<Group gap="xs" align="center">
<TrendingUpIcon
sx={{ fontSize: 24, color: "var(--color-primary-600)" }}
/>
<Text size="lg" fw={600}>
{t(
"credits.modal.meteringTitle",
"Pay-What-You-Use Overage Billing",
)}
</Text>
</Group>
<Text size="sm" c="dimmed">
{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.`,
})}
</Text>
<Stack gap="xs">
<FeatureListItem included>
{t("credits.modal.meteringIncluded", {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`,
})}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringPrice",
"Additional credits at {{price}}/credit",
{
price: formattedOveragePrice,
},
)}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringPayAsYouGo",
"Only pay for what you use",
)}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringNoCommitment",
"No commitment, cancel anytime",
)}
</FeatureListItem>
<FeatureListItem included>
{t(
"credits.modal.meteringNeverRunOut",
"Never run out of credits",
)}
</FeatureListItem>
</Stack>
<Card
padding="md"
radius="md"
style={{
backgroundColor: "light-dark(#F8F9FA, #1A1A1E)",
border: "1px solid var(--border-subtle)",
}}
>
<Text size="xs" c="dimmed">
{t(
"credits.modal.meteringBillingNote",
"Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.",
)}
</Text>
</Card>
</Stack>
</Card>
{/* Action Buttons */}
<Stack gap="sm" ml="lg" mr="lg">
<Button
onClick={handleEnableMetering}
variant="filled"
color="blue"
fullWidth
size="lg"
loading={enablingMetering}
disabled={!isTeamLeader}
leftSection={<TrendingUpIcon sx={{ fontSize: 18 }} />}
style={{
fontWeight: 600,
}}
>
{t("credits.enableOverageBilling", "Enable Overage Billing")}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t(
"credits.modal.teamLeaderOnly",
"Only team leaders can enable overage billing",
)}
</Text>
)}
<Button
onClick={onClose}
variant="subtle"
fullWidth
size="md"
c="dimmed"
disabled={enablingMetering}
>
{t("credits.maybeLater", "Maybe later")}
</Button>
</Stack>
</Stack>
</Modal>
);
}
// 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 (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
closeOnClickOutside
closeOnEscape
centered
size="60rem"
radius="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{t("credits.modal.titleExhausted", "You've used your free credits")}
</Text>
<Text size="sm" c="dimmed">
{t(
"credits.modal.subtitle",
"Upgrade to Team for 10x the credits and faster processing.",
)}
</Text>
</Stack>
}
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))",
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH}
/>
<Group gap="md" ml="lg" mr="lg" align="stretch" grow>
{/* Free Plan Card */}
<Card
padding="xl"
radius="md"
withBorder
style={{
borderColor: "var(--border-default)",
borderWidth: 1,
opacity: 0.85,
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t("credits.modal.freeTier", "Free Tier")}
</Text>
<Group gap="xs" align="baseline">
<Text size="1.75rem" fw={700}>
{currencySymbol}0
</Text>
<Text size="sm" c="dimmed">
{t("credits.modal.perMonth", "/month")}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.FREE_CREDITS_PER_MONTH}{" "}
{t("credits.modal.monthlyCredits", "monthly credits")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t("credits.modal.forRegularWork", "For regular PDF work:")}
</Text>
{FREE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem
key={index}
included
color="var(--mantine-color-gray-6)"
>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
disabled
variant="subtle"
fullWidth
size="md"
radius="lg"
style={{
fontWeight: 600,
background: "transparent",
border: "none",
cursor: "default",
}}
>
{t("credits.modal.current", "Current Plan")}
</Button>
</Stack>
</Card>
{/* Team Plan Card */}
<Card
padding="lg"
radius="md"
withBorder
style={{
borderColor: "var(--card-selected-border)",
borderWidth: 2,
cursor: "pointer",
transition: "all 0.2s ease",
position: "relative",
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
overflow: "visible",
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
onClick={() => 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)";
}}
>
<Badge
size="sm"
style={{
position: "absolute",
top: -10,
left: "50%",
transform: "translateX(-50%)",
background: "rgb(59, 130, 246)",
color: "white",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.5px",
paddingLeft: "12px",
paddingRight: "12px",
}}
>
{t("credits.modal.popular", "Popular")}
</Badge>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t("credits.modal.teamSubscription", "Team")}
</Text>
<Group gap="xs" align="baseline" mt="xs">
<Text
size="1.75rem"
fw={700}
style={{ color: "var(--text-primary)" }}
>
{currencySymbol}
{teamPrice}
</Text>
<Text size="sm" c="dimmed">
{t("credits.modal.perMonth", "/month")}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}{" "}
{t("credits.modal.monthlyCredits", "monthly credits")} +{" "}
{formattedOveragePrice}/
{t("credits.modal.overage", "overage")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t(
"credits.modal.everythingInFree",
"Everything in Free, plus:",
)}
</Text>
{TEAM_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
onClick={() => openCheckout("pro")}
variant="filled"
color="blue"
fullWidth
size="md"
radius="lg"
style={{
fontWeight: 600,
}}
>
{t("credits.upgrade", "Upgrade")}
</Button>
</Stack>
</Card>
{/* Enterprise Plan Card */}
<Card
padding="lg"
radius="md"
withBorder
style={{
borderWidth: 1,
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
}}
>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="md" fw={600} mb="xs">
{t("credits.modal.enterpriseSubscription", "Enterprise")}
</Text>
<Text size="1.75rem" fw={600}>
{t("credits.modal.customPricing", "Custom")}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t("credits.modal.unlimitedMonthlyCredits", "Site License")}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t(
"credits.modal.everythingInCredits",
"Everything in Credits, plus:",
)}
</Text>
{ENTERPRISE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
component="a"
href="mailto:[email protected]?subject=Enterprise Plan Inquiry"
variant="outline"
fullWidth
size="md"
radius="lg"
style={{
borderColor: "var(--text-primary)",
color: "var(--text-primary)",
fontWeight: 600,
}}
>
{t("credits.modal.contactSales", "Contact Sales")}
</Button>
</Stack>
</Card>
</Group>
<Text size="sm" ta="center" c="dimmed" mt="md">
{t("credits.modal.selfHostPrompt", "Want to self host?")}{" "}
<Text
component="a"
href="https://www.stirling.com/pricing"
target="_blank"
rel="noopener noreferrer"
size="sm"
style={{
color: "var(--mantine-color-blue-6)",
textDecoration: "none",
}}
onMouseEnter={(e) => {
e.currentTarget.style.textDecoration = "underline";
}}
onMouseLeave={(e) => {
e.currentTarget.style.textDecoration = "none";
}}
>
{t("credits.modal.selfHostLink", "Review the docs and plans")}
</Text>
</Text>
</Stack>
</Modal>
);
}
@@ -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 (
<>
<CreditExhaustedModal
opened={exhaustedOpen && !insufficientOpen}
onClose={() => setExhaustedOpen(false)}
/>
<InsufficientCreditsModal
opened={insufficientOpen}
onClose={() => setInsufficientOpen(false)}
toolId={insufficientDetails.toolId}
requiredCredits={insufficientDetails.requiredCredits}
/>
</>
);
}
@@ -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 (
<Stack gap="md">
<Divider />
<Stack gap="xs" pr="md" pl="md">
<Group gap="xs" justify="space-between" align="center">
<Text size="md" fw={400} c="dimmed">
{t("credits.modal.creditsThisMonth", "Monthly credits")}
</Text>
<Text size="md" fw={600} style={{ color: "var(--text-primary)" }}>
{t(
"credits.modal.creditsRemaining",
"{{current}} of {{total}} remaining",
{
current: currentCredits,
total: totalCredits,
},
)}
</Text>
</Group>
<Progress
value={percentageRemaining}
size="sm"
radius="xl"
color="blue"
styles={{
root: { backgroundColor: "var(--bg-raised)" },
}}
/>
</Stack>
<Divider />
</Stack>
);
}
@@ -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<string, number> = {
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 (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Icon
sx={{
fontSize: iconSize,
color: iconColor,
flexShrink: 0,
marginTop: "2px",
}}
/>
<Text
size={textSize}
c={dimmed ? "dimmed" : undefined}
fw={fw}
style={textSize ? undefined : { fontSize: size }}
>
{children}
</Text>
</Group>
);
}
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Group gap="xs">
<WarningIcon
sx={{ fontSize: 24, color: "var(--mantine-color-orange-6)" }}
/>
<Text size="lg" fw={500}>
{t("credits.insufficient.title", "Insufficient Credits")}
</Text>
</Group>
}
>
<Stack gap="md">
<Alert color="orange" icon={<WarningIcon />}>
<Text size="sm">
{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,
},
)}
</Text>
</Alert>
{isManagedTeamMember ? (
<>
<Text size="sm" c="dimmed">
{t(
"credits.insufficient.managedMember",
"Please contact your team leader for assistance.",
)}
</Text>
<Button onClick={onClose} fullWidth>
{t("common.close", "Close")}
</Button>
</>
) : tier === "team" ? (
<>
<Text size="sm" c="dimmed">
{t(
"credits.insufficient.teamMember",
"Enable overage billing to never run out of credits.",
)}
</Text>
{meteringError && <Alert color="red">{meteringError}</Alert>}
<Button
variant="filled"
color="blue"
fullWidth
onClick={handleEnableMetering}
loading={enablingMetering}
disabled={!isTeamLeader}
>
{t("credits.enableOverageBilling", "Enable Overage Billing")}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t(
"credits.modal.teamLeaderOnly",
"Only team leaders can enable overage billing",
)}
</Text>
)}
<Button
onClick={onClose}
variant="subtle"
fullWidth
disabled={enablingMetering}
>
{t("common.cancel", "Cancel")}
</Button>
</>
) : (
<>
<Text size="sm" c="dimmed">
{t(
"credits.insufficient.freeTier",
"Upgrade to Team for 10x more credits and unlimited overage billing.",
)}
</Text>
<Button
variant="filled"
color="blue"
fullWidth
onClick={() => {
openCheckout("pro");
onClose();
}}
>
{t("credits.upgrade", "Upgrade to Team")}
</Button>
<Button onClick={onClose} variant="subtle" fullWidth>
{t("common.cancel", "Cancel")}
</Button>
</>
)}
</Stack>
</Modal>
);
}
@@ -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()
);
}
@@ -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];
@@ -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<SaaSCheckoutContextType | undefined>(
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<SaaSCheckoutProviderProps> = ({
children,
}) => {
const [opened, setOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string | null>(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 (
<SaaSCheckoutContext.Provider
value={{
opened,
selectedPlan,
openCheckout,
closeCheckout,
}}
>
{children}
<SaaSStripeCheckout
opened={opened}
onClose={closeCheckout}
planId={selectedPlan}
onSuccess={handleCheckoutSuccess}
/>
</SaaSCheckoutContext.Provider>
);
};
@@ -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<void>;
acceptInvitation: (token: string) => Promise<void>;
rejectInvitation: (token: string) => Promise<void>;
cancelInvitation: (invitationId: number) => Promise<void>;
removeMember: (memberId: number) => Promise<void>;
leaveTeam: () => Promise<void>;
refreshTeams: () => Promise<void>;
}
const SaaSTeamContext = createContext<SaaSTeamContextType>({
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<Team | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
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<Team[]>("/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<TeamMember[]>(
`/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<TeamInvitation[]>(
`/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<TeamInvitation[]>(
"/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 (
<SaaSTeamContext.Provider
value={{
currentTeam,
teams,
teamMembers,
teamInvitations,
receivedInvitations,
isTeamLeader,
isPersonalTeam,
loading,
inviteUser,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
}}
>
{children}
</SaaSTeamContext.Provider>
);
}
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 };
@@ -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<string, PlanPrice>;
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<void>;
refreshCredits: () => Promise<void>; // Alias for refreshBilling (for clarity)
refreshPlans: () => Promise<void>;
openBillingPortal: () => Promise<void>;
}
const SaasBillingContext = createContext<SaasBillingContextType>({
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<BillingStatus | null>(
null,
);
const [plans, setPlans] = useState<Map<string, PlanPrice>>(new Map());
const [plansLoading, setPlansLoading] = useState(false);
const [plansError, setPlansError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); // Start false (lazy load)
const [error, setError] = useState<string | null>(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<number | null>(null);
const [lastFetchTimeValue, setLastFetchTimeValue] = useState<number | null>(
null,
);
// billingStatusRef mirrors billingStatus so fetchBillingData can read the current value
// without needing billingStatus in its useCallback dep array.
const billingStatusRef = useRef<BillingStatus | null>(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<number | null>(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 (
<SaasBillingContext.Provider value={contextValue}>
{children}
</SaasBillingContext.Provider>
);
}
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 };
@@ -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<never>(() => {}));
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);
});
});
@@ -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;
}
@@ -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<string | null> => {
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 };
}
@@ -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]);
}
@@ -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<void>,
onSuccess: () => void,
logPrefix: string,
): {
enablingMetering: boolean;
meteringError: string | null;
handleEnableMetering: () => Promise<void>;
} {
const [enablingMetering, setEnablingMetering] = useState(false);
const [meteringError, setMeteringError] = useState<string | null>(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 };
}
@@ -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<PlanTier[]>(() => {
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,
};
};
@@ -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<void> => {
await shellOpen(url);
};
@@ -10,6 +10,7 @@ import { tauriBackendService } from "@app/services/tauriBackendService";
import { createBackendNotReadyError } from "@app/constants/backendErrors"; import { createBackendNotReadyError } from "@app/constants/backendErrors";
import { operationRouter } from "@app/services/operationRouter"; import { operationRouter } from "@app/services/operationRouter";
import { authService } from "@app/services/authService"; import { authService } from "@app/services/authService";
import { getAccessToken } from "@app/auth/session";
import { connectionModeService } from "@app/services/connectionModeService"; import { connectionModeService } from "@app/services/connectionModeService";
import { import {
STIRLING_SAAS_URL, STIRLING_SAAS_URL,
@@ -86,7 +87,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// If another request is already refreshing, wait before attaching token // If another request is already refreshing, wait before attaching token
await authService.awaitRefreshIfInProgress(); await authService.awaitRefreshIfInProgress();
const token = await authService.getAuthToken(); const token = await getAccessToken();
if (token) { if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`; extendedConfig.headers.Authorization = `Bearer ${token}`;
@@ -224,7 +225,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
if (refreshed) { if (refreshed) {
// Retry the original request with new token // Retry the original request with new token
const token = await authService.getAuthToken(); const token = await getAccessToken();
console.debug( console.debug(
`[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`, `[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`,
); );
@@ -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<string> {
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<CheckoutSession> {
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<PortalSession> {
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 ?? "";
}
@@ -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<string, unknown> = {},
) {
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);
});
});
@@ -1,5 +1,10 @@
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { handleHttpError as coreHandleHttpError } from "@core/services/httpErrorHandler"; 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. * Desktop override of handleHttpError.
@@ -10,6 +15,32 @@ import { handleHttpError as coreHandleHttpError } from "@core/services/httpError
export async function handleHttpError(error: unknown): Promise<boolean> { export async function handleHttpError(error: unknown): Promise<boolean> {
const status = isAxiosError(error) ? error.response?.status : undefined; 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) { if (status === 401) {
// In desktop builds, 401s are handled by the auth service (token refresh + toast // In desktop builds, 401s are handled by the auth service (token refresh + toast
// shown by apiClientSetup). Authentication is done via the onboarding modal or // shown by apiClientSetup). Authentication is done via the onboarding modal or
@@ -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);
});
});
@@ -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 * @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 { private isSaaSBackendEndpoint(endpoint?: string): boolean {
if (!endpoint) return false; if (!endpoint) return false;
const saasBackendPatterns = [ const saasBackendPatterns = [
/^\/api\/v1\/team\//, // Team endpoints /^\/api\/v1\/team\//, // Team management
/^\/api\/v1\/auth\//, // Auth endpoints (Supabase auth in SaaS mode) /^\/api\/v1\/auth\//, // Supabase auth (SaaS mode)
// Add more SaaS-specific patterns here as needed /^\/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)); return saasBackendPatterns.some((pattern) => pattern.test(endpoint));
@@ -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<boolean> {
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<BillingStatus> {
// 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<void> {
// 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<ManageBillingResponse>(
"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<Map<string, PlanPrice>> {
// 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<string, { unit_amount: number; currency: string }>;
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<string, PlanPrice>();
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<void> {
// 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();
@@ -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<string, string> {
const opts = fetchMock.mock.calls[0]?.[1] ?? {};
return (opts.headers ?? {}) as Record<string, string>;
}
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");
});
});
@@ -213,6 +213,15 @@ class TauriHttpClient {
if (finalConfig.data instanceof FormData) { if (finalConfig.data instanceof FormData) {
// FormData can be passed directly // FormData can be passed directly
body = finalConfig.data; 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") { } else if (typeof finalConfig.data === "object") {
// Serialize as JSON // Serialize as JSON
body = JSON.stringify(finalConfig.data); body = JSON.stringify(finalConfig.data);
+7 -1
View File
@@ -3,7 +3,13 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "../../", "baseUrl": "../../",
"paths": { "paths": {
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"], "@app/*": [
"src/desktop/*",
"src/cloud/*",
"src/proprietary/*",
"src/core/*"
],
"@cloud/*": ["src/cloud/*"],
"@proprietary/*": ["src/proprietary/*"], "@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"], "@core/*": ["src/core/*"],
"@shared/*": ["../shared/*"] "@shared/*": ["../shared/*"]
@@ -2,7 +2,6 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
useConfigNavSections as useCoreConfigNavSections, useConfigNavSections as useCoreConfigNavSections,
createConfigNavSections as createCoreConfigNavSections,
ConfigNavSection, ConfigNavSection,
} from "@core/components/shared/config/configNavSections"; } from "@core/components/shared/config/configNavSections";
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection"; import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
@@ -270,251 +269,6 @@ export const useConfigNavSections = (
return sections; 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: <GeneralSection /> }
: item,
);
if (loginEnabled) {
preferencesSection.items.push({
key: "account",
label: "Account",
icon: "person-rounded",
component: <AccountSection />,
});
}
}
// 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: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "teams",
label: "Teams",
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
// Configuration
sections.push({
title: "Configuration",
items: [
{
key: "adminGeneral",
label: "System Settings",
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminFeatures",
label: "Features",
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminEndpoints",
label: "Endpoints",
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminDatabase",
label: "Database",
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAdvanced",
label: "Advanced",
icon: "tune-rounded",
component: <AdminAdvancedSection />,
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: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminConnections",
label: "Connections",
icon: "link-rounded",
component: <AdminConnectionsSection />,
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: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAudit",
label: "Audit",
icon: "fact-check-rounded",
component: <AdminAuditSection />,
// 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: <AdminUsageSection />,
// 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: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminPrivacy",
label: "Privacy",
icon: "visibility-rounded",
component: <AdminPrivacySection />,
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: <ApiKeys />,
},
],
};
// 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 // Re-export types for convenience
export type { export type {
ConfigNavSection, ConfigNavSection,
@@ -56,10 +56,12 @@ export async function runStoredPolicy(
): Promise<string> { ): Promise<string> {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("fileInput", file); 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<JobResponse>( const res = await apiClient.post<JobResponse>(
`/api/v1/policies/${encodeURIComponent(id)}/run`, `/api/v1/policies/${encodeURIComponent(id)}/run`,
form, form,
{ headers: { "Content-Type": "multipart/form-data" } },
); );
return res.data.jobId; return res.data.jobId;
} }
@@ -81,9 +83,8 @@ export async function runPolicyPipeline(
"json", "json",
new Blob([JSON.stringify(definition)], { type: "application/json" }), new Blob([JSON.stringify(definition)], { type: "application/json" }),
); );
const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form, { // No Content-Type: let the client set multipart/form-data with its boundary.
headers: { "Content-Type": "multipart/form-data" }, const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form);
});
return res.data.jobId; return res.data.jobId;
} }
+3 -5
View File
@@ -18,7 +18,6 @@ import OAuthConsent from "@app/routes/OAuthConsent";
import ShareLinkPage from "@app/routes/ShareLinkPage"; import ShareLinkPage from "@app/routes/ShareLinkPage";
import MobileScannerPage from "@app/pages/MobileScannerPage"; import MobileScannerPage from "@app/pages/MobileScannerPage";
import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; 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 * Onboarding / sign-up modals must never cover auth-flow pages (login, signup,
* (login, signup, OAuth consent): they steal focus from the task the user * OAuth consent): they steal focus from the task the user was sent there to
* was sent there to complete. Unmounting also stops their background polling. * complete.
*/ */
function NonAuthBootstraps() { function NonAuthBootstraps() {
const location = useLocation(); const location = useLocation();
@@ -59,7 +58,6 @@ function NonAuthBootstraps() {
return ( return (
<> <>
<OnboardingBootstrap /> <OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap /> <SignupRequiredBootstrap />
<UsageLimitModalHost /> <UsageLimitModalHost />
</> </>

Some files were not shown because too many files have changed in this diff Show More