Button for adding seats added to people tab. Should be able to use from anywhere

This commit is contained in:
Connor Yoh
2025-11-20 15:33:25 +00:00
parent 6c8d2c89fe
commit 74ad90db5d
8 changed files with 581 additions and 13 deletions
+18 -1
View File
@@ -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",
@@ -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
<AuthProvider>
<LicenseProvider>
<CheckoutProvider>
<UpgradeBanner />
{children}
<UpdateSeatsProvider>
<UpgradeBanner />
{children}
</UpdateSeatsProvider>
</CheckoutProvider>
</LicenseProvider>
</AuthProvider>
@@ -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<ButtonProps, 'onClick' | 'loading'> {
onSuccess?: () => void;
onError?: (error: string) => void;
}
export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({
onSuccess,
onError,
...buttonProps
}) => {
const { t } = useTranslation();
const { openUpdateSeats, isLoading } = useUpdateSeats();
const handleClick = async () => {
await openUpdateSeats({
onSuccess,
onError,
});
};
return (
<Button
variant="outline"
onClick={handleClick}
loading={isLoading}
{...buttonProps}
>
{t('billing.updateSeats', 'Update Seats')}
</Button>
);
};
export default UpdateSeatsButton;
@@ -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<string>; // Returns billing portal URL
}
type UpdateState = {
status: 'idle' | 'loading' | 'error';
error?: string;
};
const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
opened,
onClose,
currentSeats,
minimumSeats,
onSuccess,
onError,
onUpdateSeats,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<UpdateState>({ status: 'idle' });
const [newSeatCount, setNewSeatCount] = useState<number>(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 (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('billing.preparingUpdate', 'Preparing seat update...')}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
{state.status === 'error' && (
<Alert color="red" title={t('common.error', 'Error')}>
{state.error}
</Alert>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('billing.currentSeats', 'Current Seats')}:
</Text>
<Text size="sm" fw={600}>
{currentSeats}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('billing.minimumSeats', 'Minimum Seats')}:
</Text>
<Text size="sm" c="dimmed">
{minimumSeats} {t('billing.basedOnUsers', '(current users)')}
</Text>
</Group>
</Stack>
</Paper>
<NumberInput
label={t('billing.newSeatCount', 'New Seat Count')}
description={t(
'billing.newSeatCountDescription',
'Select the number of seats for your enterprise license'
)}
value={newSeatCount}
onChange={(value) => setNewSeatCount(typeof value === 'number' ? value : minimumSeats)}
min={minimumSeats}
max={10000}
step={1}
size="lg"
styles={{
input: {
fontSize: '1.5rem',
fontWeight: 600,
textAlign: 'center',
},
}}
/>
<Alert color="blue" title={t('billing.whatHappensNext', 'What happens next?')}>
<Text size="sm">
{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.'
)}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="outline" onClick={handleClose} disabled={state.status === 'loading'}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
onClick={handleUpdateSeats}
loading={state.status === 'loading'}
disabled={newSeatCount === currentSeats || newSeatCount < minimumSeats}
>
{t('billing.updateSeats', 'Update Seats')}
</Button>
</Group>
</Stack>
);
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t('billing.updateEnterpriseSeats', 'Update Enterprise Seats')}
</Text>
}
size="md"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default UpdateSeatsModal;
@@ -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<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
@@ -342,6 +345,17 @@ export default function PeopleSection() {
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
</Badge>
)}
{/* Enterprise Seat Management Button */}
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
<>
<Text size="sm" c="dimmed" span>•</Text>
<UpdateSeatsButton
size="xs"
onSuccess={fetchData}
/>
</>
)}
</Group>
)}
@@ -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<LicenseProviderProps> = ({ children }) => {
const { config } = useAppConfig();
const configRef = useRef(config);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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<LicenseProviderProps> = ({ 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<LicenseProviderProps> = ({ children }) =>
}
}, [config, refetchLicense]);
const contextValue: LicenseContextValue = {
licenseInfo,
loading,
error,
refetchLicense,
};
const contextValue: LicenseContextValue = useMemo(
() => ({
licenseInfo,
loading,
error,
refetchLicense,
}),
[licenseInfo, loading, error, refetchLicense]
);
return (
<LicenseContext.Provider value={contextValue}>
@@ -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<void>;
closeUpdateSeats: () => void;
isOpen: boolean;
isLoading: boolean;
}
const UpdateSeatsContext = createContext<UpdateSeatsContextValue | undefined>(undefined);
interface UpdateSeatsProviderProps {
children: ReactNode;
}
export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentSeats, setCurrentSeats] = useState<number>(1);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentOptions, setCurrentOptions] = useState<UpdateSeatsOptions>({});
// 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<string> => {
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 (
<UpdateSeatsContext.Provider
value={{
openUpdateSeats,
closeUpdateSeats,
isOpen,
isLoading,
}}
>
{children}
<UpdateSeatsModal
opened={isOpen}
onClose={closeUpdateSeats}
currentSeats={currentSeats}
minimumSeats={minimumSeats}
onSuccess={handleSuccess}
onError={handleError}
onUpdateSeats={handleUpdateSeats}
/>
</UpdateSeatsContext.Provider>
);
};
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;
@@ -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<string> {
// 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;
},
};
/**