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;
+ },
};
/**