diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d151eed..7cdd816f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -456,7 +456,7 @@ const AppContent: React.FC = () => { const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api' && currentHostname !== baseDomain; const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role); - const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role); + const isBusinessUser = ['owner', 'staff', 'resource'].includes(user.role); const isCustomer = user.role === 'customer'; // RULE: Platform users on business subdomains should be redirected to platform subdomain @@ -510,6 +510,15 @@ const AppContent: React.FC = () => { // Helper to check access based on roles const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role); + // Helper to check permission-based access (owner always has access, staff uses effective_permissions) + const canAccess = (permissionKey: string): boolean => { + if (user.role === 'owner') return true; + if (user.role === 'staff') { + return user.effective_permissions?.[permissionKey] === true; + } + return false; + }; + if (isPlatformUser) { return ( }> @@ -658,8 +667,8 @@ const AppContent: React.FC = () => { ); } - // Business users (owner, manager, staff, resource) - if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) { + // Business users (owner, staff, resource) + if (['owner', 'staff', 'resource'].includes(user.role)) { // Check if email verification is required if (!user.email_verified) { return ( @@ -799,7 +808,7 @@ const AppContent: React.FC = () => { ) : ( @@ -809,7 +818,7 @@ const AppContent: React.FC = () => { ) : ( @@ -819,7 +828,7 @@ const AppContent: React.FC = () => { ) : ( @@ -829,7 +838,7 @@ const AppContent: React.FC = () => { ) : ( @@ -841,7 +850,7 @@ const AppContent: React.FC = () => { ) : ( @@ -851,7 +860,7 @@ const AppContent: React.FC = () => { ) : ( @@ -861,7 +870,7 @@ const AppContent: React.FC = () => { ) : ( @@ -871,7 +880,7 @@ const AppContent: React.FC = () => { ) : ( @@ -881,7 +890,7 @@ const AppContent: React.FC = () => { ) : ( @@ -891,7 +900,7 @@ const AppContent: React.FC = () => { ) : ( @@ -911,7 +920,7 @@ const AppContent: React.FC = () => { ) : ( @@ -921,7 +930,7 @@ const AppContent: React.FC = () => { ) : ( @@ -931,13 +940,13 @@ const AppContent: React.FC = () => { : + canAccess('can_access_payments') ? : } /> ) : ( @@ -947,7 +956,7 @@ const AppContent: React.FC = () => { ) : ( @@ -967,7 +976,7 @@ const AppContent: React.FC = () => { ) : ( @@ -975,7 +984,8 @@ const AppContent: React.FC = () => { } /> {/* Settings Routes with Nested Layout */} - {hasAccess(['owner']) ? ( + {/* Owners have full access, staff need can_access_settings permission */} + {canAccess('can_access_settings') ? ( }> } /> } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 734960dc..915b968f 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -71,6 +71,9 @@ export interface User { business_name?: string; business_subdomain?: string; permissions?: Record; + effective_permissions?: Record; + staff_role_id?: number | null; + staff_role_name?: string | null; can_invite_staff?: boolean; can_access_tickets?: boolean; can_edit_schedule?: boolean; diff --git a/frontend/src/components/DevQuickLogin.tsx b/frontend/src/components/DevQuickLogin.tsx index 6c999333..b1fad011 100644 --- a/frontend/src/components/DevQuickLogin.tsx +++ b/frontend/src/components/DevQuickLogin.tsx @@ -55,18 +55,18 @@ const testUsers: TestUser[] = [ category: 'business', }, { - email: 'manager@demo.com', + email: 'staff@demo.com', password: 'test123', - role: 'TENANT_MANAGER', - label: 'Business Manager', + role: 'TENANT_STAFF', + label: 'Staff (Full Access)', color: 'bg-pink-600 hover:bg-pink-700', category: 'business', }, { - email: 'staff@demo.com', + email: 'limited-staff@demo.com', password: 'test123', role: 'TENANT_STAFF', - label: 'Staff Member', + label: 'Staff (Limited)', color: 'bg-teal-600 hover:bg-teal-700', category: 'business', }, diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9fa692f3..96f8fbd9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -46,10 +46,10 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo const { canUse } = usePlanFeatures(); // Helper to check if user has a specific staff permission - // Owners and managers always have all permissions + // Owners always have all permissions // Staff members check their effective_permissions (role + user overrides) const hasPermission = (permissionKey: string): boolean => { - if (role === 'owner' || role === 'manager') { + if (role === 'owner') { return true; } if (role === 'staff') { @@ -59,10 +59,11 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo return false; }; - const canViewAdminPages = role === 'owner' || role === 'manager'; - const canViewManagementPages = role === 'owner' || role === 'manager'; + // Admin/management access is based on effective permissions for staff + const canViewAdminPages = role === 'owner' || hasPermission('can_access_staff'); + const canViewManagementPages = role === 'owner' || hasPermission('can_access_scheduler'); const isStaff = role === 'staff'; - const canViewSettings = role === 'owner'; + const canViewSettings = role === 'owner' || hasPermission('can_access_settings'); const canViewTickets = hasPermission('can_access_tickets'); const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true; @@ -191,7 +192,6 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo icon={Users} label={t('nav.customers')} isCollapsed={isCollapsed} - badgeElement={} /> )} {hasPermission('can_access_services') && ( @@ -216,7 +216,6 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo icon={Users} label={t('nav.staff')} isCollapsed={isCollapsed} - badgeElement={} /> )} {hasPermission('can_access_contracts') && canUse('contracts') && ( diff --git a/frontend/src/components/StaffPermissions.tsx b/frontend/src/components/StaffPermissions.tsx index 300bd66e..cb495a86 100644 --- a/frontend/src/components/StaffPermissions.tsx +++ b/frontend/src/components/StaffPermissions.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { ChevronDown, ChevronRight } from 'lucide-react'; export interface PermissionConfig { key: string; @@ -8,20 +9,134 @@ export interface PermissionConfig { hintKey: string; hintDefault: string; defaultValue: boolean; - roles: ('manager' | 'staff')[]; } +// Business Settings sub-permissions +export const SETTINGS_PERMISSION_CONFIGS: PermissionConfig[] = [ + { + key: 'can_access_settings_general', + labelKey: 'staff.canAccessSettingsGeneral', + labelDefault: 'General Settings', + hintKey: 'staff.canAccessSettingsGeneralHint', + hintDefault: 'Business name, timezone, and basic configuration', + defaultValue: false, + }, + { + key: 'can_access_settings_business_hours', + labelKey: 'staff.canAccessSettingsBusinessHours', + labelDefault: 'Business Hours', + hintKey: 'staff.canAccessSettingsBusinessHoursHint', + hintDefault: 'Set regular operating hours', + defaultValue: false, + }, + { + key: 'can_access_settings_branding', + labelKey: 'staff.canAccessSettingsBranding', + labelDefault: 'Branding', + hintKey: 'staff.canAccessSettingsBrandingHint', + hintDefault: 'Logo, colors, and visual identity', + defaultValue: false, + }, + { + key: 'can_access_settings_booking', + labelKey: 'staff.canAccessSettingsBooking', + labelDefault: 'Booking Settings', + hintKey: 'staff.canAccessSettingsBookingHint', + hintDefault: 'Booking policies and rules', + defaultValue: false, + }, + { + key: 'can_access_settings_communication', + labelKey: 'staff.canAccessSettingsCommunication', + labelDefault: 'Communication', + hintKey: 'staff.canAccessSettingsCommunicationHint', + hintDefault: 'Notification preferences and reminders', + defaultValue: false, + }, + { + key: 'can_access_settings_embed_widget', + labelKey: 'staff.canAccessSettingsEmbedWidget', + labelDefault: 'Embed Widget', + hintKey: 'staff.canAccessSettingsEmbedWidgetHint', + hintDefault: 'Configure booking widget for websites', + defaultValue: false, + }, + { + key: 'can_access_settings_email_templates', + labelKey: 'staff.canAccessSettingsEmailTemplates', + labelDefault: 'Email Templates', + hintKey: 'staff.canAccessSettingsEmailTemplatesHint', + hintDefault: 'Customize automated emails', + defaultValue: false, + }, + { + key: 'can_access_settings_staff_roles', + labelKey: 'staff.canAccessSettingsStaffRoles', + labelDefault: 'Staff Roles', + hintKey: 'staff.canAccessSettingsStaffRolesHint', + hintDefault: 'Create and manage permission roles', + defaultValue: false, + }, + { + key: 'can_access_settings_resource_types', + labelKey: 'staff.canAccessSettingsResourceTypes', + labelDefault: 'Resource Types', + hintKey: 'staff.canAccessSettingsResourceTypesHint', + hintDefault: 'Configure resource categories', + defaultValue: false, + }, + { + key: 'can_access_settings_api', + labelKey: 'staff.canAccessSettingsApi', + labelDefault: 'API & Integrations', + hintKey: 'staff.canAccessSettingsApiHint', + hintDefault: 'Manage API tokens and webhooks', + defaultValue: false, + }, + { + key: 'can_access_settings_custom_domains', + labelKey: 'staff.canAccessSettingsCustomDomains', + labelDefault: 'Custom Domains', + hintKey: 'staff.canAccessSettingsCustomDomainsHint', + hintDefault: 'Configure custom domain settings', + defaultValue: false, + }, + { + key: 'can_access_settings_authentication', + labelKey: 'staff.canAccessSettingsAuthentication', + labelDefault: 'Authentication', + hintKey: 'staff.canAccessSettingsAuthenticationHint', + hintDefault: 'OAuth and social login configuration', + defaultValue: false, + }, + { + key: 'can_access_settings_email', + labelKey: 'staff.canAccessSettingsEmail', + labelDefault: 'Email Setup', + hintKey: 'staff.canAccessSettingsEmailHint', + hintDefault: 'Configure email addresses for tickets', + defaultValue: false, + }, + { + key: 'can_access_settings_sms_calling', + labelKey: 'staff.canAccessSettingsSmsCalling', + labelDefault: 'SMS & Calling', + hintKey: 'staff.canAccessSettingsSmsCallingHint', + hintDefault: 'Manage credits and phone numbers', + defaultValue: false, + }, +]; + // Define all available permissions in one place +// All permissions are now available to staff (via staff roles) export const PERMISSION_CONFIGS: PermissionConfig[] = [ - // Manager-only permissions { key: 'can_invite_staff', labelKey: 'staff.canInviteStaff', labelDefault: 'Can invite new staff members', hintKey: 'staff.canInviteStaffHint', - hintDefault: 'Allow this manager to send invitations to new staff members', + hintDefault: 'Allow this staff member to send invitations to new staff members', defaultValue: false, - roles: ['manager'], }, { key: 'can_manage_resources', @@ -29,8 +144,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [ labelDefault: 'Can manage resources', hintKey: 'staff.canManageResourcesHint', hintDefault: 'Create, edit, and delete bookable resources', - defaultValue: true, - roles: ['manager'], + defaultValue: false, }, { key: 'can_manage_services', @@ -38,8 +152,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [ labelDefault: 'Can manage services', hintKey: 'staff.canManageServicesHint', hintDefault: 'Create, edit, and delete service offerings', - defaultValue: true, - roles: ['manager'], + defaultValue: false, }, { key: 'can_view_reports', @@ -47,17 +160,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [ labelDefault: 'Can view reports', hintKey: 'staff.canViewReportsHint', hintDefault: 'Access business analytics and financial reports', - defaultValue: true, - roles: ['manager'], - }, - { - key: 'can_access_settings', - labelKey: 'staff.canAccessSettings', - labelDefault: 'Can access business settings', - hintKey: 'staff.canAccessSettingsHint', - hintDefault: 'Modify business profile, branding, and configuration', defaultValue: false, - roles: ['manager'], }, { key: 'can_refund_payments', @@ -66,7 +169,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [ hintKey: 'staff.canRefundPaymentsHint', hintDefault: 'Process refunds for customer payments', defaultValue: false, - roles: ['manager'], }, { key: 'can_send_messages', @@ -74,10 +176,8 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [ labelDefault: 'Can send broadcast messages', hintKey: 'staff.canSendMessagesHint', hintDefault: 'Send messages to groups of staff and customers', - defaultValue: true, - roles: ['manager'], + defaultValue: false, }, - // Staff-only permissions { key: 'can_view_all_schedules', labelKey: 'staff.canViewAllSchedules', @@ -85,7 +185,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [ hintKey: 'staff.canViewAllSchedulesHint', hintDefault: 'View schedules of other staff members (otherwise only their own)', defaultValue: false, - roles: ['staff'], }, { key: 'can_manage_own_appointments', @@ -94,112 +193,132 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [ hintKey: 'staff.canManageOwnAppointmentsHint', hintDefault: 'Create, reschedule, and cancel their own appointments', defaultValue: true, - roles: ['staff'], }, { key: 'can_self_approve_time_off', labelKey: 'staff.canSelfApproveTimeOff', labelDefault: 'Can self-approve time off', hintKey: 'staff.canSelfApproveTimeOffHint', - hintDefault: 'Add time off without requiring manager/owner approval', + hintDefault: 'Add time off without requiring owner approval', defaultValue: false, - roles: ['staff'], }, - // Shared permissions (both manager and staff) { key: 'can_access_tickets', labelKey: 'staff.canAccessTickets', labelDefault: 'Can access support tickets', hintKey: 'staff.canAccessTicketsHint', hintDefault: 'View and manage customer support tickets', - defaultValue: true, // Default for managers; staff will override to false - roles: ['manager', 'staff'], + defaultValue: false, }, ]; -// Get default permissions for a role -export const getDefaultPermissions = (role: 'manager' | 'staff'): Record => { +// Get default permissions for staff +export const getDefaultPermissions = (): Record => { const defaults: Record = {}; PERMISSION_CONFIGS.forEach((config) => { - if (config.roles.includes(role)) { - // Staff members have ticket access disabled by default - if (role === 'staff' && config.key === 'can_access_tickets') { - defaults[config.key] = false; - } else { - defaults[config.key] = config.defaultValue; - } - } + defaults[config.key] = config.defaultValue; }); + SETTINGS_PERMISSION_CONFIGS.forEach((config) => { + defaults[config.key] = config.defaultValue; + }); + defaults['can_access_settings'] = false; return defaults; }; interface StaffPermissionsProps { - role: 'manager' | 'staff'; + role: 'staff'; permissions: Record; onChange: (permissions: Record) => void; variant?: 'invite' | 'edit'; } const StaffPermissions: React.FC = ({ - role, permissions, onChange, - variant = 'edit', }) => { const { t } = useTranslation(); - - // Filter permissions for this role - const rolePermissions = PERMISSION_CONFIGS.filter((config) => - config.roles.includes(role) - ); - - const handleToggle = (key: string, checked: boolean) => { - onChange({ ...permissions, [key]: checked }); - }; + const [settingsExpanded, setSettingsExpanded] = useState(false); // Get the current value, falling back to default - const getValue = (config: PermissionConfig): boolean => { - if (permissions[config.key] !== undefined) { - return permissions[config.key]; + const getValue = (key: string, defaultValue: boolean = false): boolean => { + if (permissions[key] !== undefined) { + return permissions[key]; } - // Staff have ticket access disabled by default - if (role === 'staff' && config.key === 'can_access_tickets') { - return false; - } - return config.defaultValue; + return defaultValue; }; - // Different styling for manager vs staff permissions - const isManagerPermission = (config: PermissionConfig) => - config.roles.includes('manager') && !config.roles.includes('staff'); + const hasSettingsAccess = getValue('can_access_settings', false); - const getPermissionStyle = (config: PermissionConfig) => { - if (isManagerPermission(config) || role === 'manager') { - return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30'; + // Auto-expand settings section if any settings permissions are enabled + useEffect(() => { + if (hasSettingsAccess) { + const hasAnySettingEnabled = SETTINGS_PERMISSION_CONFIGS.some( + (config) => getValue(config.key, false) + ); + if (hasAnySettingEnabled) { + setSettingsExpanded(true); + } } - return 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'; + }, []); + + const handleToggle = (key: string, checked: boolean) => { + const newPermissions = { ...permissions, [key]: checked }; + + // If turning off main settings access, turn off all sub-settings + if (key === 'can_access_settings' && !checked) { + SETTINGS_PERMISSION_CONFIGS.forEach((config) => { + newPermissions[config.key] = false; + }); + } + + onChange(newPermissions); }; - if (rolePermissions.length === 0) { - return null; - } + const handleSettingsMainToggle = (checked: boolean) => { + const newPermissions = { ...permissions, can_access_settings: checked }; + + // If turning off, disable all sub-settings + if (!checked) { + SETTINGS_PERMISSION_CONFIGS.forEach((config) => { + newPermissions[config.key] = false; + }); + setSettingsExpanded(false); + } else { + // If turning on, expand the section + setSettingsExpanded(true); + } + + onChange(newPermissions); + }; + + const handleSelectAllSettings = (selectAll: boolean) => { + const newPermissions = { ...permissions }; + SETTINGS_PERMISSION_CONFIGS.forEach((config) => { + newPermissions[config.key] = selectAll; + }); + onChange(newPermissions); + }; + + // Count how many settings sub-permissions are enabled + const enabledSettingsCount = SETTINGS_PERMISSION_CONFIGS.filter((config) => + getValue(config.key, false) + ).length; return (

- {role === 'manager' - ? t('staff.managerPermissions', 'Manager Permissions') - : t('staff.staffPermissions', 'Staff Permissions')} + {t('staff.staffPermissions', 'Staff Permissions')}

- {rolePermissions.map((config) => ( + {/* Regular permissions */} + {PERMISSION_CONFIGS.map((config) => (
))} + + {/* Business Settings Section */} +
+ {/* Main Business Settings Toggle */} +
+ handleSettingsMainToggle(e.target.checked)} + className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500" + /> +
hasSettingsAccess && setSettingsExpanded(!settingsExpanded)} + > +
+
+ + {t('staff.canAccessSettings', 'Can access business settings')} + + {hasSettingsAccess && enabledSettingsCount > 0 && ( + + ({enabledSettingsCount}/{SETTINGS_PERMISSION_CONFIGS.length} enabled) + + )} +
+ {hasSettingsAccess && ( + + )} +
+

+ {t( + 'staff.canAccessSettingsHint', + 'Access to business settings pages (select specific pages below)' + )} +

+
+
+ + {/* Sub-permissions (collapsible) */} + {hasSettingsAccess && settingsExpanded && ( +
+ {/* Select All / None buttons */} +
+ + | + +
+ + {/* Individual settings permissions */} +
+ {SETTINGS_PERMISSION_CONFIGS.map((config) => ( + + ))} +
+
+ )} +
); }; diff --git a/frontend/src/components/__tests__/MasqueradeBanner.test.tsx b/frontend/src/components/__tests__/MasqueradeBanner.test.tsx index a8254e58..ce669017 100644 --- a/frontend/src/components/__tests__/MasqueradeBanner.test.tsx +++ b/frontend/src/components/__tests__/MasqueradeBanner.test.tsx @@ -50,7 +50,7 @@ describe('MasqueradeBanner', () => { it('shows return to previous user text when previousUser exists', () => { const propsWithPrevious = { ...defaultProps, - previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const }, + previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'owner' as const }, }; render(); expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument(); diff --git a/frontend/src/components/__tests__/TopBar.test.tsx b/frontend/src/components/__tests__/TopBar.test.tsx index ee5f3998..33e5e4ec 100644 --- a/frontend/src/components/__tests__/TopBar.test.tsx +++ b/frontend/src/components/__tests__/TopBar.test.tsx @@ -518,8 +518,8 @@ describe('TopBar', () => { expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument(); }); - it('should render for manager role', () => { - const user = createMockUser({ role: 'manager' }); + it('should render for staff with permissions', () => { + const user = createMockUser({ role: 'staff' }); renderWithRouter( { { id: 1, email: 'john@example.com', - role: 'TENANT_MANAGER', - role_display: 'Manager', + role: 'TENANT_STAFF', + role_display: 'Staff', status: 'PENDING', invited_by: 5, invited_by_name: 'Admin User', @@ -205,10 +205,10 @@ describe('useInvitations hooks', () => { expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData); }); - it('creates manager invitation with permissions', async () => { + it('creates staff invitation with permissions', async () => { const invitationData: CreateInvitationData = { - email: 'manager@example.com', - role: 'TENANT_MANAGER', + email: 'staff@example.com', + role: 'TENANT_STAFF', permissions: { can_invite_staff: true, can_manage_resources: true, @@ -219,7 +219,7 @@ describe('useInvitations hooks', () => { }, }; - const mockResponse = { id: 5, email: 'manager@example.com', role: 'TENANT_MANAGER' }; + const mockResponse = { id: 5, email: 'staff@example.com', role: 'TENANT_STAFF' }; vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse }); const { result } = renderHook(() => useCreateInvitation(), { diff --git a/frontend/src/hooks/__tests__/useStaff.test.ts b/frontend/src/hooks/__tests__/useStaff.test.ts index 47bf8ad5..d04c2744 100644 --- a/frontend/src/hooks/__tests__/useStaff.test.ts +++ b/frontend/src/hooks/__tests__/useStaff.test.ts @@ -46,7 +46,7 @@ describe('useStaff hooks', () => { name: 'John Doe', email: 'john@example.com', phone: '555-1234', - role: 'TENANT_MANAGER', + role: 'TENANT_STAFF', is_active: true, permissions: { can_invite_staff: true }, can_invite_staff: true, @@ -79,7 +79,7 @@ describe('useStaff hooks', () => { name: 'John Doe', email: 'john@example.com', phone: '555-1234', - role: 'TENANT_MANAGER', + role: 'TENANT_STAFF', is_active: true, permissions: { can_invite_staff: true }, can_invite_staff: true, diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 9be9bcb4..e024ed1f 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -122,11 +122,11 @@ export const useIsAuthenticated = (): boolean => { /** * Get the redirect path based on user role * Tenant users go to /dashboard/, platform users go to / - * Note: Backend maps tenant_owner -> owner, tenant_manager -> manager, etc. + * Note: Backend maps tenant_owner -> owner, tenant_staff -> staff, etc. */ const getRedirectPathForRole = (role: string): string => { // Tenant roles (as returned by backend after role mapping) - const tenantRoles = ['owner', 'manager', 'staff', 'customer']; + const tenantRoles = ['owner', 'staff', 'customer']; if (tenantRoles.includes(role)) { return '/dashboard/'; } diff --git a/frontend/src/hooks/useCustomers.ts b/frontend/src/hooks/useCustomers.ts index 3d3fc9a6..adac1e1d 100644 --- a/frontend/src/hooks/useCustomers.ts +++ b/frontend/src/hooks/useCustomers.ts @@ -38,6 +38,7 @@ const transformCustomer = (c: any): Customer => ({ paymentMethods: [], user_data: c.user_data, notes: c.notes || '', + email_verified: c.email_verified ?? false, }); /** @@ -208,3 +209,20 @@ export const useDeleteCustomer = () => { }, }); }; + +/** + * Hook to verify a customer's email address + */ +export const useVerifyCustomerEmail = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const { data } = await apiClient.post(`/customers/${id}/verify_email/`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['customers'] }); + }, + }); +}; diff --git a/frontend/src/hooks/useInvitations.ts b/frontend/src/hooks/useInvitations.ts index 2a8da9a2..3bbc78ee 100644 --- a/frontend/src/hooks/useInvitations.ts +++ b/frontend/src/hooks/useInvitations.ts @@ -8,7 +8,7 @@ import apiClient from '../api/client'; export interface StaffInvitation { id: number; email: string; - role: 'TENANT_MANAGER' | 'TENANT_STAFF'; + role: 'TENANT_STAFF'; role_display: string; status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED'; invited_by: number | null; @@ -50,7 +50,7 @@ export interface StaffPermissions { export interface CreateInvitationData { email: string; - role: 'TENANT_MANAGER' | 'TENANT_STAFF'; + role: 'TENANT_STAFF'; create_bookable_resource?: boolean; resource_name?: string; permissions?: StaffPermissions; diff --git a/frontend/src/hooks/useStaff.ts b/frontend/src/hooks/useStaff.ts index 08c928aa..f43f082e 100644 --- a/frontend/src/hooks/useStaff.ts +++ b/frontend/src/hooks/useStaff.ts @@ -13,6 +13,8 @@ export interface StaffPermissions { export interface StaffMember { id: string; name: string; + first_name: string; + last_name: string; email: string; phone?: string; role: string; @@ -22,6 +24,7 @@ export interface StaffMember { staff_role_id: number | null; staff_role_name: string | null; effective_permissions: Record; + email_verified: boolean; } interface StaffFilters { @@ -30,7 +33,7 @@ interface StaffFilters { /** * Hook to fetch staff members with optional filters - * Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF + * Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF */ export const useStaff = (filters?: StaffFilters) => { return useQuery({ @@ -46,6 +49,8 @@ export const useStaff = (filters?: StaffFilters) => { return data.map((s: any) => ({ id: String(s.id), name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email, + first_name: s.first_name || '', + last_name: s.last_name || '', email: s.email || '', phone: s.phone || '', role: s.role || 'staff', @@ -55,14 +60,27 @@ export const useStaff = (filters?: StaffFilters) => { staff_role_id: s.staff_role_id ?? null, staff_role_name: s.staff_role_name ?? null, effective_permissions: s.effective_permissions || {}, + email_verified: s.email_verified ?? false, })); }, retry: false, }); }; +export interface StaffProfileUpdate { + first_name?: string; + last_name?: string; + phone?: string; +} + +export interface StaffUpdate extends StaffProfileUpdate { + is_active?: boolean; + permissions?: StaffPermissions; + staff_role_id?: number | null; +} + /** - * Hook to update a staff member's settings + * Hook to update a staff member's settings and profile */ export const useUpdateStaff = () => { const queryClient = useQueryClient(); @@ -73,7 +91,7 @@ export const useUpdateStaff = () => { updates, }: { id: string; - updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null }; + updates: StaffUpdate; }) => { const { data } = await apiClient.patch(`/staff/${id}/`, updates); return data; @@ -102,3 +120,38 @@ export const useToggleStaffActive = () => { }, }); }; + +/** + * Hook to verify a staff member's email address + */ +export const useVerifyStaffEmail = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const { data } = await apiClient.post(`/staff/${id}/verify_email/`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['staff'] }); + queryClient.invalidateQueries({ queryKey: ['businessUsers'] }); + }, + }); +}; + +/** + * Hook to send a password reset email to a staff member + */ +export const useSendStaffPasswordReset = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const { data } = await apiClient.post(`/staff/${id}/send_password_reset/`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['staff'] }); + }, + }); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 14d998db..f1a9be98 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -917,7 +917,19 @@ "noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.", "addNewCustomer": "Neuen Kunden Hinzufügen", "createCustomer": "Kunden Erstellen", - "errorLoading": "Fehler beim Laden der Kunden" + "errorLoading": "Fehler beim Laden der Kunden", + "password": "Passwort", + "newPassword": "Neues Passwort", + "passwordPlaceholder": "Leer lassen, um das aktuelle Passwort zu behalten", + "accountInfo": "Kontoinformationen", + "contactDetails": "Kontaktdetails", + "verifyEmail": "E-Mail bestätigen", + "unverifyEmail": "E-Mail nicht bestätigen", + "emailVerified": "Bestätigt", + "verifyEmailTitle": "E-Mail-Adresse bestätigen", + "unverifyEmailTitle": "E-Mail-Bestätigung aufheben", + "verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?", + "unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?" }, "staff": { "title": "Personal & Management", @@ -930,7 +942,15 @@ "yes": "Ja", "errorLoading": "Fehler beim Laden des Personals", "inviteModalTitle": "Personal Einladen", - "inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein." + "inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein.", + "verifyEmail": "E-Mail bestätigen", + "emailVerified": "Bestätigt", + "emailStatus": "E-Mail-Status", + "unverifyEmail": "E-Mail nicht bestätigen", + "verifyEmailTitle": "E-Mail-Adresse bestätigen", + "unverifyEmailTitle": "E-Mail-Bestätigung aufheben", + "verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?", + "unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?" }, "resources": { "title": "Ressourcen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fe824361..2f5350ab 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -936,9 +936,8 @@ "emailPlaceholder": "colleague@example.com", "roleLabel": "Role", "roleStaff": "Staff Member", - "roleManager": "Manager", - "managerRoleHint": "Managers can manage staff, resources, and view reports", - "staffRoleHint": "Staff members can manage their own schedule and appointments", + "roleOwner": "Owner", + "staffRoleHint": "Staff permissions are determined by their assigned role", "makeBookableHint": "Create a bookable resource so customers can schedule appointments with this person", "resourceName": "Display Name (optional)", "resourceNamePlaceholder": "Defaults to person's name", @@ -958,7 +957,7 @@ "canSendMessagesHint": "Send messages to groups of staff and customers", "deactivate": "Deactivate", "canInviteStaff": "Can invite new staff members", - "canInviteStaffHint": "Allow this manager to send invitations to new staff members", + "canInviteStaffHint": "Allow this staff member to send invitations to new staff members", "canManageResources": "Can manage resources", "canManageResourcesHint": "Create, edit, and delete bookable resources", "canManageServices": "Can manage services", @@ -966,7 +965,37 @@ "canViewReports": "Can view reports", "canViewReportsHint": "Access business analytics and financial reports", "canAccessSettings": "Can access business settings", - "canAccessSettingsHint": "Modify business profile, branding, and configuration", + "canAccessSettingsHint": "Access to business settings pages (select specific pages below)", + "canAccessSettingsGeneral": "General Settings", + "canAccessSettingsGeneralHint": "Business name, timezone, and basic configuration", + "canAccessSettingsBusinessHours": "Business Hours", + "canAccessSettingsBusinessHoursHint": "Set regular operating hours", + "canAccessSettingsBranding": "Branding", + "canAccessSettingsBrandingHint": "Logo, colors, and visual identity", + "canAccessSettingsBooking": "Booking Settings", + "canAccessSettingsBookingHint": "Booking policies and rules", + "canAccessSettingsCommunication": "Communication", + "canAccessSettingsCommunicationHint": "Notification preferences and reminders", + "canAccessSettingsEmbedWidget": "Embed Widget", + "canAccessSettingsEmbedWidgetHint": "Configure booking widget for websites", + "canAccessSettingsEmailTemplates": "Email Templates", + "canAccessSettingsEmailTemplatesHint": "Customize automated emails", + "canAccessSettingsStaffRoles": "Staff Roles", + "canAccessSettingsStaffRolesHint": "Create and manage permission roles", + "canAccessSettingsResourceTypes": "Resource Types", + "canAccessSettingsResourceTypesHint": "Configure resource categories", + "canAccessSettingsApi": "API & Integrations", + "canAccessSettingsApiHint": "Manage API tokens and webhooks", + "canAccessSettingsCustomDomains": "Custom Domains", + "canAccessSettingsCustomDomainsHint": "Configure custom domain settings", + "canAccessSettingsAuthentication": "Authentication", + "canAccessSettingsAuthenticationHint": "OAuth and social login configuration", + "canAccessSettingsEmail": "Email Setup", + "canAccessSettingsEmailHint": "Configure email addresses for tickets", + "canAccessSettingsSmsCalling": "SMS & Calling", + "canAccessSettingsSmsCallingHint": "Manage credits and phone numbers", + "selectAll": "Select All", + "selectNone": "Select None", "canRefundPayments": "Can refund payments", "canRefundPaymentsHint": "Process refunds for customer payments", "canViewAllSchedules": "Can view all schedules", @@ -974,16 +1003,41 @@ "canManageOwnAppointments": "Can manage own appointments", "canManageOwnAppointmentsHint": "Create, reschedule, and cancel their own appointments", "canSelfApproveTimeOff": "Can self-approve time off", - "canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval", + "canSelfApproveTimeOffHint": "Add time off without requiring owner approval", "canAccessTickets": "Can access support tickets", "canAccessTicketsHint": "View and manage customer support tickets", - "managerPermissions": "Manager Permissions", - "staffPermissions": "Staff Permissions" + "staffPermissions": "Staff Permissions", + "verifyEmail": "Verify Email", + "unverifyEmail": "Unverify Email", + "emailVerified": "Verified", + "emailStatus": "Email Status", + "verifyEmailTitle": "Verify Email Address", + "unverifyEmailTitle": "Unverify Email Address", + "verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?", + "unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?", + "profileInformation": "Profile Information", + "firstName": "First Name", + "firstNamePlaceholder": "Enter first name", + "lastName": "Last Name", + "lastNamePlaceholder": "Enter last name", + "email": "Email", + "phone": "Phone", + "phonePlaceholder": "Enter phone number", + "verified": "Verified", + "verify": "Verify", + "roleAndPermissions": "Role & Permissions", + "accountSecurity": "Account Security", + "resetPassword": "Reset Password", + "resetPasswordHint": "Send a password reset email to this staff member", + "sendResetEmail": "Send Reset Email", + "confirmPasswordReset": "Send a password reset email to {{email}}? They will receive a temporary password.", + "passwordResetSent": "Password reset email sent successfully", + "passwordResetFailed": "Failed to send password reset email" }, "staffDashboard": { "welcomeTitle": "Welcome, {{name}}!", "weekOverview": "Here's your week at a glance", - "noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.", + "noResourceLinked": "Your account is not linked to a resource yet. Please contact the business owner to set up your schedule.", "currentAppointment": "Current Appointment", "nextAppointment": "Next Appointment", "viewSchedule": "View Schedule", @@ -1478,7 +1532,14 @@ "newPassword": "New Password", "passwordPlaceholder": "Leave blank to keep current password", "accountInfo": "Account Information", - "contactDetails": "Contact Details" + "contactDetails": "Contact Details", + "verifyEmail": "Verify Email", + "unverifyEmail": "Unverify Email", + "emailVerified": "Verified", + "verifyEmailTitle": "Verify Email Address", + "unverifyEmailTitle": "Unverify Email Address", + "verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?", + "unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?" }, "resources": { "title": "Resources", @@ -1742,6 +1803,7 @@ }, "settings": { "title": "Settings", + "noPermission": "You do not have permission to access these settings.", "businessSettings": "Business Settings", "businessSettingsDescription": "Manage your branding, domain, and policies.", "domainIdentity": "Domain & Identity", @@ -1921,6 +1983,12 @@ "roleDescriptionPlaceholder": "Brief description of this role's responsibilities", "permissions": "Permissions", "menuAccess": "Menu Access", + "menuPermissions": "Menu Access", + "menuPermissionsDescription": "Control which pages staff can see in the sidebar.", + "settingsPermissions": "Business Settings Access", + "settingsPermissionsDescription": "Control which settings pages staff can access.", + "dangerousPermissions": "Dangerous Operations", + "dangerousPermissionsDescription": "Allow staff to perform destructive or sensitive actions.", "dangerousOperations": "Dangerous Operations", "staffAssigned": "{{count}} staff assigned", "noStaffAssigned": "No staff assigned", @@ -3406,7 +3474,7 @@ "title": "My Availability", "subtitle": "Manage your time off and unavailability", "noResource": "No Resource Linked", - "noResourceDesc": "Your account is not linked to a resource. Please contact your manager to set up your availability.", + "noResourceDesc": "Your account is not linked to a resource. Please contact the business owner to set up your availability.", "addBlock": "Block Time", "businessBlocks": "Business Closures", "businessBlocksInfo": "These blocks are set by your business and apply to everyone.", @@ -3717,14 +3785,12 @@ "staffRoles": "Staff Roles", "ownerRole": "Owner", "ownerRoleDesc": "Full access to everything including billing and settings. Cannot be removed.", - "managerRole": "Manager", - "managerRoleDesc": "Can manage staff, customers, services, and appointments. No billing access.", "staffRole": "Staff", - "staffRoleDesc": "Basic access. Can view scheduler and manage own appointments if bookable.", + "staffRoleDesc": "Access is controlled by their assigned staff role. Create custom roles in Settings > Staff Roles.", "invitingStaff": "Inviting Staff", "inviteStep1": "Click the Invite Staff button", "inviteStep2": "Enter their email address", - "inviteStep3": "Select a role (Manager or Staff)", + "inviteStep3": "Select a staff role to assign", "inviteStep4": "Click Send Invitation", "inviteStep5": "They'll receive an email with a link to join", "makeBookable": "Make Bookable", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 1d7ff137..c4b53267 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -967,6 +967,13 @@ "lastVisit": "Última Visita", "nextAppointment": "Próxima Cita", "contactInfo": "Información de Contacto", + "verifyEmail": "Verificar correo", + "unverifyEmail": "Anular verificación", + "emailVerified": "Verificado", + "verifyEmailTitle": "Verificar correo electrónico", + "unverifyEmailTitle": "Anular verificación de correo", + "verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?", + "unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?", "status": "Estado", "active": "Activo", "inactive": "Inactivo", @@ -987,6 +994,14 @@ "role": "Rol", "bookableResource": "Recurso Reservable", "makeBookable": "Hacer Reservable", + "verifyEmail": "Verificar correo", + "unverifyEmail": "Anular verificación", + "emailVerified": "Verificado", + "emailStatus": "Estado del correo", + "verifyEmailTitle": "Verificar correo electrónico", + "unverifyEmailTitle": "Anular verificación de correo", + "verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?", + "unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?", "yes": "Sí", "errorLoading": "Error al cargar personal", "inviteModalTitle": "Invitar Personal", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index aaffe573..f5fa7422 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -907,6 +907,13 @@ "lastVisit": "Dernière Visite", "nextAppointment": "Prochain Rendez-vous", "contactInfo": "Informations de Contact", + "verifyEmail": "Vérifier l'e-mail", + "unverifyEmail": "Annuler la vérification", + "emailVerified": "Vérifié", + "verifyEmailTitle": "Vérifier l'adresse e-mail", + "unverifyEmailTitle": "Annuler la vérification de l'e-mail", + "verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?", + "unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?", "status": "Statut", "active": "Actif", "inactive": "Inactif", @@ -930,7 +937,17 @@ "yes": "Oui", "errorLoading": "Erreur lors du chargement du personnel", "inviteModalTitle": "Inviter du Personnel", - "inviteModalDescription": "Le flux d'invitation utilisateur irait ici." + "inviteModalDescription": "Le flux d'invitation utilisateur irait ici.", + "managerPermissions": "Permissions du Gestionnaire", + "staffPermissions": "Permissions du Personnel", + "verifyEmail": "Vérifier l'e-mail", + "unverifyEmail": "Annuler la vérification", + "verifyEmailTitle": "Vérifier l'adresse e-mail", + "unverifyEmailTitle": "Annuler la vérification de l'e-mail", + "verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?", + "unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?", + "emailVerified": "Vérifié", + "emailStatus": "Statut de l'e-mail" }, "resources": { "title": "Ressources", diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx index 80c15f96..3489320b 100644 --- a/frontend/src/layouts/SettingsLayout.tsx +++ b/frontend/src/layouts/SettingsLayout.tsx @@ -56,6 +56,16 @@ const SettingsLayout: React.FC = () => { // Get context from parent route (BusinessLayout) const parentContext = useOutletContext(); + const { user } = parentContext || {}; + const isOwner = user?.role === 'owner'; + + // Check if staff has access to a specific settings page + const hasSettingsPermission = (permissionKey: string): boolean => { + // Owners always have all permissions + if (isOwner) return true; + // Staff need the specific permission + return user?.effective_permissions?.[permissionKey] === true; + }; // Check if a feature is locked (returns true if locked) const isLocked = (feature: FeatureKey | undefined): boolean => { @@ -92,123 +102,167 @@ const SettingsLayout: React.FC = () => { {/* Navigation */} diff --git a/frontend/src/pages/Customers.tsx b/frontend/src/pages/Customers.tsx index a2dd390f..b565c136 100644 --- a/frontend/src/pages/Customers.tsx +++ b/frontend/src/pages/Customers.tsx @@ -3,7 +3,7 @@ import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Customer, User } from '../types'; -import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer } from '../hooks/useCustomers'; +import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer, useVerifyCustomerEmail } from '../hooks/useCustomers'; import { useAppointments } from '../hooks/useAppointments'; import { useServices } from '../hooks/useServices'; import { @@ -26,7 +26,9 @@ import { FileText, StickyNote, History, - Save + Save, + BadgeCheck, + Loader2, } from 'lucide-react'; import Portal from '../components/Portal'; @@ -68,6 +70,9 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => isActive: true }); + // Verify email confirmation modal state + const [verifyEmailTarget, setVerifyEmailTarget] = useState(null); + // Infinite scroll for customers const { data: customersData, @@ -81,6 +86,7 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => const { data: services = [] } = useServices(); const createCustomerMutation = useCreateCustomer(); const updateCustomerMutation = useUpdateCustomer(); + const verifyEmailMutation = useVerifyCustomerEmail(); // Transform paginated data to flat array const customers: Customer[] = useMemo(() => { @@ -222,6 +228,20 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => }); }; + const handleVerifyEmailClick = (customer: Customer) => { + setVerifyEmailTarget(customer); + }; + + const handleVerifyEmailConfirm = async () => { + if (!verifyEmailTarget) return; + try { + await verifyEmailMutation.mutateAsync(verifyEmailTarget.id); + setVerifyEmailTarget(null); + } catch (err: any) { + console.error('Failed to toggle email verification:', err); + } + }; + const getServiceName = (serviceId: string) => { const service = services.find(s => String(s.id) === serviceId); return service?.name || t('customers.unknownService'); @@ -381,6 +401,22 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => {customer.lastVisit ? customer.lastVisit.toLocaleDateString() : {t('customers.never')}}
+
)} + + {/* Verify Email Confirmation Modal */} + {verifyEmailTarget && ( + +
+
+
+

+ {verifyEmailTarget.email_verified ? t('customers.unverifyEmailTitle') : t('customers.verifyEmailTitle')} +

+
+
+

+ {verifyEmailTarget.email_verified + ? t('customers.unverifyEmailConfirm', { email: verifyEmailTarget.email }) + : t('customers.verifyEmailConfirm', { email: verifyEmailTarget.email })} +

+
+
+ + +
+
+
+
+ )} ); }; diff --git a/frontend/src/pages/Messages.tsx b/frontend/src/pages/Messages.tsx index 18781ae4..ac34e00d 100644 --- a/frontend/src/pages/Messages.tsx +++ b/frontend/src/pages/Messages.tsx @@ -251,7 +251,6 @@ const Messages: React.FC = () => { // Computed const roleOptions = [ { value: 'owner', label: 'Owners', icon: Users, description: 'Business owners' }, - { value: 'manager', label: 'Managers', icon: Users, description: 'Team leads' }, { value: 'staff', label: 'Staff', icon: Users, description: 'Employees' }, { value: 'customer', label: 'Customers', icon: Users, description: 'Clients' }, ]; diff --git a/frontend/src/pages/Payments.tsx b/frontend/src/pages/Payments.tsx index 5e17f0dc..2f458fba 100644 --- a/frontend/src/pages/Payments.tsx +++ b/frontend/src/pages/Payments.tsx @@ -50,7 +50,7 @@ const Payments: React.FC = () => { const { t } = useTranslation(); const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>(); - const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'manager'; + const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'staff'; const isCustomer = effectiveUser.role === 'customer'; // Tab state diff --git a/frontend/src/pages/Staff.tsx b/frontend/src/pages/Staff.tsx index 8028036b..92ffa3d3 100644 --- a/frontend/src/pages/Staff.tsx +++ b/frontend/src/pages/Staff.tsx @@ -1,8 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { User } from '../types'; import { useCreateResource, useResources } from '../hooks/useBusiness'; -import { useStaff, useToggleStaffActive, useUpdateStaff, StaffMember } from '../hooks/useStaff'; +import { useStaff, useToggleStaffActive, useUpdateStaff, useVerifyStaffEmail, useSendStaffPasswordReset, StaffMember } from '../hooks/useStaff'; import { useInvitations, useCreateInvitation, @@ -30,9 +30,13 @@ import { ChevronRight, UserX, Power, + BadgeCheck, + Key, + Phone, + Eye, + ArrowUpDown, } from 'lucide-react'; import Portal from '../components/Portal'; -import StaffPermissions from '../components/StaffPermissions'; interface StaffProps { onMasquerade: (user: User) => void; @@ -51,10 +55,13 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const resendInvitationMutation = useResendInvitation(); const toggleActiveMutation = useToggleStaffActive(); const updateStaffMutation = useUpdateStaff(); + const verifyEmailMutation = useVerifyStaffEmail(); + const passwordResetMutation = useSendStaffPasswordReset(); const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); - const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF'); + // All invitations are for TENANT_STAFF - manager role removed + const inviteRole = 'TENANT_STAFF'; const [inviteStaffRoleId, setInviteStaffRoleId] = useState(null); const [createBookableResource, setCreateBookableResource] = useState(false); const [resourceName, setResourceName] = useState(''); @@ -66,16 +73,55 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { // Edit modal state const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editingStaff, setEditingStaff] = useState(null); - const [editPermissions, setEditPermissions] = useState>({}); const [editStaffRoleId, setEditStaffRoleId] = useState(null); + const [editFirstName, setEditFirstName] = useState(''); + const [editLastName, setEditLastName] = useState(''); + const [editPhone, setEditPhone] = useState(''); const [editError, setEditError] = useState(''); const [editSuccess, setEditSuccess] = useState(''); - // Check if user can invite managers (only owners can) - const canInviteManagers = effectiveUser.role === 'owner'; + // Verify email confirmation modal state + const [verifyEmailTarget, setVerifyEmailTarget] = useState(null); + + // Sorting state + const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'role'; direction: 'asc' | 'desc' }>({ + key: 'name', + direction: 'asc' + }); + + const handleSort = (key: 'name' | 'role') => { + setSortConfig(current => ({ + key, + direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc', + })); + }; + + // Separate active and inactive staff, then sort + const activeStaff = useMemo(() => { + const active = staffMembers.filter((s) => s.is_active); + return [...active].sort((a, b) => { + let aValue: string; + let bValue: string; + + if (sortConfig.key === 'name') { + aValue = (a.name || a.email || '').toLowerCase(); + bValue = (b.name || b.email || '').toLowerCase(); + } else { + // Sort by role: owners first, then by staff_role_name + const aIsOwner = a.role === 'owner'; + const bIsOwner = b.role === 'owner'; + if (aIsOwner && !bIsOwner) return sortConfig.direction === 'asc' ? -1 : 1; + if (!aIsOwner && bIsOwner) return sortConfig.direction === 'asc' ? 1 : -1; + aValue = (a.staff_role_name || '').toLowerCase(); + bValue = (b.staff_role_name || '').toLowerCase(); + } + + if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + }, [staffMembers, sortConfig]); - // Separate active and inactive staff - const activeStaff = staffMembers.filter((s) => s.is_active); const inactiveStaff = staffMembers.filter((s) => !s.is_active); // Helper to check if a user is already linked to a resource @@ -151,7 +197,6 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const openInviteModal = () => { setInviteEmail(''); - setInviteRole('TENANT_STAFF'); setInviteStaffRoleId(null); setCreateBookableResource(false); setResourceName(''); @@ -196,8 +241,10 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const openEditModal = (staff: StaffMember) => { setEditingStaff(staff); - setEditPermissions(staff.permissions || {}); setEditStaffRoleId(staff.staff_role_id); + setEditFirstName(staff.first_name); + setEditLastName(staff.last_name); + setEditPhone(staff.phone || ''); setEditError(''); setEditSuccess(''); setIsEditModalOpen(true); @@ -206,8 +253,10 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { const closeEditModal = () => { setIsEditModalOpen(false); setEditingStaff(null); - setEditPermissions({}); setEditStaffRoleId(null); + setEditFirstName(''); + setEditLastName(''); + setEditPhone(''); setEditError(''); setEditSuccess(''); }; @@ -217,10 +266,17 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { setEditError(''); try { - const updates: { permissions: Record; staff_role_id?: number | null } = { - permissions: editPermissions, + const updates: { + staff_role_id?: number | null; + first_name?: string; + last_name?: string; + phone?: string; + } = { + first_name: editFirstName, + last_name: editLastName, + phone: editPhone, }; - // Only include staff_role_id for staff users (not owners/managers) + // Only include staff_role_id for staff users (not owners) if (editingStaff.role === 'staff') { updates.staff_role_id = editStaffRoleId; } @@ -237,6 +293,21 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { } }; + const handleSendPasswordReset = async () => { + if (!editingStaff) return; + + if (!confirm(t('staff.confirmPasswordReset', { email: editingStaff.email }))) { + return; + } + + try { + await passwordResetMutation.mutateAsync(editingStaff.id); + setEditSuccess(t('staff.passwordResetSent')); + } catch (err: any) { + setEditError(err.response?.data?.error || t('staff.passwordResetFailed')); + } + }; + const handleDeactivateFromModal = async () => { if (!editingStaff) return; @@ -251,6 +322,20 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { } }; + const handleVerifyEmailClick = (user: StaffMember) => { + setVerifyEmailTarget(user); + }; + + const handleVerifyEmailConfirm = async () => { + if (!verifyEmailTarget) return; + try { + await verifyEmailMutation.mutateAsync(verifyEmailTarget.id); + setVerifyEmailTarget(null); + } catch (err: any) { + console.error('Failed to toggle email verification:', err); + } + }; + return (
{/* Header */} @@ -325,9 +410,12 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { - - - + + @@ -356,34 +444,19 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { - @@ -473,10 +563,10 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => {
{t('staff.name')}{t('staff.role')}{t('staff.staffRole')} handleSort('name')}> +
{t('staff.name')}
+
handleSort('role')}> +
{t('staff.role')}
+
{t('staff.bookableResource')} {t('common.actions')}
{user.role === 'owner' && } - {user.role === 'manager' && } - {user.role} + {user.staff_role_name === 'Manager' && } + {user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))} - {user.role === 'staff' ? ( - user.staff_role_name ? ( - - {user.staff_role_name} - - ) : ( - - {t('staff.noRoleAssigned')} - - ) - ) : ( - - )} - {linkedResource ? ( @@ -401,21 +474,38 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => {
+ + {canMasquerade && ( )} -
- + {user.role === 'owner' && } - {user.role === 'manager' && } - {user.role} + {user.staff_role_name === 'Manager' && } + {user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))} @@ -545,33 +635,11 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { /> - {/* Role Selector */} -
- - -

- {inviteRole === 'TENANT_MANAGER' - ? t('staff.managerRoleHint') - : t('staff.staffRoleHint')} -

-
- - {/* Staff Role Selector (only for staff invitations) */} - {inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && ( + {/* Staff Role Selector */} + {staffRoles.length > 0 && (
setEditFirstName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder={t('staff.firstNamePlaceholder', 'Enter first name')} + /> +
+
+ + setEditLastName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder={t('staff.lastNamePlaceholder', 'Enter last name')} + /> +
-
-
{editingStaff.name}
-
{editingStaff.email}
+
+ +
+ + +
+
+
+ +
+ + setEditPhone(e.target.value)} + className="w-full pl-9 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder={t('staff.phonePlaceholder', 'Enter phone number')} + /> +
- - {editingStaff.role === 'owner' && } - {editingStaff.role === 'manager' && } - {editingStaff.role} -
- {/* Staff Role Selector (only for staff users) */} - {editingStaff.role === 'staff' && staffRoles.length > 0 && ( -
- - -

- {t('staff.staffRoleSelectHint')} -

+ {/* Role Section */} + {editingStaff.role !== 'owner' && staffRoles.length > 0 && ( +
+

+ + {t('staff.staffRole', 'Staff Role')} +

+ + {/* Staff Role Selector */} +
+ +

+ {t('staff.staffRoleSelectHint')} +

+
)} - {/* Permissions - Using shared component */} - {editingStaff.role === 'manager' && ( - - )} - - {editingStaff.role === 'staff' && ( - - )} - - {/* No permissions for owners */} + {/* Owner info banner */} {editingStaff.role === 'owner' && (
-

- {t('staff.ownerFullAccess')} -

+
+ +

+ {t('staff.ownerFullAccess')} +

+
+
+ )} + + {/* Account Security Section - Password Reset */} + {editingStaff.role !== 'owner' && ( +
+

+ + {t('staff.accountSecurity', 'Account Security')} +

+
+
+
+

+ {t('staff.resetPassword', 'Reset Password')} +

+

+ {t('staff.resetPasswordHint', 'Send a password reset email to this staff member')} +

+
+ +
+
)} @@ -838,29 +972,69 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => {
)} + - {/* Action Buttons */} -
- - {editingStaff.role !== 'owner' && ( - - )} -
+ {/* Action Buttons - Fixed footer */} +
+ + +
+ + + + )} + + {/* Verify Email Confirmation Modal */} + {verifyEmailTarget && ( + +
+
+
+

+ {verifyEmailTarget.email_verified ? t('staff.unverifyEmailTitle') : t('staff.verifyEmailTitle')} +

+
+
+

+ {verifyEmailTarget.email_verified + ? t('staff.unverifyEmailConfirm', { email: verifyEmailTarget.email }) + : t('staff.verifyEmailConfirm', { email: verifyEmailTarget.email })} +

+
+
+ +
diff --git a/frontend/src/pages/Tickets.tsx b/frontend/src/pages/Tickets.tsx index cfd24db4..0fb9098d 100644 --- a/frontend/src/pages/Tickets.tsx +++ b/frontend/src/pages/Tickets.tsx @@ -122,7 +122,9 @@ const Tickets: React.FC = () => { setIsTicketModalOpen(false); }; - const isOwnerOrManager = currentUser?.role === 'owner' || currentUser?.role === 'manager'; + // Owner and staff with can_access_tickets permission have full access + const hasFullTicketAccess = currentUser?.role === 'owner' || + (currentUser?.role === 'staff' && currentUser?.effective_permissions?.can_access_tickets); if (isLoading) { return ( @@ -163,7 +165,7 @@ const Tickets: React.FC = () => { {t('tickets.title', 'Support Tickets')}

- {isOwnerOrManager + {hasFullTicketAccess ? t('tickets.descriptionOwner', 'Manage support tickets for your business') : t('tickets.descriptionStaff', 'View and create support tickets')}

diff --git a/frontend/src/pages/settings/ApiSettings.tsx b/frontend/src/pages/settings/ApiSettings.tsx index f2c9dbfc..486d0562 100644 --- a/frontend/src/pages/settings/ApiSettings.tsx +++ b/frontend/src/pages/settings/ApiSettings.tsx @@ -21,13 +21,14 @@ const ApiSettings: React.FC = () => { }>(); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_api === true; const { canUse } = usePlanFeatures(); - if (!isOwner) { + if (!hasPermission) { return (

- Only the business owner can access these settings. + You do not have permission to access these settings.

); diff --git a/frontend/src/pages/settings/AuthenticationSettings.tsx b/frontend/src/pages/settings/AuthenticationSettings.tsx index fd385819..0ea526eb 100644 --- a/frontend/src/pages/settings/AuthenticationSettings.tsx +++ b/frontend/src/pages/settings/AuthenticationSettings.tsx @@ -59,6 +59,7 @@ const AuthenticationSettings: React.FC = () => { const [showToast, setShowToast] = useState(false); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_authentication === true; const { canUse } = usePlanFeatures(); // Update OAuth settings when data loads @@ -147,11 +148,11 @@ const AuthenticationSettings: React.FC = () => { setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); }; - if (!isOwner) { + if (!hasPermission) { return (

- Only the business owner can access these settings. + You do not have permission to access these settings.

); diff --git a/frontend/src/pages/settings/BookingSettings.tsx b/frontend/src/pages/settings/BookingSettings.tsx index 00510ab2..e9c3352c 100644 --- a/frontend/src/pages/settings/BookingSettings.tsx +++ b/frontend/src/pages/settings/BookingSettings.tsx @@ -26,6 +26,7 @@ const BookingSettings: React.FC = () => { const [returnUrlSaving, setReturnUrlSaving] = useState(false); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_booking === true; const handleSaveReturnUrl = async () => { setReturnUrlSaving(true); @@ -40,11 +41,11 @@ const BookingSettings: React.FC = () => { } }; - if (!isOwner) { + if (!hasPermission) { return (

- {t('settings.booking.onlyOwnerCanAccess', 'Only the business owner can access these settings.')} + {t('settings.noPermission', 'You do not have permission to access these settings.')}

); diff --git a/frontend/src/pages/settings/BrandingSettings.tsx b/frontend/src/pages/settings/BrandingSettings.tsx index 8b05b976..02b23467 100644 --- a/frontend/src/pages/settings/BrandingSettings.tsx +++ b/frontend/src/pages/settings/BrandingSettings.tsx @@ -139,12 +139,13 @@ const BrandingSettings: React.FC = () => { }; const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_branding === true; - if (!isOwner) { + if (!hasPermission) { return (

- Only the business owner can access these settings. + You do not have permission to access these settings.

); diff --git a/frontend/src/pages/settings/BusinessHoursSettings.tsx b/frontend/src/pages/settings/BusinessHoursSettings.tsx index 24d742d1..d68ad3ed 100644 --- a/frontend/src/pages/settings/BusinessHoursSettings.tsx +++ b/frontend/src/pages/settings/BusinessHoursSettings.tsx @@ -6,9 +6,10 @@ */ import React, { useState, useEffect } from 'react'; +import { useOutletContext } from 'react-router-dom'; import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks'; import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui'; -import { BlockPurpose, TimeBlock } from '../../types'; +import { BlockPurpose, TimeBlock, Business, User } from '../../types'; interface DayHours { enabled: boolean; @@ -58,11 +59,19 @@ const DEFAULT_HOURS: BusinessHours = { }; const BusinessHoursSettings: React.FC = () => { + const { user } = useOutletContext<{ + business: Business; + user: User; + }>(); + const [hours, setHours] = useState(DEFAULT_HOURS); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [isSaving, setIsSaving] = useState(false); + const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_business_hours === true; + // Fetch existing business hours time blocks const { data: timeBlocks, isLoading } = useTimeBlocks({ purpose: 'BUSINESS_HOURS' as BlockPurpose, @@ -248,6 +257,16 @@ const BusinessHoursSettings: React.FC = () => { } }; + if (!hasPermission) { + return ( +
+

+ You do not have permission to access these settings. +

+
+ ); + } + if (isLoading) { return (
diff --git a/frontend/src/pages/settings/CommunicationSettings.tsx b/frontend/src/pages/settings/CommunicationSettings.tsx index a9a262dc..1106ac89 100644 --- a/frontend/src/pages/settings/CommunicationSettings.tsx +++ b/frontend/src/pages/settings/CommunicationSettings.tsx @@ -91,6 +91,7 @@ const CommunicationSettings: React.FC = () => { const [wizardAvailableNumbers, setWizardAvailableNumbers] = useState([]); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_sms_calling === true; const { canUse } = usePlanFeatures(); // Update settings form when credits data loads @@ -249,11 +250,11 @@ const CommunicationSettings: React.FC = () => { setWizardStep(4); }; - if (!isOwner) { + if (!hasPermission) { return (

- Only the business owner can access these settings. + You do not have permission to access these settings.

); diff --git a/frontend/src/pages/settings/CustomDomainsSettings.tsx b/frontend/src/pages/settings/CustomDomainsSettings.tsx index f9c1471a..9cd64243 100644 --- a/frontend/src/pages/settings/CustomDomainsSettings.tsx +++ b/frontend/src/pages/settings/CustomDomainsSettings.tsx @@ -43,6 +43,7 @@ const CustomDomainsSettings: React.FC = () => { const [showToast, setShowToast] = useState(false); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_custom_domains === true; const { canUse } = usePlanFeatures(); const handleAddDomain = () => { @@ -104,11 +105,11 @@ const CustomDomainsSettings: React.FC = () => { }); }; - if (!isOwner) { + if (!hasPermission) { return (

- Only the business owner can access these settings. + You do not have permission to access these settings.

); diff --git a/frontend/src/pages/settings/EmailSettings.tsx b/frontend/src/pages/settings/EmailSettings.tsx index f0cab216..0d6f96a3 100644 --- a/frontend/src/pages/settings/EmailSettings.tsx +++ b/frontend/src/pages/settings/EmailSettings.tsx @@ -19,12 +19,13 @@ const EmailSettings: React.FC = () => { }>(); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email === true; - if (!isOwner) { + if (!hasPermission) { return (

- Only the business owner can access these settings. + You do not have permission to access these settings.

); diff --git a/frontend/src/pages/settings/EmbedWidgetSettings.tsx b/frontend/src/pages/settings/EmbedWidgetSettings.tsx index 8962f016..f2f2763b 100644 --- a/frontend/src/pages/settings/EmbedWidgetSettings.tsx +++ b/frontend/src/pages/settings/EmbedWidgetSettings.tsx @@ -37,6 +37,7 @@ const EmbedWidgetSettings: React.FC = () => { const [copied, setCopied] = useState(false); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_embed_widget === true; // Build the embed URL const embedUrl = useMemo(() => { @@ -86,11 +87,11 @@ const EmbedWidgetSettings: React.FC = () => { setTimeout(() => setCopied(false), 2000); }; - if (!isOwner) { + if (!hasPermission) { return (

- {t('settings.embedWidget.onlyOwnerCanAccess', 'Only the business owner can access these settings.')} + {t('settings.noPermission', 'You do not have permission to access these settings.')}

); diff --git a/frontend/src/pages/settings/GeneralSettings.tsx b/frontend/src/pages/settings/GeneralSettings.tsx index 7d723451..32326508 100644 --- a/frontend/src/pages/settings/GeneralSettings.tsx +++ b/frontend/src/pages/settings/GeneralSettings.tsx @@ -170,12 +170,13 @@ const GeneralSettings: React.FC = () => { }; const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_general === true; - if (!isOwner) { + if (!hasPermission) { return (

- {t('settings.ownerOnly', 'Only the business owner can access these settings.')} + {t('settings.noPermission', 'You do not have permission to access these settings.')}

); diff --git a/frontend/src/pages/settings/ResourceTypesSettings.tsx b/frontend/src/pages/settings/ResourceTypesSettings.tsx index 8d2c259e..753138ae 100644 --- a/frontend/src/pages/settings/ResourceTypesSettings.tsx +++ b/frontend/src/pages/settings/ResourceTypesSettings.tsx @@ -33,6 +33,7 @@ const ResourceTypesSettings: React.FC = () => { }); const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_resource_types === true; const openCreateModal = () => { setEditingType(null); @@ -83,11 +84,11 @@ const ResourceTypesSettings: React.FC = () => { } }; - if (!isOwner) { + if (!hasPermission) { return (

- Only the business owner can access these settings. + You do not have permission to access these settings.

); diff --git a/frontend/src/pages/settings/StaffRolesSettings.tsx b/frontend/src/pages/settings/StaffRolesSettings.tsx index d7a81bbb..9821fd66 100644 --- a/frontend/src/pages/settings/StaffRolesSettings.tsx +++ b/frontend/src/pages/settings/StaffRolesSettings.tsx @@ -41,14 +41,15 @@ const StaffRolesSettings: React.FC = () => { const [error, setError] = useState(null); const isOwner = user.role === 'owner'; - const isManager = user.role === 'manager'; - const canManageRoles = isOwner || isManager; + // Only owners can manage roles (staff with permissions can view but not edit) + const canManageRoles = isOwner; - // Merge menu and dangerous permissions for display + // Merge menu, settings, and dangerous permissions for display const allPermissions = useMemo(() => { - if (!availablePermissions) return { menu: {}, dangerous: {} }; + if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} }; return { menu: availablePermissions.menu_permissions || {}, + settings: availablePermissions.settings_permissions || {}, dangerous: availablePermissions.dangerous_permissions || {}, }; }, [availablePermissions]); @@ -82,21 +83,50 @@ const StaffRolesSettings: React.FC = () => { }; const togglePermission = (key: string) => { - setFormData((prev) => ({ - ...prev, - permissions: { - ...prev.permissions, - [key]: !prev.permissions[key], - }, - })); + setFormData((prev) => { + const newValue = !prev.permissions[key]; + const updates: Record = { [key]: newValue }; + + // If enabling any settings sub-permission, also enable the main settings access + if (newValue && key.startsWith('can_access_settings_')) { + updates['can_access_settings'] = true; + } + + // If disabling the main settings access, disable all sub-permissions + if (!newValue && key === 'can_access_settings') { + Object.keys(allPermissions.settings).forEach((settingKey) => { + if (settingKey !== 'can_access_settings') { + updates[settingKey] = false; + } + }); + } + + return { + ...prev, + permissions: { + ...prev.permissions, + ...updates, + }, + }; + }); }; - const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => { - const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous; + const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => { + const permissions = category === 'menu' + ? allPermissions.menu + : category === 'settings' + ? allPermissions.settings + : allPermissions.dangerous; const updates: Record = {}; Object.keys(permissions).forEach((key) => { updates[key] = enable; }); + + // If enabling any settings permissions, ensure main settings access is also enabled + if (category === 'settings' && enable) { + updates['can_access_settings'] = true; + } + setFormData((prev) => ({ ...prev, permissions: { @@ -160,7 +190,7 @@ const StaffRolesSettings: React.FC = () => {

- {t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')} + {t('settings.staffRoles.noAccess', 'Only the business owner can manage staff roles.')}

); @@ -324,8 +354,7 @@ const StaffRolesSettings: React.FC = () => { value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required - disabled={editingRole?.is_default} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')} />
@@ -398,6 +427,60 @@ const StaffRolesSettings: React.FC = () => { + {/* Business Settings Permissions */} +
+
+
+

+ {t('settings.staffRoles.settingsPermissions', 'Business Settings Access')} +

+

+ {t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')} +

+
+
+ + | + +
+
+
+ {Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => ( + + ))} +
+
+ {/* Dangerous Permissions */}
diff --git a/frontend/src/pages/settings/SystemEmailTemplates.tsx b/frontend/src/pages/settings/SystemEmailTemplates.tsx index 74630a90..5b7961a0 100644 --- a/frontend/src/pages/settings/SystemEmailTemplates.tsx +++ b/frontend/src/pages/settings/SystemEmailTemplates.tsx @@ -7,6 +7,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useOutletContext } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Puck, Render } from '@measured/puck'; import '@measured/puck/puck.css'; @@ -39,6 +40,8 @@ import { SystemEmailTag, SystemEmailCategory, SystemEmailType, + Business, + User, } from '../../types'; // Category metadata @@ -86,6 +89,14 @@ const CATEGORY_ORDER: SystemEmailCategory[] = [ const SystemEmailTemplates: React.FC = () => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { user } = useOutletContext<{ + business: Business; + user: User; + }>(); + + const isOwner = user.role === 'owner'; + const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email_templates === true; + const [expandedCategories, setExpandedCategories] = useState>( new Set(CATEGORY_ORDER) ); @@ -343,6 +354,16 @@ const SystemEmailTemplates: React.FC = () => { setHasUnsavedChanges(false); }; + if (!hasPermission) { + return ( +
+

+ {t('settings.noPermission', 'You do not have permission to access these settings.')} +

+
+ ); + } + if (isLoading) { return (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d39134cb..0ef55eab 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -94,7 +94,7 @@ export interface Business { planPermissions?: PlanPermissions; } -export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer'; +export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'staff' | 'resource' | 'customer'; export interface NotificationPreferences { email: boolean; @@ -163,6 +163,7 @@ export interface PermissionDefinition { export interface AvailablePermissions { menu_permissions: Record; + settings_permissions: Record; dangerous_permissions: Record; } @@ -273,6 +274,14 @@ export interface Customer { userId?: string; paymentMethods: PaymentMethod[]; notes?: string; + email_verified?: boolean; + user_data?: { + id: number; + username: string; + name: string; + email: string; + role: string; + }; } export interface Service { diff --git a/smoothschedule/smoothschedule/commerce/tickets/signals.py b/smoothschedule/smoothschedule/commerce/tickets/signals.py index 63960e3c..de621d9b 100644 --- a/smoothschedule/smoothschedule/commerce/tickets/signals.py +++ b/smoothschedule/smoothschedule/commerce/tickets/signals.py @@ -88,13 +88,13 @@ def get_platform_support_team(): def get_tenant_managers(tenant): - """Get all owners and managers for a tenant.""" + """Get all owners for a tenant (formerly owners and managers).""" try: if not tenant: return User.objects.none() return User.objects.filter( tenant=tenant, - role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER], + role=User.Role.TENANT_OWNER, is_active=True ) except Exception as e: diff --git a/smoothschedule/smoothschedule/commerce/tickets/tests/test_signals.py b/smoothschedule/smoothschedule/commerce/tickets/tests/test_signals.py index 80ee0d3c..435fedd2 100644 --- a/smoothschedule/smoothschedule/commerce/tickets/tests/test_signals.py +++ b/smoothschedule/smoothschedule/commerce/tickets/tests/test_signals.py @@ -138,7 +138,7 @@ class TestGetTenantManagers: """Test the get_tenant_managers() helper function.""" def test_returns_tenant_managers(self): - """Should return owners and managers for a tenant.""" + """Should return owners for a tenant (formerly owners and managers).""" mock_tenant = Mock(id=1) mock_queryset = Mock() mock_filtered = Mock() @@ -149,7 +149,7 @@ class TestGetTenantManagers: mock_queryset.filter.assert_called_once_with( tenant=mock_tenant, - role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER], + role=User.Role.TENANT_OWNER, is_active=True ) assert result == mock_filtered diff --git a/smoothschedule/smoothschedule/commerce/tickets/views.py b/smoothschedule/smoothschedule/commerce/tickets/views.py index 3f61ca8b..2ba845af 100644 --- a/smoothschedule/smoothschedule/commerce/tickets/views.py +++ b/smoothschedule/smoothschedule/commerce/tickets/views.py @@ -803,8 +803,8 @@ class TicketEmailAddressViewSet(viewsets.ModelViewSet): # Business users see only their own email addresses if hasattr(user, 'tenant') and user.tenant: - # Only owners and managers can view/manage email addresses - if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: + # Only owners can view/manage email addresses + if user.role == User.Role.TENANT_OWNER: return TicketEmailAddress.objects.filter(tenant=user.tenant) return TicketEmailAddress.objects.none() diff --git a/smoothschedule/smoothschedule/communication/messaging/views.py b/smoothschedule/smoothschedule/communication/messaging/views.py index b8bc0bb5..c221ec2c 100644 --- a/smoothschedule/smoothschedule/communication/messaging/views.py +++ b/smoothschedule/smoothschedule/communication/messaging/views.py @@ -204,8 +204,8 @@ class BroadcastMessageViewSet(viewsets.ModelViewSet): if message.target_owners: role_filters |= Q(role=User.Role.TENANT_OWNER) - if message.target_managers: - role_filters |= Q(role=User.Role.TENANT_MANAGER) + # Note: target_managers now targets no one (managers migrated to staff) + # Kept for backwards compatibility - messages sent to managers will just have no recipients if message.target_staff: role_filters |= Q(role=User.Role.TENANT_STAFF) if message.target_customers: @@ -251,7 +251,8 @@ class BroadcastMessageViewSet(viewsets.ModelViewSet): base_query = User.objects.filter(tenant=tenant, is_active=True).exclude(id=user.id) owner_count = base_query.filter(role=User.Role.TENANT_OWNER).count() - manager_count = base_query.filter(role=User.Role.TENANT_MANAGER).count() + # manager_count is always 0 (managers migrated to staff with permissions) + manager_count = 0 staff_count = base_query.filter(role=User.Role.TENANT_STAFF).count() customer_count = base_query.filter(role=User.Role.CUSTOMER).count() @@ -357,16 +358,13 @@ class InboxViewSet(viewsets.ReadOnlyModelViewSet): # ============================================================================= class IsOwnerOrManager(BasePermission): - """Only owners and managers can manage email templates.""" - message = "You must be an owner or manager to manage email templates." + """Only owners can manage email templates.""" + message = "You must be an owner to manage email templates." def has_permission(self, request, view): if not request.user.is_authenticated: return False - return request.user.role in [ - User.Role.TENANT_OWNER, - User.Role.TENANT_MANAGER, - ] + return request.user.role == User.Role.TENANT_OWNER class EmailTemplateViewSet(viewsets.ModelViewSet): diff --git a/smoothschedule/smoothschedule/communication/mobile/services/status_machine.py b/smoothschedule/smoothschedule/communication/mobile/services/status_machine.py index 01a12cb9..c402cdf0 100644 --- a/smoothschedule/smoothschedule/communication/mobile/services/status_machine.py +++ b/smoothschedule/smoothschedule/communication/mobile/services/status_machine.py @@ -160,8 +160,8 @@ class StatusMachine: """ from smoothschedule.identity.users.models import User - # Owners and managers can always change status - if self.user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: + # Owners can always change status + if self.user.role == User.Role.TENANT_OWNER: return True, "" # Staff must be assigned to the event diff --git a/smoothschedule/smoothschedule/communication/mobile/tests/test_services.py b/smoothschedule/smoothschedule/communication/mobile/tests/test_services.py index 52b7626c..486e513c 100644 --- a/smoothschedule/smoothschedule/communication/mobile/tests/test_services.py +++ b/smoothschedule/smoothschedule/communication/mobile/tests/test_services.py @@ -266,18 +266,6 @@ class TestStatusMachine: assert can_change is True assert reason == "" - def test_can_user_change_status_manager_allowed(self): - """Test can_user_change_status allows TENANT_MANAGER.""" - mock_user = Mock() - mock_user.role = User.Role.TENANT_MANAGER - - machine = StatusMachine(tenant=Mock(), user=mock_user) - mock_event = Mock() - - can_change, reason = machine.can_user_change_status(mock_event) - - assert can_change is True - def test_can_user_change_status_staff_assigned(self): """Test can_user_change_status allows assigned TENANT_STAFF.""" mock_user = Mock() diff --git a/smoothschedule/smoothschedule/communication/mobile/tests/test_views.py b/smoothschedule/smoothschedule/communication/mobile/tests/test_views.py index b1a4c59d..84186be4 100644 --- a/smoothschedule/smoothschedule/communication/mobile/tests/test_views.py +++ b/smoothschedule/smoothschedule/communication/mobile/tests/test_views.py @@ -70,13 +70,6 @@ class TestHelperFunctions: assert is_field_employee(mock_user) is True - def test_is_field_employee_with_manager_role(self): - """Test is_field_employee returns True for TENANT_MANAGER.""" - mock_user = Mock() - mock_user.role = User.Role.TENANT_MANAGER - - assert is_field_employee(mock_user) is True - def test_is_field_employee_with_owner_role(self): """Test is_field_employee returns True for TENANT_OWNER.""" mock_user = Mock() diff --git a/smoothschedule/smoothschedule/communication/mobile/views.py b/smoothschedule/smoothschedule/communication/mobile/views.py index 3f1c0afa..ae5c597c 100644 --- a/smoothschedule/smoothschedule/communication/mobile/views.py +++ b/smoothschedule/smoothschedule/communication/mobile/views.py @@ -56,10 +56,9 @@ def get_tenant_from_user(user): def is_field_employee(user): - """Check if user is a field employee (staff role).""" + """Check if user is a field employee (staff or owner role).""" return user.role in [ User.Role.TENANT_STAFF, - User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER, ] diff --git a/smoothschedule/smoothschedule/identity/core/api_views.py b/smoothschedule/smoothschedule/identity/core/api_views.py index 07a23c4d..499a50af 100644 --- a/smoothschedule/smoothschedule/identity/core/api_views.py +++ b/smoothschedule/smoothschedule/identity/core/api_views.py @@ -13,8 +13,13 @@ from smoothschedule.identity.users.models import User def is_owner_or_manager(user): - """Check if user is a tenant owner or manager.""" - return user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER] + """Check if user is a tenant owner or staff with management permissions.""" + if user.role == User.Role.TENANT_OWNER: + return True + if user.role == User.Role.TENANT_STAFF: + # Staff with can_manage_users permission has equivalent access + return user.has_staff_permission('can_manage_users') + return False @api_view(['GET']) diff --git a/smoothschedule/smoothschedule/identity/core/permissions.py b/smoothschedule/smoothschedule/identity/core/permissions.py index 6e1ce677..fdd1d558 100644 --- a/smoothschedule/smoothschedule/identity/core/permissions.py +++ b/smoothschedule/smoothschedule/identity/core/permissions.py @@ -14,9 +14,9 @@ def can_hijack(hijacker, hijacked): │ Hijacker Role │ Can Hijack │ ├──────────────────────┼─────────────────────────────────────────────────┤ │ SUPERUSER │ Anyone (full god mode) │ - │ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF │ + │ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_STAFF, CUSTOMER │ │ PLATFORM_SALES │ Only users with is_temporary=True │ - │ TENANT_OWNER │ TENANT_STAFF in same tenant only │ + │ TENANT_OWNER │ TENANT_STAFF, CUSTOMER in same tenant only │ │ Others │ Nobody │ └──────────────────────┴─────────────────────────────────────────────────┘ @@ -51,7 +51,6 @@ def can_hijack(hijacker, hijacked): if hijacker.role == User.Role.PLATFORM_SUPPORT: return hijacked.role in [ User.Role.TENANT_OWNER, - User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER, ] @@ -60,7 +59,7 @@ def can_hijack(hijacker, hijacked): if hijacker.role == User.Role.PLATFORM_SALES: return hijacked.is_temporary - # Rule 4: TENANT_OWNER can hijack managers, staff, and customers within their own tenant + # Rule 4: TENANT_OWNER can hijack staff and customers within their own tenant if hijacker.role == User.Role.TENANT_OWNER: # Must be in same tenant if not hijacker.tenant or not hijacked.tenant: @@ -68,9 +67,8 @@ def can_hijack(hijacker, hijacked): if hijacker.tenant.id != hijacked.tenant.id: return False - # Can hijack managers, staff, and customers (not other owners) + # Can hijack staff and customers (not other owners) return hijacked.role in [ - User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER, ] @@ -127,7 +125,6 @@ def get_hijackable_users(hijacker): # Can hijack all tenant-level users return qs.filter(role__in=[ User.Role.TENANT_OWNER, - User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER, ]) @@ -137,13 +134,13 @@ def get_hijackable_users(hijacker): return qs.filter(is_temporary=True) elif hijacker.role == User.Role.TENANT_OWNER: - # Managers, staff, and customers in same tenant + # Staff and customers in same tenant if not hijacker.tenant: return qs.none() return qs.filter( tenant=hijacker.tenant, - role__in=[User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER] + role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER] ) else: diff --git a/smoothschedule/smoothschedule/identity/core/tests/test_api_views.py b/smoothschedule/smoothschedule/identity/core/tests/test_api_views.py index da194c0d..4fc06482 100644 --- a/smoothschedule/smoothschedule/identity/core/tests/test_api_views.py +++ b/smoothschedule/smoothschedule/identity/core/tests/test_api_views.py @@ -31,25 +31,28 @@ class TestIsOwnerOrManagerHelper: assert result is True - def test_returns_true_for_manager(self): - """Should return True for tenant manager.""" - from smoothschedule.identity.core.api_views import is_owner_or_manager - from smoothschedule.identity.users.models import User - - mock_user = Mock() - mock_user.role = User.Role.TENANT_MANAGER - - result = is_owner_or_manager(mock_user) - - assert result is True - - def test_returns_false_for_staff(self): - """Should return False for staff role.""" + def test_returns_true_for_staff_with_permission(self): + """Should return True for staff with can_manage_users permission.""" from smoothschedule.identity.core.api_views import is_owner_or_manager from smoothschedule.identity.users.models import User mock_user = Mock() mock_user.role = User.Role.TENANT_STAFF + mock_user.has_staff_permission.return_value = True + + result = is_owner_or_manager(mock_user) + + assert result is True + mock_user.has_staff_permission.assert_called_once_with('can_manage_users') + + def test_returns_false_for_staff_without_permission(self): + """Should return False for staff without can_manage_users permission.""" + from smoothschedule.identity.core.api_views import is_owner_or_manager + from smoothschedule.identity.users.models import User + + mock_user = Mock() + mock_user.role = User.Role.TENANT_STAFF + mock_user.has_staff_permission.return_value = False result = is_owner_or_manager(mock_user) diff --git a/smoothschedule/smoothschedule/identity/core/tests/test_mixins.py b/smoothschedule/smoothschedule/identity/core/tests/test_mixins.py index 72805884..0aeffc5c 100644 --- a/smoothschedule/smoothschedule/identity/core/tests/test_mixins.py +++ b/smoothschedule/smoothschedule/identity/core/tests/test_mixins.py @@ -297,7 +297,7 @@ class TestDenyStaffAllAccessPermission: request.method = 'GET' request.user = Mock() request.user.is_authenticated = True - request.user.role = 'TENANT_MANAGER' + request.user.role = 'TENANT_OWNER' view = Mock() diff --git a/smoothschedule/smoothschedule/identity/core/tests/test_permissions.py b/smoothschedule/smoothschedule/identity/core/tests/test_permissions.py index fa991424..30b4a90a 100644 --- a/smoothschedule/smoothschedule/identity/core/tests/test_permissions.py +++ b/smoothschedule/smoothschedule/identity/core/tests/test_permissions.py @@ -56,7 +56,6 @@ class TestCanHijack: 'PLATFORM_SUPPORT', 'PLATFORM_SALES', 'TENANT_OWNER', - 'TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER', ] @@ -70,7 +69,7 @@ class TestCanHijack: """Should allow platform support to hijack tenant-level users.""" hijacker = Mock(id=1, role='PLATFORM_SUPPORT') - allowed_roles = ['TENANT_OWNER', 'TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER'] + allowed_roles = ['TENANT_OWNER', 'TENANT_STAFF', 'CUSTOMER'] for role in allowed_roles: hijacked = Mock(id=2, role=role) @@ -105,7 +104,7 @@ class TestCanHijack: tenant = Mock(id=1) hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant) - allowed_roles = ['TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER'] + allowed_roles = ['TENANT_STAFF', 'CUSTOMER'] for role in allowed_roles: hijacked = Mock(id=2, role=role, tenant=tenant) @@ -146,7 +145,7 @@ class TestCanHijack: def test_other_roles_cannot_hijack(self): """Should deny hijack for roles without permission.""" - forbidden_roles = ['TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER'] + forbidden_roles = ['TENANT_STAFF', 'CUSTOMER'] for role in forbidden_roles: hijacker = Mock(id=1, role=role) @@ -206,7 +205,6 @@ class TestGetHijackableUsers: hijacker = Mock(id=1, role='PLATFORM_SUPPORT') mock_user_model.Role.PLATFORM_SUPPORT = 'PLATFORM_SUPPORT' mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER' - mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER' mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF' mock_user_model.Role.CUSTOMER = 'CUSTOMER' @@ -223,7 +221,6 @@ class TestGetHijackableUsers: assert 'role__in' in filter_kwargs roles = filter_kwargs['role__in'] assert 'TENANT_OWNER' in roles - assert 'TENANT_MANAGER' in roles assert 'TENANT_STAFF' in roles assert 'CUSTOMER' in roles @@ -249,7 +246,6 @@ class TestGetHijackableUsers: tenant = Mock(id=1) hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant) mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER' - mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER' mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF' mock_user_model.Role.CUSTOMER = 'CUSTOMER' diff --git a/smoothschedule/smoothschedule/identity/users/admin.py b/smoothschedule/smoothschedule/identity/users/admin.py index c69411df..710c4258 100644 --- a/smoothschedule/smoothschedule/identity/users/admin.py +++ b/smoothschedule/smoothschedule/identity/users/admin.py @@ -98,7 +98,6 @@ class UserAdmin(HijackUserAdminMixin, BaseUserAdmin): 'PLATFORM_SALES': '#fbc02d', # Yellow 'PLATFORM_SUPPORT': '#7cb342', # Light green 'TENANT_OWNER': '#1976d2', # Blue - 'TENANT_MANAGER': '#0288d1', # Light blue 'TENANT_STAFF': '#0097a7', # Cyan 'CUSTOMER': '#5e35b1', # Purple } diff --git a/smoothschedule/smoothschedule/identity/users/api_views.py b/smoothschedule/smoothschedule/identity/users/api_views.py index b3054dbe..156e32b7 100644 --- a/smoothschedule/smoothschedule/identity/users/api_views.py +++ b/smoothschedule/smoothschedule/identity/users/api_views.py @@ -130,8 +130,8 @@ def current_user_view(request): else: business_subdomain = user.tenant.schema_name - # Check for active quota overages (for owners and managers) - if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: + # Check for active quota overages (for owners and staff with management permissions) + if user.role == User.Role.TENANT_OWNER or (user.role == User.Role.TENANT_STAFF and user.has_staff_permission('can_manage_users')): from smoothschedule.identity.core.quota_service import QuotaService try: service = QuotaService(user.tenant) @@ -153,10 +153,10 @@ def current_user_view(request): } frontend_role = role_mapping.get(user.role.lower(), user.role.lower()) - # Get linked resource info for tenant users (staff, managers, owners can all be linked to resources) + # Get linked resource info for tenant users (staff and owners can be linked to resources) linked_resource_id = None can_edit_schedule = False - if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]: + if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]: try: with schema_context(user.tenant.schema_name): linked_resource = Resource.objects.filter(user=user).first() @@ -183,6 +183,9 @@ def current_user_view(request): 'business_name': business_name, 'business_subdomain': business_subdomain, 'permissions': user.permissions, + 'effective_permissions': user.get_effective_permissions(), + 'staff_role_id': user.staff_role_id, + 'staff_role_name': user.staff_role.name if user.staff_role else None, 'can_invite_staff': user.can_invite_staff(), 'can_access_tickets': user.can_access_tickets(), 'can_send_messages': user.can_send_messages(), @@ -316,11 +319,11 @@ def _get_user_data(user): } frontend_role = role_mapping.get(user.role.lower(), user.role.lower()) - # Get linked resource info for tenant users (staff, managers, owners can all be linked to resources) + # Get linked resource info for tenant users (staff and owners can be linked to resources) linked_resource_id = None linked_resource_name = None can_edit_schedule = False - if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]: + if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]: try: with schema_context(user.tenant.schema_name): linked_resource = Resource.objects.filter(user=user).first() @@ -519,7 +522,6 @@ class StaffInvitationSerializer(serializers.ModelSerializer): def get_role_display(self, obj): role_map = { - 'TENANT_MANAGER': 'Manager', 'TENANT_STAFF': 'Staff', } return role_map.get(obj.role, obj.role) @@ -572,21 +574,13 @@ def staff_invitations_view(request): status=status.HTTP_400_BAD_REQUEST ) - # Validate role - only allow manager and staff roles - if role not in [User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF]: + # Validate role - only allow staff role + if role != User.Role.TENANT_STAFF: return Response( - {"error": "Invalid role. Must be 'TENANT_MANAGER' or 'TENANT_STAFF'."}, + {"error": "Invalid role. Must be 'TENANT_STAFF'."}, status=status.HTTP_400_BAD_REQUEST ) - # Managers can only invite staff, not other managers - # TODO: Add owner control to allow/disallow managers inviting managers - if user.role == User.Role.TENANT_MANAGER and role == User.Role.TENANT_MANAGER: - return Response( - {"error": "Managers can only invite staff members, not other managers."}, - status=status.HTTP_403_FORBIDDEN - ) - # Check if user already exists in this tenant existing_user = User.objects.filter( email=email, @@ -708,7 +702,6 @@ def invitation_details_view(request, token): # Return limited info for the acceptance page role_map = { - 'TENANT_MANAGER': 'Manager', 'TENANT_STAFF': 'Staff', } @@ -867,7 +860,6 @@ def _send_invitation_email(invitation): invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}" role_map = { - 'TENANT_MANAGER': 'Manager', 'TENANT_STAFF': 'Staff Member', } role_display = role_map.get(invitation.role, 'team member') diff --git a/smoothschedule/smoothschedule/identity/users/management/commands/create_test_users.py b/smoothschedule/smoothschedule/identity/users/management/commands/create_test_users.py index c53a448d..e7cea244 100644 --- a/smoothschedule/smoothschedule/identity/users/management/commands/create_test_users.py +++ b/smoothschedule/smoothschedule/identity/users/management/commands/create_test_users.py @@ -68,15 +68,6 @@ class Command(BaseCommand): 'last_name': 'Owner', 'tenant': demo_tenant, }, - { - 'username': 'manager@demo.com', - 'email': 'manager@demo.com', - 'password': 'test123', - 'role': User.Role.TENANT_MANAGER, - 'first_name': 'Business', - 'last_name': 'Manager', - 'tenant': demo_tenant, - }, { 'username': 'staff@demo.com', 'email': 'staff@demo.com', diff --git a/smoothschedule/smoothschedule/identity/users/migrations/0014_remove_tenant_manager_role.py b/smoothschedule/smoothschedule/identity/users/migrations/0014_remove_tenant_manager_role.py new file mode 100644 index 00000000..d772c48a --- /dev/null +++ b/smoothschedule/smoothschedule/identity/users/migrations/0014_remove_tenant_manager_role.py @@ -0,0 +1,71 @@ +""" +Migration to remove TENANT_MANAGER role. + +Converts all existing TENANT_MANAGER users to TENANT_STAFF with +the 'Full Access Staff' role assigned. +""" + +from django.db import migrations + + +def migrate_managers_to_staff(apps, schema_editor): + """ + Convert all TENANT_MANAGER users to TENANT_STAFF. + Assign them the 'Full Access Staff' role for their tenant. + """ + User = apps.get_model('users', 'User') + StaffRole = apps.get_model('users', 'StaffRole') + + # Find all managers + managers = User.objects.filter(role='TENANT_MANAGER') + + for manager in managers: + # Get the Full Access Staff role for this tenant + full_access_role = StaffRole.objects.filter( + tenant=manager.tenant, + name='Full Access Staff' + ).first() + + if full_access_role: + manager.role = 'TENANT_STAFF' + manager.staff_role = full_access_role + manager.save(update_fields=['role', 'staff_role']) + else: + # If no Full Access Staff role exists, just convert to staff + # They'll need to be assigned a role manually + manager.role = 'TENANT_STAFF' + manager.save(update_fields=['role']) + + +def reverse_migration(apps, schema_editor): + """ + Reverse: Convert staff with Full Access role back to managers. + Note: This is a best-effort reversal - we can't know for sure + which staff were originally managers. + """ + User = apps.get_model('users', 'User') + StaffRole = apps.get_model('users', 'StaffRole') + + # Find all Full Access Staff roles + full_access_roles = StaffRole.objects.filter(name='Full Access Staff') + + for role in full_access_roles: + # Convert users with this role back to managers + User.objects.filter( + role='TENANT_STAFF', + staff_role=role + ).update(role='TENANT_MANAGER', staff_role=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0013_add_notes_to_user'), + ] + + operations = [ + migrations.RunPython( + migrate_managers_to_staff, + reverse_code=reverse_migration, + ), + ] diff --git a/smoothschedule/smoothschedule/identity/users/migrations/0015_update_staff_invitation_role_choices.py b/smoothschedule/smoothschedule/identity/users/migrations/0015_update_staff_invitation_role_choices.py new file mode 100644 index 00000000..75e963b9 --- /dev/null +++ b/smoothschedule/smoothschedule/identity/users/migrations/0015_update_staff_invitation_role_choices.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-12-17 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_remove_tenant_manager_role'), + ] + + operations = [ + migrations.AlterField( + model_name='staffinvitation', + name='role', + field=models.CharField(choices=[('TENANT_STAFF', 'Staff')], default='TENANT_STAFF', help_text='Role the invited user will have', max_length=20), + ), + migrations.AlterField( + model_name='user', + name='role', + field=models.CharField(choices=[('SUPERUSER', 'Platform Superuser'), ('PLATFORM_MANAGER', 'Platform Manager'), ('PLATFORM_SALES', 'Platform Sales'), ('PLATFORM_SUPPORT', 'Platform Support'), ('TENANT_OWNER', 'Tenant Owner'), ('TENANT_STAFF', 'Tenant Staff'), ('CUSTOMER', 'Customer')], default='CUSTOMER', help_text="User's role in the system hierarchy", max_length=20), + ), + ] diff --git a/smoothschedule/smoothschedule/identity/users/models.py b/smoothschedule/smoothschedule/identity/users/models.py index af197e8d..fd010710 100644 --- a/smoothschedule/smoothschedule/identity/users/models.py +++ b/smoothschedule/smoothschedule/identity/users/models.py @@ -23,12 +23,12 @@ class User(AbstractUser): PLATFORM_MANAGER = 'PLATFORM_MANAGER', _('Platform Manager') PLATFORM_SALES = 'PLATFORM_SALES', _('Platform Sales') PLATFORM_SUPPORT = 'PLATFORM_SUPPORT', _('Platform Support') - + # Tenant-level roles (access within single tenant) TENANT_OWNER = 'TENANT_OWNER', _('Tenant Owner') - TENANT_MANAGER = 'TENANT_MANAGER', _('Tenant Manager') + # TENANT_MANAGER removed - use TENANT_STAFF with "Full Access Staff" role instead TENANT_STAFF = 'TENANT_STAFF', _('Tenant Staff') - + # Customer role (end users of the tenant) CUSTOMER = 'CUSTOMER', _('Customer') @@ -199,19 +199,22 @@ class User(AbstractUser): """Check if user is tenant-scoped""" return self.role in [ self.Role.TENANT_OWNER, - self.Role.TENANT_MANAGER, self.Role.TENANT_STAFF, self.Role.CUSTOMER, ] def can_manage_users(self): """Check if user can manage other users""" - return self.role in [ + if self.role in [ self.Role.SUPERUSER, self.Role.PLATFORM_MANAGER, self.Role.TENANT_OWNER, - self.Role.TENANT_MANAGER, - ] + ]: + return True + # Staff can manage users if they have the permission + if self.role == self.Role.TENANT_STAFF: + return self.has_staff_permission('can_manage_users') + return False def can_access_billing(self): """Check if user can access billing information""" @@ -226,9 +229,9 @@ class User(AbstractUser): # Owners can always invite if self.role == self.Role.TENANT_OWNER: return True - # Managers can invite if they have the permission - if self.role == self.Role.TENANT_MANAGER: - return self.permissions.get('can_invite_staff', False) + # Staff can invite if they have the permission + if self.role == self.Role.TENANT_STAFF: + return self.has_staff_permission('can_invite_staff') return False def can_access_tickets(self): @@ -236,12 +239,12 @@ class User(AbstractUser): # Platform users can always access if self.is_platform_user(): return True - # Owners and managers can always access - if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: + # Owners can always access + if self.role == self.Role.TENANT_OWNER: return True - # Staff can access if granted permission (default: False) + # Staff can access if granted permission via staff role if self.role == self.Role.TENANT_STAFF: - return self.permissions.get('can_access_tickets', False) + return self.has_staff_permission('can_access_tickets') # Customers can create tickets if self.role == self.Role.CUSTOMER: return True @@ -280,41 +283,39 @@ class User(AbstractUser): """ Check if user can self-approve time off requests. Owners can always self-approve. - Managers can self-approve by default but can be denied. - Staff need explicit permission. + Staff need explicit permission via staff role. """ # Owners can always self-approve if self.role == self.Role.TENANT_OWNER: return True - # Managers can self-approve by default, but can be denied - if self.role == self.Role.TENANT_MANAGER: - return self.permissions.get('can_self_approve_time_off', True) - # Staff can self-approve if granted permission (default: False) + # Staff can self-approve if granted permission via staff role if self.role == self.Role.TENANT_STAFF: - return self.permissions.get('can_self_approve_time_off', False) + return self.has_staff_permission('can_self_approve_time_off') return False def can_review_time_off_requests(self): """ Check if user can review (approve/deny) time off requests from others. - Only owners and managers can review. + Owners can always review. Staff need explicit permission. """ - return self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER] + if self.role == self.Role.TENANT_OWNER: + return True + if self.role == self.Role.TENANT_STAFF: + return self.has_staff_permission('can_review_time_off') + return False def can_send_messages(self): """ Check if user can send broadcast messages to staff/customers. Owners can always send messages. - Managers can by default but can be revoked. - Staff cannot send messages. + Staff need explicit permission via staff role. """ # Owners can always send messages if self.role == self.Role.TENANT_OWNER: return True - # Managers can send by default, but can be revoked - if self.role == self.Role.TENANT_MANAGER: - return self.permissions.get('can_send_messages', True) - # Staff and others cannot send messages + # Staff can send if they have the permission via staff role + if self.role == self.Role.TENANT_STAFF: + return self.has_staff_permission('can_access_messages') return False def has_staff_permission(self, permission_key): @@ -322,7 +323,7 @@ class User(AbstractUser): Check if staff member has a specific permission. Permission Resolution Order: - 1. Owners and Managers always have all permissions (return True) + 1. Owners always have all permissions (return True) 2. For staff: User-level override takes priority 3. Then check staff role permissions 4. Default: False @@ -333,8 +334,8 @@ class User(AbstractUser): Returns: bool: Whether the user has the permission """ - # Owners and managers have all permissions - if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: + # Owners have all permissions + if self.role == self.Role.TENANT_OWNER: return True # For staff, check permissions @@ -356,8 +357,8 @@ class User(AbstractUser): Returns: dict: All effective permissions for this user """ - if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: - # Return all permissions as True for owner/manager + if self.role == self.Role.TENANT_OWNER: + # Return all permissions as True for owner from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS return {k: True for k in ALL_PERMISSIONS.keys()} @@ -684,7 +685,7 @@ class StaffInvitation(models.Model): Invitation for new staff members to join a business. Flow: - 1. Owner/Manager creates invitation with email and role + 1. Owner/Staff with invite permission creates invitation with email and staff role 2. System sends email with unique token link 3. Invitee clicks link, creates account, and is added to tenant """ @@ -701,7 +702,6 @@ class StaffInvitation(models.Model): role = models.CharField( max_length=20, choices=[ - (User.Role.TENANT_MANAGER, _('Manager')), (User.Role.TENANT_STAFF, _('Staff')), ], default=User.Role.TENANT_STAFF, @@ -833,7 +833,7 @@ class StaffInvitation(models.Model): Args: email: Email address to invite - role: Role for the invited user (TENANT_MANAGER or TENANT_STAFF) + role: Role for the invited user (TENANT_STAFF) tenant: Tenant/business the user is being invited to invited_by: User sending the invitation create_bookable_resource: Whether to create a bookable resource when accepted diff --git a/smoothschedule/smoothschedule/identity/users/staff_permissions.py b/smoothschedule/smoothschedule/identity/users/staff_permissions.py index 7c19ee7b..f3bfc3c5 100644 --- a/smoothschedule/smoothschedule/identity/users/staff_permissions.py +++ b/smoothschedule/smoothschedule/identity/users/staff_permissions.py @@ -106,6 +106,86 @@ MENU_PERMISSIONS = { }, } +# Business Settings Permissions +# These control access to individual settings pages +SETTINGS_PERMISSIONS = { + 'can_access_settings': { + 'label': 'Access Settings', + 'description': 'View Business Settings menu (required for any settings access)', + 'default': False, + }, + 'can_access_settings_general': { + 'label': 'General Settings', + 'description': 'Business name, timezone, and basic configuration', + 'default': False, + }, + 'can_access_settings_business_hours': { + 'label': 'Business Hours', + 'description': 'Set regular operating hours', + 'default': False, + }, + 'can_access_settings_branding': { + 'label': 'Branding', + 'description': 'Logo, colors, and visual identity', + 'default': False, + }, + 'can_access_settings_booking': { + 'label': 'Booking Settings', + 'description': 'Booking policies and rules', + 'default': False, + }, + 'can_access_settings_communication': { + 'label': 'Communication', + 'description': 'Notification preferences and reminders', + 'default': False, + }, + 'can_access_settings_embed_widget': { + 'label': 'Embed Widget', + 'description': 'Configure booking widget for websites', + 'default': False, + }, + 'can_access_settings_email_templates': { + 'label': 'Email Templates', + 'description': 'Customize automated emails', + 'default': False, + }, + 'can_access_settings_staff_roles': { + 'label': 'Staff Roles', + 'description': 'Create and manage permission roles', + 'default': False, + }, + 'can_access_settings_resource_types': { + 'label': 'Resource Types', + 'description': 'Configure resource categories', + 'default': False, + }, + 'can_access_settings_api': { + 'label': 'API & Integrations', + 'description': 'Manage API tokens and webhooks', + 'default': False, + }, + 'can_access_settings_custom_domains': { + 'label': 'Custom Domains', + 'description': 'Configure custom domain settings', + 'default': False, + }, + 'can_access_settings_authentication': { + 'label': 'Authentication', + 'description': 'OAuth and social login configuration', + 'default': False, + }, + 'can_access_settings_email': { + 'label': 'Email Setup', + 'description': 'Configure email addresses for tickets', + 'default': False, + }, + 'can_access_settings_sms_calling': { + 'label': 'SMS & Calling', + 'description': 'Manage credits and phone numbers', + 'default': False, + }, +} + # Dangerous Operation Permissions # These control specific destructive or sensitive operations at the API level DANGEROUS_PERMISSIONS = { @@ -149,10 +229,20 @@ DANGEROUS_PERMISSIONS = { 'description': 'Approve own time off requests without manager approval', 'default': False, }, + 'can_manage_users': { + 'label': 'Manage Users', + 'description': 'Invite and manage staff members', + 'default': False, + }, + 'can_review_time_off': { + 'label': 'Review Time Off', + 'description': 'Approve or deny time off requests from other staff', + 'default': False, + }, } # All permissions combined for easy iteration -ALL_PERMISSIONS = {**MENU_PERMISSIONS, **DANGEROUS_PERMISSIONS} +ALL_PERMISSIONS = {**MENU_PERMISSIONS, **SETTINGS_PERMISSIONS, **DANGEROUS_PERMISSIONS} def get_default_permissions_for_role(role_name: str) -> dict: diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_api_views.py b/smoothschedule/smoothschedule/identity/users/tests/test_api_views.py index 37c1c5b9..f10adbdc 100644 --- a/smoothschedule/smoothschedule/identity/users/tests/test_api_views.py +++ b/smoothschedule/smoothschedule/identity/users/tests/test_api_views.py @@ -189,7 +189,6 @@ class TestGetUserData: (User.Role.PLATFORM_SALES, 'platform_sales'), (User.Role.PLATFORM_SUPPORT, 'platform_support'), (User.Role.TENANT_OWNER, 'owner'), - (User.Role.TENANT_MANAGER, 'manager'), (User.Role.TENANT_STAFF, 'staff'), (User.Role.CUSTOMER, 'customer'), ] @@ -1104,29 +1103,6 @@ class TestStaffInvitationsView: assert response.status_code == status.HTTP_400_BAD_REQUEST assert 'Invalid role' in response.data['error'] - @patch('smoothschedule.identity.users.api_views.User') - def test_post_manager_cannot_invite_manager(self, mock_user_model): - factory = APIRequestFactory() - request = factory.post('/api/staff/invitations/', { - 'email': 'manager@test.com', - 'role': User.Role.TENANT_MANAGER - }) - - mock_tenant = Mock() - mock_user = Mock() - mock_user.can_invite_staff.return_value = True - mock_user.tenant = mock_tenant - mock_user.role = User.Role.TENANT_MANAGER - request.user = mock_user - - mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER' - mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF' - - response = api_views.staff_invitations_view(request) - - assert response.status_code == status.HTTP_403_FORBIDDEN - assert 'Managers can only invite staff' in response.data['error'] - @patch('smoothschedule.identity.users.api_views.User') def test_post_rejects_existing_user(self, mock_user_model): factory = APIRequestFactory() @@ -1142,7 +1118,6 @@ class TestStaffInvitationsView: mock_user.role = User.Role.TENANT_OWNER request.user = mock_user - mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER' mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF' # User already exists @@ -1178,7 +1153,6 @@ class TestStaffInvitationsView: mock_user.role = User.Role.TENANT_OWNER request.user = mock_user - mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER' mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF' mock_user_model.objects.filter.return_value.first.return_value = None diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_models.py b/smoothschedule/smoothschedule/identity/users/tests/test_models.py index 0e0f4004..49bdd53f 100644 --- a/smoothschedule/smoothschedule/identity/users/tests/test_models.py +++ b/smoothschedule/smoothschedule/identity/users/tests/test_models.py @@ -80,9 +80,6 @@ class TestRoleClassification: user = create_user_instance(User.Role.TENANT_OWNER) assert user.is_tenant_user() is True - def test_is_tenant_user_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.is_tenant_user() is True def test_is_tenant_user_returns_true_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -116,9 +113,6 @@ class TestCanManageUsers: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_manage_users() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_manage_users() is True def test_returns_false_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -148,9 +142,6 @@ class TestCanAccessBilling: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_access_billing() is True - def test_returns_false_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_access_billing() is False def test_returns_false_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -168,17 +159,6 @@ class TestCanInviteStaff: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_invite_staff() is True - def test_returns_true_for_manager_with_permission(self): - user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': True}) - assert user.can_invite_staff() is True - - def test_returns_false_for_manager_without_permission(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_invite_staff() is False - - def test_returns_false_for_manager_with_explicit_false_permission(self): - user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': False}) - assert user.can_invite_staff() is False def test_returns_false_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -204,9 +184,6 @@ class TestCanAccessTickets: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_access_tickets() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_access_tickets() is True def test_returns_true_for_staff_with_permission(self): user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_access_tickets': True}) @@ -292,9 +269,6 @@ class TestCanSelfApproveTimeOff: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_self_approve_time_off() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_self_approve_time_off() is True def test_returns_true_for_staff_with_permission(self): user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_self_approve_time_off': True}) @@ -320,9 +294,6 @@ class TestCanReviewTimeOffRequests: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_review_time_off_requests() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_review_time_off_requests() is True def test_returns_false_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -579,16 +550,6 @@ class TestUserCanSendMessages: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_send_messages() is True - def test_manager_can_send_by_default(self): - """Tenant manager can send messages by default.""" - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_send_messages() is True - - def test_manager_cannot_send_when_revoked(self): - """Tenant manager cannot send when permission revoked.""" - user = create_user_instance(User.Role.TENANT_MANAGER) - user.permissions = {'can_send_messages': False} - assert user.can_send_messages() is False def test_staff_cannot_send_messages(self): """Staff should not be able to send messages.""" diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py b/smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py index a5e69b2d..350a49f2 100644 --- a/smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py +++ b/smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py @@ -68,19 +68,7 @@ class TestUserHasStaffPermission: mock_user.role = 'TENANT_OWNER' # Simulate the has_staff_permission logic - if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: - result = True - else: - result = False - - assert result is True - - def test_manager_always_has_permission(self): - """Managers have all permissions""" - mock_user = Mock() - mock_user.role = 'TENANT_MANAGER' - - if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: + if mock_user.role == 'TENANT_OWNER': result = True else: result = False @@ -97,7 +85,7 @@ class TestUserHasStaffPermission: # Simulate permission resolution permission_key = 'can_access_scheduler' - if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: + if mock_user.role == 'TENANT_OWNER': result = True elif mock_user.role == 'TENANT_STAFF': if mock_user.permissions and permission_key in mock_user.permissions: @@ -120,7 +108,7 @@ class TestUserHasStaffPermission: mock_user.staff_role.permissions = {'can_access_scheduler': True} permission_key = 'can_access_scheduler' - if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: + if mock_user.role == 'TENANT_OWNER': result = True elif mock_user.role == 'TENANT_STAFF': if mock_user.permissions and permission_key in mock_user.permissions: @@ -142,7 +130,7 @@ class TestUserHasStaffPermission: mock_user.staff_role = None permission_key = 'can_access_scheduler' - if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: + if mock_user.role == 'TENANT_OWNER': result = True elif mock_user.role == 'TENANT_STAFF': if mock_user.permissions and permission_key in mock_user.permissions: @@ -163,7 +151,7 @@ class TestUserHasStaffPermission: mock_user.permissions = {'can_access_scheduler': True} # Even if set permission_key = 'can_access_scheduler' - if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: + if mock_user.role == 'TENANT_OWNER': result = True elif mock_user.role == 'TENANT_STAFF': if mock_user.permissions and permission_key in mock_user.permissions: diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py b/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py index 063b770c..6b25c879 100644 --- a/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py +++ b/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py @@ -71,9 +71,6 @@ class TestIsPlatformUser: user = create_user_instance(User.Role.TENANT_OWNER) assert user.is_platform_user() is False - def test_returns_false_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.is_platform_user() is False def test_returns_false_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -107,9 +104,6 @@ class TestIsTenantUser: user = create_user_instance(User.Role.TENANT_OWNER) assert user.is_tenant_user() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.is_tenant_user() is True def test_returns_true_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -139,9 +133,6 @@ class TestCanManageUsers: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_manage_users() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_manage_users() is True def test_returns_false_for_platform_sales(self): user = create_user_instance(User.Role.PLATFORM_SALES) @@ -187,9 +178,6 @@ class TestCanAccessBilling: user = create_user_instance(User.Role.PLATFORM_SUPPORT) assert user.can_access_billing() is False - def test_returns_false_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_access_billing() is False def test_returns_false_for_tenant_staff(self): user = create_user_instance(User.Role.TENANT_STAFF) @@ -211,23 +199,6 @@ class TestCanInviteStaff: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_invite_staff() is True - def test_returns_true_for_manager_with_permission(self): - user = create_user_instance( - User.Role.TENANT_MANAGER, - permissions={'can_invite_staff': True} - ) - assert user.can_invite_staff() is True - - def test_returns_false_for_manager_without_permission(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_invite_staff() is False - - def test_returns_false_for_manager_with_explicit_false_permission(self): - user = create_user_instance( - User.Role.TENANT_MANAGER, - permissions={'can_invite_staff': False} - ) - assert user.can_invite_staff() is False def test_returns_false_for_superuser(self): user = create_user_instance(User.Role.SUPERUSER) @@ -273,9 +244,6 @@ class TestCanAccessTickets: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_access_tickets() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_access_tickets() is True def test_returns_true_for_staff_with_permission(self): user = create_user_instance( @@ -341,9 +309,6 @@ class TestCanApprovePlugins: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_approve_plugins() is False - def test_returns_false_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_approve_plugins() is False def test_returns_false_for_customer(self): user = create_user_instance(User.Role.CUSTOMER) @@ -407,9 +372,6 @@ class TestCanSelfApproveTimeOff: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_self_approve_time_off() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_self_approve_time_off() is True def test_returns_true_for_staff_with_permission(self): user = create_user_instance( @@ -449,9 +411,6 @@ class TestCanReviewTimeOffRequests: user = create_user_instance(User.Role.TENANT_OWNER) assert user.can_review_time_off_requests() is True - def test_returns_true_for_tenant_manager(self): - user = create_user_instance(User.Role.TENANT_MANAGER) - assert user.can_review_time_off_requests() is True def test_returns_false_for_superuser(self): user = create_user_instance(User.Role.SUPERUSER) diff --git a/smoothschedule/smoothschedule/platform/api/views.py b/smoothschedule/smoothschedule/platform/api/views.py index 51b3ce24..c80f1449 100644 --- a/smoothschedule/smoothschedule/platform/api/views.py +++ b/smoothschedule/smoothschedule/platform/api/views.py @@ -172,7 +172,7 @@ class APITokenViewSet(viewsets.ViewSet): self._check_api_access_permission(tenant) # Only owners can manage API tokens (roles are uppercase in DB) - allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER'] + allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER'] if user.role.upper() not in allowed_roles: return Response( {'error': 'forbidden', 'message': 'Only business owners can manage API tokens'}, @@ -200,7 +200,7 @@ class APITokenViewSet(viewsets.ViewSet): # Check API access permission self._check_api_access_permission(tenant) - allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER'] + allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER'] if user.role.upper() not in allowed_roles: return Response( {'error': 'forbidden', 'message': 'Only business owners can create API tokens'}, @@ -261,7 +261,7 @@ class APITokenViewSet(viewsets.ViewSet): status=status.HTTP_403_FORBIDDEN ) - allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER'] + allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER'] if user.role.upper() not in allowed_roles: return Response( {'error': 'forbidden', 'message': 'Only business owners can revoke API tokens'}, diff --git a/smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py index c8af09c1..20a7aff1 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py @@ -229,13 +229,13 @@ class Command(BaseCommand): status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS") self.stdout.write(f" {status} {owner.email} (Owner)") - # Manager + # Manager (now TENANT_STAFF with Full Access Staff role) manager_data = { "username": "manager@demo.com", "email": "manager@demo.com", "first_name": "Marcus", "last_name": "Chen", - "role": User.Role.TENANT_MANAGER, + "role": User.Role.TENANT_STAFF, "tenant": tenant, "phone": "555-100-0002", } @@ -246,10 +246,18 @@ class Command(BaseCommand): if created: manager.set_password("test123") manager.save() + # Assign Full Access Staff role + full_access_role = StaffRole.objects.filter( + tenant=tenant, + name="Full Access Staff" + ).first() + if full_access_role and manager.staff_role != full_access_role: + manager.staff_role = full_access_role + manager.save(update_fields=['staff_role']) users["manager"] = manager if not self.quiet: status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS") - self.stdout.write(f" {status} {manager.email} (Manager)") + self.stdout.write(f" {status} {manager.email} (Full Access Staff)") # Staff members (stylists and spa therapists) staff_data = [ diff --git a/smoothschedule/smoothschedule/scheduling/schedule/management/commands/seed_data.py b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/seed_data.py index 39bd8dc4..63d8a938 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/management/commands/seed_data.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/seed_data.py @@ -21,7 +21,7 @@ from django.utils import timezone from django_tenants.utils import schema_context, tenant_context from smoothschedule.identity.core.models import Tenant, Domain -from smoothschedule.identity.users.models import User +from smoothschedule.identity.users.models import User, StaffRole from smoothschedule.scheduling.schedule.models import ( Event, Participant, @@ -254,11 +254,12 @@ class Command(BaseCommand): "username": "manager@demo.com", "email": "manager@demo.com", "password": "test123", - "role": User.Role.TENANT_MANAGER, + "role": User.Role.TENANT_STAFF, "first_name": "Business", "last_name": "Manager", "tenant": tenant, "phone": "555-100-0002", + "_assign_full_access": True, # Flag to assign Full Access Staff role }, { "username": "staff@demo.com", @@ -273,8 +274,10 @@ class Command(BaseCommand): ] created_users = {} + manager_user = None # Track manager user separately for user_data in tenant_users: password = user_data.pop("password") + assign_full_access = user_data.pop("_assign_full_access", False) user, created = User.objects.get_or_create( username=user_data["username"], defaults=user_data, @@ -285,9 +288,24 @@ class Command(BaseCommand): status = self.style.SUCCESS("CREATED") else: status = self.style.WARNING("EXISTS") + + # Assign Full Access Staff role if flagged + if assign_full_access: + full_access_role = StaffRole.objects.filter( + tenant=tenant, + name="Full Access Staff" + ).first() + if full_access_role and user.staff_role != full_access_role: + user.staff_role = full_access_role + user.save(update_fields=['staff_role']) + manager_user = user # Track for resource creation + self.stdout.write(f" {status} {user.email} ({user.get_role_display()})") created_users[user_data["role"]] = user + # Store manager user under a special key for backward compatibility + created_users["_manager"] = manager_user + return created_users def create_resource_types(self): @@ -405,7 +423,7 @@ class Command(BaseCommand): }, { "name": "Business Manager", - "user": tenant_users.get(User.Role.TENANT_MANAGER), + "user": tenant_users.get("_manager"), "description": "Business manager - handles VIP appointments", "resource_type": staff_type, "type": Resource.Type.STAFF, diff --git a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py index 1fd12c2b..ae215084 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py @@ -172,9 +172,9 @@ class CustomerSerializer(serializers.ModelSerializer): fields = [ 'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip', 'total_spend', 'last_visit', 'status', 'avatar_url', 'tags', - 'user_id', 'user_data', 'notes', + 'user_id', 'user_data', 'notes', 'email_verified', ] - read_only_fields = ['id'] + read_only_fields = ['id', 'email_verified'] def create(self, validated_data): """Create a customer with email as username""" @@ -260,8 +260,9 @@ class StaffSerializer(serializers.ModelSerializer): 'id', 'username', 'name', 'email', 'phone', 'role', 'is_active', 'permissions', 'can_invite_staff', 'staff_role_id', 'staff_role_name', 'effective_permissions', + 'email_verified', ] - read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff', 'effective_permissions'] + read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff', 'effective_permissions', 'email_verified'] def get_name(self, obj): return obj.full_name @@ -270,7 +271,6 @@ class StaffSerializer(serializers.ModelSerializer): # Map database roles to frontend roles role_mapping = { 'TENANT_OWNER': 'owner', - 'TENANT_MANAGER': 'manager', 'TENANT_STAFF': 'staff', } return role_mapping.get(obj.role, obj.role.lower()) diff --git a/smoothschedule/smoothschedule/scheduling/schedule/signals.py b/smoothschedule/smoothschedule/scheduling/schedule/signals.py index 08f07e0c..592f1717 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/signals.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/signals.py @@ -675,13 +675,26 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs): f"for resource '{instance.resource.name}'" ) - # Find all managers and owners to notify + # Find all users who can review time off requests to notify from smoothschedule.identity.users.models import User + # Get owners (always have permission) + staff with can_review_time_off permission reviewers = User.objects.filter( - role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER], + role=User.Role.TENANT_OWNER, is_active=True ) + # Also include staff who have the permission (via role or override) + # Note: This is a simplified query - for proper permission checking, + # we'd need to check each staff's effective_permissions + staff_reviewers = User.objects.filter( + role=User.Role.TENANT_STAFF, + is_active=True + ) + # Filter staff to those who can review time off + reviewers = list(reviewers) + [ + staff for staff in staff_reviewers + if staff.has_staff_permission('can_review_time_off') + ] # Create in-app notifications for each reviewer for reviewer in reviewers: diff --git a/smoothschedule/smoothschedule/scheduling/schedule/tests/test_serializers.py b/smoothschedule/smoothschedule/scheduling/schedule/tests/test_serializers.py index 3329b4e6..04b4ca89 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/tests/test_serializers.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/tests/test_serializers.py @@ -193,19 +193,6 @@ class TestStaffSerializer: # Assert assert role == 'owner' - def test_get_role_maps_tenant_manager(self): - """Test that TENANT_MANAGER maps to manager.""" - # Arrange - mock_user = Mock() - mock_user.role = 'TENANT_MANAGER' - - serializer = StaffSerializer() - - # Act - role = serializer.get_role(mock_user) - - # Assert - assert role == 'manager' def test_get_role_maps_tenant_staff(self): """Test that TENANT_STAFF maps to staff.""" @@ -1682,14 +1669,6 @@ class TestStaffSerializerMethodFields: result = serializer.get_name(mock_obj) assert result == 'Jane Smith' - def test_get_role_maps_tenant_manager_to_manager(self): - """Test get_role maps TENANT_MANAGER to manager.""" - serializer = StaffSerializer() - mock_obj = Mock() - mock_obj.role = 'TENANT_MANAGER' - - result = serializer.get_role(mock_obj) - assert result == 'manager' class TestResourceSerializerFields: diff --git a/smoothschedule/smoothschedule/scheduling/schedule/views.py b/smoothschedule/smoothschedule/scheduling/schedule/views.py index 67defa61..2b290f8f 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/views.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/views.py @@ -159,10 +159,15 @@ class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet): This endpoint provides the frontend with the full list of permission keys that can be configured on a staff role. """ - from smoothschedule.identity.users.staff_permissions import MENU_PERMISSIONS, DANGEROUS_PERMISSIONS + from smoothschedule.identity.users.staff_permissions import ( + MENU_PERMISSIONS, + SETTINGS_PERMISSIONS, + DANGEROUS_PERMISSIONS, + ) return Response({ 'menu_permissions': MENU_PERMISSIONS, + 'settings_permissions': SETTINGS_PERMISSIONS, 'dangerous_permissions': DANGEROUS_PERMISSIONS, }) @@ -769,6 +774,21 @@ class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet): tenant=tenant, ) + @action(detail=True, methods=['post']) + def verify_email(self, request, pk=None): + """Toggle a customer's email verification status.""" + customer = self.get_object() + + customer.email_verified = not customer.email_verified + customer.save(update_fields=['email_verified']) + + action = 'verified' if customer.email_verified else 'unverified' + return Response({ + 'id': customer.id, + 'email_verified': customer.email_verified, + 'message': f'Email {action} successfully.' + }) + class ServiceViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet): """ @@ -828,7 +848,7 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet): """ API endpoint for managing staff members (Users who can be assigned to resources). - Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF. + Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF. Supports: - GET /api/staff/ - List staff members @@ -851,14 +871,13 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet): """ Return staff members for the current tenant. - Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF. + Staff are Users with roles: TENANT_OWNER, TENANT_STAFF. """ from django.db.models import Q # Set base queryset to staff roles only self.queryset = User.objects.filter( Q(role=User.Role.TENANT_OWNER) | - Q(role=User.Role.TENANT_MANAGER) | Q(role=User.Role.TENANT_STAFF) ) @@ -890,24 +909,31 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet): """ Update staff member. - Allowed fields: is_active, permissions + Allowed fields: is_active, permissions, staff_role_id, first_name, last_name, phone Owners can edit any staff member. - Managers can only edit staff (not other managers or owners). + Staff with can_access_staff permission can edit other staff (not owners). """ instance = self.get_object() - # TODO: Add permission checks when authentication is enabled - # current_user = request.user - # if current_user.role == User.Role.TENANT_MANAGER: - # if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: - # return Response( - # {'error': 'Managers cannot edit owners or other managers.'}, - # status=status.HTTP_403_FORBIDDEN - # ) + # Permission check: staff can only edit other staff, not owners + current_user = request.user + if current_user.role == User.Role.TENANT_STAFF: + # Staff can only edit if they have can_access_staff permission + if not current_user.has_staff_permission('can_access_staff'): + return Response( + {'error': 'You do not have permission to edit staff members.'}, + status=status.HTTP_403_FORBIDDEN + ) + # Staff cannot edit owner accounts + if instance.role == User.Role.TENANT_OWNER: + return Response( + {'error': 'You cannot edit owner accounts.'}, + status=status.HTTP_403_FORBIDDEN + ) # Only allow updating specific fields - allowed_fields = {'is_active', 'permissions'} + allowed_fields = {'is_active', 'permissions', 'staff_role_id', 'first_name', 'last_name', 'phone'} update_data = {k: v for k, v in request.data.items() if k in allowed_fields} serializer = self.get_serializer(instance, data=update_data, partial=True) @@ -938,6 +964,98 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet): 'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully." }) + @action(detail=True, methods=['post']) + def verify_email(self, request, pk=None): + """Toggle a staff member's email verification status.""" + staff = self.get_object() + + staff.email_verified = not staff.email_verified + staff.save(update_fields=['email_verified']) + + action = 'verified' if staff.email_verified else 'unverified' + return Response({ + 'id': staff.id, + 'email_verified': staff.email_verified, + 'message': f'Email {action} successfully.' + }) + + @action(detail=True, methods=['post']) + def send_password_reset(self, request, pk=None): + """ + Send a password reset email to the staff member. + + Owners or staff with can_access_staff permission can trigger password resets. + """ + from django.conf import settings + from smoothschedule.communication.messaging.email_service import send_plain_email + import secrets + + # Only owners or staff with can_access_staff permission can send password resets + can_manage = ( + request.user.role == User.Role.TENANT_OWNER or + request.user.has_staff_permission('can_access_staff') + ) + if not can_manage: + return Response( + {'error': 'You do not have permission to reset passwords.'}, + status=status.HTTP_403_FORBIDDEN + ) + + staff = self.get_object() + + # Generate a secure random password + temp_password = secrets.token_urlsafe(12) + + # Set the temporary password + staff.set_password(temp_password) + staff.save(update_fields=['password']) + + # Build login URL + port = ':5173' if settings.DEBUG else '' + subdomain = '' + if staff.tenant: + primary_domain = staff.tenant.domains.filter(is_primary=True).first() + if primary_domain: + subdomain = primary_domain.domain.split('.')[0] + '.' + + base_domain = 'lvh.me' if settings.DEBUG else 'smoothschedule.com' + login_url = f"https://{subdomain}{base_domain}{port}/login" + + # Send email + subject = "Password Reset - SmoothSchedule" + message = f"""Hi {staff.full_name}, + +Your password has been reset by the business owner. + +Your temporary password is: {temp_password} + +Please log in at {login_url} and change your password immediately. + +If you did not expect this email, please contact your business administrator. + +Thanks, +The SmoothSchedule Team +""" + + try: + send_plain_email( + subject, + message, + settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com', + [staff.email], + fail_silently=False, + ) + except Exception as e: + return Response( + {'error': f'Failed to send password reset email: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return Response({ + 'id': staff.id, + 'message': f'Password reset email sent to {staff.email}.' + }) + class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, viewsets.ModelViewSet): """