From 74ad90db5d07cbd588e485759bbdcd91872335f8 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Thu, 20 Nov 2025 15:33:25 +0000 Subject: [PATCH] Button for adding seats added to people tab. Should be able to use from anywhere --- .../public/locales/en-GB/translation.json | 19 +- .../proprietary/components/AppProviders.tsx | 7 +- .../components/shared/UpdateSeatsButton.tsx | 38 +++ .../components/shared/UpdateSeatsModal.tsx | 208 ++++++++++++++++ .../config/configSections/PeopleSection.tsx | 14 ++ .../proprietary/contexts/LicenseContext.tsx | 50 +++- .../contexts/UpdateSeatsContext.tsx | 222 ++++++++++++++++++ .../proprietary/services/licenseService.ts | 36 +++ 8 files changed, 581 insertions(+), 13 deletions(-) create mode 100644 frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx create mode 100644 frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx create mode 100644 frontend/src/proprietary/contexts/UpdateSeatsContext.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2b70c36caa..d43dd7063f 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5358,7 +5358,24 @@ "manageBilling": "Manage Billing", "portal": { "error": "Failed to open billing portal" - } + }, + "updateSeats": "Update Seats", + "updateEnterpriseSeats": "Update Enterprise Seats", + "currentSeats": "Current Seats", + "minimumSeats": "Minimum Seats", + "basedOnUsers": "(current users)", + "newSeatCount": "New Seat Count", + "newSeatCountDescription": "Select the number of seats for your enterprise licence", + "whatHappensNext": "What happens next?", + "stripePortalRedirect": "You will be redirected to Stripe's billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.", + "preparingUpdate": "Preparing seat update...", + "seatCountTooLow": "Seat count must be at least {{minimum}} (current number of users)", + "seatCountUnchanged": "Please select a different seat count", + "seatsUpdated": "Seats Updated", + "seatsUpdatedMessage": "Your enterprise seats have been updated to {{seats}}", + "updateProcessing": "Update Processing", + "updateProcessingMessage": "Your seat update is being processed. Please refresh in a few moments.", + "notEnterprise": "Seat management is only available for enterprise licences" }, "upgradeBanner": { "title": "Upgrade to Server Plan", diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index b865e4f268..00964a866d 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -2,6 +2,7 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo import { AuthProvider } from "@app/auth/UseSession"; import { LicenseProvider } from "@app/contexts/LicenseContext"; import { CheckoutProvider } from "@app/contexts/CheckoutContext"; +import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext"; import UpgradeBanner from "@app/components/shared/UpgradeBanner"; export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { @@ -13,8 +14,10 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - {children} + + + {children} + diff --git a/frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx b/frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx new file mode 100644 index 0000000000..ddc43350b7 --- /dev/null +++ b/frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Button, ButtonProps } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useUpdateSeats } from '@app/contexts/UpdateSeatsContext'; + +interface UpdateSeatsButtonProps extends Omit { + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export const UpdateSeatsButton: React.FC = ({ + onSuccess, + onError, + ...buttonProps +}) => { + const { t } = useTranslation(); + const { openUpdateSeats, isLoading } = useUpdateSeats(); + + const handleClick = async () => { + await openUpdateSeats({ + onSuccess, + onError, + }); + }; + + return ( + + ); +}; + +export default UpdateSeatsButton; diff --git a/frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx b/frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx new file mode 100644 index 0000000000..7cb0eb6aab --- /dev/null +++ b/frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Text, Alert, Loader, Stack, Group, NumberInput, Paper } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; + +interface UpdateSeatsModalProps { + opened: boolean; + onClose: () => void; + currentSeats: number; + minimumSeats: number; + onSuccess?: () => void; + onError?: (error: string) => void; + onUpdateSeats?: (newSeats: number) => Promise; // Returns billing portal URL +} + +type UpdateState = { + status: 'idle' | 'loading' | 'error'; + error?: string; +}; + +const UpdateSeatsModal: React.FC = ({ + opened, + onClose, + currentSeats, + minimumSeats, + onSuccess, + onError, + onUpdateSeats, +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ status: 'idle' }); + const [newSeatCount, setNewSeatCount] = useState(currentSeats); + + // Reset seat count when modal opens + useEffect(() => { + if (opened) { + setNewSeatCount(currentSeats); + setState({ status: 'idle' }); + } + }, [opened, currentSeats]); + + const handleUpdateSeats = async () => { + if (!onUpdateSeats) { + setState({ + status: 'error', + error: 'Update function not provided', + }); + return; + } + + if (newSeatCount < minimumSeats) { + setState({ + status: 'error', + error: t( + 'billing.seatCountTooLow', + 'Seat count must be at least {{minimum}} (current number of users)', + { minimum: minimumSeats } + ), + }); + return; + } + + if (newSeatCount === currentSeats) { + setState({ + status: 'error', + error: t('billing.seatCountUnchanged', 'Please select a different seat count'), + }); + return; + } + + try { + setState({ status: 'loading' }); + + // Call the update function (will call manage-billing) + const portalUrl = await onUpdateSeats(newSeatCount); + + // Redirect to Stripe billing portal + console.log('Redirecting to Stripe billing portal:', portalUrl); + window.location.href = portalUrl; + + // Note: No need to call onSuccess here since we're redirecting + // The return flow will handle success notification + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to update seat count'; + setState({ + status: 'error', + error: errorMessage, + }); + onError?.(errorMessage); + } + }; + + const handleClose = () => { + setState({ status: 'idle' }); + setNewSeatCount(currentSeats); + onClose(); + }; + + const renderContent = () => { + if (state.status === 'loading') { + return ( + + + + {t('billing.preparingUpdate', 'Preparing seat update...')} + + + ); + } + + return ( + + {state.status === 'error' && ( + + {state.error} + + )} + + + + + + {t('billing.currentSeats', 'Current Seats')}: + + + {currentSeats} + + + + + {t('billing.minimumSeats', 'Minimum Seats')}: + + + {minimumSeats} {t('billing.basedOnUsers', '(current users)')} + + + + + + setNewSeatCount(typeof value === 'number' ? value : minimumSeats)} + min={minimumSeats} + max={10000} + step={1} + size="lg" + styles={{ + input: { + fontSize: '1.5rem', + fontWeight: 600, + textAlign: 'center', + }, + }} + /> + + + + {t( + 'billing.stripePortalRedirect', + 'You will be redirected to Stripe\'s billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.' + )} + + + + + + + + + ); + }; + + return ( + + {t('billing.updateEnterpriseSeats', 'Update Enterprise Seats')} + + } + size="md" + centered + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; + +export default UpdateSeatsModal; diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 0ed92665a3..adaa61f9c2 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -27,11 +27,14 @@ import { useAppConfig } from '@app/contexts/AppConfigContext'; import InviteMembersModal from '@app/components/shared/InviteMembersModal'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; +import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton'; +import { useLicense } from '@app/contexts/LicenseContext'; export default function PeopleSection() { const { t } = useTranslation(); const { config } = useAppConfig(); const { loginEnabled } = useLoginRequired(); + const { licenseInfo: globalLicenseInfo } = useLicense(); const [users, setUsers] = useState([]); const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); @@ -342,6 +345,17 @@ export default function PeopleSection() { +{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')} )} + + {/* Enterprise Seat Management Button */} + {globalLicenseInfo?.licenseType === 'ENTERPRISE' && ( + <> + • + + + )} )} diff --git a/frontend/src/proprietary/contexts/LicenseContext.tsx b/frontend/src/proprietary/contexts/LicenseContext.tsx index 9414dcf5d6..a76596c2e6 100644 --- a/frontend/src/proprietary/contexts/LicenseContext.tsx +++ b/frontend/src/proprietary/contexts/LicenseContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react'; import licenseService, { LicenseInfo } from '@app/services/licenseService'; import { useAppConfig } from '@app/contexts/AppConfigContext'; @@ -17,18 +17,45 @@ interface LicenseProviderProps { export const LicenseProvider: React.FC = ({ children }) => { const { config } = useAppConfig(); + const configRef = useRef(config); const [licenseInfo, setLicenseInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Keep ref updated with latest config + useEffect(() => { + configRef.current = config; + }, [config]); + const refetchLicense = useCallback(async () => { + // Wait for config to load if it's not available yet + let currentConfig = configRef.current; + if (!currentConfig) { + console.log('[LicenseContext] Config not loaded yet, waiting...'); + // Wait up to 5 seconds for config to load + const maxWait = 5000; + const startTime = Date.now(); + while (!configRef.current && Date.now() - startTime < maxWait) { + await new Promise(resolve => setTimeout(resolve, 100)); + currentConfig = configRef.current; + } + + if (!currentConfig) { + console.error('[LicenseContext] Config failed to load after waiting'); + setLoading(false); + return; + } + } + // Only fetch license info if user is an admin - if (!config?.isAdmin) { - console.debug('[LicenseContext] User is not an admin, skipping license fetch'); + if (!currentConfig.isAdmin) { + console.log('[LicenseContext] User is not an admin, skipping license fetch'); setLoading(false); return; } + console.log('[LicenseContext] Fetching license info'); + try { setLoading(true); setError(null); @@ -42,7 +69,7 @@ export const LicenseProvider: React.FC = ({ children }) => } finally { setLoading(false); } - }, [config?.isAdmin]); + }, []); // Fetch license info when config changes (only if user is admin) useEffect(() => { @@ -51,12 +78,15 @@ export const LicenseProvider: React.FC = ({ children }) => } }, [config, refetchLicense]); - const contextValue: LicenseContextValue = { - licenseInfo, - loading, - error, - refetchLicense, - }; + const contextValue: LicenseContextValue = useMemo( + () => ({ + licenseInfo, + loading, + error, + refetchLicense, + }), + [licenseInfo, loading, error, refetchLicense] + ); return ( diff --git a/frontend/src/proprietary/contexts/UpdateSeatsContext.tsx b/frontend/src/proprietary/contexts/UpdateSeatsContext.tsx new file mode 100644 index 0000000000..ace40379c2 --- /dev/null +++ b/frontend/src/proprietary/contexts/UpdateSeatsContext.tsx @@ -0,0 +1,222 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import licenseService, { LicenseInfo } from '@app/services/licenseService'; +import UpdateSeatsModal from '@app/components/shared/UpdateSeatsModal'; +import { userManagementService } from '@app/services/userManagementService'; +import { alert } from '@app/components/toast'; +import { useLicense } from '@app/contexts/LicenseContext'; +import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils'; + +export interface UpdateSeatsOptions { + onSuccess?: () => void; + onError?: (error: string) => void; +} + +interface UpdateSeatsContextValue { + openUpdateSeats: (options?: UpdateSeatsOptions) => Promise; + closeUpdateSeats: () => void; + isOpen: boolean; + isLoading: boolean; +} + +const UpdateSeatsContext = createContext(undefined); + +interface UpdateSeatsProviderProps { + children: ReactNode; +} + +export const UpdateSeatsProvider: React.FC = ({ children }) => { + const { t } = useTranslation(); + const { refetchLicense } = useLicense(); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentSeats, setCurrentSeats] = useState(1); + const [minimumSeats, setMinimumSeats] = useState(1); + const [currentOptions, setCurrentOptions] = useState({}); + + // Handle return from Stripe billing portal + useEffect(() => { + const handleBillingReturn = async () => { + const urlParams = new URLSearchParams(window.location.search); + const seatsUpdated = urlParams.get('seats_updated'); + + if (seatsUpdated === 'true') { + console.log('Seats updated successfully, syncing license with Keygen'); + + // Clear URL parameters + window.history.replaceState({}, '', window.location.pathname); + + try { + // Wait a moment for Stripe webhook to process + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Resync license with Keygen (not just local fetch) + console.log('Seat update detected - resyncing license with Keygen'); + const activation = await resyncExistingLicense(); + + if (activation.success) { + console.log('License synced successfully after seat update'); + + // Refresh global license context + await refetchLicense(); + + // Get updated license info for notification + const updatedLicense = await licenseService.getLicenseInfo(); + + alert({ + alertType: 'success', + title: t('billing.seatsUpdated', 'Seats Updated'), + message: t( + 'billing.seatsUpdatedMessage', + 'Your enterprise seats have been updated to {{seats}}', + { seats: updatedLicense.maxUsers } + ), + }); + } else { + throw new Error(activation.error || 'Failed to sync license'); + } + } catch (error) { + console.error('Failed to sync license after seat update:', error); + alert({ + alertType: 'warning', + title: t('billing.updateProcessing', 'Update Processing'), + message: t( + 'billing.updateProcessingMessage', + 'Your seat update is being processed. Please refresh in a few moments.' + ), + }); + } + } + }; + + handleBillingReturn(); + }, [t, refetchLicense]); + + const openUpdateSeats = useCallback(async (options: UpdateSeatsOptions = {}) => { + try { + setIsLoading(true); + + // Fetch current license info and user count + const [licenseInfo, userData] = await Promise.all([ + licenseService.getLicenseInfo(), + userManagementService.getUsers(), + ]); + + // Validate this is an enterprise license + if (!licenseInfo || licenseInfo.licenseType !== 'ENTERPRISE') { + throw new Error( + t('billing.notEnterprise', 'Seat management is only available for enterprise licenses') + ); + } + + const currentLicenseSeats = licenseInfo.maxUsers || 1; + const currentUserCount = userData.totalUsers || 0; + + // Minimum seats must be at least the current number of users + const calculatedMinSeats = Math.max(currentUserCount, 1); + + console.log( + `Opening seat update: current seats=${currentLicenseSeats}, current users=${currentUserCount}, minimum=${calculatedMinSeats}` + ); + + setCurrentSeats(currentLicenseSeats); + setMinimumSeats(calculatedMinSeats); + setCurrentOptions(options); + setIsOpen(true); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to open seat update'; + console.error('Error opening seat update:', errorMessage); + alert({ + alertType: 'error', + title: t('common.error', 'Error'), + message: errorMessage, + }); + options.onError?.(errorMessage); + } finally { + setIsLoading(false); + } + }, [t]); + + const closeUpdateSeats = useCallback(() => { + setIsOpen(false); + setCurrentOptions({}); + + // Refetch license after modal closes to update UI + refetchLicense(); + }, [refetchLicense]); + + const handleUpdateSeats = useCallback( + async (newSeatCount: number): Promise => { + try { + // Get current license key + const licenseInfo = await licenseService.getLicenseInfo(); + if (!licenseInfo?.licenseKey) { + throw new Error('No license key found'); + } + + console.log(`Updating seats from ${currentSeats} to ${newSeatCount}`); + + // Call manage-billing function with new seat count + const portalUrl = await licenseService.updateEnterpriseSeats( + newSeatCount, + licenseInfo.licenseKey + ); + + return portalUrl; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to update seats'; + console.error('Error updating seats:', errorMessage); + currentOptions.onError?.(errorMessage); + throw err; + } + }, + [currentSeats, currentOptions] + ); + + const handleSuccess = useCallback(() => { + console.log('Seat update initiated successfully'); + currentOptions.onSuccess?.(); + }, [currentOptions]); + + const handleError = useCallback( + (error: string) => { + console.error('Seat update error:', error); + currentOptions.onError?.(error); + }, + [currentOptions] + ); + + return ( + + {children} + + + ); +}; + +export const useUpdateSeats = (): UpdateSeatsContextValue => { + const context = useContext(UpdateSeatsContext); + if (!context) { + throw new Error('useUpdateSeats must be used within an UpdateSeatsProvider'); + } + return context; +}; + +export default UpdateSeatsContext; diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index 409dbfd74d..6d72ac7da3 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -443,6 +443,42 @@ const licenseService = { throw error; } }, + + /** + * Update enterprise seat count + * Creates a Stripe billing portal session for confirming seat changes + * @param newSeatCount - New number of seats + * @param licenseKey - Current license key for authentication + * @returns Billing portal URL for confirming the change + */ + async updateEnterpriseSeats(newSeatCount: number, licenseKey: string): Promise { + // Check if Supabase is configured + if (!isSupabaseConfigured || !supabase) { + throw new Error('Supabase is not configured. Seat updates are not available.'); + } + + const baseUrl = window.location.origin; + const returnUrl = `${baseUrl}/settings/adminPlan?seats_updated=true`; + + const { data, error } = await supabase.functions.invoke('manage-billing', { + body: { + return_url: returnUrl, + license_key: licenseKey, + self_hosted: true, + new_seat_count: newSeatCount, + }, + }); + + if (error) { + throw new Error(`Failed to update seat count: ${error.message}`); + } + + if (!data || !data.url) { + throw new Error('No billing portal URL returned'); + } + + return data.url; + }, }; /**