Compare commits
3 Commits
507222316c
...
ba2c656243
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba2c656243 | ||
|
|
485f86086b | ||
|
|
2f6ea82114 |
@@ -41,11 +41,38 @@ export interface PlatformBusinessUpdate {
|
||||
subscription_tier?: string;
|
||||
max_users?: number;
|
||||
max_resources?: number;
|
||||
// Platform permissions
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
can_accept_payments?: boolean;
|
||||
can_use_custom_domain?: boolean;
|
||||
can_white_label?: boolean;
|
||||
can_api_access?: boolean;
|
||||
// Feature permissions
|
||||
can_add_video_conferencing?: boolean;
|
||||
can_connect_to_api?: boolean;
|
||||
can_book_repeated_events?: boolean;
|
||||
can_require_2fa?: boolean;
|
||||
can_download_logs?: boolean;
|
||||
can_delete_data?: boolean;
|
||||
can_use_sms_reminders?: boolean;
|
||||
can_use_masked_phone_numbers?: boolean;
|
||||
can_use_pos?: boolean;
|
||||
can_use_mobile_app?: boolean;
|
||||
can_export_data?: boolean;
|
||||
can_use_plugins?: boolean;
|
||||
can_use_tasks?: boolean;
|
||||
can_create_plugins?: boolean;
|
||||
can_use_webhooks?: boolean;
|
||||
can_use_calendar_sync?: boolean;
|
||||
can_use_contracts?: boolean;
|
||||
can_process_refunds?: boolean;
|
||||
can_create_packages?: boolean;
|
||||
can_use_email_templates?: boolean;
|
||||
can_customize_booking_page?: boolean;
|
||||
advanced_reporting?: boolean;
|
||||
priority_support?: boolean;
|
||||
dedicated_support?: boolean;
|
||||
sso_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessCreate {
|
||||
|
||||
531
frontend/src/components/platform/FeaturesPermissionsEditor.tsx
Normal file
531
frontend/src/components/platform/FeaturesPermissionsEditor.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
useUpdatePlatformOAuthSettings,
|
||||
} from '../../hooks/usePlatformOAuth';
|
||||
import { Link } from 'react-router-dom';
|
||||
import FeaturesPermissionsEditor, { getPermissionKey, PERMISSION_DEFINITIONS } from '../../components/platform/FeaturesPermissionsEditor';
|
||||
|
||||
type TabType = 'general' | 'stripe' | 'tiers' | 'oauth';
|
||||
|
||||
@@ -241,6 +242,7 @@ const GeneralSettingsTab: React.FC = () => {
|
||||
};
|
||||
|
||||
const StripeSettingsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings, isLoading, error } = usePlatformSettings();
|
||||
const updateKeysMutation = useUpdateStripeKeys();
|
||||
const validateKeysMutation = useValidateStripeKeys();
|
||||
@@ -1254,25 +1256,6 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contracts Feature */}
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Contracts</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Allow tenants to create and manage contracts with customers</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.contracts_enabled || false}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, contracts_enabled: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Credit Settings */}
|
||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Default Credit Settings</h4>
|
||||
@@ -1422,238 +1405,25 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
|
||||
Features & Permissions
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Control which features are available to businesses on this plan.
|
||||
</p>
|
||||
|
||||
{/* Payments & Revenue */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_accept_payments || false}
|
||||
onChange={(e) => handlePermissionChange('can_accept_payments', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_process_refunds || false}
|
||||
onChange={(e) => handlePermissionChange('can_process_refunds', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Process Refunds</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_create_packages || false}
|
||||
onChange={(e) => handlePermissionChange('can_create_packages', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Service Packages</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communication */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.sms_reminders || false}
|
||||
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_masked_phone_numbers || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_masked_phone_numbers', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_email_templates || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_email_templates', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Email Templates</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customization */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_customize_booking_page || false}
|
||||
onChange={(e) => handlePermissionChange('can_customize_booking_page', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Booking Page</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_custom_domain || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_custom_domain', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_white_label || false}
|
||||
onChange={(e) => handlePermissionChange('can_white_label', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.advanced_reporting || false}
|
||||
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Analytics</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_api_access || false}
|
||||
onChange={(e) => handlePermissionChange('can_api_access', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_plugins || false}
|
||||
onChange={(e) => {
|
||||
handlePermissionChange('can_use_plugins', e.target.checked);
|
||||
// If disabling plugins, also disable dependent permissions
|
||||
if (!e.target.checked) {
|
||||
handlePermissionChange('can_use_tasks', false);
|
||||
handlePermissionChange('can_create_plugins', false);
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use Plugins</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer ${formData.permissions?.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_tasks || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_tasks', e.target.checked)}
|
||||
disabled={!formData.permissions?.can_use_plugins}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Scheduled Tasks</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer ${formData.permissions?.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_create_plugins || false}
|
||||
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
|
||||
disabled={!formData.permissions?.can_use_plugins}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_export_data || false}
|
||||
onChange={(e) => handlePermissionChange('can_export_data', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.can_use_webhooks || false}
|
||||
onChange={(e) => handlePermissionChange('can_use_webhooks', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.calendar_sync || false}
|
||||
onChange={(e) => handlePermissionChange('calendar_sync', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Support & Enterprise */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Support & Enterprise</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.priority_support || false}
|
||||
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Support</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.dedicated_support || false}
|
||||
onChange={(e) => handlePermissionChange('dedicated_support', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Dedicated Support</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions?.sso_enabled || false}
|
||||
onChange={(e) => handlePermissionChange('sso_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SSO / SAML</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Permissions Configuration - Using unified FeaturesPermissionsEditor */}
|
||||
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{
|
||||
...formData.permissions,
|
||||
// Map contracts_enabled to the permission key used by the component
|
||||
contracts_enabled: formData.contracts_enabled || false,
|
||||
}}
|
||||
onChange={(key, value) => {
|
||||
// Handle contracts_enabled specially since it's a top-level plan field
|
||||
if (key === 'contracts_enabled') {
|
||||
setFormData((prev) => ({ ...prev, contracts_enabled: value }));
|
||||
} else {
|
||||
handlePermissionChange(key, value);
|
||||
}
|
||||
}}
|
||||
headerTitle="Features & Permissions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Features (List of strings) */}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, Key, RefreshCw } from 'lucide-react';
|
||||
import { X, Save, RefreshCw } from 'lucide-react';
|
||||
import { useUpdateBusiness } from '../../../hooks/usePlatform';
|
||||
import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings';
|
||||
import { PlatformBusiness } from '../../../api/platform';
|
||||
import FeaturesPermissionsEditor, { PERMISSION_DEFINITIONS, getPermissionKey } from '../../../components/platform/FeaturesPermissionsEditor';
|
||||
|
||||
// Default tier settings - used when no subscription plans are loaded
|
||||
const TIER_DEFAULTS: Record<string, {
|
||||
@@ -92,6 +93,15 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
can_create_plugins: false,
|
||||
can_use_webhooks: false,
|
||||
can_use_calendar_sync: false,
|
||||
can_use_contracts: false,
|
||||
can_process_refunds: false,
|
||||
can_create_packages: false,
|
||||
can_use_email_templates: false,
|
||||
can_customize_booking_page: false,
|
||||
advanced_reporting: false,
|
||||
priority_support: false,
|
||||
dedicated_support: false,
|
||||
sso_enabled: false,
|
||||
});
|
||||
|
||||
// Get tier defaults from subscription plans or fallback to static defaults
|
||||
@@ -215,6 +225,15 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
can_create_plugins: b.can_create_plugins || false,
|
||||
can_use_webhooks: b.can_use_webhooks || false,
|
||||
can_use_calendar_sync: b.can_use_calendar_sync || false,
|
||||
can_use_contracts: b.can_use_contracts || false,
|
||||
can_process_refunds: b.can_process_refunds || false,
|
||||
can_create_packages: b.can_create_packages || false,
|
||||
can_use_email_templates: b.can_use_email_templates || false,
|
||||
can_customize_booking_page: b.can_customize_booking_page || false,
|
||||
advanced_reporting: b.advanced_reporting || false,
|
||||
priority_support: b.priority_support || false,
|
||||
dedicated_support: b.dedicated_support || false,
|
||||
sso_enabled: b.sso_enabled || false,
|
||||
});
|
||||
}
|
||||
}, [business]);
|
||||
@@ -355,207 +374,18 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features & Permissions */}
|
||||
{/* Features & Permissions - Using unified FeaturesPermissionsEditor */}
|
||||
<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 flex items-center gap-2">
|
||||
<Key size={16} className="text-purple-500" />
|
||||
Features & Permissions
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Control which features are available to this business.
|
||||
</p>
|
||||
|
||||
{/* Payments & Revenue */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_accept_payments}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_accept_payments: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communication */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_sms_reminders}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_use_sms_reminders: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_masked_phone_numbers}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_use_masked_phone_numbers: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customization */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_custom_domain}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_use_custom_domain: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_white_label}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_white_label: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plugins & Automation */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Plugins & Automation</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_plugins}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
can_use_plugins: checked,
|
||||
// If disabling plugins, also disable tasks and create plugins
|
||||
...(checked ? {} : { can_use_tasks: false, can_create_plugins: false })
|
||||
}));
|
||||
}}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use Plugins</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${editForm.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_tasks}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_use_tasks: e.target.checked })}
|
||||
disabled={!editForm.can_use_plugins}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Scheduled Tasks</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${editForm.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_create_plugins}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_create_plugins: e.target.checked })}
|
||||
disabled={!editForm.can_use_plugins}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
|
||||
</label>
|
||||
</div>
|
||||
{!editForm.can_use_plugins && (
|
||||
<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>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_api_access}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_api_access: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_webhooks}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_use_webhooks: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_calendar_sync}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_use_calendar_sync: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_export_data}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_export_data: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_add_video_conferencing}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_add_video_conferencing: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Video Conferencing</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enterprise */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Enterprise</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_manage_oauth_credentials}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_manage_oauth_credentials: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Manage OAuth</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_require_2fa}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_require_2fa: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Require 2FA</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<FeaturesPermissionsEditor
|
||||
mode="business"
|
||||
values={Object.fromEntries(
|
||||
Object.entries(editForm).filter(([_, v]) => typeof v === 'boolean')
|
||||
) as Record<string, boolean>}
|
||||
onChange={(key, value) => {
|
||||
setEditForm(prev => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
headerTitle="Features & Permissions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -146,6 +146,7 @@ dev = [
|
||||
"pytest==9.0.1",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest-sugar==1.1.1",
|
||||
"pytest-xdist>=3.5.0",
|
||||
"ruff==0.14.6",
|
||||
"sphinx==8.2.3",
|
||||
"sphinx-autobuild==2025.8.25",
|
||||
|
||||
@@ -4,38 +4,13 @@ Unit tests for Stripe webhook signal handlers.
|
||||
Tests webhook signal handling logic with mocks to avoid database calls.
|
||||
Follows CLAUDE.md guidelines: prefer mocks, avoid @pytest.mark.django_db.
|
||||
|
||||
Note: The webhooks.py module uses incorrect signal names (signals.payment_intent_succeeded
|
||||
instead of signals.WEBHOOK_SIGNALS['payment_intent.succeeded']). These tests work around
|
||||
this by mocking the signals module before import.
|
||||
Note: The webhooks.py module uses djstripe signals. These tests mock the
|
||||
handler functions' dependencies to test their logic in isolation.
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
import sys
|
||||
|
||||
|
||||
# Create a complete mock of djstripe.signals that matches what webhooks.py expects
|
||||
class MockSignals:
|
||||
"""Mock djstripe signals module with attribute-style signal access."""
|
||||
|
||||
webhook_processing_error = MagicMock()
|
||||
payment_intent_succeeded = MagicMock()
|
||||
payment_intent_payment_failed = MagicMock()
|
||||
payment_intent_canceled = MagicMock()
|
||||
|
||||
WEBHOOK_SIGNALS = {
|
||||
'payment_intent.succeeded': payment_intent_succeeded,
|
||||
'payment_intent.payment_failed': payment_intent_payment_failed,
|
||||
'payment_intent.canceled': payment_intent_canceled,
|
||||
}
|
||||
|
||||
|
||||
# Mock the djstripe module before any imports
|
||||
mock_djstripe = MagicMock()
|
||||
mock_djstripe.signals = MockSignals()
|
||||
sys.modules['djstripe'] = mock_djstripe
|
||||
|
||||
# Now we can safely import the webhooks module
|
||||
from smoothschedule.commerce.payments import webhooks
|
||||
from smoothschedule.commerce.payments.models import TransactionLink
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Stripe Webhook Signal Handlers
|
||||
Listens to dj-stripe signals to update TransactionLink and Event status.
|
||||
"""
|
||||
from django.dispatch import receiver
|
||||
from djstripe import signals
|
||||
from djstripe.signals import WEBHOOK_SIGNALS, webhook_processing_error
|
||||
from django.utils import timezone
|
||||
from .models import TransactionLink
|
||||
from smoothschedule.scheduling.schedule.models import Event
|
||||
@@ -13,11 +13,11 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(signals.webhook_processing_error)
|
||||
@receiver(webhook_processing_error)
|
||||
def handle_webhook_error(sender, exception, event_type, **kwargs):
|
||||
"""
|
||||
Log webhook processing errors for debugging.
|
||||
|
||||
|
||||
This helps identify issues with Stripe webhook delivery or processing.
|
||||
"""
|
||||
logger.error(
|
||||
@@ -31,7 +31,7 @@ def handle_webhook_error(sender, exception, event_type, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.payment_intent_succeeded)
|
||||
@receiver(WEBHOOK_SIGNALS['payment_intent.succeeded'])
|
||||
def handle_payment_succeeded(sender, event, **kwargs):
|
||||
"""
|
||||
Handle successful payment and update Event status to PAID.
|
||||
@@ -80,7 +80,7 @@ def handle_payment_succeeded(sender, event, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.payment_intent_payment_failed)
|
||||
@receiver(WEBHOOK_SIGNALS['payment_intent.payment_failed'])
|
||||
def handle_payment_failed(sender, event, **kwargs):
|
||||
"""Handle failed payments"""
|
||||
payment_intent = event.data.object
|
||||
@@ -109,7 +109,7 @@ def handle_payment_failed(sender, event, **kwargs):
|
||||
logger.error(f"Error processing payment_failed: {str(e)}", exc_info=e)
|
||||
|
||||
|
||||
@receiver(signals.payment_intent_canceled)
|
||||
@receiver(WEBHOOK_SIGNALS['payment_intent.canceled'])
|
||||
def handle_payment_canceled(sender, event, **kwargs):
|
||||
"""Handle canceled payments"""
|
||||
payment_intent = event.data.object
|
||||
|
||||
@@ -18,3 +18,56 @@ def user(db) -> User:
|
||||
For unit tests, use create_mock_user() from factories.py instead.
|
||||
"""
|
||||
return UserFactory()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared Tenant Fixtures (Session-scoped for performance)
|
||||
# =============================================================================
|
||||
# Creating a tenant in django-tenants runs all migrations (~40 seconds).
|
||||
# These fixtures are session-scoped to avoid recreating tenants for each test.
|
||||
|
||||
_shared_tenant_cache = {}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_tenant(django_db_setup, django_db_blocker):
|
||||
"""
|
||||
Session-scoped tenant fixture for tests that need a real tenant.
|
||||
|
||||
This tenant is created ONCE per test session and reused across all tests.
|
||||
Use this instead of creating tenants in individual tests to avoid the
|
||||
~40 second migration overhead per tenant.
|
||||
|
||||
Usage:
|
||||
@pytest.mark.django_db
|
||||
def test_something(shared_tenant):
|
||||
user = User(tenant=shared_tenant, ...)
|
||||
"""
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
|
||||
with django_db_blocker.unblock():
|
||||
# Check if tenant already exists from a previous run (--reuse-db)
|
||||
tenant = Tenant.objects.filter(schema_name="sharedtest").first()
|
||||
if not tenant:
|
||||
tenant = Tenant.objects.create(
|
||||
name="Shared Test Business",
|
||||
schema_name="sharedtest"
|
||||
)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def second_shared_tenant(django_db_setup, django_db_blocker):
|
||||
"""
|
||||
Second session-scoped tenant for tests that need multiple tenants.
|
||||
"""
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
|
||||
with django_db_blocker.unblock():
|
||||
tenant = Tenant.objects.filter(schema_name="sharedtest2").first()
|
||||
if not tenant:
|
||||
tenant = Tenant.objects.create(
|
||||
name="Shared Test Business 2",
|
||||
schema_name="sharedtest2"
|
||||
)
|
||||
return tenant
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-10 06:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_add_can_use_tasks'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='can_use_contracts',
|
||||
field=models.BooleanField(default=False, help_text='Whether this business can create and manage e-signature contracts'),
|
||||
),
|
||||
]
|
||||
@@ -227,6 +227,10 @@ class Tenant(TenantMixin):
|
||||
default=False,
|
||||
help_text="Whether this business can sync Google Calendar and other calendar providers"
|
||||
)
|
||||
can_use_contracts = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this business can create and manage e-signature contracts"
|
||||
)
|
||||
|
||||
# Stripe Payment Configuration
|
||||
payment_mode = models.CharField(
|
||||
|
||||
@@ -15,18 +15,23 @@ class TestTimezoneSerializerMixin:
|
||||
"""Test TimezoneSerializerMixin class."""
|
||||
|
||||
def test_adds_business_timezone_field_to_serializer(self):
|
||||
"""Should add business_timezone as a SerializerMethodField."""
|
||||
"""Should add business_timezone as a SerializerMethodField when in Meta.fields."""
|
||||
from smoothschedule.identity.core.mixins import TimezoneSerializerMixin
|
||||
|
||||
class TestSerializer(TimezoneSerializerMixin, serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
fields = ['name', 'business_timezone']
|
||||
|
||||
# Need to instantiate with context to bind the serializer
|
||||
serializer = TestSerializer(context={})
|
||||
|
||||
# Check that the mixin defines the business_timezone attribute
|
||||
assert hasattr(TimezoneSerializerMixin, 'business_timezone')
|
||||
assert isinstance(TimezoneSerializerMixin.business_timezone, serializers.SerializerMethodField)
|
||||
# Check that the mixin provides the get_business_timezone method
|
||||
assert hasattr(TimezoneSerializerMixin, 'get_business_timezone')
|
||||
# Check that business_timezone field is dynamically added when in Meta.fields
|
||||
assert 'business_timezone' in serializer.fields
|
||||
assert isinstance(serializer.fields['business_timezone'], serializers.SerializerMethodField)
|
||||
|
||||
def test_get_business_timezone_from_context_tenant(self):
|
||||
"""Should get timezone from tenant in context."""
|
||||
@@ -258,6 +263,9 @@ class TestTimezoneSerializerMixin:
|
||||
class TestSerializer(TimezoneSerializerMixin, serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
fields = ['name', 'business_timezone']
|
||||
|
||||
# Attempt to create with business_timezone
|
||||
data = {
|
||||
'name': 'Test Event',
|
||||
@@ -270,7 +278,9 @@ class TestTimezoneSerializerMixin:
|
||||
assert serializer.is_valid()
|
||||
|
||||
# The business_timezone field is a SerializerMethodField which is always read-only
|
||||
assert isinstance(TimezoneSerializerMixin.business_timezone, serializers.SerializerMethodField)
|
||||
assert isinstance(serializer.fields['business_timezone'], serializers.SerializerMethodField)
|
||||
# Validated data should not include business_timezone (it's read-only)
|
||||
assert 'business_timezone' not in serializer.validated_data
|
||||
|
||||
|
||||
class TestTimezoneContextMixin:
|
||||
|
||||
@@ -341,45 +341,32 @@ class TestGetAccessibleTenants:
|
||||
"""
|
||||
Test get_accessible_tenants() method.
|
||||
|
||||
Note: These tests use database access because the method accesses
|
||||
ForeignKey relationships which trigger database queries even with mocking.
|
||||
Uses shared tenant fixtures (session-scoped) to avoid ~40s migration overhead
|
||||
per tenant creation. See conftest.py for fixture definitions.
|
||||
"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_returns_all_tenants_for_platform_user(self):
|
||||
# Arrange
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
import uuid
|
||||
|
||||
# Create a couple of tenants
|
||||
unique_id1 = str(uuid.uuid4())[:8]
|
||||
Tenant.objects.create(name=f"Tenant1 {unique_id1}", schema_name=f"tenant1{unique_id1}")
|
||||
|
||||
unique_id2 = str(uuid.uuid4())[:8]
|
||||
Tenant.objects.create(name=f"Tenant2 {unique_id2}", schema_name=f"tenant2{unique_id2}")
|
||||
|
||||
def test_returns_all_tenants_for_platform_user(self, shared_tenant, second_shared_tenant):
|
||||
# Arrange - use shared fixtures instead of creating new tenants
|
||||
user = create_user_instance(User.Role.PLATFORM_MANAGER)
|
||||
|
||||
# Act
|
||||
result = user.get_accessible_tenants()
|
||||
|
||||
# Assert
|
||||
assert result.count() >= 2 # At least the two we created
|
||||
# Assert - at least our two shared tenants exist
|
||||
assert result.count() >= 2
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_returns_single_tenant_for_tenant_user(self):
|
||||
# Arrange
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
def test_returns_single_tenant_for_tenant_user(self, shared_tenant):
|
||||
# Arrange - use shared fixture
|
||||
import uuid
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(name=f"My Business {unique_id}", schema_name=f"mybiz{unique_id}")
|
||||
|
||||
user = User(
|
||||
username=f"owner{unique_id}",
|
||||
email=f"owner{unique_id}@test.com",
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=tenant
|
||||
tenant=shared_tenant
|
||||
)
|
||||
user.save()
|
||||
|
||||
@@ -388,7 +375,7 @@ class TestGetAccessibleTenants:
|
||||
|
||||
# Assert
|
||||
assert result.count() == 1
|
||||
assert result.first() == tenant
|
||||
assert result.first() == shared_tenant
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_returns_empty_queryset_for_tenant_user_without_tenant(self):
|
||||
@@ -482,23 +469,16 @@ class TestSaveMethodValidation:
|
||||
assert user.is_superuser is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clears_tenant_for_platform_users(self):
|
||||
# Arrange
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
def test_clears_tenant_for_platform_users(self, shared_tenant):
|
||||
# Arrange - use shared fixture to avoid ~40s migration overhead
|
||||
import uuid
|
||||
|
||||
# Create a tenant first with unique schema_name
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(
|
||||
name=f"Test Business {unique_id}",
|
||||
schema_name=f"testbiz{unique_id}"
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=f"platformuser{unique_id}",
|
||||
email=f"platform{unique_id}@example.com",
|
||||
role=User.Role.PLATFORM_MANAGER,
|
||||
tenant=tenant # Should be cleared
|
||||
tenant=shared_tenant # Should be cleared
|
||||
)
|
||||
|
||||
# Act
|
||||
@@ -527,55 +507,43 @@ class TestSaveMethodValidation:
|
||||
assert "must be assigned to a tenant" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_allows_tenant_user_with_tenant(self):
|
||||
# Arrange
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
def test_allows_tenant_user_with_tenant(self, shared_tenant):
|
||||
# Arrange - use shared fixture to avoid ~40s migration overhead
|
||||
import uuid
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(
|
||||
name=f"Test Business {unique_id}",
|
||||
schema_name=f"testbiz{unique_id}"
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=f"owner{unique_id}",
|
||||
email=f"owner{unique_id}@testbiz.com",
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=tenant
|
||||
tenant=shared_tenant
|
||||
)
|
||||
|
||||
# Act
|
||||
user.save()
|
||||
|
||||
# Assert
|
||||
assert user.tenant == tenant
|
||||
assert user.tenant == shared_tenant
|
||||
assert user.id is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_allows_customer_with_tenant(self):
|
||||
# Arrange
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
def test_allows_customer_with_tenant(self, shared_tenant):
|
||||
# Arrange - use shared fixture to avoid ~40s migration overhead
|
||||
import uuid
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(
|
||||
name=f"Test Business {unique_id}",
|
||||
schema_name=f"testbiz{unique_id}"
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=f"customer{unique_id}",
|
||||
email=f"customer{unique_id}@example.com",
|
||||
role=User.Role.CUSTOMER,
|
||||
tenant=tenant
|
||||
tenant=shared_tenant
|
||||
)
|
||||
|
||||
# Act
|
||||
user.save()
|
||||
|
||||
# Assert
|
||||
assert user.tenant == tenant
|
||||
assert user.tenant == shared_tenant
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -494,29 +494,18 @@ class TestGetAccessibleTenants:
|
||||
assert result == mock_queryset
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_returns_single_tenant_for_tenant_user(self):
|
||||
# This test requires DB to create a real Tenant instance
|
||||
# because Django's ForeignKey and ORM make mocking too complex
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
import uuid
|
||||
|
||||
# Create a real tenant
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(
|
||||
name=f'Test Business {unique_id}',
|
||||
schema_name=f'testbiz{unique_id}'
|
||||
)
|
||||
|
||||
def test_returns_single_tenant_for_tenant_user(self, shared_tenant):
|
||||
# Use shared fixture to avoid ~40s migration overhead per tenant
|
||||
# Create user with that tenant
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
user.tenant = tenant
|
||||
user.tenant = shared_tenant
|
||||
|
||||
# Act
|
||||
result = user.get_accessible_tenants()
|
||||
|
||||
# Assert
|
||||
assert result.count() == 1
|
||||
assert list(result)[0] == tenant
|
||||
assert list(result)[0] == shared_tenant
|
||||
|
||||
def test_returns_empty_queryset_for_tenant_user_without_tenant(self):
|
||||
# Arrange
|
||||
@@ -633,22 +622,16 @@ class TestSaveMethodValidation:
|
||||
assert user.is_superuser is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clears_tenant_for_platform_users(self):
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
def test_clears_tenant_for_platform_users(self, shared_tenant):
|
||||
# Use shared fixture to avoid ~40s migration overhead per tenant
|
||||
import uuid
|
||||
|
||||
# Create unique schema name to avoid collisions
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(
|
||||
name=f'Test Business {unique_id}',
|
||||
schema_name=f'testbiz{unique_id}'
|
||||
)
|
||||
|
||||
user = User(
|
||||
username='platformuser',
|
||||
email='platform@example.com',
|
||||
username=f'platformuser{unique_id}',
|
||||
email=f'platform{unique_id}@example.com',
|
||||
role=User.Role.PLATFORM_MANAGER,
|
||||
tenant=tenant # Should be cleared
|
||||
tenant=shared_tenant # Should be cleared
|
||||
)
|
||||
user.save()
|
||||
|
||||
@@ -669,49 +652,37 @@ class TestSaveMethodValidation:
|
||||
assert 'must be assigned to a tenant' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_allows_tenant_user_with_tenant(self):
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
def test_allows_tenant_user_with_tenant(self, shared_tenant):
|
||||
# Use shared fixture to avoid ~40s migration overhead per tenant
|
||||
import uuid
|
||||
|
||||
# Create unique schema name to avoid collisions
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(
|
||||
name=f'Test Business {unique_id}',
|
||||
schema_name=f'testbiz{unique_id}'
|
||||
)
|
||||
|
||||
user = User(
|
||||
username='owner',
|
||||
email='owner@testbiz.com',
|
||||
username=f'owner{unique_id}',
|
||||
email=f'owner{unique_id}@testbiz.com',
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=tenant
|
||||
tenant=shared_tenant
|
||||
)
|
||||
user.save()
|
||||
|
||||
assert user.tenant == tenant
|
||||
assert user.tenant == shared_tenant
|
||||
assert user.id is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_allows_customer_with_tenant(self):
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
def test_allows_customer_with_tenant(self, shared_tenant):
|
||||
# Use shared fixture to avoid ~40s migration overhead per tenant
|
||||
import uuid
|
||||
|
||||
# Create unique schema name to avoid collisions
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
tenant = Tenant.objects.create(
|
||||
name=f'Test Business {unique_id}',
|
||||
schema_name=f'testbiz{unique_id}'
|
||||
)
|
||||
|
||||
user = User(
|
||||
username='customer',
|
||||
email='customer@example.com',
|
||||
username=f'customer{unique_id}',
|
||||
email=f'customer{unique_id}@example.com',
|
||||
role=User.Role.CUSTOMER,
|
||||
tenant=tenant
|
||||
tenant=shared_tenant
|
||||
)
|
||||
user.save()
|
||||
|
||||
assert user.tenant == tenant
|
||||
assert user.tenant == shared_tenant
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -227,6 +227,7 @@ class TenantSerializer(serializers.ModelSerializer):
|
||||
'can_create_plugins',
|
||||
'can_use_webhooks',
|
||||
'can_use_calendar_sync',
|
||||
'can_use_contracts',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -298,6 +299,7 @@ class TenantUpdateSerializer(serializers.ModelSerializer):
|
||||
'can_create_plugins',
|
||||
'can_use_webhooks',
|
||||
'can_use_calendar_sync',
|
||||
'can_use_contracts',
|
||||
]
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
@@ -274,6 +274,9 @@ def sync_subscription_plan_to_tenants(self, plan_id: int):
|
||||
'can_use_webhooks': 'can_use_webhooks',
|
||||
'calendar_sync': 'can_use_calendar_sync',
|
||||
'can_use_calendar_sync': 'can_use_calendar_sync',
|
||||
'contracts': 'can_use_contracts',
|
||||
'contracts_enabled': 'can_use_contracts',
|
||||
'can_use_contracts': 'can_use_contracts',
|
||||
}
|
||||
|
||||
# Limit field mappings from plan.limits JSON to Tenant fields
|
||||
|
||||
@@ -1361,15 +1361,19 @@ class TestTenantViewSet:
|
||||
request.query_params = {'is_active': 'true'}
|
||||
|
||||
mock_queryset = Mock()
|
||||
excluded_queryset = Mock()
|
||||
filtered_queryset = Mock()
|
||||
mock_queryset.filter.return_value = filtered_queryset
|
||||
# Chain: queryset.exclude().filter()
|
||||
mock_queryset.exclude.return_value = excluded_queryset
|
||||
excluded_queryset.filter.return_value = filtered_queryset
|
||||
|
||||
with patch.object(self.viewset, 'queryset', mock_queryset):
|
||||
view = self.viewset()
|
||||
view.request = request
|
||||
result = view.get_queryset()
|
||||
|
||||
mock_queryset.filter.assert_called_once_with(is_active=True)
|
||||
mock_queryset.exclude.assert_called_once_with(schema_name='public')
|
||||
excluded_queryset.filter.assert_called_once_with(is_active=True)
|
||||
|
||||
def test_destroy_requires_superuser(self):
|
||||
"""Test destroy requires superuser role"""
|
||||
@@ -1424,17 +1428,23 @@ class TestTenantViewSet:
|
||||
role=User.Role.SUPERUSER
|
||||
)
|
||||
|
||||
with patch('smoothschedule.identity.core.models.Tenant.objects.count', return_value=10):
|
||||
with patch('smoothschedule.identity.core.models.Tenant.objects.filter') as mock_filter:
|
||||
mock_filter.return_value.count.return_value = 8
|
||||
with patch('smoothschedule.identity.users.models.User.objects.count', return_value=100):
|
||||
view = self.viewset.as_view({'get': 'metrics'})
|
||||
response = view(request)
|
||||
# Mock the Tenant.objects.exclude().count() and .filter().count() chains
|
||||
with patch('smoothschedule.identity.core.models.Tenant.objects.exclude') as mock_exclude:
|
||||
mock_excluded = Mock()
|
||||
mock_exclude.return_value = mock_excluded
|
||||
mock_excluded.count.return_value = 10 # total_tenants
|
||||
mock_excluded.filter.return_value.count.return_value = 8 # active_tenants
|
||||
with patch('smoothschedule.identity.users.models.User.objects.count', return_value=100):
|
||||
view = self.viewset.as_view({'get': 'metrics'})
|
||||
response = view(request)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert 'total_tenants' in response.data
|
||||
assert 'active_tenants' in response.data
|
||||
assert 'total_users' in response.data
|
||||
assert response.data['total_tenants'] == 10
|
||||
assert response.data['active_tenants'] == 8
|
||||
assert response.data['total_users'] == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -185,7 +185,7 @@ def current_business_view(request):
|
||||
'masked_calling': tenant.can_use_masked_phone_numbers or plan_permissions.get('masked_calling', False),
|
||||
'pos_system': tenant.can_use_pos or plan_permissions.get('pos_system', False),
|
||||
'mobile_app': tenant.can_use_mobile_app or plan_permissions.get('mobile_app', False),
|
||||
'contracts': getattr(tenant.subscription_plan, 'contracts_enabled', False) if tenant.subscription_plan else False,
|
||||
'contracts': tenant.can_use_contracts or (getattr(tenant.subscription_plan, 'contracts_enabled', False) if tenant.subscription_plan else False),
|
||||
}
|
||||
|
||||
business_data = {
|
||||
|
||||
Reference in New Issue
Block a user