Add max_public_pages feature and site builder access control

- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10)
- Gate site builder access based on max_public_pages entitlement
- Auto-create Site with default booking page for new tenants
- Update PageEditor to use useEntitlements hook for permission checks
- Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor
- Add force update functionality for superusers in PlanEditorWizard
- Add comprehensive filters to all safe scripting get_* methods
- Update plugin documentation with full filter reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-13 00:27:15 -05:00
parent aa9d920612
commit 41caccd31a
11 changed files with 4413 additions and 255 deletions

View File

@@ -19,6 +19,7 @@ import {
Star,
Loader2,
ChevronLeft,
AlertTriangle,
} from 'lucide-react';
import { Modal, Alert } from '../../components/ui';
import { FeaturePicker } from './FeaturePicker';
@@ -29,8 +30,11 @@ import {
useCreatePlanVersion,
useUpdatePlan,
useUpdatePlanVersion,
useForceUpdatePlanVersion,
isForceUpdateConfirmRequired,
type PlanFeatureWrite,
} from '../../hooks/useBillingAdmin';
import { useCurrentUser } from '../../hooks/useAuth';
// =============================================================================
// Types
@@ -110,13 +114,20 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
}) => {
const { data: features, isLoading: featuresLoading } = useFeatures();
const { data: addons } = useAddOnProducts();
const { data: currentUser } = useCurrentUser();
const createPlanMutation = useCreatePlan();
const createVersionMutation = useCreatePlanVersion();
const updatePlanMutation = useUpdatePlan();
const updateVersionMutation = useUpdatePlanVersion();
const forceUpdateMutation = useForceUpdatePlanVersion();
const isNewPlan = mode === 'create';
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
const isSuperuser = currentUser?.role === 'superuser';
// Force update state (for updating without creating new version)
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
const [newMarketingFeature, setNewMarketingFeature] = useState('');
@@ -313,11 +324,49 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
}
};
// Force update handler (updates existing version without creating new one)
const handleForceUpdate = async () => {
if (!initialData?.version?.id) return;
try {
setForceUpdateError(null);
// First call without confirm to get affected subscriber count
const response = await forceUpdateMutation.mutateAsync({
id: initialData.version.id,
name: formData.version_name,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
confirm: true, // Confirm immediately since user already acknowledged
});
// If successful, close the modal
if (!isForceUpdateConfirmRequired(response)) {
onClose();
}
} catch (error) {
console.error('Failed to force update plan:', error);
setForceUpdateError('Failed to update plan. Please try again.');
}
};
const isLoading =
createPlanMutation.isPending ||
createVersionMutation.isPending ||
updatePlanMutation.isPending ||
updateVersionMutation.isPending;
updateVersionMutation.isPending ||
forceUpdateMutation.isPending;
// Derived values for display
const monthlyEquivalent = formData.price_yearly_cents > 0
@@ -345,7 +394,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
size="4xl"
>
{/* Grandfathering Warning */}
{hasSubscribers && (
{hasSubscribers && !showForceUpdateConfirm && (
<Alert
variant="warning"
className="mb-4"
@@ -359,6 +408,53 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
/>
)}
{/* Force Update Confirmation Dialog */}
{showForceUpdateConfirm && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
Warning: This will affect existing customers
</h4>
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
You are about to update this plan version <strong>in place</strong>. This will immediately
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
subscriber(s). This action cannot be undone.
</p>
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
Only use this for correcting errors or minor adjustments. For significant changes,
use the standard save which creates a new version and grandfathers existing subscribers.
</p>
{forceUpdateError && (
<Alert variant="error" message={forceUpdateError} className="mb-3" />
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => {
setShowForceUpdateConfirm(false);
setForceUpdateError(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="button"
onClick={handleForceUpdate}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Yes, Update All Subscribers
</button>
</div>
</div>
</div>
</div>
)}
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
{steps.map((step, index) => {
@@ -834,7 +930,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
{/* Footer */}
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div>
{!isFirstStep && (
{!isFirstStep && !showForceUpdateConfirm && (
<button
type="button"
onClick={goPrev}
@@ -845,35 +941,51 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
</button>
)}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
{!isLastStep ? (
{!showForceUpdateConfirm && (
<div className="flex gap-3">
<button
type="button"
onClick={goNext}
disabled={!canProceed}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Next
Cancel
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
</button>
)}
</div>
{!isLastStep ? (
<button
type="button"
onClick={goNext}
disabled={!canProceed}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
) : (
<>
{/* Force Update button - only for superusers editing plans with subscribers */}
{hasSubscribers && isSuperuser && (
<button
type="button"
onClick={() => setShowForceUpdateConfirm(true)}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<AlertTriangle className="w-4 h-4" />
Update Without Versioning
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
</button>
</>
)}
</div>
)}
</div>
</Modal>
);

View File

@@ -57,6 +57,23 @@ vi.mock('../../../hooks/useBillingAdmin', () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
isPending: false,
}),
useForceUpdatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }),
isPending: false,
}),
isForceUpdateConfirmRequired: (response: unknown) =>
response !== null &&
typeof response === 'object' &&
'requires_confirm' in response &&
(response as { requires_confirm: boolean }).requires_confirm === true,
}));
// Mock useCurrentUser from useAuth
vi.mock('../../../hooks/useAuth', () => ({
useCurrentUser: () => ({
data: { id: 1, role: 'superuser', email: 'admin@test.com' },
isLoading: false,
}),
}));
describe('PlanEditorWizard', () => {
@@ -409,4 +426,135 @@ describe('PlanEditorWizard', () => {
});
});
});
describe('Force Update (Superuser)', () => {
it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should show "Update Without Versioning" button
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('shows confirmation dialog when clicking "Update Without Versioning"', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Should show confirmation dialog with warning
expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument();
expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count
expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument();
});
it('can cancel force update confirmation', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Click Cancel
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog
// Confirmation dialog should be hidden, back to normal footer
expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('does not show "Update Without Versioning" for plans without subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 0, // No subscribers
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should NOT show "Update Without Versioning" button
expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument();
});
});
});

