Button for adding seats added to people tab. Should be able to use from anywhere
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user