Desktop connection SaaS: config, billing, team support (#5768)

Co-authored-by: James Brunton <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-02-25 14:13:07 +00:00
committed by GitHub
co-authored by James Brunton James Brunton
parent 86072ec91a
commit 5c39acecd8
76 changed files with 6231 additions and 119 deletions
@@ -0,0 +1,75 @@
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>
);
};
@@ -0,0 +1,322 @@
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');
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`);
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`);
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');
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 };
@@ -0,0 +1,315 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } 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';
/**
* 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;
// 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,
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);
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null);
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 (billingStatus && lastFetchTime && (now - lastFetchTime) < 300000) {
return;
}
// Only set loading if no existing data (prevents flicker on refresh)
if (!billingStatus) {
setLoading(true);
}
try {
const status = await saasBillingService.getBillingStatus();
setBillingStatus(status); // Atomic update
setLastFetchTime(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, billingStatus, lastFetchTime, teamLoading]);
// Fetch available plans
const fetchPlansData = useCallback(async () => {
// Guard: Skip if not in SaaS mode or not authenticated
if (!isSaasMode || !isAuthenticated) {
return;
}
setPlansLoading(true);
setPlansError(null);
try {
const priceMap = await saasBillingService.getAvailablePlans('usd');
setPlans(priceMap);
} catch (err) {
console.error('[SaasBillingContext] Failed to fetch plans:', err);
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
// Non-blocking: continue with empty plans
} finally {
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
setBillingStatus(null);
setPlans(new Map());
setLastFetchTime(null);
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 &&
billingStatus === null &&
lastFetchTime === null
) {
fetchBillingData();
}
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, billingStatus, lastFetchTime, fetchBillingData]);
// Public refresh methods
const refreshBilling = useCallback(async () => {
if (!isSaasMode || !isAuthenticated) {
return;
}
// Force cache invalidation
setLastFetchTime(null);
await fetchBillingData();
await fetchPlansData();
}, [isSaasMode, isAuthenticated, fetchBillingData, fetchPlansData]);
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
setBillingStatus({
...billingStatus,
creditBalance: newBalance,
});
// 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]);
return (
<SaasBillingContext.Provider value={{
billingStatus,
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: billingStatus?.price,
currency: billingStatus?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime,
refreshBilling,
refreshCredits: refreshBilling, // Alias for clarity
refreshPlans,
openBillingPortal,
}}>
{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;
}
export { SaasBillingContext };
export type { SaasBillingContextType };