mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
co-authored by
James Brunton
parent
ef0deef4f2
commit
cd7264a76a
@@ -2747,7 +2747,7 @@ summary = "Ran {{count}} tools"
|
||||
unknownTool = "Unknown tool"
|
||||
|
||||
[cloudBadge]
|
||||
tooltip = "This operation will use your cloud credits"
|
||||
tooltip = "Runs in the cloud (included, no extra charge)"
|
||||
|
||||
[color.eyeDropper]
|
||||
tooltip = "Pick colour from screen"
|
||||
@@ -2769,7 +2769,6 @@ error = "Error"
|
||||
expand = "Expand"
|
||||
loading = "Loading..."
|
||||
next = "Next"
|
||||
operation = "this operation"
|
||||
preview = "Preview"
|
||||
previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
@@ -3222,68 +3221,25 @@ posthog = "PostHog Analytics"
|
||||
scarf = "Scarf Pixel"
|
||||
|
||||
[credits]
|
||||
enableOverageBilling = "Enable Overage Billing"
|
||||
maybeLater = "Maybe later"
|
||||
upgrade = "Upgrade"
|
||||
|
||||
[credits.insufficient]
|
||||
brief = "Insufficient credits. You need {{required}} credits but have {{current}}."
|
||||
freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing."
|
||||
managedMember = "Please contact your team leader for assistance."
|
||||
message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits."
|
||||
messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}."
|
||||
teamMember = "Enable overage billing to never run out of credits."
|
||||
title = "Insufficient Credits"
|
||||
|
||||
[credits.modal]
|
||||
advancedMonitoring = "Advanced monitoring"
|
||||
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
|
||||
apiSandbox = "API sandbox"
|
||||
contactSales = "Contact Sales"
|
||||
creditsRemaining = "{{current}} of {{total}} remaining"
|
||||
creditsThisMonth = "Monthly credits"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
dedicatedSupportSlas = "Dedicated support & SLAs"
|
||||
enterpriseSubscription = "Enterprise"
|
||||
everythingInCredits = "Everything in Credits, plus:"
|
||||
everythingInFree = "Everything in Free, plus:"
|
||||
fasterThroughput = "10x faster throughput"
|
||||
forRegularWork = "For regular PDF work:"
|
||||
freeTier = "Free Tier"
|
||||
fullyPrivateFiles = "Fully private files"
|
||||
largeFileProcessing = "Large file processing"
|
||||
managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader."
|
||||
managedMemberTitle = "Unlimited Credits"
|
||||
meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings."
|
||||
meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working."
|
||||
meteringIncluded = "{{credits}} credits/month included with Team"
|
||||
meteringNeverRunOut = "Never run out of credits"
|
||||
meteringNoCommitment = "No commitment, cancel anytime"
|
||||
meteringPayAsYouGo = "Only pay for what you use"
|
||||
meteringPrice = "Additional credits at {{price}}/credit"
|
||||
meteringTitle = "Pay-What-You-Use Overage Billing"
|
||||
monthlyCredits = "monthly credits"
|
||||
orgWideAccess = "Org-wide access controls"
|
||||
overage = "overage"
|
||||
perMonth = "/month"
|
||||
popular = "Popular"
|
||||
premiumAiModels = "Premium AI models"
|
||||
prioritySupport = "Priority support"
|
||||
privateDocCloud = "Private Document Cloud"
|
||||
ragFineTuning = "RAG + fine-tuning"
|
||||
secureApiAccess = "Secure API access"
|
||||
selfHostLink = "Review the docs and plans"
|
||||
selfHostPrompt = "Want to self host?"
|
||||
standardThroughput = "Standard throughput"
|
||||
subtitle = "Upgrade to Team for 10x the credits and faster processing."
|
||||
subtitlePro = "Enable automatic overage billing to never run out of credits."
|
||||
teamLeaderOnly = "Only team leaders can enable overage billing"
|
||||
teamSubscription = "Team"
|
||||
titleExhausted = "You've used your free credits"
|
||||
titleExhaustedPro = "You have run out of credits"
|
||||
unlimitedApiAccess = "Unlimited API access"
|
||||
unlimitedMonthlyCredits = "Site Licence"
|
||||
unlimitedSeats = "Unlimited seats"
|
||||
|
||||
[crop]
|
||||
@@ -5394,11 +5350,7 @@ resetsTomorrow = "Resets tomorrow"
|
||||
thisPeriod = "This billing period"
|
||||
|
||||
[payment]
|
||||
autoClose = "This window will close automatically..."
|
||||
canCloseWindow = "You can now close this window."
|
||||
checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information."
|
||||
checkoutOpened = "Checkout Opened in Browser"
|
||||
closeLater = "I'll Do This Later"
|
||||
error = "Payment Error"
|
||||
generatingLicense = "Generating your licence key..."
|
||||
licenseActivated = "Licence activated! Your licence key has been saved. A confirmation email has been sent to your registered email address."
|
||||
@@ -5414,7 +5366,6 @@ paymentCanceled = "Payment was cancelled. No charges were made."
|
||||
perMonth = "/month"
|
||||
preparing = "Preparing your checkout..."
|
||||
redirecting = "Redirecting to secure checkout..."
|
||||
refreshBilling = "I've Completed Payment - Refresh Billing"
|
||||
success = "Payment Successful!"
|
||||
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly."
|
||||
syncError = "Payment successful but licence sync failed. Your licence will be updated shortly. Please contact support if issues persist."
|
||||
@@ -5704,13 +5655,10 @@ title = "Pipeline"
|
||||
|
||||
[plan]
|
||||
contact = "Contact Us"
|
||||
currency = "Currency"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
featureComparison = "Feature Comparison"
|
||||
from = "From"
|
||||
hideComparison = "Hide Feature Comparison"
|
||||
included = "Included"
|
||||
licensedSeats = "Licensed: {{count}} seats"
|
||||
manage = "Manage"
|
||||
perMonth = "/month"
|
||||
@@ -5728,7 +5676,6 @@ small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.availablePlans]
|
||||
loadError = "Unable to load plan pricing. Using default values."
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
title = "Available Plans"
|
||||
|
||||
@@ -5739,7 +5686,6 @@ highlight3 = "Latest features"
|
||||
name = "Enterprise"
|
||||
requiresServer = "Requires Server"
|
||||
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
|
||||
siteLicense = "Site Licence"
|
||||
|
||||
[plan.feature]
|
||||
api = "API Access"
|
||||
@@ -5846,16 +5792,9 @@ success = "Licence Activated!"
|
||||
successMessage = "Your licence has been successfully activated. You can now close this window."
|
||||
|
||||
[plan.team]
|
||||
name = "Team"
|
||||
|
||||
[plan.trial]
|
||||
continueWithFree = "Continue with Free"
|
||||
expired = "Your Trial Has Ended"
|
||||
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
|
||||
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
|
||||
message = ""
|
||||
subscribe = "Subscribe to Pro"
|
||||
subscribeToPro = "Subscribe to Pro"
|
||||
|
||||
[policies]
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
|
||||
@@ -2743,7 +2743,6 @@ error = "Error"
|
||||
expand = "Expand"
|
||||
loading = "Loading..."
|
||||
next = "Next"
|
||||
operation = "this operation"
|
||||
preview = "Preview"
|
||||
previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
@@ -3196,68 +3195,25 @@ posthog = "PostHog Analytics"
|
||||
scarf = "Scarf Pixel"
|
||||
|
||||
[credits]
|
||||
enableOverageBilling = "Enable Overage Billing"
|
||||
maybeLater = "Maybe later"
|
||||
upgrade = "Upgrade"
|
||||
|
||||
[credits.insufficient]
|
||||
brief = "Insufficient credits. You need {{required}} credits but have {{current}}."
|
||||
freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing."
|
||||
managedMember = "Please contact your team leader for assistance."
|
||||
message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits."
|
||||
messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}."
|
||||
teamMember = "Enable overage billing to never run out of credits."
|
||||
title = "Insufficient Credits"
|
||||
|
||||
[credits.modal]
|
||||
advancedMonitoring = "Advanced monitoring"
|
||||
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
|
||||
apiSandbox = "API sandbox"
|
||||
contactSales = "Contact Sales"
|
||||
creditsRemaining = "{{current}} of {{total}} remaining"
|
||||
creditsThisMonth = "Monthly credits"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
dedicatedSupportSlas = "Dedicated support & SLAs"
|
||||
enterpriseSubscription = "Enterprise"
|
||||
everythingInCredits = "Everything in Credits, plus:"
|
||||
everythingInFree = "Everything in Free, plus:"
|
||||
fasterThroughput = "10x faster throughput"
|
||||
forRegularWork = "For regular PDF work:"
|
||||
freeTier = "Free Tier"
|
||||
fullyPrivateFiles = "Fully private files"
|
||||
largeFileProcessing = "Large file processing"
|
||||
managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader."
|
||||
managedMemberTitle = "Unlimited Credits"
|
||||
meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings."
|
||||
meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working."
|
||||
meteringIncluded = "{{credits}} credits/month included with Team"
|
||||
meteringNeverRunOut = "Never run out of credits"
|
||||
meteringNoCommitment = "No commitment, cancel anytime"
|
||||
meteringPayAsYouGo = "Only pay for what you use"
|
||||
meteringPrice = "Additional credits at {{price}}/credit"
|
||||
meteringTitle = "Pay-What-You-Use Overage Billing"
|
||||
monthlyCredits = "monthly credits"
|
||||
orgWideAccess = "Org-wide access controls"
|
||||
overage = "overage"
|
||||
perMonth = "/month"
|
||||
popular = "Popular"
|
||||
premiumAiModels = "Premium AI models"
|
||||
prioritySupport = "Priority support"
|
||||
privateDocCloud = "Private Document Cloud"
|
||||
ragFineTuning = "RAG + fine-tuning"
|
||||
secureApiAccess = "Secure API access"
|
||||
selfHostLink = "Review the docs and plans"
|
||||
selfHostPrompt = "Want to self host?"
|
||||
standardThroughput = "Standard throughput"
|
||||
subtitle = "Upgrade to Team for 10x the credits and faster processing."
|
||||
subtitlePro = "Enable automatic overage billing to never run out of credits."
|
||||
teamLeaderOnly = "Only team leaders can enable overage billing"
|
||||
teamSubscription = "Team"
|
||||
titleExhausted = "You've used your free credits"
|
||||
titleExhaustedPro = "You have run out of credits"
|
||||
unlimitedApiAccess = "Unlimited API access"
|
||||
unlimitedMonthlyCredits = "Site License"
|
||||
unlimitedSeats = "Unlimited seats"
|
||||
|
||||
[crop]
|
||||
@@ -5363,11 +5319,7 @@ resetsTomorrow = "Resets tomorrow"
|
||||
thisPeriod = "This billing period"
|
||||
|
||||
[payment]
|
||||
autoClose = "This window will close automatically..."
|
||||
canCloseWindow = "You can now close this window."
|
||||
checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information."
|
||||
checkoutOpened = "Checkout Opened in Browser"
|
||||
closeLater = "I'll Do This Later"
|
||||
error = "Payment Error"
|
||||
generatingLicense = "Generating your license key..."
|
||||
licenseActivated = "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address."
|
||||
@@ -5383,7 +5335,6 @@ paymentCanceled = "Payment was canceled. No charges were made."
|
||||
perMonth = "/month"
|
||||
preparing = "Preparing your checkout..."
|
||||
redirecting = "Redirecting to secure checkout..."
|
||||
refreshBilling = "I've Completed Payment - Refresh Billing"
|
||||
success = "Payment Successful!"
|
||||
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly."
|
||||
syncError = "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist."
|
||||
@@ -5673,13 +5624,10 @@ title = "Pipeline"
|
||||
|
||||
[plan]
|
||||
contact = "Contact Us"
|
||||
currency = "Currency"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
featureComparison = "Feature Comparison"
|
||||
from = "From"
|
||||
hideComparison = "Hide Feature Comparison"
|
||||
included = "Included"
|
||||
licensedSeats = "Licensed: {{count}} seats"
|
||||
manage = "Manage"
|
||||
perMonth = "/month"
|
||||
@@ -5697,7 +5645,6 @@ small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.availablePlans]
|
||||
loadError = "Unable to load plan pricing. Using default values."
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
title = "Available Plans"
|
||||
|
||||
@@ -5708,7 +5655,6 @@ highlight3 = "Latest features"
|
||||
name = "Enterprise"
|
||||
requiresServer = "Requires Server"
|
||||
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
|
||||
siteLicense = "Site License"
|
||||
|
||||
[plan.feature]
|
||||
api = "API Access"
|
||||
@@ -5815,15 +5761,8 @@ success = "License Activated!"
|
||||
successMessage = "Your license has been successfully activated. You can now close this window."
|
||||
|
||||
[plan.team]
|
||||
name = "Team"
|
||||
|
||||
[plan.trial]
|
||||
continueWithFree = "Continue with Free"
|
||||
expired = "Your Trial Has Ended"
|
||||
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
|
||||
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
|
||||
subscribe = "Subscribe to Pro"
|
||||
subscribeToPro = "Subscribe to Pro"
|
||||
|
||||
[policies]
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
export const CLOUD_LAYER_PROBE = "cloud" as const;
|
||||
@@ -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 () => {},
|
||||
};
|
||||
}
|
||||
+6
@@ -14,6 +14,12 @@ import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
interface SaasOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Drop the closing "desktop-install" slide. Set by the desktop app, which
|
||||
* reuses this flow but has no reason to pitch its own download. Defaults to
|
||||
* false (slide shown) so the web (saas) flow is unchanged.
|
||||
*/
|
||||
hideDesktopInstall?: boolean;
|
||||
}
|
||||
|
||||
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
+11
-2
@@ -5,21 +5,30 @@ export interface SaasFlowInputs {
|
||||
showUsageSlide: boolean;
|
||||
/** Team leaders only — invited members and anonymous guests skip the team slide. */
|
||||
showTeamSlide: boolean;
|
||||
/**
|
||||
* Drop the closing "desktop-install" slide. The web (saas) flow pitches the
|
||||
* desktop download, but the desktop app reuses this same flow and is already
|
||||
* the desktop app, so it omits that slide. Defaults to false (slide shown).
|
||||
*/
|
||||
hideDesktopInstall?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the SaaS onboarding slide sequence. The free-editor pitch and
|
||||
* desktop install bookend the flow; the usage meter and team slides slot in
|
||||
* when their conditions hold.
|
||||
* when their conditions hold. When {@link SaasFlowInputs.hideDesktopInstall} is
|
||||
* set, the closing desktop-install slide is dropped (used by the desktop app,
|
||||
* which has no reason to pitch its own download).
|
||||
*/
|
||||
export function resolveSaasFlow({
|
||||
showUsageSlide,
|
||||
showTeamSlide,
|
||||
hideDesktopInstall = false,
|
||||
}: SaasFlowInputs): SlideId[] {
|
||||
return [
|
||||
"free-editor",
|
||||
...(showUsageSlide ? (["usage"] as const) : []),
|
||||
...(showTeamSlide ? (["team"] as const) : []),
|
||||
"desktop-install",
|
||||
...(hideDesktopInstall ? [] : (["desktop-install"] as const)),
|
||||
];
|
||||
}
|
||||
+14
-4
@@ -11,6 +11,7 @@ import {
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver";
|
||||
import { DOWNLOAD_URLS } from "@app/constants/downloads";
|
||||
import { openExternal } from "@app/platform/openExternal";
|
||||
|
||||
interface UseSaasOnboardingStateResult {
|
||||
currentStep: number;
|
||||
@@ -24,11 +25,18 @@ interface UseSaasOnboardingStateResult {
|
||||
interface UseSaasOnboardingStateProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Drop the closing "desktop-install" slide. The desktop app reuses this
|
||||
* flow but has no reason to pitch its own download. Defaults to false
|
||||
* (slide shown) so the web (saas) flow is unchanged.
|
||||
*/
|
||||
hideDesktopInstall?: boolean;
|
||||
}
|
||||
|
||||
export function useSaasOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
hideDesktopInstall = false,
|
||||
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
|
||||
const { loading } = useAuth();
|
||||
const { wallet } = useWallet();
|
||||
@@ -80,8 +88,9 @@ export function useSaasOnboardingState({
|
||||
const showTeamSlide = isTeamLeader;
|
||||
|
||||
const flowSlideIds = useMemo(
|
||||
() => resolveSaasFlow({ showUsageSlide, showTeamSlide }),
|
||||
[showUsageSlide, showTeamSlide],
|
||||
() =>
|
||||
resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }),
|
||||
[showUsageSlide, showTeamSlide, hideDesktopInstall],
|
||||
);
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
@@ -136,10 +145,11 @@ export function useSaasOnboardingState({
|
||||
onClose();
|
||||
return;
|
||||
case "download-selected": {
|
||||
// Open download URL in new tab
|
||||
// Open the download URL in the user's browser via the platform seam
|
||||
// (saas opens a new tab, desktop hands off to the OS shell).
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, "_blank", "noopener,noreferrer");
|
||||
void openExternal(downloadUrl);
|
||||
}
|
||||
// Then advance to next slide or close if last
|
||||
if (currentStep === maxIndex) {
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
+57
-72
@@ -9,7 +9,7 @@
|
||||
* <pre>
|
||||
* // In UpgradeModal.tsx — only when the user advances to step 2:
|
||||
* const StripeCheckoutPanel = React.lazy(
|
||||
* () => import("./StripeCheckoutPanel"),
|
||||
* () => import("@app/components/shared/config/configSections/StripeCheckoutPanel"),
|
||||
* );
|
||||
* </pre>
|
||||
*
|
||||
@@ -27,11 +27,12 @@
|
||||
*
|
||||
* <h2>Architecture</h2>
|
||||
*
|
||||
* Stripe-touching code lives in Supabase edge functions, not the Java
|
||||
* backend. This panel invokes {@code create-checkout-session}
|
||||
* directly via {@code supabase.functions.invoke()} - same pattern {@link
|
||||
* usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is
|
||||
* attached automatically by the Supabase client.
|
||||
* Stripe-touching code lives in Supabase edge functions, not the Java backend.
|
||||
* This panel no longer talks to Supabase directly — it mints the PAYG checkout
|
||||
* session through the {@code @app/services/billing} seam
|
||||
* ({@link createCheckoutSession} with a {@code teamId}), which each platform
|
||||
* implements (web supabase client vs Tauri fetch). The seam drives the
|
||||
* {@code create-checkout-session} edge function.
|
||||
*
|
||||
* <p>The edge function is the canonical place Stripe Checkout
|
||||
* Sessions get created - it uses the Stripe Sync Engine tables, has dedicated
|
||||
@@ -42,16 +43,19 @@
|
||||
* <h2>Behaviour</h2>
|
||||
*
|
||||
* <ol>
|
||||
* <li>On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id,
|
||||
* currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is
|
||||
* NOT set here — it's an application-layer setting applied via {@code PATCH /payg/cap} after
|
||||
* the subscription lands.
|
||||
* <li>If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge
|
||||
* function isn't deployed yet (errors out / returns a {@code cs_mock_}
|
||||
* sentinel), render a clearly-labelled placeholder + "Continue with
|
||||
* mock" button so the post-completion path stays testable.
|
||||
* <li>On mount: calls {@link createCheckoutSession} with the {@code teamId},
|
||||
* {@code currency} and billing email to obtain a {@code clientSecret}. The
|
||||
* spending cap is NOT set here — it's an application-layer setting applied
|
||||
* via {@code PATCH /payg/cap} after the subscription lands.
|
||||
* <li>If no Stripe publishable key is configured OR the edge function isn't
|
||||
* deployed yet (errors out / returns a {@code cs_mock_} sentinel), render
|
||||
* a clearly-labelled placeholder + "Continue with mock" button so the
|
||||
* post-completion path stays testable.
|
||||
* <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>} +
|
||||
* {@code <EmbeddedCheckout>} iframe.
|
||||
* {@code <EmbeddedCheckout>} iframe. The Tauri webview has no CSP, so
|
||||
* embedded checkout works on desktop too.
|
||||
* </ol>
|
||||
*
|
||||
* The parent {@link UpgradeModal} passes {@code onComplete} which fires when
|
||||
@@ -60,8 +64,13 @@
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import {
|
||||
createCheckoutSession,
|
||||
getStripePublishableKey,
|
||||
} from "@app/services/billing";
|
||||
import { openExternal } from "@app/platform/openExternal";
|
||||
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
|
||||
|
||||
// Eager static imports here are OK because this whole module is itself lazy-
|
||||
// imported by the modal. They land in the same lazy chunk.
|
||||
@@ -89,21 +98,6 @@ export interface StripeCheckoutPanelProps {
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape from the {@code create-checkout-session} Supabase edge
|
||||
* function. Mirrors what the function returns.
|
||||
*/
|
||||
interface CheckoutResponse {
|
||||
/** Stripe Checkout Session client_secret. */
|
||||
client_secret: string;
|
||||
/**
|
||||
* Optional sentinel: edge functions in non-prod environments may return a
|
||||
* stubbed secret prefixed {@code cs_mock_} so the FE knows to render the
|
||||
* placeholder rather than try to mount a real iframe with a bad secret.
|
||||
*/
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
// Singleton Stripe promise — created on first use and reused for the lifetime
|
||||
// of the tab. {@code loadStripe} is dynamically imported so the actual SDK
|
||||
// chunk is only pulled when this code path runs.
|
||||
@@ -148,16 +142,14 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const publishableKey = getStripePublishableKey();
|
||||
|
||||
// Dev preview route has no backend — skip the API call and go straight
|
||||
// to the mock placeholder so the design + completion path stay testable.
|
||||
// Both checks required so a production tenant with a /dev/ URL prefix
|
||||
// can't accidentally trigger the placeholder.
|
||||
const devPreview =
|
||||
import.meta.env.DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname.startsWith("/dev/");
|
||||
// The dev-preview detection (import.meta.env.DEV + a /dev/ path) lives behind
|
||||
// the walletDevPreview seam since cloud may not read either directly; it is
|
||||
// a saas-only affordance and resolves to null on desktop / prod.
|
||||
const devPreview = getWalletDevPreview() !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (devPreview) {
|
||||
@@ -177,44 +169,37 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
|
||||
let cancelled = false;
|
||||
async function createSession() {
|
||||
try {
|
||||
// Direct Supabase edge function invocation. We call
|
||||
// {@code create-checkout-session} (not {@code create-payg-team-subscription} —
|
||||
// that one creates a subscription directly without going through the
|
||||
// hosted Stripe Embedded Checkout iframe and doesn't return a
|
||||
// client_secret). team_id is required because the edge fn runs
|
||||
// outside our Spring Security context and can't resolve it from the
|
||||
// JWT alone. The cap is *not* set during checkout — it's an
|
||||
// application-layer setting, applied via PATCH /payg/cap after the
|
||||
// subscription lands. window.location.href is the success_url so the
|
||||
// user comes back to the Plan tab after Stripe finishes the redirect.
|
||||
const returnUrl = window.location.href;
|
||||
const { data, error: invokeError } =
|
||||
await supabase.functions.invoke<CheckoutResponse>(
|
||||
"create-checkout-session",
|
||||
{
|
||||
body: {
|
||||
team_id: teamId,
|
||||
currency,
|
||||
success_url: returnUrl,
|
||||
cancel_url: returnUrl,
|
||||
// Maps to Stripe's customer_email when the team has no Stripe
|
||||
// customer yet — prefills + locks the email field in Checkout.
|
||||
// Teams with an existing customer get the email locked from the
|
||||
// customer record instead; this field is ignored for them.
|
||||
...(billingEmail ? { billing_owner_email: billingEmail } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (invokeError) {
|
||||
throw invokeError;
|
||||
// Mint the PAYG checkout session through the billing seam. Passing a
|
||||
// teamId routes it to the {@code create-checkout-session} edge function
|
||||
// (not {@code create-payg-team-subscription} — that one subscribes
|
||||
// directly without the embedded iframe and returns no client_secret).
|
||||
// team_id is required because the edge fn runs outside our Spring
|
||||
// Security context and can't resolve it from the JWT alone. The cap is
|
||||
// *not* set during checkout — it's applied via PATCH /payg/cap after
|
||||
// the subscription lands. The platform impl supplies the success/cancel
|
||||
// return URL (browser origin on web, deep link on desktop).
|
||||
const session = await createCheckoutSession({
|
||||
teamId,
|
||||
currency,
|
||||
// Maps to Stripe's customer_email when the team has no Stripe
|
||||
// customer yet — prefills + locks the email field in Checkout. Teams
|
||||
// with an existing customer get the email locked from the customer
|
||||
// record instead; this field is ignored for them.
|
||||
billingOwnerEmail: billingEmail,
|
||||
});
|
||||
if (cancelled) return;
|
||||
// Hosted-url fallback: no embedded iframe, hand the URL to the system
|
||||
// browser. The deep-link / origin return URL brings the user back.
|
||||
if (session.url && !session.clientSecret) {
|
||||
await openExternal(session.url);
|
||||
return;
|
||||
}
|
||||
if (!data?.client_secret) {
|
||||
if (!session.clientSecret) {
|
||||
throw new Error("Edge function returned no client_secret");
|
||||
}
|
||||
if (cancelled) return;
|
||||
setClientSecret(data.client_secret);
|
||||
setClientSecret(session.clientSecret);
|
||||
setIsMock(
|
||||
Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"),
|
||||
Boolean(session.mock) || session.clientSecret.startsWith("cs_mock_"),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (cancelled) return;
|
||||
+4
-1
@@ -43,7 +43,10 @@ function dispatchOverlay(open: boolean) {
|
||||
// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user
|
||||
// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the
|
||||
// chunk-graph reasoning.
|
||||
const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel"));
|
||||
const StripeCheckoutPanel = React.lazy(
|
||||
() =>
|
||||
import("@app/components/shared/config/configSections/StripeCheckoutPanel"),
|
||||
);
|
||||
|
||||
interface UpgradeModalProps {
|
||||
open: boolean;
|
||||
+11
-10
@@ -7,12 +7,16 @@ import {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
import { useTeamAuth } from "@app/auth/teamSession";
|
||||
|
||||
/**
|
||||
* SaaS web implementation of SaaS Team Context
|
||||
* Provides team management for authenticated (non-anonymous) users
|
||||
* Shared (cloud) SaaS Team Context.
|
||||
*
|
||||
* Provides team management for authenticated (non-anonymous) users. The
|
||||
* platform-specific auth bits — whether teams may be used at all, and how to
|
||||
* refresh derived auth state after a membership change — come from the
|
||||
* {@code @app/auth/teamSession} seam (Supabase web session on saas, authService
|
||||
* on desktop), keeping this context free of any platform auth coupling.
|
||||
*/
|
||||
|
||||
interface Team {
|
||||
@@ -92,8 +96,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { user, refreshCredits, refreshSession } = useAuth();
|
||||
const canUseTeams = !!user && !isUserAnonymous(user);
|
||||
const { canUseTeams, refreshAfterMembershipChange } = useTeamAuth();
|
||||
|
||||
const fetchMyTeams = useCallback(async () => {
|
||||
if (!canUseTeams) return null;
|
||||
@@ -223,8 +226,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
|
||||
await fetchReceivedInvitations();
|
||||
await refreshTeams();
|
||||
await refreshCredits();
|
||||
await refreshSession();
|
||||
await refreshAfterMembershipChange();
|
||||
};
|
||||
|
||||
const rejectInvitation = async (token: string) => {
|
||||
@@ -255,8 +257,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
|
||||
await refreshTeams();
|
||||
await refreshCredits();
|
||||
await refreshSession();
|
||||
await refreshAfterMembershipChange();
|
||||
};
|
||||
|
||||
const isTeamLeader = currentTeam?.isLeader ?? false;
|
||||
+37
-131
@@ -37,15 +37,19 @@
|
||||
* When the hook is rendered outside the saas app (e.g. on {@code
|
||||
* /dev/payg-preview} during local design work) the {@code AppConfigContext}
|
||||
* provider is not mounted and no backend is available. The hook detects that
|
||||
* via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to
|
||||
* a synthesised snapshot whose subscription state is read from
|
||||
* {@code localStorage} (key {@code stirling.payg.devSubscription}). Both
|
||||
* conditions are required so a production tenant whose URL happens to start
|
||||
* with {@code /dev/} can't trigger the fallback.
|
||||
* via the {@code @app/hooks/walletDevPreview} seam and, when it returns a live
|
||||
* channel, falls back to a synthesised snapshot whose subscription state is
|
||||
* read from {@code localStorage}. The detection + synthesis (which read
|
||||
* {@code import.meta.env}, {@code window.location} and web storage — all banned
|
||||
* in cloud/) live in the saas leaf's impl of that seam; this hook just consults
|
||||
* it. Desktop's cascade falls through to the cloud default (no dev preview), so
|
||||
* it always fetches the real wallet.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { createPortalSession } from "@app/services/billing";
|
||||
import { openExternal } from "@app/platform/openExternal";
|
||||
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -185,24 +189,20 @@ export interface UseWalletResult {
|
||||
*/
|
||||
updateCap: (capUsd: number | null) => Promise<void>;
|
||||
/**
|
||||
* Mint a Stripe Customer Portal session and navigate to it. Calls
|
||||
* the {@code create-customer-portal-session} Supabase edge function
|
||||
* directly with the user's JWT (same pattern as checkout — no backend
|
||||
* proxy; the function's RPC enforces team membership) and assigns
|
||||
* {@code window.location} to the returned URL — same-tab redirect.
|
||||
* We do not use {@code window.open(...,"_blank")} after an {@code await}
|
||||
* because browsers treat it as non-user-gesture and silently popup-block
|
||||
* it; the portal is a full-page experience anyway and Stripe redirects
|
||||
* back to {@code return_url} on close. Throws on error so the caller can
|
||||
* show a friendly toast — notably 404 {@code team_not_subscribed}.
|
||||
* Mint a Stripe Customer Portal session and send the user to it. Mints the
|
||||
* session via the {@code @app/services/billing} seam (passing the caller's
|
||||
* {@code teamId}, which the PAYG portal edge function needs to resolve the
|
||||
* team outside Spring Security) and opens the returned URL via the
|
||||
* {@code @app/platform/openExternal} seam — so web and desktop each route it
|
||||
* the platform-appropriate way (new tab on web, system browser on desktop).
|
||||
* Throws on error so the caller can show a friendly toast — notably 404
|
||||
* {@code team_not_subscribed}.
|
||||
*/
|
||||
openPortal: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Implementation ─────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = "stirling.payg.devSubscription";
|
||||
|
||||
/**
|
||||
* Stable reference reuse — if the new payload deep-equals the previous one,
|
||||
* return the previous object so React's reference check short-circuits child
|
||||
@@ -216,6 +216,7 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
|
||||
if (!prev) return next;
|
||||
if (
|
||||
prev.status !== next.status ||
|
||||
prev.teamId !== next.teamId ||
|
||||
prev.role !== next.role ||
|
||||
prev.billingPeriodStart !== next.billingPeriodStart ||
|
||||
prev.billingPeriodEnd !== next.billingPeriodEnd ||
|
||||
@@ -263,87 +264,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
|
||||
* shape the backend returns. Subscription state comes from localStorage so
|
||||
* the modal's "mark subscribed" action survives a reload.
|
||||
*/
|
||||
function buildDevPreviewWallet(role: WalletRole): Wallet {
|
||||
const subscribed =
|
||||
typeof window !== "undefined" &&
|
||||
(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
return {
|
||||
teamId: null,
|
||||
status: subscribed ? "subscribed" : "free",
|
||||
role,
|
||||
billingPeriodStart: isoDay(periodStart),
|
||||
billingPeriodEnd: isoDay(periodEnd),
|
||||
billableUsed: 62,
|
||||
billableLimit: subscribed ? 1250 : 500,
|
||||
freeAllowance: 500,
|
||||
// One-time grant: a free team has used 62 of 500 (438 left); the dev
|
||||
// subscribed team is shown with its grant fully spent (kept across the
|
||||
// subscribe — it just no longer gates them).
|
||||
freeRemaining: subscribed ? 0 : 438,
|
||||
// Free teams also carry a rate now — the backend resolves it from the
|
||||
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
|
||||
// PDFs/month") can render before subscribing. Mirror that here.
|
||||
pricePerDocMinor: 2,
|
||||
currency: "usd",
|
||||
estimatedBillMinor: subscribed ? 0 : null,
|
||||
capUsd: subscribed ? 25 : null,
|
||||
noCap: false,
|
||||
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
|
||||
spendUnitsThisPeriod: 62,
|
||||
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
|
||||
// hero panel can split AI / automation / API. Use realistic but
|
||||
// tier-distinguishable mock values so the dev preview shows a
|
||||
// different visual when the localStorage flip toggles subscribed.
|
||||
categoryBreakdown: subscribed
|
||||
? { api: 12, ai: 35, automation: 15 }
|
||||
: { api: 5, ai: 40, automation: 17 },
|
||||
// Members are populated in the leader view by the real backend
|
||||
// (joining team_memberships); the dev preview returns an empty
|
||||
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
|
||||
// so empty members just hides the sub-caps card.
|
||||
members: [],
|
||||
// Activity feed is V1 = [], the backend ships this in Wave 2 once
|
||||
// payg_meter_event_log is read-accessible from the wallet endpoint.
|
||||
recent: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
|
||||
function isDevPreviewContext(): boolean {
|
||||
// Both checks required: production builds drop the path check, so a real
|
||||
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
|
||||
// fallback.
|
||||
if (!import.meta.env.DEV) return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.location.pathname.startsWith("/dev/");
|
||||
}
|
||||
|
||||
/** Best-effort role read for dev preview — flips per query string ?role=member. */
|
||||
function devPreviewRole(): WalletRole {
|
||||
if (typeof window === "undefined") return "leader";
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get("role") === "member" ? "member" : "leader";
|
||||
}
|
||||
|
||||
export function useWallet(): UseWalletResult {
|
||||
const devPreview = useRef<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 [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -369,7 +296,7 @@ export function useWallet(): UseWalletResult {
|
||||
setError(null);
|
||||
|
||||
if (devPreview) {
|
||||
const synth = buildDevPreviewWallet(devPreviewRole());
|
||||
const synth = devPreview.buildWallet(devPreview.role());
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
setWallet((prev) => reuseIfEqual(prev, synth));
|
||||
setLoading(false);
|
||||
@@ -417,11 +344,7 @@ export function useWallet(): UseWalletResult {
|
||||
const markSubscribed = useCallback(
|
||||
async (capUsd: number | null) => {
|
||||
if (devPreview) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, "subscribed");
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
devPreview.markSubscribed();
|
||||
await refetch();
|
||||
return;
|
||||
}
|
||||
@@ -479,39 +402,22 @@ export function useWallet(): UseWalletResult {
|
||||
|
||||
const openPortal = useCallback(async () => {
|
||||
if (devPreview) {
|
||||
// No real Stripe in dev preview — navigate to a placeholder so the
|
||||
// click still feels alive. Same-tab to match the real-flow behaviour.
|
||||
window.location.assign("https://billing.stripe.com/p/login/mock");
|
||||
// No real Stripe in dev preview — open a placeholder so the click still
|
||||
// feels alive. Routed through the openExternal seam to stay portable.
|
||||
await openExternal("https://billing.stripe.com/p/login/mock");
|
||||
return;
|
||||
}
|
||||
// Direct edge-fn invocation with the user's JWT — same pattern as
|
||||
// create-checkout-session. The payg_get_checkout_context RPC inside the
|
||||
// fn enforces team membership, so no backend proxy is needed; team_id
|
||||
// must be a NUMBER (the fn type-checks and rejects strings).
|
||||
// Mint the portal session through the billing seam, passing teamId: the
|
||||
// PAYG portal edge function needs it to resolve the caller's team outside
|
||||
// Spring Security (its RPC enforces team membership). Then hand the URL to
|
||||
// the openExternal seam so each platform routes it appropriately. The seam
|
||||
// throws on error (e.g. 404 team_not_subscribed) so callers can toast.
|
||||
const teamId = wallet?.teamId;
|
||||
if (teamId == null) {
|
||||
throw new Error("No team resolved yet");
|
||||
}
|
||||
const { data, error: invokeError } = await supabase.functions.invoke<{
|
||||
url?: string;
|
||||
error?: string;
|
||||
}>("create-customer-portal-session", {
|
||||
body: { team_id: teamId, return_url: window.location.href },
|
||||
});
|
||||
if (invokeError) {
|
||||
// FunctionsHttpError etc. — the StripePortalLink caller catches and
|
||||
// shows a friendly toast (404 team_not_subscribed, 403, outage).
|
||||
throw invokeError;
|
||||
}
|
||||
if (!data?.url) {
|
||||
throw new Error(data?.error ?? "Portal session response missing url");
|
||||
}
|
||||
// Same-tab navigation rather than window.open(...,"_blank"): Stripe's
|
||||
// customer portal is a full-page experience and brings the user back
|
||||
// via return_url, so a popup buys us nothing — and window.open after
|
||||
// an awaited promise is treated as non-user-gesture and silently
|
||||
// popup-blocked by most browsers.
|
||||
window.location.assign(data.url);
|
||||
const { url } = await createPortalSession({ teamId });
|
||||
await openExternal(url);
|
||||
}, [devPreview, wallet?.teamId]);
|
||||
|
||||
return {
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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
|
||||
* Desktop layer shadows this with the real implementation
|
||||
* In web builds, always allows the operation (no credit system)
|
||||
* No-op stub kept only to satisfy useToolOperation's @app/hooks/useCreditCheck
|
||||
* import. There is no pre-flight credit/usage check anymore — PAYG is enforced
|
||||
* server-side (the BE returns 402 FEATURE_DEGRADED / PAYG_LIMIT_REACHED, which
|
||||
* paygErrorInterceptor turns into the usage-limit modal). Always allows.
|
||||
*/
|
||||
export function useCreditCheck(_operationType?: string, _endpoint?: string) {
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,12 @@ export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
|
||||
export const Z_ANALYTICS_MODAL = 1301;
|
||||
// Config/Settings modal - should appear above analytics modal when navigating from onboarding
|
||||
export const Z_INDEX_CONFIG_MODAL = 1400;
|
||||
// Modal layered directly over the settings/config modal (e.g. the Stripe
|
||||
// checkout modal). Consumed by the shared cloud/ checkout component, so it
|
||||
// lives in the core base both the saas and cloud cascades resolve.
|
||||
// Must be strictly ABOVE the config modal (1400); 1450 is taken by the cookie
|
||||
// preferences modal, so sit above the whole settings cluster (below 2000).
|
||||
export const Z_INDEX_OVER_SETTINGS_MODAL = 1500;
|
||||
|
||||
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
|
||||
|
||||
|
||||
@@ -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 { SaveShortcutListener } from "@app/components/SaveShortcutListener";
|
||||
import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal";
|
||||
import { DesktopSaasOnboardingBootstrap } from "@app/components/DesktopSaasOnboardingBootstrap";
|
||||
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
||||
import { SignInModal } from "@app/components/SignInModal";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import { ToolActionsContext } from "@app/contexts/ToolActionsContext";
|
||||
@@ -22,9 +24,6 @@ import { endpointAvailabilityService } from "@app/services/endpointAvailabilityS
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
|
||||
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
|
||||
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
|
||||
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
|
||||
import UpdateModal from "@core/components/shared/UpdateModal";
|
||||
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
|
||||
|
||||
@@ -338,21 +337,24 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<SaaSTeamProvider key={appKey}>
|
||||
<SaasBillingProvider>
|
||||
<SaaSCheckoutProvider>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
<CreditModalBootstrap />
|
||||
{children}
|
||||
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
|
||||
<DesktopOnboardingModal />
|
||||
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
|
||||
<SignInModal />
|
||||
{/* Desktop auto-update popup */}
|
||||
{updatePopupModal}
|
||||
</SaaSCheckoutProvider>
|
||||
</SaasBillingProvider>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
{children}
|
||||
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
|
||||
<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 */}
|
||||
<SignInModal />
|
||||
{/* Desktop auto-update popup */}
|
||||
{updatePopupModal}
|
||||
</SaaSTeamProvider>
|
||||
</ToolActionsContext.Provider>
|
||||
</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();
|
||||
}
|
||||
-90
@@ -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
|
||||
label={t(
|
||||
"cloudBadge.tooltip",
|
||||
"This operation will use your cloud credits",
|
||||
"Runs in the cloud (included, no extra charge)",
|
||||
)}
|
||||
position="top"
|
||||
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 { useState, useEffect } from "react";
|
||||
import {
|
||||
useConfigNavSections as useProprietaryConfigNavSections,
|
||||
createConfigNavSections as createProprietaryConfigNavSections,
|
||||
} from "@proprietary/components/shared/config/configNavSections";
|
||||
import { useConfigNavSections as useProprietaryConfigNavSections } from "@proprietary/components/shared/config/configNavSections";
|
||||
import { ConfigNavSection } from "@core/components/shared/config/configNavSections";
|
||||
import { ConnectionSettings } from "@app/components/ConnectionSettings";
|
||||
import { SaasPlanSection } from "@app/components/shared/config/configSections/SaasPlanSection";
|
||||
import { SaaSTeamsSection } from "@app/components/shared/config/configSections/SaaSTeamsSection";
|
||||
import {
|
||||
createCloudPlanNavItem,
|
||||
createCloudTeamNavItem,
|
||||
} from "@app/components/shared/config/cloudConfigNavSections";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
@@ -100,29 +99,18 @@ export const useConfigNavSections = (
|
||||
// Connection Mode always sits immediately after Preferences
|
||||
result.push(connectionModeSection);
|
||||
|
||||
// Plan & Billing and Team sections only when authenticated in SaaS mode
|
||||
// Plan & Billing and Team sections only when authenticated in SaaS mode.
|
||||
// These are the SHARED cloud sections — the wallet-driven PAYG Plan
|
||||
// (dashboard + spend cap) and the team management UI — so a desktop SaaS
|
||||
// user sees the same Plan / billing / team experience as the web app.
|
||||
if (isSaasMode && isAuthenticated) {
|
||||
result.push({
|
||||
title: t("settings.planBilling.title", "Plan & Billing"),
|
||||
items: [
|
||||
{
|
||||
key: "planBilling",
|
||||
label: t("settings.planBilling.title", "Plan & Billing"),
|
||||
icon: "credit-card",
|
||||
component: <SaasPlanSection />,
|
||||
},
|
||||
],
|
||||
items: [createCloudPlanNavItem(t)],
|
||||
});
|
||||
result.push({
|
||||
title: t("settings.team.title", "Team"),
|
||||
items: [
|
||||
{
|
||||
key: "teams",
|
||||
label: t("settings.team.title", "Team"),
|
||||
icon: "groups-rounded",
|
||||
component: <SaaSTeamsSection />,
|
||||
},
|
||||
],
|
||||
items: [createCloudTeamNavItem(t)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,52 +134,3 @@ export const useConfigNavSections = (
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deprecated: Use useConfigNavSections hook instead
|
||||
* Desktop extension of createConfigNavSections that adds connection settings
|
||||
*/
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false,
|
||||
): ConfigNavSection[] => {
|
||||
console.warn(
|
||||
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
|
||||
);
|
||||
|
||||
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||
const sections = createProprietaryConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
);
|
||||
|
||||
// Add Connection section at the beginning (after Preferences)
|
||||
sections.splice(1, 0, {
|
||||
title: "Connection",
|
||||
items: [
|
||||
{
|
||||
key: "connectionMode",
|
||||
label: "Connection Mode",
|
||||
icon: "desktop-cloud-rounded",
|
||||
component: <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;
|
||||
};
|
||||
|
||||
-542
@@ -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>
|
||||
);
|
||||
}
|
||||
-260
@@ -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>
|
||||
);
|
||||
}
|
||||
-257
@@ -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>
|
||||
);
|
||||
}
|
||||
-102
@@ -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>
|
||||
);
|
||||
}
|
||||
-79
@@ -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>
|
||||
);
|
||||
};
|
||||
-218
@@ -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>
|
||||
);
|
||||
};
|
||||
-127
@@ -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 { operationRouter } from "@app/services/operationRouter";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { getAccessToken } from "@app/auth/session";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import {
|
||||
STIRLING_SAAS_URL,
|
||||
@@ -86,7 +87,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
// If another request is already refreshing, wait before attaching token
|
||||
await authService.awaitRefreshIfInProgress();
|
||||
const token = await authService.getAuthToken();
|
||||
const token = await getAccessToken();
|
||||
|
||||
if (token) {
|
||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||
@@ -224,7 +225,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
const token = await authService.getAuthToken();
|
||||
const token = await getAccessToken();
|
||||
console.debug(
|
||||
`[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`,
|
||||
);
|
||||
|
||||
@@ -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 { handleHttpError as coreHandleHttpError } from "@core/services/httpErrorHandler";
|
||||
import {
|
||||
classifyPaygError,
|
||||
handlePaygError,
|
||||
} from "@app/services/paygErrorInterceptor";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
|
||||
/**
|
||||
* Desktop override of handleHttpError.
|
||||
@@ -10,6 +15,32 @@ import { handleHttpError as coreHandleHttpError } from "@core/services/httpError
|
||||
export async function handleHttpError(error: unknown): Promise<boolean> {
|
||||
const status = isAxiosError(error) ? error.response?.status : undefined;
|
||||
|
||||
// PAYG entitlement sentinels (402 FEATURE_DEGRADED / PAYG_LIMIT_REACHED,
|
||||
// 401 SIGNUP_REQUIRED) come from the backend EntitlementGuard on DIRECT API
|
||||
// calls. Mirror saas's paygErrorInterceptor: pop the matching usage-limit
|
||||
// modal (free vs spend-cap, keyed on `subscribed`) and suppress the generic
|
||||
// toast — the modal is the actionable surface. Classified strictly so we
|
||||
// don't hijack the session-expired 401 flow below. Server-side run paths
|
||||
// (policy auto-run, AI agent) broadcast the usageLimitBridge event instead,
|
||||
// which the mounted UsageLimitModalHost handles.
|
||||
const paygKind = classifyPaygError(error);
|
||||
if (paygKind !== null) {
|
||||
if (paygKind === "SIGNUP_REQUIRED") {
|
||||
// Desktop has no web signup-page bootstrap (handlePaygError's
|
||||
// payg:signupRequired event has no desktop listener). The desktop account
|
||||
// flow is the SignInModal, so open that directly for an anonymous user who
|
||||
// hit a billable endpoint.
|
||||
try {
|
||||
window.dispatchEvent(new Event(OPEN_SIGN_IN_EVENT));
|
||||
} catch {
|
||||
// non-browser env (tests / SSR) — no-op.
|
||||
}
|
||||
} else {
|
||||
handlePaygError(paygKind, error);
|
||||
}
|
||||
return true; // Suppress generic toast — modal handles it.
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
// In desktop builds, 401s are handled by the auth service (token refresh + toast
|
||||
// shown by apiClientSetup). Authentication is done via the onboarding modal or
|
||||
|
||||
@@ -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
|
||||
* @returns true if endpoint should route to SaaS backend
|
||||
* @returns true if endpoint must route to the SaaS backend
|
||||
*/
|
||||
private isSaaSBackendEndpoint(endpoint?: string): boolean {
|
||||
if (!endpoint) return false;
|
||||
|
||||
const saasBackendPatterns = [
|
||||
/^\/api\/v1\/team\//, // Team endpoints
|
||||
/^\/api\/v1\/auth\//, // Auth endpoints (Supabase auth in SaaS mode)
|
||||
// Add more SaaS-specific patterns here as needed
|
||||
/^\/api\/v1\/team\//, // Team management
|
||||
/^\/api\/v1\/auth\//, // Supabase auth (SaaS mode)
|
||||
/^\/api\/v1\/payg\//, // PAYG wallet / spend-cap / billing
|
||||
/^\/api\/v1\/policies(?:\/|$)/, // Policy runs — must bill via the cloud
|
||||
// Add more cloud-only feature prefixes here as they land.
|
||||
];
|
||||
|
||||
return saasBackendPatterns.some((pattern) => pattern.test(endpoint));
|
||||
|
||||
@@ -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) {
|
||||
// FormData can be passed directly
|
||||
body = finalConfig.data;
|
||||
// Drop any caller-supplied Content-Type so the native fetch generates
|
||||
// multipart/form-data WITH its boundary (matches axios's FormData
|
||||
// handling). A boundary-less "multipart/form-data" header makes the
|
||||
// server reject the body ("No multipart boundary parameter").
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === "content-type") {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
} else if (typeof finalConfig.data === "object") {
|
||||
// Serialize as JSON
|
||||
body = JSON.stringify(finalConfig.data);
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
|
||||
"@app/*": [
|
||||
"src/desktop/*",
|
||||
"src/cloud/*",
|
||||
"src/proprietary/*",
|
||||
"src/core/*"
|
||||
],
|
||||
"@cloud/*": ["src/cloud/*"],
|
||||
"@proprietary/*": ["src/proprietary/*"],
|
||||
"@core/*": ["src/core/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useConfigNavSections as useCoreConfigNavSections,
|
||||
createConfigNavSections as createCoreConfigNavSections,
|
||||
ConfigNavSection,
|
||||
} from "@core/components/shared/config/configNavSections";
|
||||
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
|
||||
@@ -270,251 +269,6 @@ export const useConfigNavSections = (
|
||||
return sections;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deprecated: Use useConfigNavSections hook instead
|
||||
* Proprietary extension of createConfigNavSections that adds all admin and workspace sections
|
||||
*/
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false,
|
||||
): ConfigNavSection[] => {
|
||||
console.warn(
|
||||
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
|
||||
);
|
||||
|
||||
// Get the core sections (just Preferences)
|
||||
const sections = createCoreConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
);
|
||||
|
||||
// Add account management under Preferences
|
||||
const preferencesSection = sections.find((section) =>
|
||||
section.items.some((item) => item.key === "general"),
|
||||
);
|
||||
if (preferencesSection) {
|
||||
preferencesSection.items = preferencesSection.items.map((item) =>
|
||||
item.key === "general"
|
||||
? { ...item, component: <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
|
||||
export type {
|
||||
ConfigNavSection,
|
||||
|
||||
@@ -56,10 +56,12 @@ export async function runStoredPolicy(
|
||||
): Promise<string> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append("fileInput", file);
|
||||
// Don't set Content-Type: the HTTP client must generate multipart/form-data
|
||||
// WITH its boundary from the FormData body. A manual boundary-less header makes
|
||||
// the server reject the request ("no multipart boundary parameter").
|
||||
const res = await apiClient.post<JobResponse>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return res.data.jobId;
|
||||
}
|
||||
@@ -81,9 +83,8 @@ export async function runPolicyPipeline(
|
||||
"json",
|
||||
new Blob([JSON.stringify(definition)], { type: "application/json" }),
|
||||
);
|
||||
const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
// No Content-Type: let the client set multipart/form-data with its boundary.
|
||||
const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form);
|
||||
return res.data.jobId;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import OAuthConsent from "@app/routes/OAuthConsent";
|
||||
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
||||
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
||||
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
||||
|
||||
@@ -47,9 +46,9 @@ function PublicRouteProviders({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding and trial-expired modals must never cover auth-flow pages
|
||||
* (login, signup, OAuth consent): they steal focus from the task the user
|
||||
* was sent there to complete. Unmounting also stops their background polling.
|
||||
* Onboarding / sign-up modals must never cover auth-flow pages (login, signup,
|
||||
* OAuth consent): they steal focus from the task the user was sent there to
|
||||
* complete.
|
||||
*/
|
||||
function NonAuthBootstraps() {
|
||||
const location = useLocation();
|
||||
@@ -59,7 +58,6 @@ function NonAuthBootstraps() {
|
||||
return (
|
||||
<>
|
||||
<OnboardingBootstrap />
|
||||
<TrialExpiredBootstrap />
|
||||
<SignupRequiredBootstrap />
|
||||
<UsageLimitModalHost />
|
||||
</>
|
||||
|
||||
@@ -54,15 +54,6 @@ export function deriveDisplayName(
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
@@ -83,7 +74,6 @@ interface AuthContextType {
|
||||
subscription: SubscriptionInfo | null;
|
||||
creditSummary: CreditSummary | null;
|
||||
isPro: boolean | null;
|
||||
trialStatus: TrialStatus | null;
|
||||
profilePictureUrl: string | null;
|
||||
profilePictureMetadata: ProfilePictureMetadata | null;
|
||||
signOut: () => Promise<void>;
|
||||
@@ -92,7 +82,6 @@ interface AuthContextType {
|
||||
updateCredits: (newBalance: number) => void;
|
||||
refreshCredits: () => Promise<void>;
|
||||
refreshProStatus: () => Promise<void>;
|
||||
refreshTrialStatus: () => Promise<void>;
|
||||
refreshProfilePicture: () => Promise<void>;
|
||||
refreshProfilePictureMetadata: () => Promise<void>;
|
||||
}
|
||||
@@ -108,7 +97,6 @@ const AuthContext = createContext<AuthContextType>({
|
||||
subscription: null,
|
||||
creditSummary: null,
|
||||
isPro: null,
|
||||
trialStatus: null,
|
||||
profilePictureUrl: null,
|
||||
profilePictureMetadata: null,
|
||||
signOut: async () => {},
|
||||
@@ -121,7 +109,6 @@ const AuthContext = createContext<AuthContextType>({
|
||||
updateCredits: () => {},
|
||||
refreshCredits: async () => {},
|
||||
refreshProStatus: async () => {},
|
||||
refreshTrialStatus: async () => {},
|
||||
refreshProfilePicture: async () => {},
|
||||
refreshProfilePictureMetadata: async () => {},
|
||||
});
|
||||
@@ -138,7 +125,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
null,
|
||||
);
|
||||
const [isPro, setIsPro] = useState<boolean | null>(null);
|
||||
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null);
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -197,75 +183,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
await fetchProStatus();
|
||||
}, [fetchProStatus]);
|
||||
|
||||
const fetchTrialStatus = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug(
|
||||
"[Auth Debug] No user session, skipping trial status fetch",
|
||||
);
|
||||
setTrialStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching trial status for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
const { data, error } = await supabase
|
||||
.from("billing_subscriptions")
|
||||
.select(
|
||||
"status, trial_end, has_payment_method, scheduled_subscription_id",
|
||||
)
|
||||
.in("status", ["trialing", "incomplete_expired", "canceled"])
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth Debug] Error fetching trial status:", error);
|
||||
setTrialStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.trial_end) {
|
||||
const trialEnd = new Date(data.trial_end);
|
||||
const now = new Date();
|
||||
const daysRemaining = Math.ceil(
|
||||
(trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
setTrialStatus({
|
||||
isTrialing: data.status === "trialing" && daysRemaining > 0,
|
||||
trialEnd: data.trial_end,
|
||||
daysRemaining: Math.max(0, daysRemaining),
|
||||
hasPaymentMethod: data.has_payment_method || false,
|
||||
hasScheduledSub: !!data.scheduled_subscription_id,
|
||||
status: data.status,
|
||||
});
|
||||
console.debug("[Auth Debug] Trial status fetched:", {
|
||||
status: data.status,
|
||||
daysRemaining: Math.max(0, daysRemaining),
|
||||
hasPaymentMethod: data.has_payment_method,
|
||||
isTrialing: data.status === "trialing" && daysRemaining > 0,
|
||||
});
|
||||
} else {
|
||||
setTrialStatus(null);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch trial status:", error);
|
||||
setTrialStatus(null);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const refreshTrialStatus = useCallback(async () => {
|
||||
await fetchTrialStatus();
|
||||
}, [fetchTrialStatus]);
|
||||
|
||||
// Provider photo as interim fallback when the bucket copy is missing —
|
||||
// skipped when the user explicitly chose upload/removal (source "upload").
|
||||
const providerAvatarFallback = useCallback(
|
||||
@@ -482,7 +399,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
setSession(data.session);
|
||||
|
||||
// Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
|
||||
// Fetch credits, pro status, profile picture metadata, and profile picture using the session from the response
|
||||
if (data.session?.user) {
|
||||
// Sync OAuth avatar in background; fetch the picture once the
|
||||
// sync settles instead of guessing with a fixed delay.
|
||||
@@ -498,7 +415,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
await fetchCredits(data.session);
|
||||
await fetchProStatus(data.session);
|
||||
await fetchTrialStatus(data.session);
|
||||
await fetchProfilePictureMetadata(data.session);
|
||||
}
|
||||
}
|
||||
@@ -542,12 +458,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Additional handling for specific events
|
||||
if (event === "SIGNED_OUT") {
|
||||
console.debug("[Auth Debug] User signed out, clearing session");
|
||||
// Clear credit data, pro status, trial status, profile picture, and metadata on sign out
|
||||
// Clear credit data, pro status, profile picture, and metadata on sign out
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
setIsPro(null);
|
||||
setTrialStatus(null);
|
||||
setProfilePictureUrl(null);
|
||||
setProfilePictureMetadata(null);
|
||||
} else if (event === "SIGNED_IN") {
|
||||
@@ -577,7 +492,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
]).then(() => {
|
||||
// Fetch the picture once the avatar sync settles.
|
||||
@@ -592,12 +506,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
} else if (event === "TOKEN_REFRESHED") {
|
||||
console.debug("[Auth Debug] Token refreshed");
|
||||
// Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh
|
||||
// Optionally refresh credits, pro status, profile picture metadata, and profile picture on token refresh
|
||||
if (newSession?.user) {
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession),
|
||||
]).then(() => {
|
||||
@@ -634,12 +547,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
"[Auth Debug] User upgrade synchronized successfully",
|
||||
);
|
||||
|
||||
// Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade
|
||||
// Refresh credits, pro status, profile picture metadata, and profile picture after upgrade
|
||||
if (newSession?.user) {
|
||||
return Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchTrialStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession),
|
||||
]);
|
||||
@@ -681,7 +593,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
trialStatus,
|
||||
profilePictureUrl,
|
||||
profilePictureMetadata,
|
||||
signOut,
|
||||
@@ -690,7 +601,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
updateCredits,
|
||||
refreshCredits,
|
||||
refreshProStatus,
|
||||
refreshTrialStatus,
|
||||
refreshProfilePicture,
|
||||
refreshProfilePictureMetadata,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import type { AppSession } from "@cloud/auth/session";
|
||||
|
||||
export type { AppSession };
|
||||
|
||||
/**
|
||||
* SaaS (web) implementation of the @app/auth/session seam.
|
||||
*
|
||||
* Reads the Supabase web session and returns its access token. Mirrors the
|
||||
* token lookup that apiClientSetup.getAuthHeaders performed previously.
|
||||
*/
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
return session?.access_token ?? null;
|
||||
} catch (e) {
|
||||
console.warn("[auth/session] Failed to read Supabase session", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* saas (web) implementation of the @app/auth/teamSession seam.
|
||||
*
|
||||
* Wires the cloud {@link SaaSTeamContext} to the SaaS-only auth surface it used
|
||||
* to reach directly before the move to cloud/: the Supabase web session
|
||||
* ({@code useAuth()}), the anonymous check ({@code isUserAnonymous}), and the
|
||||
* credit + session refreshers. Those imports are banned in cloud (Supabase web
|
||||
* client + the SaaS-only {@code useAuth} shape), so cloud reaches them through
|
||||
* {@link useTeamAuth} instead. Behaviour is preserved verbatim from the
|
||||
* pre-move saas SaaSTeamContext.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
import type { TeamAuth } from "@cloud/auth/teamSession";
|
||||
|
||||
export type { TeamAuth } from "@cloud/auth/teamSession";
|
||||
|
||||
export function useTeamAuth(): TeamAuth {
|
||||
const { user, refreshCredits, refreshSession } = useAuth();
|
||||
|
||||
// Teams require a signed-in, non-anonymous user — anonymous (guest) sessions
|
||||
// never load or manage teams.
|
||||
const canUseTeams = !!user && !isUserAnonymous(user);
|
||||
|
||||
// After a membership change the user's billing tier may have changed, so
|
||||
// refresh credits + the Supabase session (mirrors the pre-move accept/leave
|
||||
// flow in saas SaaSTeamContext).
|
||||
const refreshAfterMembershipChange = useCallback(async () => {
|
||||
await refreshCredits();
|
||||
await refreshSession();
|
||||
}, [refreshCredits, refreshSession]);
|
||||
|
||||
return { canUseTeams, refreshAfterMembershipChange };
|
||||
}
|
||||
@@ -15,64 +15,16 @@ export default function OnboardingBootstrap() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { clearPendingTourRequest, setStartAfterToolModeSelection } =
|
||||
useOnboarding();
|
||||
const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
|
||||
const { user, loading } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [pollAttempts, setPollAttempts] = useState(0);
|
||||
|
||||
// Start polling when user logs in
|
||||
// Show the onboarding modal once on first login, after the user has loaded.
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
|
||||
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
|
||||
console.debug("[Onboarding] Starting poll for trial data");
|
||||
setIsPolling(true);
|
||||
setPollAttempts(0);
|
||||
}
|
||||
}, [user, loading, isPolling, showModal]);
|
||||
|
||||
// Poll for trial data
|
||||
useEffect(() => {
|
||||
if (!isPolling) return;
|
||||
|
||||
const pollInterval = 500; // Check every 500ms
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const newAttempts = pollAttempts + 1;
|
||||
console.debug(
|
||||
"[Onboarding] Polling for trial data, attempt:",
|
||||
newAttempts,
|
||||
);
|
||||
|
||||
await refreshTrialStatus();
|
||||
setPollAttempts(newAttempts);
|
||||
|
||||
// Check will happen in the next effect
|
||||
}, pollInterval);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isPolling, pollAttempts, refreshTrialStatus]);
|
||||
|
||||
// Stop polling when data arrives or timeout
|
||||
useEffect(() => {
|
||||
if (!isPolling) return;
|
||||
|
||||
const hasData = trialStatus !== undefined && trialStatus !== null;
|
||||
const hasProStatus = isPro !== null;
|
||||
const maxAttempts = 10;
|
||||
|
||||
if (hasData || pollAttempts >= maxAttempts) {
|
||||
console.debug("[Onboarding] Trial data ready or timeout, showing modal", {
|
||||
hasData,
|
||||
hasProStatus,
|
||||
attempts: pollAttempts,
|
||||
trialStatus,
|
||||
isPro,
|
||||
});
|
||||
setIsPolling(false);
|
||||
if (user && !hasSeenOnboarding && !loading && !showModal) {
|
||||
setShowModal(true);
|
||||
}
|
||||
}, [isPolling, trialStatus, isPro, pollAttempts]);
|
||||
}, [user, loading, showModal]);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user