- Create reusable FeaturesPermissionsEditor component with support for both subscription plan editing and individual business permission overrides - Add can_use_contracts field to Tenant model for per-business contracts toggle - Update PlatformSettings.tsx to use unified component for plan permissions - Update BusinessEditModal.tsx to use unified component for business permissions - Update PlatformBusinessUpdate API interface with all permission fields - Add contracts permission mapping to tenant sync task 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
532 lines
14 KiB
TypeScript
532 lines
14 KiB
TypeScript
/**
|
|
* FeaturesPermissionsEditor
|
|
*
|
|
* A unified component for editing features and permissions.
|
|
* Used by both subscription plan editing (PlatformSettings) and
|
|
* individual business editing (BusinessEditModal).
|
|
*
|
|
* Supports two modes:
|
|
* - 'plan': For editing subscription plan permissions (uses plan-style keys)
|
|
* - 'business': For editing individual business permissions (uses tenant-style keys)
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { Key } from 'lucide-react';
|
|
|
|
/**
|
|
* Permission definition with metadata
|
|
*/
|
|
interface PermissionDefinition {
|
|
key: string;
|
|
planKey?: string; // Key used in subscription plan permissions JSON
|
|
businessKey?: string; // Key used in tenant/business model fields
|
|
label: string;
|
|
description?: string;
|
|
category: PermissionCategory;
|
|
dependsOn?: string; // Key of permission this depends on
|
|
}
|
|
|
|
type PermissionCategory =
|
|
| 'payments'
|
|
| 'communication'
|
|
| 'customization'
|
|
| 'plugins'
|
|
| 'advanced'
|
|
| 'enterprise'
|
|
| 'scheduling';
|
|
|
|
/**
|
|
* All available permissions with their mappings
|
|
*/
|
|
export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|
// Payments & Revenue
|
|
{
|
|
key: 'can_accept_payments',
|
|
planKey: 'can_accept_payments',
|
|
businessKey: 'can_accept_payments',
|
|
label: 'Online Payments',
|
|
description: 'Accept payments via Stripe Connect',
|
|
category: 'payments',
|
|
},
|
|
{
|
|
key: 'can_process_refunds',
|
|
planKey: 'can_process_refunds',
|
|
businessKey: 'can_process_refunds',
|
|
label: 'Process Refunds',
|
|
description: 'Issue refunds for payments',
|
|
category: 'payments',
|
|
},
|
|
{
|
|
key: 'can_create_packages',
|
|
planKey: 'can_create_packages',
|
|
businessKey: 'can_create_packages',
|
|
label: 'Service Packages',
|
|
description: 'Create and sell service packages',
|
|
category: 'payments',
|
|
},
|
|
{
|
|
key: 'can_use_pos',
|
|
planKey: 'can_use_pos',
|
|
businessKey: 'can_use_pos',
|
|
label: 'POS System',
|
|
description: 'Point of sale for in-person payments',
|
|
category: 'payments',
|
|
},
|
|
|
|
// Communication
|
|
{
|
|
key: 'sms_reminders',
|
|
planKey: 'sms_reminders',
|
|
businessKey: 'can_use_sms_reminders',
|
|
label: 'SMS Reminders',
|
|
description: 'Send SMS appointment reminders',
|
|
category: 'communication',
|
|
},
|
|
{
|
|
key: 'masked_calling',
|
|
planKey: 'can_use_masked_phone_numbers',
|
|
businessKey: 'can_use_masked_phone_numbers',
|
|
label: 'Masked Calling',
|
|
description: 'Use masked phone numbers for privacy',
|
|
category: 'communication',
|
|
},
|
|
{
|
|
key: 'email_templates',
|
|
planKey: 'can_use_email_templates',
|
|
businessKey: 'can_use_email_templates',
|
|
label: 'Email Templates',
|
|
description: 'Custom email templates for communications',
|
|
category: 'communication',
|
|
},
|
|
|
|
// Customization
|
|
{
|
|
key: 'custom_booking_page',
|
|
planKey: 'can_customize_booking_page',
|
|
businessKey: 'can_customize_booking_page',
|
|
label: 'Custom Booking Page',
|
|
description: 'Customize the public booking page',
|
|
category: 'customization',
|
|
},
|
|
{
|
|
key: 'custom_domain',
|
|
planKey: 'can_use_custom_domain',
|
|
businessKey: 'can_use_custom_domain',
|
|
label: 'Custom Domains',
|
|
description: 'Use your own domain for booking',
|
|
category: 'customization',
|
|
},
|
|
{
|
|
key: 'white_label',
|
|
planKey: 'can_white_label',
|
|
businessKey: 'can_white_label',
|
|
label: 'White Labelling',
|
|
description: 'Remove SmoothSchedule branding',
|
|
category: 'customization',
|
|
},
|
|
|
|
// Plugins & Automation
|
|
{
|
|
key: 'plugins',
|
|
planKey: 'can_use_plugins',
|
|
businessKey: 'can_use_plugins',
|
|
label: 'Use Plugins',
|
|
description: 'Install and use marketplace plugins',
|
|
category: 'plugins',
|
|
},
|
|
{
|
|
key: 'tasks',
|
|
planKey: 'can_use_tasks',
|
|
businessKey: 'can_use_tasks',
|
|
label: 'Scheduled Tasks',
|
|
description: 'Create automated scheduled tasks',
|
|
category: 'plugins',
|
|
dependsOn: 'plugins',
|
|
},
|
|
{
|
|
key: 'create_plugins',
|
|
planKey: 'can_create_plugins',
|
|
businessKey: 'can_create_plugins',
|
|
label: 'Create Plugins',
|
|
description: 'Build custom plugins',
|
|
category: 'plugins',
|
|
dependsOn: 'plugins',
|
|
},
|
|
|
|
// Advanced Features
|
|
{
|
|
key: 'api_access',
|
|
planKey: 'can_api_access',
|
|
businessKey: 'can_api_access',
|
|
label: 'API Access',
|
|
description: 'Access REST API for integrations',
|
|
category: 'advanced',
|
|
},
|
|
{
|
|
key: 'webhooks',
|
|
planKey: 'can_use_webhooks',
|
|
businessKey: 'can_use_webhooks',
|
|
label: 'Webhooks',
|
|
description: 'Receive webhook notifications',
|
|
category: 'advanced',
|
|
},
|
|
{
|
|
key: 'calendar_sync',
|
|
planKey: 'calendar_sync',
|
|
businessKey: 'can_use_calendar_sync',
|
|
label: 'Calendar Sync',
|
|
description: 'Sync with Google Calendar, etc.',
|
|
category: 'advanced',
|
|
},
|
|
{
|
|
key: 'export_data',
|
|
planKey: 'can_export_data',
|
|
businessKey: 'can_export_data',
|
|
label: 'Data Export',
|
|
description: 'Export data to CSV/Excel',
|
|
category: 'advanced',
|
|
},
|
|
{
|
|
key: 'video_conferencing',
|
|
planKey: 'video_conferencing',
|
|
businessKey: 'can_add_video_conferencing',
|
|
label: 'Video Conferencing',
|
|
description: 'Add video links to appointments',
|
|
category: 'advanced',
|
|
},
|
|
{
|
|
key: 'advanced_reporting',
|
|
planKey: 'advanced_reporting',
|
|
businessKey: 'advanced_reporting',
|
|
label: 'Advanced Analytics',
|
|
description: 'Detailed reporting and analytics',
|
|
category: 'advanced',
|
|
},
|
|
{
|
|
key: 'contracts',
|
|
planKey: 'contracts_enabled',
|
|
businessKey: 'can_use_contracts',
|
|
label: 'Contracts',
|
|
description: 'Create and manage e-signature contracts',
|
|
category: 'advanced',
|
|
},
|
|
{
|
|
key: 'mobile_app',
|
|
planKey: 'can_use_mobile_app',
|
|
businessKey: 'can_use_mobile_app',
|
|
label: 'Mobile App',
|
|
description: 'Access via mobile application',
|
|
category: 'advanced',
|
|
},
|
|
|
|
// Enterprise & Security
|
|
{
|
|
key: 'manage_oauth',
|
|
planKey: 'can_manage_oauth_credentials',
|
|
businessKey: 'can_manage_oauth_credentials',
|
|
label: 'Manage OAuth',
|
|
description: 'Configure custom OAuth credentials',
|
|
category: 'enterprise',
|
|
},
|
|
{
|
|
key: 'require_2fa',
|
|
planKey: 'can_require_2fa',
|
|
businessKey: 'can_require_2fa',
|
|
label: 'Require 2FA',
|
|
description: 'Enforce two-factor authentication',
|
|
category: 'enterprise',
|
|
},
|
|
{
|
|
key: 'sso_enabled',
|
|
planKey: 'sso_enabled',
|
|
businessKey: 'sso_enabled',
|
|
label: 'SSO / SAML',
|
|
description: 'Single sign-on integration',
|
|
category: 'enterprise',
|
|
},
|
|
{
|
|
key: 'priority_support',
|
|
planKey: 'priority_support',
|
|
businessKey: 'priority_support',
|
|
label: 'Priority Support',
|
|
description: 'Faster response times',
|
|
category: 'enterprise',
|
|
},
|
|
{
|
|
key: 'dedicated_support',
|
|
planKey: 'dedicated_support',
|
|
businessKey: 'dedicated_support',
|
|
label: 'Dedicated Support',
|
|
description: 'Dedicated account manager',
|
|
category: 'enterprise',
|
|
},
|
|
|
|
// Scheduling
|
|
{
|
|
key: 'repeated_events',
|
|
planKey: 'can_book_repeated_events',
|
|
businessKey: 'can_book_repeated_events',
|
|
label: 'Recurring Events',
|
|
description: 'Schedule recurring appointments',
|
|
category: 'scheduling',
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Category metadata for display
|
|
*/
|
|
const CATEGORY_META: Record<PermissionCategory, { label: string; order: number }> = {
|
|
payments: { label: 'Payments & Revenue', order: 1 },
|
|
communication: { label: 'Communication', order: 2 },
|
|
customization: { label: 'Customization', order: 3 },
|
|
plugins: { label: 'Plugins & Automation', order: 4 },
|
|
advanced: { label: 'Advanced Features', order: 5 },
|
|
scheduling: { label: 'Scheduling', order: 6 },
|
|
enterprise: { label: 'Enterprise & Security', order: 7 },
|
|
};
|
|
|
|
export type EditorMode = 'plan' | 'business';
|
|
|
|
export interface FeaturesPermissionsEditorProps {
|
|
/**
|
|
* Mode determines which keys are used and which permissions are shown
|
|
*/
|
|
mode: EditorMode;
|
|
|
|
/**
|
|
* Current permission values
|
|
* For 'plan' mode: the permissions object from subscription plan
|
|
* For 'business' mode: flat object with tenant field names
|
|
*/
|
|
values: Record<string, boolean>;
|
|
|
|
/**
|
|
* Callback when a permission changes
|
|
*/
|
|
onChange: (key: string, value: boolean) => void;
|
|
|
|
/**
|
|
* Optional: Limit which categories to show
|
|
*/
|
|
categories?: PermissionCategory[];
|
|
|
|
/**
|
|
* Optional: Limit which permissions to show by key
|
|
*/
|
|
includeOnly?: string[];
|
|
|
|
/**
|
|
* Optional: Hide specific permissions
|
|
*/
|
|
exclude?: string[];
|
|
|
|
/**
|
|
* Number of columns in the grid (default: 3)
|
|
*/
|
|
columns?: 2 | 3 | 4;
|
|
|
|
/**
|
|
* Show section header
|
|
*/
|
|
showHeader?: boolean;
|
|
|
|
/**
|
|
* Custom header title
|
|
*/
|
|
headerTitle?: string;
|
|
|
|
/**
|
|
* Show descriptions under labels
|
|
*/
|
|
showDescriptions?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Get the appropriate key for a permission based on mode
|
|
*/
|
|
export function getPermissionKey(def: PermissionDefinition, mode: EditorMode): string {
|
|
if (mode === 'plan') {
|
|
return def.planKey || def.key;
|
|
}
|
|
return def.businessKey || def.key;
|
|
}
|
|
|
|
/**
|
|
* Convert permissions from one mode to another
|
|
*/
|
|
export function convertPermissions(
|
|
values: Record<string, boolean>,
|
|
fromMode: EditorMode,
|
|
toMode: EditorMode
|
|
): Record<string, boolean> {
|
|
const result: Record<string, boolean> = {};
|
|
|
|
for (const def of PERMISSION_DEFINITIONS) {
|
|
const fromKey = getPermissionKey(def, fromMode);
|
|
const toKey = getPermissionKey(def, toMode);
|
|
|
|
if (fromKey in values) {
|
|
result[toKey] = values[fromKey];
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get permission value from values object
|
|
*/
|
|
function getPermissionValue(
|
|
values: Record<string, boolean>,
|
|
def: PermissionDefinition,
|
|
mode: EditorMode
|
|
): boolean {
|
|
const key = getPermissionKey(def, mode);
|
|
return values[key] ?? false;
|
|
}
|
|
|
|
/**
|
|
* Check if a dependent permission should be disabled
|
|
*/
|
|
function isDependencyDisabled(
|
|
values: Record<string, boolean>,
|
|
def: PermissionDefinition,
|
|
mode: EditorMode
|
|
): boolean {
|
|
if (!def.dependsOn) return false;
|
|
|
|
const parentDef = PERMISSION_DEFINITIONS.find(d => d.key === def.dependsOn);
|
|
if (!parentDef) return false;
|
|
|
|
return !getPermissionValue(values, parentDef, mode);
|
|
}
|
|
|
|
const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
|
|
mode,
|
|
values,
|
|
onChange,
|
|
categories,
|
|
includeOnly,
|
|
exclude = [],
|
|
columns = 3,
|
|
showHeader = true,
|
|
headerTitle = 'Features & Permissions',
|
|
showDescriptions = false,
|
|
}) => {
|
|
// Filter permissions based on props
|
|
const filteredPermissions = PERMISSION_DEFINITIONS.filter(def => {
|
|
if (exclude.includes(def.key)) return false;
|
|
if (includeOnly && !includeOnly.includes(def.key)) return false;
|
|
if (categories && !categories.includes(def.category)) return false;
|
|
return true;
|
|
});
|
|
|
|
// Group by category
|
|
const groupedPermissions = filteredPermissions.reduce((acc, def) => {
|
|
if (!acc[def.category]) {
|
|
acc[def.category] = [];
|
|
}
|
|
acc[def.category].push(def);
|
|
return acc;
|
|
}, {} as Record<PermissionCategory, PermissionDefinition[]>);
|
|
|
|
// Sort categories by order
|
|
const sortedCategories = Object.keys(groupedPermissions).sort(
|
|
(a, b) => CATEGORY_META[a as PermissionCategory].order - CATEGORY_META[b as PermissionCategory].order
|
|
) as PermissionCategory[];
|
|
|
|
const handleChange = (def: PermissionDefinition, checked: boolean) => {
|
|
const key = getPermissionKey(def, mode);
|
|
onChange(key, checked);
|
|
|
|
// If disabling a parent permission, also disable dependents
|
|
if (!checked) {
|
|
const dependents = PERMISSION_DEFINITIONS.filter(d => d.dependsOn === def.key);
|
|
for (const dep of dependents) {
|
|
const depKey = getPermissionKey(dep, mode);
|
|
if (values[depKey]) {
|
|
onChange(depKey, false);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const gridCols = {
|
|
2: 'grid-cols-2',
|
|
3: 'grid-cols-3',
|
|
4: 'grid-cols-4',
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{showHeader && (
|
|
<>
|
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
|
<Key size={16} className="text-purple-500" />
|
|
{headerTitle}
|
|
</h3>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Control which features are available.
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{sortedCategories.map(category => (
|
|
<div key={category}>
|
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
|
{CATEGORY_META[category].label}
|
|
</h4>
|
|
<div className={`grid ${gridCols[columns]} gap-3`}>
|
|
{groupedPermissions[category].map(def => {
|
|
const isChecked = getPermissionValue(values, def, mode);
|
|
const isDisabled = isDependencyDisabled(values, def, mode);
|
|
const key = getPermissionKey(def, mode);
|
|
|
|
return (
|
|
<label
|
|
key={def.key}
|
|
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
|
|
isDisabled
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={(e) => handleChange(def, e.target.checked)}
|
|
disabled={isDisabled}
|
|
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm text-gray-700 dark:text-gray-300 block">
|
|
{def.label}
|
|
</span>
|
|
{showDescriptions && def.description && (
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
|
{def.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
{/* Show dependency hint for plugins category */}
|
|
{category === 'plugins' && !getPermissionValue(
|
|
values,
|
|
PERMISSION_DEFINITIONS.find(d => d.key === 'plugins')!,
|
|
mode
|
|
) && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
|
Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FeaturesPermissionsEditor;
|