View File

@@ -127,6 +127,7 @@ export const FEATURE_CODES = {
MAX_RESOURCES: 'max_resources',
MAX_EVENT_TYPES: 'max_event_types',
MAX_CALENDARS_CONNECTED: 'max_calendars_connected',
MAX_PUBLIC_PAGES: 'max_public_pages',
} as const;
export type FeatureCode = (typeof FEATURE_CODES)[keyof typeof FEATURE_CODES];

View File

@@ -5,11 +5,11 @@ import { config } from "../puckConfig";
import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites";
import { Loader2, Plus, Trash2, FileText } from "lucide-react";
import toast from 'react-hot-toast';
import { useAuth } from '../hooks/useAuth';
import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
export const PageEditor: React.FC = () => {
const { data: pages, isLoading } = usePages();
const { user } = useAuth();
const { getLimit, isLoading: entitlementsLoading } = useEntitlements();
const updatePage = useUpdatePage();
const createPage = useCreatePage();
const deletePage = useDeletePage();
@@ -18,6 +18,13 @@ export const PageEditor: React.FC = () => {
const [showNewPageModal, setShowNewPageModal] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
// Get max_public_pages from billing entitlements
// null = unlimited, 0 = no access, >0 = limited pages
const maxPagesLimit = getLimit(FEATURE_CODES.MAX_PUBLIC_PAGES);
const canCustomize = maxPagesLimit === null || maxPagesLimit > 0;
const pageCount = pages?.length || 0;
const canCreateMore = canCustomize && (maxPagesLimit === null || pageCount < maxPagesLimit);
const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0];
useEffect(() => {
@@ -35,9 +42,8 @@ export const PageEditor: React.FC = () => {
const handlePublish = async (newData: any) => {
if (!currentPage) return;
// Check if user has permission to customize
const hasPermission = (user as any)?.tenant?.can_customize_booking_page || false;
if (!hasPermission) {
// Check if user has permission to customize (based on max_public_pages entitlement)
if (!canCustomize) {
toast.error("Your plan does not include site customization. Please upgrade to edit pages.");
return;
}
@@ -84,7 +90,7 @@ export const PageEditor: React.FC = () => {
}
};
if (isLoading) {
if (isLoading || entitlementsLoading) {
return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
}
@@ -94,10 +100,8 @@ export const PageEditor: React.FC = () => {
if (!data) return null;
const maxPages = (user as any)?.tenant?.max_pages || 1;
const pageCount = pages?.length || 0;
const canCustomize = (user as any)?.tenant?.can_customize_booking_page || false;
const canCreateMore = canCustomize && (maxPages === -1 || pageCount < maxPages);
// Display max pages as string for UI (null = unlimited shown as ∞)
const maxPagesDisplay = maxPagesLimit === null ? '∞' : maxPagesLimit;
return (
<div className="h-screen flex flex-col">
@@ -136,7 +140,7 @@ export const PageEditor: React.FC = () => {
onClick={() => setShowNewPageModal(true)}
disabled={!canCreateMore}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPages})`}
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPagesDisplay})`}
>
<Plus size={16} />
New Page
@@ -156,7 +160,7 @@ export const PageEditor: React.FC = () => {
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pageCount} / {maxPages === -1 ? '∞' : maxPages} pages
{pageCount} / {maxPagesDisplay} pages
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -422,64 +422,18 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
</p>
</div>
{/* Limits Configuration */}
{/* Limits & Quotas - Dynamic from billing system */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Limits Configuration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Use -1 for unlimited. These limits control what this business can create.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Users
</label>
<input
type="number"
min="-1"
value={featureValues.max_users ?? 5}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Resources
</label>
<input
type="number"
min="-1"
value={featureValues.max_resources ?? 10}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_resources: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Pages
</label>
<input
type="number"
min="-1"
value={featureValues.max_pages ?? 1}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_pages: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Locations
</label>
<input
type="number"
min="-1"
value={featureValues.max_locations ?? 1}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_locations: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<DynamicFeaturesEditor
values={featureValues}
onChange={(fieldName, value) => {
setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
}}
featureType="integer"
headerTitle="Limits & Quotas"
showDescriptions
columns={4}
/>
</div>
{/* Site Builder Access */}
@@ -488,7 +442,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
Site Builder
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Access the public-facing website builder for this business. Current limit: {featureValues.max_pages === -1 ? 'unlimited' : (featureValues.max_pages ?? 1)} page{(featureValues.max_pages ?? 1) !== 1 ? 's' : ''}.
Access the public-facing website builder for this business. Current limit: {(featureValues.max_public_pages === null || featureValues.max_public_pages === -1) ? 'unlimited' : (featureValues.max_public_pages ?? 0)} page{(featureValues.max_public_pages ?? 0) !== 1 ? 's' : ''}.
</p>
<a
href={`http://${business.subdomain}.lvh.me:5173/site-editor`}
@@ -510,14 +464,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
onChange={(fieldName, value) => {
setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
}}
// Show only boolean features (limits are in the grid above)
featureType="boolean"
// Skip limits category since they're shown in the dedicated grid
excludeCodes={[
'max_users', 'max_resources', 'max_locations', 'max_services',
'max_customers', 'max_appointments_per_month', 'max_sms_per_month',
'max_email_per_month', 'max_storage_mb', 'max_api_calls_per_day',
]}
headerTitle="Features & Permissions"
showDescriptions
/>