Files
smoothschedule/frontend/src/components/platform/FeaturesPermissionsEditor.tsx
poduck 485f86086b feat: Unified FeaturesPermissionsEditor component for plan and business permissions
- 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>
2025-12-10 01:37:04 -05:00

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;