Improve staff management UI and add sorting functionality

- Remove WIP badge from staff sidebar navigation
- Make action buttons consistent between Customers and Staff pages
  - Edit button: icon + text with gray border
  - Masquerade button: icon + text with indigo border
  - Verify email button: icon-only with colored border (green/amber)
- Add sortable columns to Staff list (name and role)
- Include migrations for tenant manager role removal

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-17 19:29:13 -05:00
parent a80b35a806
commit 92019aac7e
68 changed files with 1827 additions and 788 deletions

View File

@@ -456,7 +456,7 @@ const AppContent: React.FC = () => {
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api' && currentHostname !== baseDomain; const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api' && currentHostname !== baseDomain;
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role); 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'; const isCustomer = user.role === 'customer';
// RULE: Platform users on business subdomains should be redirected to platform subdomain // 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 // Helper to check access based on roles
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role); 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) { if (isPlatformUser) {
return ( return (
<Suspense fallback={<LoadingScreen />}> <Suspense fallback={<LoadingScreen />}>
@@ -658,8 +667,8 @@ const AppContent: React.FC = () => {
); );
} }
// Business users (owner, manager, staff, resource) // Business users (owner, staff, resource)
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) { if (['owner', 'staff', 'resource'].includes(user.role)) {
// Check if email verification is required // Check if email verification is required
if (!user.email_verified) { if (!user.email_verified) {
return ( return (
@@ -799,7 +808,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/automations/marketplace" path="/dashboard/automations/marketplace"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_automations') ? (
<AutomationMarketplace /> <AutomationMarketplace />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -809,7 +818,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/automations/my-automations" path="/dashboard/automations/my-automations"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_automations') ? (
<MyAutomations /> <MyAutomations />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -819,7 +828,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/automations/create" path="/dashboard/automations/create"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_automations') ? (
<CreateAutomation /> <CreateAutomation />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -829,7 +838,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/tasks" path="/dashboard/tasks"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_tasks') ? (
<Tasks /> <Tasks />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -841,7 +850,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/customers" path="/dashboard/customers"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_customers') ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} /> <Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -851,7 +860,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/services" path="/dashboard/services"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_services') ? (
<Services /> <Services />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -861,7 +870,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/resources" path="/dashboard/resources"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_resources') ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} /> <Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -871,7 +880,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/staff" path="/dashboard/staff"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_staff') ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} /> <Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -881,7 +890,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/time-blocks" path="/dashboard/time-blocks"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_time_blocks') ? (
<TimeBlocks /> <TimeBlocks />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -891,7 +900,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/locations" path="/dashboard/locations"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_locations') ? (
<Locations /> <Locations />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -911,7 +920,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/contracts" path="/dashboard/contracts"
element={ element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? ( canAccess('can_access_contracts') && canUse('contracts') ? (
<Contracts /> <Contracts />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -921,7 +930,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/contracts/templates" path="/dashboard/contracts/templates"
element={ element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? ( canAccess('can_access_contracts') && canUse('contracts') ? (
<ContractTemplates /> <ContractTemplates />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -931,13 +940,13 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/payments" path="/dashboard/payments"
element={ element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" /> canAccess('can_access_payments') ? <Payments /> : <Navigate to="/dashboard" />
} }
/> />
<Route <Route
path="/dashboard/messages" path="/dashboard/messages"
element={ element={
hasAccess(['owner', 'manager']) && user?.can_send_messages ? ( canAccess('can_access_messages') ? (
<Messages /> <Messages />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -947,7 +956,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/site-editor" path="/dashboard/site-editor"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_site_editor') ? (
<PageEditor /> <PageEditor />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -967,7 +976,7 @@ const AppContent: React.FC = () => {
<Route <Route
path="/dashboard/gallery" path="/dashboard/gallery"
element={ element={
hasAccess(['owner', 'manager']) ? ( canAccess('can_access_gallery') ? (
<MediaGalleryPage /> <MediaGalleryPage />
) : ( ) : (
<Navigate to="/dashboard" /> <Navigate to="/dashboard" />
@@ -975,7 +984,8 @@ const AppContent: React.FC = () => {
} }
/> />
{/* Settings Routes with Nested Layout */} {/* Settings Routes with Nested Layout */}
{hasAccess(['owner']) ? ( {/* Owners have full access, staff need can_access_settings permission */}
{canAccess('can_access_settings') ? (
<Route path="/dashboard/settings" element={<SettingsLayout />}> <Route path="/dashboard/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/dashboard/settings/general" replace />} /> <Route index element={<Navigate to="/dashboard/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} /> <Route path="general" element={<GeneralSettings />} />

View File

@@ -71,6 +71,9 @@ export interface User {
business_name?: string; business_name?: string;
business_subdomain?: string; business_subdomain?: string;
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
effective_permissions?: Record<string, boolean>;
staff_role_id?: number | null;
staff_role_name?: string | null;
can_invite_staff?: boolean; can_invite_staff?: boolean;
can_access_tickets?: boolean; can_access_tickets?: boolean;
can_edit_schedule?: boolean; can_edit_schedule?: boolean;

View File

@@ -55,18 +55,18 @@ const testUsers: TestUser[] = [
category: 'business', category: 'business',
}, },
{ {
email: 'manager@demo.com', email: 'staff@demo.com',
password: 'test123', password: 'test123',
role: 'TENANT_MANAGER', role: 'TENANT_STAFF',
label: 'Business Manager', label: 'Staff (Full Access)',
color: 'bg-pink-600 hover:bg-pink-700', color: 'bg-pink-600 hover:bg-pink-700',
category: 'business', category: 'business',
}, },
{ {
email: 'staff@demo.com', email: 'limited-staff@demo.com',
password: 'test123', password: 'test123',
role: 'TENANT_STAFF', role: 'TENANT_STAFF',
label: 'Staff Member', label: 'Staff (Limited)',
color: 'bg-teal-600 hover:bg-teal-700', color: 'bg-teal-600 hover:bg-teal-700',
category: 'business', category: 'business',
}, },

View File

@@ -46,10 +46,10 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { canUse } = usePlanFeatures(); const { canUse } = usePlanFeatures();
// Helper to check if user has a specific staff permission // 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) // Staff members check their effective_permissions (role + user overrides)
const hasPermission = (permissionKey: string): boolean => { const hasPermission = (permissionKey: string): boolean => {
if (role === 'owner' || role === 'manager') { if (role === 'owner') {
return true; return true;
} }
if (role === 'staff') { if (role === 'staff') {
@@ -59,10 +59,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
return false; return false;
}; };
const canViewAdminPages = role === 'owner' || role === 'manager'; // Admin/management access is based on effective permissions for staff
const canViewManagementPages = role === 'owner' || role === 'manager'; const canViewAdminPages = role === 'owner' || hasPermission('can_access_staff');
const canViewManagementPages = role === 'owner' || hasPermission('can_access_scheduler');
const isStaff = role === 'staff'; const isStaff = role === 'staff';
const canViewSettings = role === 'owner'; const canViewSettings = role === 'owner' || hasPermission('can_access_settings');
const canViewTickets = hasPermission('can_access_tickets'); const canViewTickets = hasPermission('can_access_tickets');
const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true; const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true;
@@ -191,7 +192,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users} icon={Users}
label={t('nav.customers')} label={t('nav.customers')}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/> />
)} )}
{hasPermission('can_access_services') && ( {hasPermission('can_access_services') && (
@@ -216,7 +216,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users} icon={Users}
label={t('nav.staff')} label={t('nav.staff')}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/> />
)} )}
{hasPermission('can_access_contracts') && canUse('contracts') && ( {hasPermission('can_access_contracts') && canUse('contracts') && (

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight } from 'lucide-react';
export interface PermissionConfig { export interface PermissionConfig {
key: string; key: string;
@@ -8,20 +9,134 @@ export interface PermissionConfig {
hintKey: string; hintKey: string;
hintDefault: string; hintDefault: string;
defaultValue: boolean; 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 // Define all available permissions in one place
// All permissions are now available to staff (via staff roles)
export const PERMISSION_CONFIGS: PermissionConfig[] = [ export const PERMISSION_CONFIGS: PermissionConfig[] = [
// Manager-only permissions
{ {
key: 'can_invite_staff', key: 'can_invite_staff',
labelKey: 'staff.canInviteStaff', labelKey: 'staff.canInviteStaff',
labelDefault: 'Can invite new staff members', labelDefault: 'Can invite new staff members',
hintKey: 'staff.canInviteStaffHint', 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, defaultValue: false,
roles: ['manager'],
}, },
{ {
key: 'can_manage_resources', key: 'can_manage_resources',
@@ -29,8 +144,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can manage resources', labelDefault: 'Can manage resources',
hintKey: 'staff.canManageResourcesHint', hintKey: 'staff.canManageResourcesHint',
hintDefault: 'Create, edit, and delete bookable resources', hintDefault: 'Create, edit, and delete bookable resources',
defaultValue: true, defaultValue: false,
roles: ['manager'],
}, },
{ {
key: 'can_manage_services', key: 'can_manage_services',
@@ -38,8 +152,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can manage services', labelDefault: 'Can manage services',
hintKey: 'staff.canManageServicesHint', hintKey: 'staff.canManageServicesHint',
hintDefault: 'Create, edit, and delete service offerings', hintDefault: 'Create, edit, and delete service offerings',
defaultValue: true, defaultValue: false,
roles: ['manager'],
}, },
{ {
key: 'can_view_reports', key: 'can_view_reports',
@@ -47,17 +160,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can view reports', labelDefault: 'Can view reports',
hintKey: 'staff.canViewReportsHint', hintKey: 'staff.canViewReportsHint',
hintDefault: 'Access business analytics and financial reports', 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, defaultValue: false,
roles: ['manager'],
}, },
{ {
key: 'can_refund_payments', key: 'can_refund_payments',
@@ -66,7 +169,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canRefundPaymentsHint', hintKey: 'staff.canRefundPaymentsHint',
hintDefault: 'Process refunds for customer payments', hintDefault: 'Process refunds for customer payments',
defaultValue: false, defaultValue: false,
roles: ['manager'],
}, },
{ {
key: 'can_send_messages', key: 'can_send_messages',
@@ -74,10 +176,8 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can send broadcast messages', labelDefault: 'Can send broadcast messages',
hintKey: 'staff.canSendMessagesHint', hintKey: 'staff.canSendMessagesHint',
hintDefault: 'Send messages to groups of staff and customers', hintDefault: 'Send messages to groups of staff and customers',
defaultValue: true, defaultValue: false,
roles: ['manager'],
}, },
// Staff-only permissions
{ {
key: 'can_view_all_schedules', key: 'can_view_all_schedules',
labelKey: 'staff.canViewAllSchedules', labelKey: 'staff.canViewAllSchedules',
@@ -85,7 +185,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canViewAllSchedulesHint', hintKey: 'staff.canViewAllSchedulesHint',
hintDefault: 'View schedules of other staff members (otherwise only their own)', hintDefault: 'View schedules of other staff members (otherwise only their own)',
defaultValue: false, defaultValue: false,
roles: ['staff'],
}, },
{ {
key: 'can_manage_own_appointments', key: 'can_manage_own_appointments',
@@ -94,112 +193,132 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canManageOwnAppointmentsHint', hintKey: 'staff.canManageOwnAppointmentsHint',
hintDefault: 'Create, reschedule, and cancel their own appointments', hintDefault: 'Create, reschedule, and cancel their own appointments',
defaultValue: true, defaultValue: true,
roles: ['staff'],
}, },
{ {
key: 'can_self_approve_time_off', key: 'can_self_approve_time_off',
labelKey: 'staff.canSelfApproveTimeOff', labelKey: 'staff.canSelfApproveTimeOff',
labelDefault: 'Can self-approve time off', labelDefault: 'Can self-approve time off',
hintKey: 'staff.canSelfApproveTimeOffHint', hintKey: 'staff.canSelfApproveTimeOffHint',
hintDefault: 'Add time off without requiring manager/owner approval', hintDefault: 'Add time off without requiring owner approval',
defaultValue: false, defaultValue: false,
roles: ['staff'],
}, },
// Shared permissions (both manager and staff)
{ {
key: 'can_access_tickets', key: 'can_access_tickets',
labelKey: 'staff.canAccessTickets', labelKey: 'staff.canAccessTickets',
labelDefault: 'Can access support tickets', labelDefault: 'Can access support tickets',
hintKey: 'staff.canAccessTicketsHint', hintKey: 'staff.canAccessTicketsHint',
hintDefault: 'View and manage customer support tickets', hintDefault: 'View and manage customer support tickets',
defaultValue: true, // Default for managers; staff will override to false defaultValue: false,
roles: ['manager', 'staff'],
}, },
]; ];
// Get default permissions for a role // Get default permissions for staff
export const getDefaultPermissions = (role: 'manager' | 'staff'): Record<string, boolean> => { export const getDefaultPermissions = (): Record<string, boolean> => {
const defaults: Record<string, boolean> = {}; const defaults: Record<string, boolean> = {};
PERMISSION_CONFIGS.forEach((config) => { PERMISSION_CONFIGS.forEach((config) => {
if (config.roles.includes(role)) { defaults[config.key] = config.defaultValue;
// 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;
}
}
}); });
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
defaults[config.key] = config.defaultValue;
});
defaults['can_access_settings'] = false;
return defaults; return defaults;
}; };
interface StaffPermissionsProps { interface StaffPermissionsProps {
role: 'manager' | 'staff'; role: 'staff';
permissions: Record<string, boolean>; permissions: Record<string, boolean>;
onChange: (permissions: Record<string, boolean>) => void; onChange: (permissions: Record<string, boolean>) => void;
variant?: 'invite' | 'edit'; variant?: 'invite' | 'edit';
} }
const StaffPermissions: React.FC<StaffPermissionsProps> = ({ const StaffPermissions: React.FC<StaffPermissionsProps> = ({
role,
permissions, permissions,
onChange, onChange,
variant = 'edit',
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [settingsExpanded, setSettingsExpanded] = useState(false);
// 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 });
};
// Get the current value, falling back to default // Get the current value, falling back to default
const getValue = (config: PermissionConfig): boolean => { const getValue = (key: string, defaultValue: boolean = false): boolean => {
if (permissions[config.key] !== undefined) { if (permissions[key] !== undefined) {
return permissions[config.key]; return permissions[key];
} }
// Staff have ticket access disabled by default return defaultValue;
if (role === 'staff' && config.key === 'can_access_tickets') {
return false;
}
return config.defaultValue;
}; };
// Different styling for manager vs staff permissions const hasSettingsAccess = getValue('can_access_settings', false);
const isManagerPermission = (config: PermissionConfig) =>
config.roles.includes('manager') && !config.roles.includes('staff');
const getPermissionStyle = (config: PermissionConfig) => { // Auto-expand settings section if any settings permissions are enabled
if (isManagerPermission(config) || role === 'manager') { useEffect(() => {
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'; 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) { const handleSettingsMainToggle = (checked: boolean) => {
return null; 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 ( return (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300"> <h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{role === 'manager' {t('staff.staffPermissions', 'Staff Permissions')}
? t('staff.managerPermissions', 'Manager Permissions')
: t('staff.staffPermissions', 'Staff Permissions')}
</h4> </h4>
{rolePermissions.map((config) => ( {/* Regular permissions */}
{PERMISSION_CONFIGS.map((config) => (
<label <label
key={config.key} key={config.key}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${getPermissionStyle(config)}`} className="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
<input <input
type="checkbox" type="checkbox"
checked={getValue(config)} checked={getValue(config.key, config.defaultValue)}
onChange={(e) => handleToggle(config.key, e.target.checked)} onChange={(e) => handleToggle(config.key, e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500" className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/> />
@@ -213,6 +332,113 @@ const StaffPermissions: React.FC<StaffPermissionsProps> = ({
</div> </div>
</label> </label>
))} ))}
{/* Business Settings Section */}
<div className="border rounded-lg border-gray-200 dark:border-gray-600 overflow-hidden">
{/* Main Business Settings Toggle */}
<div
className={`flex items-start gap-3 p-3 cursor-pointer transition-colors ${
hasSettingsAccess
? 'bg-brand-50 dark:bg-brand-900/20'
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<input
type="checkbox"
checked={hasSettingsAccess}
onChange={(e) => handleSettingsMainToggle(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div
className="flex-1"
onClick={() => hasSettingsAccess && setSettingsExpanded(!settingsExpanded)}
>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{t('staff.canAccessSettings', 'Can access business settings')}
</span>
{hasSettingsAccess && enabledSettingsCount > 0 && (
<span className="ml-2 text-xs text-brand-600 dark:text-brand-400">
({enabledSettingsCount}/{SETTINGS_PERMISSION_CONFIGS.length} enabled)
</span>
)}
</div>
{hasSettingsAccess && (
<button
type="button"
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
onClick={(e) => {
e.stopPropagation();
setSettingsExpanded(!settingsExpanded);
}}
>
{settingsExpanded ? (
<ChevronDown size={16} className="text-gray-500" />
) : (
<ChevronRight size={16} className="text-gray-500" />
)}
</button>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t(
'staff.canAccessSettingsHint',
'Access to business settings pages (select specific pages below)'
)}
</p>
</div>
</div>
{/* Sub-permissions (collapsible) */}
{hasSettingsAccess && settingsExpanded && (
<div className="border-t border-gray-200 dark:border-gray-600 bg-gray-25 dark:bg-gray-800/50">
{/* Select All / None buttons */}
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-600 flex gap-2">
<button
type="button"
onClick={() => handleSelectAllSettings(true)}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
>
{t('staff.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => handleSelectAllSettings(false)}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
>
{t('staff.selectNone', 'Select None')}
</button>
</div>
{/* Individual settings permissions */}
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{SETTINGS_PERMISSION_CONFIGS.map((config) => (
<label
key={config.key}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<input
type="checkbox"
checked={getValue(config.key, config.defaultValue)}
onChange={(e) => handleToggle(config.key, e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{t(config.labelKey, config.labelDefault)}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t(config.hintKey, config.hintDefault)}
</p>
</div>
</label>
))}
</div>
</div>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -50,7 +50,7 @@ describe('MasqueradeBanner', () => {
it('shows return to previous user text when previousUser exists', () => { it('shows return to previous user text when previousUser exists', () => {
const propsWithPrevious = { const propsWithPrevious = {
...defaultProps, ...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(<MasqueradeBanner {...propsWithPrevious} />); render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument(); expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();

View File

@@ -518,8 +518,8 @@ describe('TopBar', () => {
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument(); expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
}); });
it('should render for manager role', () => { it('should render for staff with permissions', () => {
const user = createMockUser({ role: 'manager' }); const user = createMockUser({ role: 'staff' });
renderWithRouter( renderWithRouter(
<TopBar <TopBar

View File

@@ -51,8 +51,8 @@ describe('useInvitations hooks', () => {
{ {
id: 1, id: 1,
email: 'john@example.com', email: 'john@example.com',
role: 'TENANT_MANAGER', role: 'TENANT_STAFF',
role_display: 'Manager', role_display: 'Staff',
status: 'PENDING', status: 'PENDING',
invited_by: 5, invited_by: 5,
invited_by_name: 'Admin User', invited_by_name: 'Admin User',
@@ -205,10 +205,10 @@ describe('useInvitations hooks', () => {
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData); expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData);
}); });
it('creates manager invitation with permissions', async () => { it('creates staff invitation with permissions', async () => {
const invitationData: CreateInvitationData = { const invitationData: CreateInvitationData = {
email: 'manager@example.com', email: 'staff@example.com',
role: 'TENANT_MANAGER', role: 'TENANT_STAFF',
permissions: { permissions: {
can_invite_staff: true, can_invite_staff: true,
can_manage_resources: 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 }); vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const { result } = renderHook(() => useCreateInvitation(), { const { result } = renderHook(() => useCreateInvitation(), {

View File

@@ -46,7 +46,7 @@ describe('useStaff hooks', () => {
name: 'John Doe', name: 'John Doe',
email: 'john@example.com', email: 'john@example.com',
phone: '555-1234', phone: '555-1234',
role: 'TENANT_MANAGER', role: 'TENANT_STAFF',
is_active: true, is_active: true,
permissions: { can_invite_staff: true }, permissions: { can_invite_staff: true },
can_invite_staff: true, can_invite_staff: true,
@@ -79,7 +79,7 @@ describe('useStaff hooks', () => {
name: 'John Doe', name: 'John Doe',
email: 'john@example.com', email: 'john@example.com',
phone: '555-1234', phone: '555-1234',
role: 'TENANT_MANAGER', role: 'TENANT_STAFF',
is_active: true, is_active: true,
permissions: { can_invite_staff: true }, permissions: { can_invite_staff: true },
can_invite_staff: true, can_invite_staff: true,

View File

@@ -122,11 +122,11 @@ export const useIsAuthenticated = (): boolean => {
/** /**
* Get the redirect path based on user role * Get the redirect path based on user role
* Tenant users go to /dashboard/, platform users go to / * 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 => { const getRedirectPathForRole = (role: string): string => {
// Tenant roles (as returned by backend after role mapping) // Tenant roles (as returned by backend after role mapping)
const tenantRoles = ['owner', 'manager', 'staff', 'customer']; const tenantRoles = ['owner', 'staff', 'customer'];
if (tenantRoles.includes(role)) { if (tenantRoles.includes(role)) {
return '/dashboard/'; return '/dashboard/';
} }

View File

@@ -38,6 +38,7 @@ const transformCustomer = (c: any): Customer => ({
paymentMethods: [], paymentMethods: [],
user_data: c.user_data, user_data: c.user_data,
notes: c.notes || '', 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'] });
},
});
};

View File

@@ -8,7 +8,7 @@ import apiClient from '../api/client';
export interface StaffInvitation { export interface StaffInvitation {
id: number; id: number;
email: string; email: string;
role: 'TENANT_MANAGER' | 'TENANT_STAFF'; role: 'TENANT_STAFF';
role_display: string; role_display: string;
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED'; status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED';
invited_by: number | null; invited_by: number | null;
@@ -50,7 +50,7 @@ export interface StaffPermissions {
export interface CreateInvitationData { export interface CreateInvitationData {
email: string; email: string;
role: 'TENANT_MANAGER' | 'TENANT_STAFF'; role: 'TENANT_STAFF';
create_bookable_resource?: boolean; create_bookable_resource?: boolean;
resource_name?: string; resource_name?: string;
permissions?: StaffPermissions; permissions?: StaffPermissions;

View File

@@ -13,6 +13,8 @@ export interface StaffPermissions {
export interface StaffMember { export interface StaffMember {
id: string; id: string;
name: string; name: string;
first_name: string;
last_name: string;
email: string; email: string;
phone?: string; phone?: string;
role: string; role: string;
@@ -22,6 +24,7 @@ export interface StaffMember {
staff_role_id: number | null; staff_role_id: number | null;
staff_role_name: string | null; staff_role_name: string | null;
effective_permissions: Record<string, boolean>; effective_permissions: Record<string, boolean>;
email_verified: boolean;
} }
interface StaffFilters { interface StaffFilters {
@@ -30,7 +33,7 @@ interface StaffFilters {
/** /**
* Hook to fetch staff members with optional filters * 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) => { export const useStaff = (filters?: StaffFilters) => {
return useQuery<StaffMember[]>({ return useQuery<StaffMember[]>({
@@ -46,6 +49,8 @@ export const useStaff = (filters?: StaffFilters) => {
return data.map((s: any) => ({ return data.map((s: any) => ({
id: String(s.id), id: String(s.id),
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email, 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 || '', email: s.email || '',
phone: s.phone || '', phone: s.phone || '',
role: s.role || 'staff', role: s.role || 'staff',
@@ -55,14 +60,27 @@ export const useStaff = (filters?: StaffFilters) => {
staff_role_id: s.staff_role_id ?? null, staff_role_id: s.staff_role_id ?? null,
staff_role_name: s.staff_role_name ?? null, staff_role_name: s.staff_role_name ?? null,
effective_permissions: s.effective_permissions || {}, effective_permissions: s.effective_permissions || {},
email_verified: s.email_verified ?? false,
})); }));
}, },
retry: 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 = () => { export const useUpdateStaff = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -73,7 +91,7 @@ export const useUpdateStaff = () => {
updates, updates,
}: { }: {
id: string; id: string;
updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null }; updates: StaffUpdate;
}) => { }) => {
const { data } = await apiClient.patch(`/staff/${id}/`, updates); const { data } = await apiClient.patch(`/staff/${id}/`, updates);
return data; 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'] });
},
});
};

View File

@@ -917,7 +917,19 @@
"noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.", "noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.",
"addNewCustomer": "Neuen Kunden Hinzufügen", "addNewCustomer": "Neuen Kunden Hinzufügen",
"createCustomer": "Kunden Erstellen", "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": { "staff": {
"title": "Personal & Management", "title": "Personal & Management",
@@ -930,7 +942,15 @@
"yes": "Ja", "yes": "Ja",
"errorLoading": "Fehler beim Laden des Personals", "errorLoading": "Fehler beim Laden des Personals",
"inviteModalTitle": "Personal Einladen", "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": { "resources": {
"title": "Ressourcen", "title": "Ressourcen",

View File

@@ -936,9 +936,8 @@
"emailPlaceholder": "colleague@example.com", "emailPlaceholder": "colleague@example.com",
"roleLabel": "Role", "roleLabel": "Role",
"roleStaff": "Staff Member", "roleStaff": "Staff Member",
"roleManager": "Manager", "roleOwner": "Owner",
"managerRoleHint": "Managers can manage staff, resources, and view reports", "staffRoleHint": "Staff permissions are determined by their assigned role",
"staffRoleHint": "Staff members can manage their own schedule and appointments",
"makeBookableHint": "Create a bookable resource so customers can schedule appointments with this person", "makeBookableHint": "Create a bookable resource so customers can schedule appointments with this person",
"resourceName": "Display Name (optional)", "resourceName": "Display Name (optional)",
"resourceNamePlaceholder": "Defaults to person's name", "resourceNamePlaceholder": "Defaults to person's name",
@@ -958,7 +957,7 @@
"canSendMessagesHint": "Send messages to groups of staff and customers", "canSendMessagesHint": "Send messages to groups of staff and customers",
"deactivate": "Deactivate", "deactivate": "Deactivate",
"canInviteStaff": "Can invite new staff members", "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", "canManageResources": "Can manage resources",
"canManageResourcesHint": "Create, edit, and delete bookable resources", "canManageResourcesHint": "Create, edit, and delete bookable resources",
"canManageServices": "Can manage services", "canManageServices": "Can manage services",
@@ -966,7 +965,37 @@
"canViewReports": "Can view reports", "canViewReports": "Can view reports",
"canViewReportsHint": "Access business analytics and financial reports", "canViewReportsHint": "Access business analytics and financial reports",
"canAccessSettings": "Can access business settings", "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", "canRefundPayments": "Can refund payments",
"canRefundPaymentsHint": "Process refunds for customer payments", "canRefundPaymentsHint": "Process refunds for customer payments",
"canViewAllSchedules": "Can view all schedules", "canViewAllSchedules": "Can view all schedules",
@@ -974,16 +1003,41 @@
"canManageOwnAppointments": "Can manage own appointments", "canManageOwnAppointments": "Can manage own appointments",
"canManageOwnAppointmentsHint": "Create, reschedule, and cancel their own appointments", "canManageOwnAppointmentsHint": "Create, reschedule, and cancel their own appointments",
"canSelfApproveTimeOff": "Can self-approve time off", "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", "canAccessTickets": "Can access support tickets",
"canAccessTicketsHint": "View and manage customer 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": { "staffDashboard": {
"welcomeTitle": "Welcome, {{name}}!", "welcomeTitle": "Welcome, {{name}}!",
"weekOverview": "Here's your week at a glance", "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", "currentAppointment": "Current Appointment",
"nextAppointment": "Next Appointment", "nextAppointment": "Next Appointment",
"viewSchedule": "View Schedule", "viewSchedule": "View Schedule",
@@ -1478,7 +1532,14 @@
"newPassword": "New Password", "newPassword": "New Password",
"passwordPlaceholder": "Leave blank to keep current password", "passwordPlaceholder": "Leave blank to keep current password",
"accountInfo": "Account Information", "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": { "resources": {
"title": "Resources", "title": "Resources",
@@ -1742,6 +1803,7 @@
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"noPermission": "You do not have permission to access these settings.",
"businessSettings": "Business Settings", "businessSettings": "Business Settings",
"businessSettingsDescription": "Manage your branding, domain, and policies.", "businessSettingsDescription": "Manage your branding, domain, and policies.",
"domainIdentity": "Domain & Identity", "domainIdentity": "Domain & Identity",
@@ -1921,6 +1983,12 @@
"roleDescriptionPlaceholder": "Brief description of this role's responsibilities", "roleDescriptionPlaceholder": "Brief description of this role's responsibilities",
"permissions": "Permissions", "permissions": "Permissions",
"menuAccess": "Menu Access", "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", "dangerousOperations": "Dangerous Operations",
"staffAssigned": "{{count}} staff assigned", "staffAssigned": "{{count}} staff assigned",
"noStaffAssigned": "No staff assigned", "noStaffAssigned": "No staff assigned",
@@ -3406,7 +3474,7 @@
"title": "My Availability", "title": "My Availability",
"subtitle": "Manage your time off and unavailability", "subtitle": "Manage your time off and unavailability",
"noResource": "No Resource Linked", "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", "addBlock": "Block Time",
"businessBlocks": "Business Closures", "businessBlocks": "Business Closures",
"businessBlocksInfo": "These blocks are set by your business and apply to everyone.", "businessBlocksInfo": "These blocks are set by your business and apply to everyone.",
@@ -3717,14 +3785,12 @@
"staffRoles": "Staff Roles", "staffRoles": "Staff Roles",
"ownerRole": "Owner", "ownerRole": "Owner",
"ownerRoleDesc": "Full access to everything including billing and settings. Cannot be removed.", "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", "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", "invitingStaff": "Inviting Staff",
"inviteStep1": "Click the Invite Staff button", "inviteStep1": "Click the Invite Staff button",
"inviteStep2": "Enter their email address", "inviteStep2": "Enter their email address",
"inviteStep3": "Select a role (Manager or Staff)", "inviteStep3": "Select a staff role to assign",
"inviteStep4": "Click Send Invitation", "inviteStep4": "Click Send Invitation",
"inviteStep5": "They'll receive an email with a link to join", "inviteStep5": "They'll receive an email with a link to join",
"makeBookable": "Make Bookable", "makeBookable": "Make Bookable",

View File

@@ -967,6 +967,13 @@
"lastVisit": "Última Visita", "lastVisit": "Última Visita",
"nextAppointment": "Próxima Cita", "nextAppointment": "Próxima Cita",
"contactInfo": "Información de Contacto", "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", "status": "Estado",
"active": "Activo", "active": "Activo",
"inactive": "Inactivo", "inactive": "Inactivo",
@@ -987,6 +994,14 @@
"role": "Rol", "role": "Rol",
"bookableResource": "Recurso Reservable", "bookableResource": "Recurso Reservable",
"makeBookable": "Hacer 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í", "yes": "Sí",
"errorLoading": "Error al cargar personal", "errorLoading": "Error al cargar personal",
"inviteModalTitle": "Invitar Personal", "inviteModalTitle": "Invitar Personal",

View File

@@ -907,6 +907,13 @@
"lastVisit": "Dernière Visite", "lastVisit": "Dernière Visite",
"nextAppointment": "Prochain Rendez-vous", "nextAppointment": "Prochain Rendez-vous",
"contactInfo": "Informations de Contact", "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", "status": "Statut",
"active": "Actif", "active": "Actif",
"inactive": "Inactif", "inactive": "Inactif",
@@ -930,7 +937,17 @@
"yes": "Oui", "yes": "Oui",
"errorLoading": "Erreur lors du chargement du personnel", "errorLoading": "Erreur lors du chargement du personnel",
"inviteModalTitle": "Inviter 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": { "resources": {
"title": "Ressources", "title": "Ressources",

View File

@@ -56,6 +56,16 @@ const SettingsLayout: React.FC = () => {
// Get context from parent route (BusinessLayout) // Get context from parent route (BusinessLayout)
const parentContext = useOutletContext<ParentContext>(); const parentContext = useOutletContext<ParentContext>();
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) // Check if a feature is locked (returns true if locked)
const isLocked = (feature: FeatureKey | undefined): boolean => { const isLocked = (feature: FeatureKey | undefined): boolean => {
@@ -92,123 +102,167 @@ const SettingsLayout: React.FC = () => {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto"> <nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
{/* Business Section */} {/* Business Section */}
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}> {(hasSettingsPermission('can_access_settings_general') ||
<SettingsSidebarItem hasSettingsPermission('can_access_settings_resource_types') ||
to="/dashboard/settings/general" hasSettingsPermission('can_access_settings_booking') ||
icon={Building2} hasSettingsPermission('can_access_settings_business_hours')) && (
label={t('settings.general.title', 'General')} <SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
description={t('settings.general.description', 'Name, timezone, contact')} {hasSettingsPermission('can_access_settings_general') && (
/> <SettingsSidebarItem
<SettingsSidebarItem to="/dashboard/settings/general"
to="/dashboard/settings/resource-types" icon={Building2}
icon={Layers} label={t('settings.general.title', 'General')}
label={t('settings.resourceTypes.title', 'Resource Types')} description={t('settings.general.description', 'Name, timezone, contact')}
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')} />
/> )}
<SettingsSidebarItem {hasSettingsPermission('can_access_settings_resource_types') && (
to="/dashboard/settings/booking" <SettingsSidebarItem
icon={Calendar} to="/dashboard/settings/resource-types"
label={t('settings.booking.title', 'Booking')} icon={Layers}
description={t('settings.booking.description', 'Booking URL, redirects')} label={t('settings.resourceTypes.title', 'Resource Types')}
/> description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
<SettingsSidebarItem />
to="/dashboard/settings/business-hours" )}
icon={Clock} {hasSettingsPermission('can_access_settings_booking') && (
label={t('settings.businessHours.title', 'Business Hours')} <SettingsSidebarItem
description={t('settings.businessHours.description', 'Operating hours')} to="/dashboard/settings/booking"
/> icon={Calendar}
</SettingsSidebarSection> label={t('settings.booking.title', 'Booking')}
description={t('settings.booking.description', 'Booking URL, redirects')}
/>
)}
{hasSettingsPermission('can_access_settings_business_hours') && (
<SettingsSidebarItem
to="/dashboard/settings/business-hours"
icon={Clock}
label={t('settings.businessHours.title', 'Business Hours')}
description={t('settings.businessHours.description', 'Operating hours')}
/>
)}
</SettingsSidebarSection>
)}
{/* Branding Section */} {/* Branding Section */}
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}> {(hasSettingsPermission('can_access_settings_branding') ||
<SettingsSidebarItem hasSettingsPermission('can_access_settings_email_templates') ||
to="/dashboard/settings/branding" hasSettingsPermission('can_access_settings_custom_domains') ||
icon={Palette} hasSettingsPermission('can_access_settings_embed_widget')) && (
label={t('settings.appearance.title', 'Appearance')} <SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
description={t('settings.appearance.description', 'Logo, colors, theme')} {hasSettingsPermission('can_access_settings_branding') && (
locked={isLocked('remove_branding')} <SettingsSidebarItem
/> to="/dashboard/settings/branding"
<SettingsSidebarItem icon={Palette}
to="/dashboard/settings/email-templates" label={t('settings.appearance.title', 'Appearance')}
icon={Mail} description={t('settings.appearance.description', 'Logo, colors, theme')}
label={t('settings.emailTemplates.title', 'Email Templates')} locked={isLocked('remove_branding')}
description={t('settings.emailTemplates.description', 'Customize automated emails')} />
/> )}
<SettingsSidebarItem {hasSettingsPermission('can_access_settings_email_templates') && (
to="/dashboard/settings/custom-domains" <SettingsSidebarItem
icon={Globe} to="/dashboard/settings/email-templates"
label={t('settings.customDomains.title', 'Custom Domains')} icon={Mail}
description={t('settings.customDomains.description', 'Use your own domain')} label={t('settings.emailTemplates.title', 'Email Templates')}
locked={isLocked('custom_domain')} description={t('settings.emailTemplates.description', 'Customize automated emails')}
/> />
<SettingsSidebarItem )}
to="/dashboard/settings/embed-widget" {hasSettingsPermission('can_access_settings_custom_domains') && (
icon={Code2} <SettingsSidebarItem
label={t('settings.embedWidget.title', 'Embed Widget')} to="/dashboard/settings/custom-domains"
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')} icon={Globe}
/> label={t('settings.customDomains.title', 'Custom Domains')}
</SettingsSidebarSection> description={t('settings.customDomains.description', 'Use your own domain')}
locked={isLocked('custom_domain')}
/>
)}
{hasSettingsPermission('can_access_settings_embed_widget') && (
<SettingsSidebarItem
to="/dashboard/settings/embed-widget"
icon={Code2}
label={t('settings.embedWidget.title', 'Embed Widget')}
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')}
/>
)}
</SettingsSidebarSection>
)}
{/* Integrations Section */} {/* Integrations Section */}
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}> {hasSettingsPermission('can_access_settings_api') && (
<SettingsSidebarItem <SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
to="/dashboard/settings/api" <SettingsSidebarItem
icon={Key} to="/dashboard/settings/api"
label={t('settings.api.title', 'API & Webhooks')} icon={Key}
description={t('settings.api.description', 'API tokens, webhooks')} label={t('settings.api.title', 'API & Webhooks')}
locked={isLocked('api_access')} description={t('settings.api.description', 'API tokens, webhooks')}
/> locked={isLocked('api_access')}
</SettingsSidebarSection> />
</SettingsSidebarSection>
)}
{/* Access Section */} {/* Access Section */}
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}> {(hasSettingsPermission('can_access_settings_staff_roles') ||
<SettingsSidebarItem hasSettingsPermission('can_access_settings_authentication')) && (
to="/dashboard/settings/staff-roles" <SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
icon={Users} {hasSettingsPermission('can_access_settings_staff_roles') && (
label={t('settings.staffRoles.title', 'Staff Roles')} <SettingsSidebarItem
description={t('settings.staffRoles.description', 'Role permissions')} to="/dashboard/settings/staff-roles"
/> icon={Users}
<SettingsSidebarItem label={t('settings.staffRoles.title', 'Staff Roles')}
to="/dashboard/settings/authentication" description={t('settings.staffRoles.description', 'Role permissions')}
icon={Lock} />
label={t('settings.authentication.title', 'Authentication')} )}
description={t('settings.authentication.description', 'OAuth, social login')} {hasSettingsPermission('can_access_settings_authentication') && (
locked={isLocked('custom_oauth')} <SettingsSidebarItem
/> to="/dashboard/settings/authentication"
</SettingsSidebarSection> icon={Lock}
label={t('settings.authentication.title', 'Authentication')}
description={t('settings.authentication.description', 'OAuth, social login')}
locked={isLocked('custom_oauth')}
/>
)}
</SettingsSidebarSection>
)}
{/* Communication Section */} {/* Communication Section */}
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}> {(hasSettingsPermission('can_access_settings_email') ||
<SettingsSidebarItem hasSettingsPermission('can_access_settings_sms_calling')) && (
to="/dashboard/settings/email" <SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
icon={Mail} {hasSettingsPermission('can_access_settings_email') && (
label={t('settings.email.title', 'Email Setup')} <SettingsSidebarItem
description={t('settings.email.description', 'Email addresses for tickets')} to="/dashboard/settings/email"
/> icon={Mail}
<SettingsSidebarItem label={t('settings.email.title', 'Email Setup')}
to="/dashboard/settings/sms-calling" description={t('settings.email.description', 'Email addresses for tickets')}
icon={Phone} />
label={t('settings.smsCalling.title', 'SMS & Calling')} )}
description={t('settings.smsCalling.description', 'Credits, phone numbers')} {hasSettingsPermission('can_access_settings_sms_calling') && (
locked={isLocked('sms_reminders')} <SettingsSidebarItem
/> to="/dashboard/settings/sms-calling"
</SettingsSidebarSection> icon={Phone}
label={t('settings.smsCalling.title', 'SMS & Calling')}
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
locked={isLocked('sms_reminders')}
/>
)}
</SettingsSidebarSection>
)}
{/* Billing Section */} {/* Billing Section - Owner only */}
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}> {isOwner && (
<SettingsSidebarItem <SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
to="/dashboard/settings/billing" <SettingsSidebarItem
icon={CreditCard} to="/dashboard/settings/billing"
label={t('settings.billing.title', 'Plan & Billing')} icon={CreditCard}
description={t('settings.billing.description', 'Subscription, invoices')} label={t('settings.billing.title', 'Plan & Billing')}
/> description={t('settings.billing.description', 'Subscription, invoices')}
<SettingsSidebarItem />
to="/dashboard/settings/quota" <SettingsSidebarItem
icon={AlertTriangle} to="/dashboard/settings/quota"
label={t('settings.quota.title', 'Quota Management')} icon={AlertTriangle}
description={t('settings.quota.description', 'Usage limits, archiving')} label={t('settings.quota.title', 'Quota Management')}
/> description={t('settings.quota.description', 'Usage limits, archiving')}
</SettingsSidebarSection> />
</SettingsSidebarSection>
)}
</nav> </nav>
</aside> </aside>

View File

@@ -3,7 +3,7 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Customer, User } from '../types'; 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 { useAppointments } from '../hooks/useAppointments';
import { useServices } from '../hooks/useServices'; import { useServices } from '../hooks/useServices';
import { import {
@@ -26,7 +26,9 @@ import {
FileText, FileText,
StickyNote, StickyNote,
History, History,
Save Save,
BadgeCheck,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import Portal from '../components/Portal'; import Portal from '../components/Portal';
@@ -68,6 +70,9 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
isActive: true isActive: true
}); });
// Verify email confirmation modal state
const [verifyEmailTarget, setVerifyEmailTarget] = useState<Customer | null>(null);
// Infinite scroll for customers // Infinite scroll for customers
const { const {
data: customersData, data: customersData,
@@ -81,6 +86,7 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
const { data: services = [] } = useServices(); const { data: services = [] } = useServices();
const createCustomerMutation = useCreateCustomer(); const createCustomerMutation = useCreateCustomer();
const updateCustomerMutation = useUpdateCustomer(); const updateCustomerMutation = useUpdateCustomer();
const verifyEmailMutation = useVerifyCustomerEmail();
// Transform paginated data to flat array // Transform paginated data to flat array
const customers: Customer[] = useMemo(() => { const customers: Customer[] = useMemo(() => {
@@ -222,6 +228,20 @@ const Customers: React.FC<CustomersProps> = ({ 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 getServiceName = (serviceId: string) => {
const service = services.find(s => String(s.id) === serviceId); const service = services.find(s => String(s.id) === serviceId);
return service?.name || t('customers.unknownService'); return service?.name || t('customers.unknownService');
@@ -381,6 +401,22 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
<td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td> <td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleVerifyEmailClick(customer); }}
disabled={verifyEmailMutation.isPending}
className={`p-1.5 border rounded-lg transition-colors ${
customer.email_verified
? 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-900/30'
: 'text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-900/30'
}`}
title={customer.email_verified ? t('customers.emailVerified') : t('customers.verifyEmail')}
>
{verifyEmailMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<BadgeCheck size={16} />
)}
</button>
<button <button
onClick={(e) => { e.stopPropagation(); handleEditClick(customer); }} onClick={(e) => { e.stopPropagation(); handleEditClick(customer); }}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
@@ -807,6 +843,48 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
</div> </div>
</Portal> </Portal>
)} )}
{/* Verify Email Confirmation Modal */}
{verifyEmailTarget && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{verifyEmailTarget.email_verified ? t('customers.unverifyEmailTitle') : t('customers.verifyEmailTitle')}
</h3>
</div>
<div className="p-6">
<p className="text-sm text-gray-600 dark:text-gray-400">
{verifyEmailTarget.email_verified
? t('customers.unverifyEmailConfirm', { email: verifyEmailTarget.email })
: t('customers.verifyEmailConfirm', { email: verifyEmailTarget.email })}
</p>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
<button
onClick={() => setVerifyEmailTarget(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
<button
onClick={handleVerifyEmailConfirm}
disabled={verifyEmailMutation.isPending}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
verifyEmailTarget.email_verified
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-green-600 hover:bg-green-700'
}`}
>
{verifyEmailMutation.isPending && <Loader2 size={16} className="animate-spin" />}
{verifyEmailTarget.email_verified ? t('customers.unverifyEmail') : t('customers.verifyEmail')}
</button>
</div>
</div>
</div>
</Portal>
)}
</div> </div>
); );
}; };

View File

@@ -251,7 +251,6 @@ const Messages: React.FC = () => {
// Computed // Computed
const roleOptions = [ const roleOptions = [
{ value: 'owner', label: 'Owners', icon: Users, description: 'Business owners' }, { 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: 'staff', label: 'Staff', icon: Users, description: 'Employees' },
{ value: 'customer', label: 'Customers', icon: Users, description: 'Clients' }, { value: 'customer', label: 'Customers', icon: Users, description: 'Clients' },
]; ];

View File

@@ -50,7 +50,7 @@ const Payments: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>(); 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'; const isCustomer = effectiveUser.role === 'customer';
// Tab state // Tab state

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { User } from '../types'; import { User } from '../types';
import { useCreateResource, useResources } from '../hooks/useBusiness'; 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 { import {
useInvitations, useInvitations,
useCreateInvitation, useCreateInvitation,
@@ -30,9 +30,13 @@ import {
ChevronRight, ChevronRight,
UserX, UserX,
Power, Power,
BadgeCheck,
Key,
Phone,
Eye,
ArrowUpDown,
} from 'lucide-react'; } from 'lucide-react';
import Portal from '../components/Portal'; import Portal from '../components/Portal';
import StaffPermissions from '../components/StaffPermissions';
interface StaffProps { interface StaffProps {
onMasquerade: (user: User) => void; onMasquerade: (user: User) => void;
@@ -51,10 +55,13 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const resendInvitationMutation = useResendInvitation(); const resendInvitationMutation = useResendInvitation();
const toggleActiveMutation = useToggleStaffActive(); const toggleActiveMutation = useToggleStaffActive();
const updateStaffMutation = useUpdateStaff(); const updateStaffMutation = useUpdateStaff();
const verifyEmailMutation = useVerifyStaffEmail();
const passwordResetMutation = useSendStaffPasswordReset();
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState(''); 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<number | null>(null); const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
const [createBookableResource, setCreateBookableResource] = useState(false); const [createBookableResource, setCreateBookableResource] = useState(false);
const [resourceName, setResourceName] = useState(''); const [resourceName, setResourceName] = useState('');
@@ -66,16 +73,55 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
// Edit modal state // Edit modal state
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null); const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null); const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
const [editFirstName, setEditFirstName] = useState('');
const [editLastName, setEditLastName] = useState('');
const [editPhone, setEditPhone] = useState('');
const [editError, setEditError] = useState(''); const [editError, setEditError] = useState('');
const [editSuccess, setEditSuccess] = useState(''); const [editSuccess, setEditSuccess] = useState('');
// Check if user can invite managers (only owners can) // Verify email confirmation modal state
const canInviteManagers = effectiveUser.role === 'owner'; const [verifyEmailTarget, setVerifyEmailTarget] = useState<StaffMember | null>(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); const inactiveStaff = staffMembers.filter((s) => !s.is_active);
// Helper to check if a user is already linked to a resource // Helper to check if a user is already linked to a resource
@@ -151,7 +197,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const openInviteModal = () => { const openInviteModal = () => {
setInviteEmail(''); setInviteEmail('');
setInviteRole('TENANT_STAFF');
setInviteStaffRoleId(null); setInviteStaffRoleId(null);
setCreateBookableResource(false); setCreateBookableResource(false);
setResourceName(''); setResourceName('');
@@ -196,8 +241,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const openEditModal = (staff: StaffMember) => { const openEditModal = (staff: StaffMember) => {
setEditingStaff(staff); setEditingStaff(staff);
setEditPermissions(staff.permissions || {});
setEditStaffRoleId(staff.staff_role_id); setEditStaffRoleId(staff.staff_role_id);
setEditFirstName(staff.first_name);
setEditLastName(staff.last_name);
setEditPhone(staff.phone || '');
setEditError(''); setEditError('');
setEditSuccess(''); setEditSuccess('');
setIsEditModalOpen(true); setIsEditModalOpen(true);
@@ -206,8 +253,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const closeEditModal = () => { const closeEditModal = () => {
setIsEditModalOpen(false); setIsEditModalOpen(false);
setEditingStaff(null); setEditingStaff(null);
setEditPermissions({});
setEditStaffRoleId(null); setEditStaffRoleId(null);
setEditFirstName('');
setEditLastName('');
setEditPhone('');
setEditError(''); setEditError('');
setEditSuccess(''); setEditSuccess('');
}; };
@@ -217,10 +266,17 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setEditError(''); setEditError('');
try { try {
const updates: { permissions: Record<string, boolean>; staff_role_id?: number | null } = { const updates: {
permissions: editPermissions, 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') { if (editingStaff.role === 'staff') {
updates.staff_role_id = editStaffRoleId; updates.staff_role_id = editStaffRoleId;
} }
@@ -237,6 +293,21 @@ const Staff: React.FC<StaffProps> = ({ 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 () => { const handleDeactivateFromModal = async () => {
if (!editingStaff) return; if (!editingStaff) return;
@@ -251,6 +322,20 @@ const Staff: React.FC<StaffProps> = ({ 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 ( return (
<div className="p-8 max-w-7xl mx-auto space-y-6"> <div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */} {/* Header */}
@@ -325,9 +410,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700"> <thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr> <tr>
<th className="px-6 py-4 font-medium">{t('staff.name')}</th> <th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('name')}>
<th className="px-6 py-4 font-medium">{t('staff.role')}</th> <div className="flex items-center gap-1">{t('staff.name')} <ArrowUpDown size={14} className="text-gray-400" /></div>
<th className="px-6 py-4 font-medium">{t('staff.staffRole')}</th> </th>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('role')}>
<div className="flex items-center gap-1">{t('staff.role')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th> <th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th> <th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr> </tr>
@@ -356,34 +444,19 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span <span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${ className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === 'owner' user.role === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: user.role === 'manager' : user.staff_role_name === 'Manager'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`} }`}
> >
{user.role === 'owner' && <Shield size={12} />} {user.role === 'owner' && <Shield size={12} />}
{user.role === 'manager' && <Briefcase size={12} />} {user.staff_role_name === 'Manager' && <Briefcase size={12} />}
{user.role} {user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
</span> </span>
</td> </td>
<td className="px-6 py-4">
{user.role === 'staff' ? (
user.staff_role_name ? (
<span className="text-xs text-gray-700 dark:text-gray-300">
{user.staff_role_name}
</span>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
{t('staff.noRoleAssigned')}
</span>
)
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{linkedResource ? ( {linkedResource ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded"> <span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
@@ -401,21 +474,38 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button
onClick={() => handleVerifyEmailClick(user)}
disabled={verifyEmailMutation.isPending}
className={`p-1.5 border rounded-lg transition-colors ${
user.email_verified
? 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-900/30'
: 'text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-900/30'
}`}
title={user.email_verified ? t('staff.emailVerified') : t('staff.verifyEmail')}
>
{verifyEmailMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<BadgeCheck size={16} />
)}
</button>
<button
onClick={() => openEditModal(user)}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
title={t('common.edit')}
>
<Pencil size={14} /> {t('common.edit')}
</button>
{canMasquerade && ( {canMasquerade && (
<button <button
onClick={() => onMasquerade(user)} onClick={() => onMasquerade(user)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors" className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
title={t('common.masqueradeAsUser')}
> >
{t('common.masquerade')} <Eye size={14} /> {t('common.masquerade')}
</button> </button>
)} )}
<button
onClick={() => openEditModal(user)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('common.edit')}
>
<Pencil size={16} />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -473,10 +563,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400"> <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{user.role === 'owner' && <Shield size={12} />} {user.role === 'owner' && <Shield size={12} />}
{user.role === 'manager' && <Briefcase size={12} />} {user.staff_role_name === 'Manager' && <Briefcase size={12} />}
{user.role} {user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
</span> </span>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
@@ -545,33 +635,11 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
/> />
</div> </div>
{/* Role Selector */} {/* Staff Role Selector */}
<div> {staffRoles.length > 0 && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.roleLabel')} *
</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'TENANT_MANAGER' | 'TENANT_STAFF')}
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"
>
<option value="TENANT_STAFF">{t('staff.roleStaff')}</option>
{canInviteManagers && (
<option value="TENANT_MANAGER">{t('staff.roleManager')}</option>
)}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{inviteRole === 'TENANT_MANAGER'
? t('staff.managerRoleHint')
: t('staff.staffRoleHint')}
</p>
</div>
{/* Staff Role Selector (only for staff invitations) */}
{inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.staffRole')} {t('staff.roleLabel')} *
</label> </label>
<select <select
value={inviteStaffRoleId ?? ''} value={inviteStaffRoleId ?? ''}
@@ -592,23 +660,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
)} )}
{/* Permissions - Using shared component */} {/* Permissions - Using shared component */}
{inviteRole === 'TENANT_MANAGER' && ( <StaffPermissions
<StaffPermissions role="staff"
role="manager" permissions={invitePermissions}
permissions={invitePermissions} onChange={setInvitePermissions}
onChange={setInvitePermissions} variant="invite"
variant="invite" />
/>
)}
{inviteRole === 'TENANT_STAFF' && (
<StaffPermissions
role="staff"
permissions={invitePermissions}
onChange={setInvitePermissions}
variant="invite"
/>
)}
{/* Make Bookable Option */} {/* Make Bookable Option */}
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600"> <div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
@@ -692,8 +749,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{isEditModalOpen && editingStaff && ( {isEditModalOpen && editingStaff && (
<Portal> <Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden max-h-[90vh] flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center flex-shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('staff.editStaff')} {t('staff.editStaff')}
</h3> </h3>
@@ -705,80 +762,157 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</button> </button>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-6 overflow-y-auto flex-1">
{/* Staff Info */} {/* Profile Information Section */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"> <div>
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 font-medium text-lg"> <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
{editingStaff.name.charAt(0).toUpperCase()} <UserIcon size={16} />
{t('staff.profileInformation', 'Profile Information')}
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.firstName', 'First Name')}
</label>
<input
type="text"
value={editFirstName}
onChange={(e) => 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')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.lastName', 'Last Name')}
</label>
<input
type="text"
value={editLastName}
onChange={(e) => 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')}
/>
</div>
</div> </div>
<div> <div className="mt-4">
<div className="font-medium text-gray-900 dark:text-white">{editingStaff.name}</div> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div className="text-sm text-gray-500 dark:text-gray-400">{editingStaff.email}</div> {t('staff.email', 'Email')}
</label>
<div className="flex items-center gap-2">
<input
type="email"
value={editingStaff.email}
disabled
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed"
/>
<button
onClick={() => handleVerifyEmailClick(editingStaff)}
disabled={verifyEmailMutation.isPending}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
editingStaff.email_verified
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
}`}
>
{verifyEmailMutation.isPending ? (
<Loader2 size={12} className="animate-spin" />
) : (
<BadgeCheck size={12} />
)}
{editingStaff.email_verified ? t('staff.verified', 'Verified') : t('staff.verify', 'Verify')}
</button>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.phone', 'Phone')}
</label>
<div className="relative">
<Phone size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="tel"
value={editPhone}
onChange={(e) => 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')}
/>
</div>
</div> </div>
<span
className={`ml-auto inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
editingStaff.role === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: editingStaff.role === 'manager'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{editingStaff.role === 'owner' && <Shield size={12} />}
{editingStaff.role === 'manager' && <Briefcase size={12} />}
{editingStaff.role}
</span>
</div> </div>
{/* Staff Role Selector (only for staff users) */} {/* Role Section */}
{editingStaff.role === 'staff' && staffRoles.length > 0 && ( {editingStaff.role !== 'owner' && staffRoles.length > 0 && (
<div> <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
{t('staff.staffRole')} <Shield size={16} />
</label> {t('staff.staffRole', 'Staff Role')}
<select </h4>
value={editStaffRoleId ?? ''}
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)} {/* Staff Role Selector */}
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" <div>
> <select
<option value="">{t('staff.selectRole')}</option> value={editStaffRoleId ?? ''}
{staffRoles.map((role) => ( onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
<option key={role.id} value={role.id}> 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"
{role.name} >
</option> <option value="">{t('staff.selectRole')}</option>
))} {staffRoles.map((role) => (
</select> <option key={role.id} value={role.id}>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> {role.name}
{t('staff.staffRoleSelectHint')} </option>
</p> ))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('staff.staffRoleSelectHint')}
</p>
</div>
</div> </div>
)} )}
{/* Permissions - Using shared component */} {/* Owner info banner */}
{editingStaff.role === 'manager' && (
<StaffPermissions
role="manager"
permissions={editPermissions}
onChange={setEditPermissions}
variant="edit"
/>
)}
{editingStaff.role === 'staff' && (
<StaffPermissions
role="staff"
permissions={editPermissions}
onChange={setEditPermissions}
variant="edit"
/>
)}
{/* No permissions for owners */}
{editingStaff.role === 'owner' && ( {editingStaff.role === 'owner' && (
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800"> <div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<p className="text-sm text-purple-700 dark:text-purple-300"> <div className="flex items-center gap-2">
{t('staff.ownerFullAccess')} <Shield size={16} className="text-purple-600 dark:text-purple-400" />
</p> <p className="text-sm text-purple-700 dark:text-purple-300">
{t('staff.ownerFullAccess')}
</p>
</div>
</div>
)}
{/* Account Security Section - Password Reset */}
{editingStaff.role !== 'owner' && (
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Key size={16} />
{t('staff.accountSecurity', 'Account Security')}
</h4>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{t('staff.resetPassword', 'Reset Password')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t('staff.resetPasswordHint', 'Send a password reset email to this staff member')}
</p>
</div>
<button
onClick={handleSendPasswordReset}
disabled={passwordResetMutation.isPending}
className="ml-4 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 flex-shrink-0 text-brand-600 border border-brand-300 hover:bg-brand-50 dark:text-brand-400 dark:border-brand-700 dark:hover:bg-brand-900/30"
>
{passwordResetMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Mail size={14} />
)}
{t('staff.sendResetEmail', 'Send Reset Email')}
</button>
</div>
</div>
</div> </div>
)} )}
@@ -838,29 +972,69 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</div> </div>
</div> </div>
)} )}
</div>
{/* Action Buttons */} {/* Action Buttons - Fixed footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700"> <div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
<button <button
type="button" type="button"
onClick={closeEditModal} onClick={closeEditModal}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600" className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
{editingStaff.role !== 'owner' && ( <button
<button onClick={handleSaveStaffSettings}
onClick={handleSaveStaffSettings} disabled={updateStaffMutation.isPending || !!editSuccess}
disabled={updateStaffMutation.isPending || !!editSuccess} className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" >
> {updateStaffMutation.isPending ? (
{updateStaffMutation.isPending ? ( <Loader2 size={16} className="animate-spin" />
<Loader2 size={16} className="animate-spin" /> ) : null}
) : null} {t('common.save')}
{t('common.save')} </button>
</button> </div>
)} </div>
</div> </div>
</Portal>
)}
{/* Verify Email Confirmation Modal */}
{verifyEmailTarget && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{verifyEmailTarget.email_verified ? t('staff.unverifyEmailTitle') : t('staff.verifyEmailTitle')}
</h3>
</div>
<div className="p-6">
<p className="text-sm text-gray-600 dark:text-gray-400">
{verifyEmailTarget.email_verified
? t('staff.unverifyEmailConfirm', { email: verifyEmailTarget.email })
: t('staff.verifyEmailConfirm', { email: verifyEmailTarget.email })}
</p>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
<button
onClick={() => setVerifyEmailTarget(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
<button
onClick={handleVerifyEmailConfirm}
disabled={verifyEmailMutation.isPending}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
verifyEmailTarget.email_verified
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-green-600 hover:bg-green-700'
}`}
>
{verifyEmailMutation.isPending && <Loader2 size={16} className="animate-spin" />}
{verifyEmailTarget.email_verified ? t('staff.unverifyEmail') : t('staff.verifyEmail')}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -122,7 +122,9 @@ const Tickets: React.FC = () => {
setIsTicketModalOpen(false); 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) { if (isLoading) {
return ( return (
@@ -163,7 +165,7 @@ const Tickets: React.FC = () => {
{t('tickets.title', 'Support Tickets')} {t('tickets.title', 'Support Tickets')}
</h2> </h2>
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
{isOwnerOrManager {hasFullTicketAccess
? t('tickets.descriptionOwner', 'Manage support tickets for your business') ? t('tickets.descriptionOwner', 'Manage support tickets for your business')
: t('tickets.descriptionStaff', 'View and create support tickets')} : t('tickets.descriptionStaff', 'View and create support tickets')}
</p> </p>

View File

@@ -21,13 +21,14 @@ const ApiSettings: React.FC = () => {
}>(); }>();
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_api === true;
const { canUse } = usePlanFeatures(); const { canUse } = usePlanFeatures();
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings. You do not have permission to access these settings.
</p> </p>
</div> </div>
); );

View File

@@ -59,6 +59,7 @@ const AuthenticationSettings: React.FC = () => {
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_authentication === true;
const { canUse } = usePlanFeatures(); const { canUse } = usePlanFeatures();
// Update OAuth settings when data loads // Update OAuth settings when data loads
@@ -147,11 +148,11 @@ const AuthenticationSettings: React.FC = () => {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}; };
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings. You do not have permission to access these settings.
</p> </p>
</div> </div>
); );

View File

@@ -26,6 +26,7 @@ const BookingSettings: React.FC = () => {
const [returnUrlSaving, setReturnUrlSaving] = useState(false); const [returnUrlSaving, setReturnUrlSaving] = useState(false);
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_booking === true;
const handleSaveReturnUrl = async () => { const handleSaveReturnUrl = async () => {
setReturnUrlSaving(true); setReturnUrlSaving(true);
@@ -40,11 +41,11 @@ const BookingSettings: React.FC = () => {
} }
}; };
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
{t('settings.booking.onlyOwnerCanAccess', 'Only the business owner can access these settings.')} {t('settings.noPermission', 'You do not have permission to access these settings.')}
</p> </p>
</div> </div>
); );

View File

@@ -139,12 +139,13 @@ const BrandingSettings: React.FC = () => {
}; };
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_branding === true;
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings. You do not have permission to access these settings.
</p> </p>
</div> </div>
); );

View File

@@ -6,9 +6,10 @@
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks'; import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks';
import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui'; import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui';
import { BlockPurpose, TimeBlock } from '../../types'; import { BlockPurpose, TimeBlock, Business, User } from '../../types';
interface DayHours { interface DayHours {
enabled: boolean; enabled: boolean;
@@ -58,11 +59,19 @@ const DEFAULT_HOURS: BusinessHours = {
}; };
const BusinessHoursSettings: React.FC = () => { const BusinessHoursSettings: React.FC = () => {
const { user } = useOutletContext<{
business: Business;
user: User;
}>();
const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS); const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [success, setSuccess] = useState<string>(''); const [success, setSuccess] = useState<string>('');
const [isSaving, setIsSaving] = useState(false); 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 // Fetch existing business hours time blocks
const { data: timeBlocks, isLoading } = useTimeBlocks({ const { data: timeBlocks, isLoading } = useTimeBlocks({
purpose: 'BUSINESS_HOURS' as BlockPurpose, purpose: 'BUSINESS_HOURS' as BlockPurpose,
@@ -248,6 +257,16 @@ const BusinessHoursSettings: React.FC = () => {
} }
}; };
if (!hasPermission) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
You do not have permission to access these settings.
</p>
</div>
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">

View File

@@ -91,6 +91,7 @@ const CommunicationSettings: React.FC = () => {
const [wizardAvailableNumbers, setWizardAvailableNumbers] = useState<AvailablePhoneNumber[]>([]); const [wizardAvailableNumbers, setWizardAvailableNumbers] = useState<AvailablePhoneNumber[]>([]);
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_sms_calling === true;
const { canUse } = usePlanFeatures(); const { canUse } = usePlanFeatures();
// Update settings form when credits data loads // Update settings form when credits data loads
@@ -249,11 +250,11 @@ const CommunicationSettings: React.FC = () => {
setWizardStep(4); setWizardStep(4);
}; };
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings. You do not have permission to access these settings.
</p> </p>
</div> </div>
); );

View File

@@ -43,6 +43,7 @@ const CustomDomainsSettings: React.FC = () => {
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_custom_domains === true;
const { canUse } = usePlanFeatures(); const { canUse } = usePlanFeatures();
const handleAddDomain = () => { const handleAddDomain = () => {
@@ -104,11 +105,11 @@ const CustomDomainsSettings: React.FC = () => {
}); });
}; };
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings. You do not have permission to access these settings.
</p> </p>
</div> </div>
); );

View File

@@ -19,12 +19,13 @@ const EmailSettings: React.FC = () => {
}>(); }>();
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email === true;
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings. You do not have permission to access these settings.
</p> </p>
</div> </div>
); );

View File

@@ -37,6 +37,7 @@ const EmbedWidgetSettings: React.FC = () => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_embed_widget === true;
// Build the embed URL // Build the embed URL
const embedUrl = useMemo(() => { const embedUrl = useMemo(() => {
@@ -86,11 +87,11 @@ const EmbedWidgetSettings: React.FC = () => {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
{t('settings.embedWidget.onlyOwnerCanAccess', 'Only the business owner can access these settings.')} {t('settings.noPermission', 'You do not have permission to access these settings.')}
</p> </p>
</div> </div>
); );

View File

@@ -170,12 +170,13 @@ const GeneralSettings: React.FC = () => {
}; };
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_general === true;
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
{t('settings.ownerOnly', 'Only the business owner can access these settings.')} {t('settings.noPermission', 'You do not have permission to access these settings.')}
</p> </p>
</div> </div>
); );

View File

@@ -33,6 +33,7 @@ const ResourceTypesSettings: React.FC = () => {
}); });
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_resource_types === true;
const openCreateModal = () => { const openCreateModal = () => {
setEditingType(null); setEditingType(null);
@@ -83,11 +84,11 @@ const ResourceTypesSettings: React.FC = () => {
} }
}; };
if (!isOwner) { if (!hasPermission) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings. You do not have permission to access these settings.
</p> </p>
</div> </div>
); );

View File

@@ -41,14 +41,15 @@ const StaffRolesSettings: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isOwner = user.role === 'owner'; const isOwner = user.role === 'owner';
const isManager = user.role === 'manager'; // Only owners can manage roles (staff with permissions can view but not edit)
const canManageRoles = isOwner || isManager; const canManageRoles = isOwner;
// Merge menu and dangerous permissions for display // Merge menu, settings, and dangerous permissions for display
const allPermissions = useMemo(() => { const allPermissions = useMemo(() => {
if (!availablePermissions) return { menu: {}, dangerous: {} }; if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
return { return {
menu: availablePermissions.menu_permissions || {}, menu: availablePermissions.menu_permissions || {},
settings: availablePermissions.settings_permissions || {},
dangerous: availablePermissions.dangerous_permissions || {}, dangerous: availablePermissions.dangerous_permissions || {},
}; };
}, [availablePermissions]); }, [availablePermissions]);
@@ -82,21 +83,50 @@ const StaffRolesSettings: React.FC = () => {
}; };
const togglePermission = (key: string) => { const togglePermission = (key: string) => {
setFormData((prev) => ({ setFormData((prev) => {
...prev, const newValue = !prev.permissions[key];
permissions: { const updates: Record<string, boolean> = { [key]: newValue };
...prev.permissions,
[key]: !prev.permissions[key], // 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 toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous; const permissions = category === 'menu'
? allPermissions.menu
: category === 'settings'
? allPermissions.settings
: allPermissions.dangerous;
const updates: Record<string, boolean> = {}; const updates: Record<string, boolean> = {};
Object.keys(permissions).forEach((key) => { Object.keys(permissions).forEach((key) => {
updates[key] = enable; 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) => ({ setFormData((prev) => ({
...prev, ...prev,
permissions: { permissions: {
@@ -160,7 +190,7 @@ const StaffRolesSettings: React.FC = () => {
<div className="text-center py-12"> <div className="text-center py-12">
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" /> <Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
{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.')}
</p> </p>
</div> </div>
); );
@@ -324,8 +354,7 @@ const StaffRolesSettings: React.FC = () => {
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required 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"
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"
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')} placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
/> />
</div> </div>
@@ -398,6 +427,60 @@ const StaffRolesSettings: React.FC = () => {
</div> </div>
</div> </div>
{/* Business Settings Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('settings', true)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => toggleAllPermissions('settings', false)}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30">
{Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100/50 dark:hover:bg-blue-900/20 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Dangerous Permissions */} {/* Dangerous Permissions */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">

View File

@@ -7,6 +7,7 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'; import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Puck, Render } from '@measured/puck'; import { Puck, Render } from '@measured/puck';
import '@measured/puck/puck.css'; import '@measured/puck/puck.css';
@@ -39,6 +40,8 @@ import {
SystemEmailTag, SystemEmailTag,
SystemEmailCategory, SystemEmailCategory,
SystemEmailType, SystemEmailType,
Business,
User,
} from '../../types'; } from '../../types';
// Category metadata // Category metadata
@@ -86,6 +89,14 @@ const CATEGORY_ORDER: SystemEmailCategory[] = [
const SystemEmailTemplates: React.FC = () => { const SystemEmailTemplates: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); 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<Set<SystemEmailCategory>>( const [expandedCategories, setExpandedCategories] = useState<Set<SystemEmailCategory>>(
new Set(CATEGORY_ORDER) new Set(CATEGORY_ORDER)
); );
@@ -343,6 +354,16 @@ const SystemEmailTemplates: React.FC = () => {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
}; };
if (!hasPermission) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{t('settings.noPermission', 'You do not have permission to access these settings.')}
</p>
</div>
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">

View File

@@ -94,7 +94,7 @@ export interface Business {
planPermissions?: PlanPermissions; 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 { export interface NotificationPreferences {
email: boolean; email: boolean;
@@ -163,6 +163,7 @@ export interface PermissionDefinition {
export interface AvailablePermissions { export interface AvailablePermissions {
menu_permissions: Record<string, PermissionDefinition>; menu_permissions: Record<string, PermissionDefinition>;
settings_permissions: Record<string, PermissionDefinition>;
dangerous_permissions: Record<string, PermissionDefinition>; dangerous_permissions: Record<string, PermissionDefinition>;
} }
@@ -273,6 +274,14 @@ export interface Customer {
userId?: string; userId?: string;
paymentMethods: PaymentMethod[]; paymentMethods: PaymentMethod[];
notes?: string; notes?: string;
email_verified?: boolean;
user_data?: {
id: number;
username: string;
name: string;
email: string;
role: string;
};
} }
export interface Service { export interface Service {

View File

@@ -88,13 +88,13 @@ def get_platform_support_team():
def get_tenant_managers(tenant): def get_tenant_managers(tenant):
"""Get all owners and managers for a tenant.""" """Get all owners for a tenant (formerly owners and managers)."""
try: try:
if not tenant: if not tenant:
return User.objects.none() return User.objects.none()
return User.objects.filter( return User.objects.filter(
tenant=tenant, tenant=tenant,
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER], role=User.Role.TENANT_OWNER,
is_active=True is_active=True
) )
except Exception as e: except Exception as e:

View File

@@ -138,7 +138,7 @@ class TestGetTenantManagers:
"""Test the get_tenant_managers() helper function.""" """Test the get_tenant_managers() helper function."""
def test_returns_tenant_managers(self): 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_tenant = Mock(id=1)
mock_queryset = Mock() mock_queryset = Mock()
mock_filtered = Mock() mock_filtered = Mock()
@@ -149,7 +149,7 @@ class TestGetTenantManagers:
mock_queryset.filter.assert_called_once_with( mock_queryset.filter.assert_called_once_with(
tenant=mock_tenant, tenant=mock_tenant,
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER], role=User.Role.TENANT_OWNER,
is_active=True is_active=True
) )
assert result == mock_filtered assert result == mock_filtered

View File

@@ -803,8 +803,8 @@ class TicketEmailAddressViewSet(viewsets.ModelViewSet):
# Business users see only their own email addresses # Business users see only their own email addresses
if hasattr(user, 'tenant') and user.tenant: if hasattr(user, 'tenant') and user.tenant:
# Only owners and managers can view/manage email addresses # Only owners can view/manage email addresses
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: if user.role == User.Role.TENANT_OWNER:
return TicketEmailAddress.objects.filter(tenant=user.tenant) return TicketEmailAddress.objects.filter(tenant=user.tenant)
return TicketEmailAddress.objects.none() return TicketEmailAddress.objects.none()

View File

@@ -204,8 +204,8 @@ class BroadcastMessageViewSet(viewsets.ModelViewSet):
if message.target_owners: if message.target_owners:
role_filters |= Q(role=User.Role.TENANT_OWNER) role_filters |= Q(role=User.Role.TENANT_OWNER)
if message.target_managers: # Note: target_managers now targets no one (managers migrated to staff)
role_filters |= Q(role=User.Role.TENANT_MANAGER) # Kept for backwards compatibility - messages sent to managers will just have no recipients
if message.target_staff: if message.target_staff:
role_filters |= Q(role=User.Role.TENANT_STAFF) role_filters |= Q(role=User.Role.TENANT_STAFF)
if message.target_customers: 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) 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() 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() staff_count = base_query.filter(role=User.Role.TENANT_STAFF).count()
customer_count = base_query.filter(role=User.Role.CUSTOMER).count() customer_count = base_query.filter(role=User.Role.CUSTOMER).count()
@@ -357,16 +358,13 @@ class InboxViewSet(viewsets.ReadOnlyModelViewSet):
# ============================================================================= # =============================================================================
class IsOwnerOrManager(BasePermission): class IsOwnerOrManager(BasePermission):
"""Only owners and managers can manage email templates.""" """Only owners can manage email templates."""
message = "You must be an owner or manager to manage email templates." message = "You must be an owner to manage email templates."
def has_permission(self, request, view): def has_permission(self, request, view):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return False return False
return request.user.role in [ return request.user.role == User.Role.TENANT_OWNER
User.Role.TENANT_OWNER,
User.Role.TENANT_MANAGER,
]
class EmailTemplateViewSet(viewsets.ModelViewSet): class EmailTemplateViewSet(viewsets.ModelViewSet):

View File

@@ -160,8 +160,8 @@ class StatusMachine:
""" """
from smoothschedule.identity.users.models import User from smoothschedule.identity.users.models import User
# Owners and managers can always change status # Owners can always change status
if self.user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: if self.user.role == User.Role.TENANT_OWNER:
return True, "" return True, ""
# Staff must be assigned to the event # Staff must be assigned to the event

View File

@@ -266,18 +266,6 @@ class TestStatusMachine:
assert can_change is True assert can_change is True
assert reason == "" 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): def test_can_user_change_status_staff_assigned(self):
"""Test can_user_change_status allows assigned TENANT_STAFF.""" """Test can_user_change_status allows assigned TENANT_STAFF."""
mock_user = Mock() mock_user = Mock()

View File

@@ -70,13 +70,6 @@ class TestHelperFunctions:
assert is_field_employee(mock_user) is True 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): def test_is_field_employee_with_owner_role(self):
"""Test is_field_employee returns True for TENANT_OWNER.""" """Test is_field_employee returns True for TENANT_OWNER."""
mock_user = Mock() mock_user = Mock()

View File

@@ -56,10 +56,9 @@ def get_tenant_from_user(user):
def is_field_employee(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 [ return user.role in [
User.Role.TENANT_STAFF, User.Role.TENANT_STAFF,
User.Role.TENANT_MANAGER,
User.Role.TENANT_OWNER, User.Role.TENANT_OWNER,
] ]

View File

@@ -13,8 +13,13 @@ from smoothschedule.identity.users.models import User
def is_owner_or_manager(user): def is_owner_or_manager(user):
"""Check if user is a tenant owner or manager.""" """Check if user is a tenant owner or staff with management permissions."""
return user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER] 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']) @api_view(['GET'])

View File

@@ -14,9 +14,9 @@ def can_hijack(hijacker, hijacked):
│ Hijacker Role │ Can Hijack │ │ Hijacker Role │ Can Hijack │
├──────────────────────┼─────────────────────────────────────────────────┤ ├──────────────────────┼─────────────────────────────────────────────────┤
│ SUPERUSER │ Anyone (full god mode) │ │ 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 │ │ 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 │ │ Others │ Nobody │
└──────────────────────┴─────────────────────────────────────────────────┘ └──────────────────────┴─────────────────────────────────────────────────┘
@@ -51,7 +51,6 @@ def can_hijack(hijacker, hijacked):
if hijacker.role == User.Role.PLATFORM_SUPPORT: if hijacker.role == User.Role.PLATFORM_SUPPORT:
return hijacked.role in [ return hijacked.role in [
User.Role.TENANT_OWNER, User.Role.TENANT_OWNER,
User.Role.TENANT_MANAGER,
User.Role.TENANT_STAFF, User.Role.TENANT_STAFF,
User.Role.CUSTOMER, User.Role.CUSTOMER,
] ]
@@ -60,7 +59,7 @@ def can_hijack(hijacker, hijacked):
if hijacker.role == User.Role.PLATFORM_SALES: if hijacker.role == User.Role.PLATFORM_SALES:
return hijacked.is_temporary 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: if hijacker.role == User.Role.TENANT_OWNER:
# Must be in same tenant # Must be in same tenant
if not hijacker.tenant or not hijacked.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: if hijacker.tenant.id != hijacked.tenant.id:
return False return False
# Can hijack managers, staff, and customers (not other owners) # Can hijack staff and customers (not other owners)
return hijacked.role in [ return hijacked.role in [
User.Role.TENANT_MANAGER,
User.Role.TENANT_STAFF, User.Role.TENANT_STAFF,
User.Role.CUSTOMER, User.Role.CUSTOMER,
] ]
@@ -127,7 +125,6 @@ def get_hijackable_users(hijacker):
# Can hijack all tenant-level users # Can hijack all tenant-level users
return qs.filter(role__in=[ return qs.filter(role__in=[
User.Role.TENANT_OWNER, User.Role.TENANT_OWNER,
User.Role.TENANT_MANAGER,
User.Role.TENANT_STAFF, User.Role.TENANT_STAFF,
User.Role.CUSTOMER, User.Role.CUSTOMER,
]) ])
@@ -137,13 +134,13 @@ def get_hijackable_users(hijacker):
return qs.filter(is_temporary=True) return qs.filter(is_temporary=True)
elif hijacker.role == User.Role.TENANT_OWNER: elif hijacker.role == User.Role.TENANT_OWNER:
# Managers, staff, and customers in same tenant # Staff and customers in same tenant
if not hijacker.tenant: if not hijacker.tenant:
return qs.none() return qs.none()
return qs.filter( return qs.filter(
tenant=hijacker.tenant, 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: else:

View File

@@ -31,25 +31,28 @@ class TestIsOwnerOrManagerHelper:
assert result is True assert result is True
def test_returns_true_for_manager(self): def test_returns_true_for_staff_with_permission(self):
"""Should return True for tenant manager.""" """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_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."""
from smoothschedule.identity.core.api_views import is_owner_or_manager from smoothschedule.identity.core.api_views import is_owner_or_manager
from smoothschedule.identity.users.models import User from smoothschedule.identity.users.models import User
mock_user = Mock() mock_user = Mock()
mock_user.role = User.Role.TENANT_STAFF 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) result = is_owner_or_manager(mock_user)

View File

@@ -297,7 +297,7 @@ class TestDenyStaffAllAccessPermission:
request.method = 'GET' request.method = 'GET'
request.user = Mock() request.user = Mock()
request.user.is_authenticated = True request.user.is_authenticated = True
request.user.role = 'TENANT_MANAGER' request.user.role = 'TENANT_OWNER'
view = Mock() view = Mock()

View File

@@ -56,7 +56,6 @@ class TestCanHijack:
'PLATFORM_SUPPORT', 'PLATFORM_SUPPORT',
'PLATFORM_SALES', 'PLATFORM_SALES',
'TENANT_OWNER', 'TENANT_OWNER',
'TENANT_MANAGER',
'TENANT_STAFF', 'TENANT_STAFF',
'CUSTOMER', 'CUSTOMER',
] ]
@@ -70,7 +69,7 @@ class TestCanHijack:
"""Should allow platform support to hijack tenant-level users.""" """Should allow platform support to hijack tenant-level users."""
hijacker = Mock(id=1, role='PLATFORM_SUPPORT') 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: for role in allowed_roles:
hijacked = Mock(id=2, role=role) hijacked = Mock(id=2, role=role)
@@ -105,7 +104,7 @@ class TestCanHijack:
tenant = Mock(id=1) tenant = Mock(id=1)
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant) 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: for role in allowed_roles:
hijacked = Mock(id=2, role=role, tenant=tenant) hijacked = Mock(id=2, role=role, tenant=tenant)
@@ -146,7 +145,7 @@ class TestCanHijack:
def test_other_roles_cannot_hijack(self): def test_other_roles_cannot_hijack(self):
"""Should deny hijack for roles without permission.""" """Should deny hijack for roles without permission."""
forbidden_roles = ['TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER'] forbidden_roles = ['TENANT_STAFF', 'CUSTOMER']
for role in forbidden_roles: for role in forbidden_roles:
hijacker = Mock(id=1, role=role) hijacker = Mock(id=1, role=role)
@@ -206,7 +205,6 @@ class TestGetHijackableUsers:
hijacker = Mock(id=1, role='PLATFORM_SUPPORT') hijacker = Mock(id=1, role='PLATFORM_SUPPORT')
mock_user_model.Role.PLATFORM_SUPPORT = 'PLATFORM_SUPPORT' mock_user_model.Role.PLATFORM_SUPPORT = 'PLATFORM_SUPPORT'
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER' 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.TENANT_STAFF = 'TENANT_STAFF'
mock_user_model.Role.CUSTOMER = 'CUSTOMER' mock_user_model.Role.CUSTOMER = 'CUSTOMER'
@@ -223,7 +221,6 @@ class TestGetHijackableUsers:
assert 'role__in' in filter_kwargs assert 'role__in' in filter_kwargs
roles = filter_kwargs['role__in'] roles = filter_kwargs['role__in']
assert 'TENANT_OWNER' in roles assert 'TENANT_OWNER' in roles
assert 'TENANT_MANAGER' in roles
assert 'TENANT_STAFF' in roles assert 'TENANT_STAFF' in roles
assert 'CUSTOMER' in roles assert 'CUSTOMER' in roles
@@ -249,7 +246,6 @@ class TestGetHijackableUsers:
tenant = Mock(id=1) tenant = Mock(id=1)
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant) hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant)
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER' 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.TENANT_STAFF = 'TENANT_STAFF'
mock_user_model.Role.CUSTOMER = 'CUSTOMER' mock_user_model.Role.CUSTOMER = 'CUSTOMER'

View File

@@ -98,7 +98,6 @@ class UserAdmin(HijackUserAdminMixin, BaseUserAdmin):
'PLATFORM_SALES': '#fbc02d', # Yellow 'PLATFORM_SALES': '#fbc02d', # Yellow
'PLATFORM_SUPPORT': '#7cb342', # Light green 'PLATFORM_SUPPORT': '#7cb342', # Light green
'TENANT_OWNER': '#1976d2', # Blue 'TENANT_OWNER': '#1976d2', # Blue
'TENANT_MANAGER': '#0288d1', # Light blue
'TENANT_STAFF': '#0097a7', # Cyan 'TENANT_STAFF': '#0097a7', # Cyan
'CUSTOMER': '#5e35b1', # Purple 'CUSTOMER': '#5e35b1', # Purple
} }

View File

@@ -130,8 +130,8 @@ def current_user_view(request):
else: else:
business_subdomain = user.tenant.schema_name business_subdomain = user.tenant.schema_name
# Check for active quota overages (for owners and managers) # Check for active quota overages (for owners and staff with management permissions)
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: 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 from smoothschedule.identity.core.quota_service import QuotaService
try: try:
service = QuotaService(user.tenant) service = QuotaService(user.tenant)
@@ -153,10 +153,10 @@ def current_user_view(request):
} }
frontend_role = role_mapping.get(user.role.lower(), user.role.lower()) 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_id = None
can_edit_schedule = False 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: try:
with schema_context(user.tenant.schema_name): with schema_context(user.tenant.schema_name):
linked_resource = Resource.objects.filter(user=user).first() linked_resource = Resource.objects.filter(user=user).first()
@@ -183,6 +183,9 @@ def current_user_view(request):
'business_name': business_name, 'business_name': business_name,
'business_subdomain': business_subdomain, 'business_subdomain': business_subdomain,
'permissions': user.permissions, '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_invite_staff': user.can_invite_staff(),
'can_access_tickets': user.can_access_tickets(), 'can_access_tickets': user.can_access_tickets(),
'can_send_messages': user.can_send_messages(), '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()) 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_id = None
linked_resource_name = None linked_resource_name = None
can_edit_schedule = False 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: try:
with schema_context(user.tenant.schema_name): with schema_context(user.tenant.schema_name):
linked_resource = Resource.objects.filter(user=user).first() linked_resource = Resource.objects.filter(user=user).first()
@@ -519,7 +522,6 @@ class StaffInvitationSerializer(serializers.ModelSerializer):
def get_role_display(self, obj): def get_role_display(self, obj):
role_map = { role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff', 'TENANT_STAFF': 'Staff',
} }
return role_map.get(obj.role, obj.role) return role_map.get(obj.role, obj.role)
@@ -572,21 +574,13 @@ def staff_invitations_view(request):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Validate role - only allow manager and staff roles # Validate role - only allow staff role
if role not in [User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF]: if role != User.Role.TENANT_STAFF:
return Response( 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 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 # Check if user already exists in this tenant
existing_user = User.objects.filter( existing_user = User.objects.filter(
email=email, email=email,
@@ -708,7 +702,6 @@ def invitation_details_view(request, token):
# Return limited info for the acceptance page # Return limited info for the acceptance page
role_map = { role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff', '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}" invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}"
role_map = { role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff Member', 'TENANT_STAFF': 'Staff Member',
} }
role_display = role_map.get(invitation.role, 'team member') role_display = role_map.get(invitation.role, 'team member')

View File

@@ -68,15 +68,6 @@ class Command(BaseCommand):
'last_name': 'Owner', 'last_name': 'Owner',
'tenant': demo_tenant, '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', 'username': 'staff@demo.com',
'email': 'staff@demo.com', 'email': 'staff@demo.com',

View File

@@ -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,
),
]

View File

@@ -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),
),
]

View File

@@ -26,7 +26,7 @@ class User(AbstractUser):
# Tenant-level roles (access within single tenant) # Tenant-level roles (access within single tenant)
TENANT_OWNER = 'TENANT_OWNER', _('Tenant Owner') 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') TENANT_STAFF = 'TENANT_STAFF', _('Tenant Staff')
# Customer role (end users of the tenant) # Customer role (end users of the tenant)
@@ -199,19 +199,22 @@ class User(AbstractUser):
"""Check if user is tenant-scoped""" """Check if user is tenant-scoped"""
return self.role in [ return self.role in [
self.Role.TENANT_OWNER, self.Role.TENANT_OWNER,
self.Role.TENANT_MANAGER,
self.Role.TENANT_STAFF, self.Role.TENANT_STAFF,
self.Role.CUSTOMER, self.Role.CUSTOMER,
] ]
def can_manage_users(self): def can_manage_users(self):
"""Check if user can manage other users""" """Check if user can manage other users"""
return self.role in [ if self.role in [
self.Role.SUPERUSER, self.Role.SUPERUSER,
self.Role.PLATFORM_MANAGER, self.Role.PLATFORM_MANAGER,
self.Role.TENANT_OWNER, 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): def can_access_billing(self):
"""Check if user can access billing information""" """Check if user can access billing information"""
@@ -226,9 +229,9 @@ class User(AbstractUser):
# Owners can always invite # Owners can always invite
if self.role == self.Role.TENANT_OWNER: if self.role == self.Role.TENANT_OWNER:
return True return True
# Managers can invite if they have the permission # Staff can invite if they have the permission
if self.role == self.Role.TENANT_MANAGER: if self.role == self.Role.TENANT_STAFF:
return self.permissions.get('can_invite_staff', False) return self.has_staff_permission('can_invite_staff')
return False return False
def can_access_tickets(self): def can_access_tickets(self):
@@ -236,12 +239,12 @@ class User(AbstractUser):
# Platform users can always access # Platform users can always access
if self.is_platform_user(): if self.is_platform_user():
return True return True
# Owners and managers can always access # Owners can always access
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: if self.role == self.Role.TENANT_OWNER:
return True 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: 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 # Customers can create tickets
if self.role == self.Role.CUSTOMER: if self.role == self.Role.CUSTOMER:
return True return True
@@ -280,41 +283,39 @@ class User(AbstractUser):
""" """
Check if user can self-approve time off requests. Check if user can self-approve time off requests.
Owners can always self-approve. Owners can always self-approve.
Managers can self-approve by default but can be denied. Staff need explicit permission via staff role.
Staff need explicit permission.
""" """
# Owners can always self-approve # Owners can always self-approve
if self.role == self.Role.TENANT_OWNER: if self.role == self.Role.TENANT_OWNER:
return True return True
# Managers can self-approve by default, but can be denied # Staff can self-approve if granted permission via staff role
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)
if self.role == self.Role.TENANT_STAFF: 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 return False
def can_review_time_off_requests(self): def can_review_time_off_requests(self):
""" """
Check if user can review (approve/deny) time off requests from others. 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): def can_send_messages(self):
""" """
Check if user can send broadcast messages to staff/customers. Check if user can send broadcast messages to staff/customers.
Owners can always send messages. Owners can always send messages.
Managers can by default but can be revoked. Staff need explicit permission via staff role.
Staff cannot send messages.
""" """
# Owners can always send messages # Owners can always send messages
if self.role == self.Role.TENANT_OWNER: if self.role == self.Role.TENANT_OWNER:
return True return True
# Managers can send by default, but can be revoked # Staff can send if they have the permission via staff role
if self.role == self.Role.TENANT_MANAGER: if self.role == self.Role.TENANT_STAFF:
return self.permissions.get('can_send_messages', True) return self.has_staff_permission('can_access_messages')
# Staff and others cannot send messages
return False return False
def has_staff_permission(self, permission_key): def has_staff_permission(self, permission_key):
@@ -322,7 +323,7 @@ class User(AbstractUser):
Check if staff member has a specific permission. Check if staff member has a specific permission.
Permission Resolution Order: 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 2. For staff: User-level override takes priority
3. Then check staff role permissions 3. Then check staff role permissions
4. Default: False 4. Default: False
@@ -333,8 +334,8 @@ class User(AbstractUser):
Returns: Returns:
bool: Whether the user has the permission bool: Whether the user has the permission
""" """
# Owners and managers have all permissions # Owners have all permissions
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: if self.role == self.Role.TENANT_OWNER:
return True return True
# For staff, check permissions # For staff, check permissions
@@ -356,8 +357,8 @@ class User(AbstractUser):
Returns: Returns:
dict: All effective permissions for this user dict: All effective permissions for this user
""" """
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: if self.role == self.Role.TENANT_OWNER:
# Return all permissions as True for owner/manager # Return all permissions as True for owner
from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS
return {k: True for k in ALL_PERMISSIONS.keys()} 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. Invitation for new staff members to join a business.
Flow: 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 2. System sends email with unique token link
3. Invitee clicks link, creates account, and is added to tenant 3. Invitee clicks link, creates account, and is added to tenant
""" """
@@ -701,7 +702,6 @@ class StaffInvitation(models.Model):
role = models.CharField( role = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
(User.Role.TENANT_MANAGER, _('Manager')),
(User.Role.TENANT_STAFF, _('Staff')), (User.Role.TENANT_STAFF, _('Staff')),
], ],
default=User.Role.TENANT_STAFF, default=User.Role.TENANT_STAFF,
@@ -833,7 +833,7 @@ class StaffInvitation(models.Model):
Args: Args:
email: Email address to invite 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 tenant: Tenant/business the user is being invited to
invited_by: User sending the invitation invited_by: User sending the invitation
create_bookable_resource: Whether to create a bookable resource when accepted create_bookable_resource: Whether to create a bookable resource when accepted

View File

@@ -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 # Dangerous Operation Permissions
# These control specific destructive or sensitive operations at the API level # These control specific destructive or sensitive operations at the API level
DANGEROUS_PERMISSIONS = { DANGEROUS_PERMISSIONS = {
@@ -149,10 +229,20 @@ DANGEROUS_PERMISSIONS = {
'description': 'Approve own time off requests without manager approval', 'description': 'Approve own time off requests without manager approval',
'default': False, '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 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: def get_default_permissions_for_role(role_name: str) -> dict:

View File

@@ -189,7 +189,6 @@ class TestGetUserData:
(User.Role.PLATFORM_SALES, 'platform_sales'), (User.Role.PLATFORM_SALES, 'platform_sales'),
(User.Role.PLATFORM_SUPPORT, 'platform_support'), (User.Role.PLATFORM_SUPPORT, 'platform_support'),
(User.Role.TENANT_OWNER, 'owner'), (User.Role.TENANT_OWNER, 'owner'),
(User.Role.TENANT_MANAGER, 'manager'),
(User.Role.TENANT_STAFF, 'staff'), (User.Role.TENANT_STAFF, 'staff'),
(User.Role.CUSTOMER, 'customer'), (User.Role.CUSTOMER, 'customer'),
] ]
@@ -1104,29 +1103,6 @@ class TestStaffInvitationsView:
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
assert 'Invalid role' in response.data['error'] 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') @patch('smoothschedule.identity.users.api_views.User')
def test_post_rejects_existing_user(self, mock_user_model): def test_post_rejects_existing_user(self, mock_user_model):
factory = APIRequestFactory() factory = APIRequestFactory()
@@ -1142,7 +1118,6 @@ class TestStaffInvitationsView:
mock_user.role = User.Role.TENANT_OWNER mock_user.role = User.Role.TENANT_OWNER
request.user = mock_user request.user = mock_user
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF' mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
# User already exists # User already exists
@@ -1178,7 +1153,6 @@ class TestStaffInvitationsView:
mock_user.role = User.Role.TENANT_OWNER mock_user.role = User.Role.TENANT_OWNER
request.user = mock_user request.user = mock_user
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF' mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
mock_user_model.objects.filter.return_value.first.return_value = None mock_user_model.objects.filter.return_value.first.return_value = None

View File

@@ -80,9 +80,6 @@ class TestRoleClassification:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.is_tenant_user() is True 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): def test_is_tenant_user_returns_true_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -116,9 +113,6 @@ class TestCanManageUsers:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_manage_users() is True 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): def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -148,9 +142,6 @@ class TestCanAccessBilling:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_access_billing() is True 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): def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -168,17 +159,6 @@ class TestCanInviteStaff:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_invite_staff() is True 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): def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -204,9 +184,6 @@ class TestCanAccessTickets:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_access_tickets() is True 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): def test_returns_true_for_staff_with_permission(self):
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_access_tickets': True}) 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) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_self_approve_time_off() is True 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): def test_returns_true_for_staff_with_permission(self):
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_self_approve_time_off': True}) 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) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_review_time_off_requests() is True 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): def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -579,16 +550,6 @@ class TestUserCanSendMessages:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_send_messages() is True 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): def test_staff_cannot_send_messages(self):
"""Staff should not be able to send messages.""" """Staff should not be able to send messages."""

View File

@@ -68,19 +68,7 @@ class TestUserHasStaffPermission:
mock_user.role = 'TENANT_OWNER' mock_user.role = 'TENANT_OWNER'
# Simulate the has_staff_permission logic # Simulate the has_staff_permission logic
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: if mock_user.role == 'TENANT_OWNER':
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']:
result = True result = True
else: else:
result = False result = False
@@ -97,7 +85,7 @@ class TestUserHasStaffPermission:
# Simulate permission resolution # Simulate permission resolution
permission_key = 'can_access_scheduler' permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: if mock_user.role == 'TENANT_OWNER':
result = True result = True
elif mock_user.role == 'TENANT_STAFF': elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions: 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} mock_user.staff_role.permissions = {'can_access_scheduler': True}
permission_key = 'can_access_scheduler' permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: if mock_user.role == 'TENANT_OWNER':
result = True result = True
elif mock_user.role == 'TENANT_STAFF': elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions: if mock_user.permissions and permission_key in mock_user.permissions:
@@ -142,7 +130,7 @@ class TestUserHasStaffPermission:
mock_user.staff_role = None mock_user.staff_role = None
permission_key = 'can_access_scheduler' permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: if mock_user.role == 'TENANT_OWNER':
result = True result = True
elif mock_user.role == 'TENANT_STAFF': elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions: 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 mock_user.permissions = {'can_access_scheduler': True} # Even if set
permission_key = 'can_access_scheduler' permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']: if mock_user.role == 'TENANT_OWNER':
result = True result = True
elif mock_user.role == 'TENANT_STAFF': elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions: if mock_user.permissions and permission_key in mock_user.permissions:

View File

@@ -71,9 +71,6 @@ class TestIsPlatformUser:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.is_platform_user() is False 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): def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -107,9 +104,6 @@ class TestIsTenantUser:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.is_tenant_user() is True 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): def test_returns_true_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -139,9 +133,6 @@ class TestCanManageUsers:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_manage_users() is True 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): def test_returns_false_for_platform_sales(self):
user = create_user_instance(User.Role.PLATFORM_SALES) user = create_user_instance(User.Role.PLATFORM_SALES)
@@ -187,9 +178,6 @@ class TestCanAccessBilling:
user = create_user_instance(User.Role.PLATFORM_SUPPORT) user = create_user_instance(User.Role.PLATFORM_SUPPORT)
assert user.can_access_billing() is False 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): def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF) user = create_user_instance(User.Role.TENANT_STAFF)
@@ -211,23 +199,6 @@ class TestCanInviteStaff:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_invite_staff() is True 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): def test_returns_false_for_superuser(self):
user = create_user_instance(User.Role.SUPERUSER) user = create_user_instance(User.Role.SUPERUSER)
@@ -273,9 +244,6 @@ class TestCanAccessTickets:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_access_tickets() is True 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): def test_returns_true_for_staff_with_permission(self):
user = create_user_instance( user = create_user_instance(
@@ -341,9 +309,6 @@ class TestCanApprovePlugins:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_approve_plugins() is False 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): def test_returns_false_for_customer(self):
user = create_user_instance(User.Role.CUSTOMER) user = create_user_instance(User.Role.CUSTOMER)
@@ -407,9 +372,6 @@ class TestCanSelfApproveTimeOff:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_self_approve_time_off() is True 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): def test_returns_true_for_staff_with_permission(self):
user = create_user_instance( user = create_user_instance(
@@ -449,9 +411,6 @@ class TestCanReviewTimeOffRequests:
user = create_user_instance(User.Role.TENANT_OWNER) user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_review_time_off_requests() is True 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): def test_returns_false_for_superuser(self):
user = create_user_instance(User.Role.SUPERUSER) user = create_user_instance(User.Role.SUPERUSER)

View File

@@ -172,7 +172,7 @@ class APITokenViewSet(viewsets.ViewSet):
self._check_api_access_permission(tenant) self._check_api_access_permission(tenant)
# Only owners can manage API tokens (roles are uppercase in DB) # 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: if user.role.upper() not in allowed_roles:
return Response( return Response(
{'error': 'forbidden', 'message': 'Only business owners can manage API tokens'}, {'error': 'forbidden', 'message': 'Only business owners can manage API tokens'},
@@ -200,7 +200,7 @@ class APITokenViewSet(viewsets.ViewSet):
# Check API access permission # Check API access permission
self._check_api_access_permission(tenant) 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: if user.role.upper() not in allowed_roles:
return Response( return Response(
{'error': 'forbidden', 'message': 'Only business owners can create API tokens'}, {'error': 'forbidden', 'message': 'Only business owners can create API tokens'},
@@ -261,7 +261,7 @@ class APITokenViewSet(viewsets.ViewSet):
status=status.HTTP_403_FORBIDDEN 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: if user.role.upper() not in allowed_roles:
return Response( return Response(
{'error': 'forbidden', 'message': 'Only business owners can revoke API tokens'}, {'error': 'forbidden', 'message': 'Only business owners can revoke API tokens'},

View File

@@ -229,13 +229,13 @@ class Command(BaseCommand):
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS") status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
self.stdout.write(f" {status} {owner.email} (Owner)") self.stdout.write(f" {status} {owner.email} (Owner)")
# Manager # Manager (now TENANT_STAFF with Full Access Staff role)
manager_data = { manager_data = {
"username": "manager@demo.com", "username": "manager@demo.com",
"email": "manager@demo.com", "email": "manager@demo.com",
"first_name": "Marcus", "first_name": "Marcus",
"last_name": "Chen", "last_name": "Chen",
"role": User.Role.TENANT_MANAGER, "role": User.Role.TENANT_STAFF,
"tenant": tenant, "tenant": tenant,
"phone": "555-100-0002", "phone": "555-100-0002",
} }
@@ -246,10 +246,18 @@ class Command(BaseCommand):
if created: if created:
manager.set_password("test123") manager.set_password("test123")
manager.save() 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 users["manager"] = manager
if not self.quiet: if not self.quiet:
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS") 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 members (stylists and spa therapists)
staff_data = [ staff_data = [

View File

@@ -21,7 +21,7 @@ from django.utils import timezone
from django_tenants.utils import schema_context, tenant_context from django_tenants.utils import schema_context, tenant_context
from smoothschedule.identity.core.models import Tenant, Domain 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 ( from smoothschedule.scheduling.schedule.models import (
Event, Event,
Participant, Participant,
@@ -254,11 +254,12 @@ class Command(BaseCommand):
"username": "manager@demo.com", "username": "manager@demo.com",
"email": "manager@demo.com", "email": "manager@demo.com",
"password": "test123", "password": "test123",
"role": User.Role.TENANT_MANAGER, "role": User.Role.TENANT_STAFF,
"first_name": "Business", "first_name": "Business",
"last_name": "Manager", "last_name": "Manager",
"tenant": tenant, "tenant": tenant,
"phone": "555-100-0002", "phone": "555-100-0002",
"_assign_full_access": True, # Flag to assign Full Access Staff role
}, },
{ {
"username": "staff@demo.com", "username": "staff@demo.com",
@@ -273,8 +274,10 @@ class Command(BaseCommand):
] ]
created_users = {} created_users = {}
manager_user = None # Track manager user separately
for user_data in tenant_users: for user_data in tenant_users:
password = user_data.pop("password") password = user_data.pop("password")
assign_full_access = user_data.pop("_assign_full_access", False)
user, created = User.objects.get_or_create( user, created = User.objects.get_or_create(
username=user_data["username"], username=user_data["username"],
defaults=user_data, defaults=user_data,
@@ -285,9 +288,24 @@ class Command(BaseCommand):
status = self.style.SUCCESS("CREATED") status = self.style.SUCCESS("CREATED")
else: else:
status = self.style.WARNING("EXISTS") 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()})") self.stdout.write(f" {status} {user.email} ({user.get_role_display()})")
created_users[user_data["role"]] = user created_users[user_data["role"]] = user
# Store manager user under a special key for backward compatibility
created_users["_manager"] = manager_user
return created_users return created_users
def create_resource_types(self): def create_resource_types(self):
@@ -405,7 +423,7 @@ class Command(BaseCommand):
}, },
{ {
"name": "Business Manager", "name": "Business Manager",
"user": tenant_users.get(User.Role.TENANT_MANAGER), "user": tenant_users.get("_manager"),
"description": "Business manager - handles VIP appointments", "description": "Business manager - handles VIP appointments",
"resource_type": staff_type, "resource_type": staff_type,
"type": Resource.Type.STAFF, "type": Resource.Type.STAFF,

View File

@@ -172,9 +172,9 @@ class CustomerSerializer(serializers.ModelSerializer):
fields = [ fields = [
'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip', 'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags', '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): def create(self, validated_data):
"""Create a customer with email as username""" """Create a customer with email as username"""
@@ -260,8 +260,9 @@ class StaffSerializer(serializers.ModelSerializer):
'id', 'username', 'name', 'email', 'phone', 'role', 'id', 'username', 'name', 'email', 'phone', 'role',
'is_active', 'permissions', 'can_invite_staff', 'is_active', 'permissions', 'can_invite_staff',
'staff_role_id', 'staff_role_name', 'effective_permissions', '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): def get_name(self, obj):
return obj.full_name return obj.full_name
@@ -270,7 +271,6 @@ class StaffSerializer(serializers.ModelSerializer):
# Map database roles to frontend roles # Map database roles to frontend roles
role_mapping = { role_mapping = {
'TENANT_OWNER': 'owner', 'TENANT_OWNER': 'owner',
'TENANT_MANAGER': 'manager',
'TENANT_STAFF': 'staff', 'TENANT_STAFF': 'staff',
} }
return role_mapping.get(obj.role, obj.role.lower()) return role_mapping.get(obj.role, obj.role.lower())

View File

@@ -675,13 +675,26 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
f"for resource '{instance.resource.name}'" 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 from smoothschedule.identity.users.models import User
# Get owners (always have permission) + staff with can_review_time_off permission
reviewers = User.objects.filter( reviewers = User.objects.filter(
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER], role=User.Role.TENANT_OWNER,
is_active=True 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 # Create in-app notifications for each reviewer
for reviewer in reviewers: for reviewer in reviewers:

View File

@@ -193,19 +193,6 @@ class TestStaffSerializer:
# Assert # Assert
assert role == 'owner' 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): def test_get_role_maps_tenant_staff(self):
"""Test that TENANT_STAFF maps to staff.""" """Test that TENANT_STAFF maps to staff."""
@@ -1682,14 +1669,6 @@ class TestStaffSerializerMethodFields:
result = serializer.get_name(mock_obj) result = serializer.get_name(mock_obj)
assert result == 'Jane Smith' 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: class TestResourceSerializerFields:

View File

@@ -159,10 +159,15 @@ class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
This endpoint provides the frontend with the full list of permission This endpoint provides the frontend with the full list of permission
keys that can be configured on a staff role. 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({ return Response({
'menu_permissions': MENU_PERMISSIONS, 'menu_permissions': MENU_PERMISSIONS,
'settings_permissions': SETTINGS_PERMISSIONS,
'dangerous_permissions': DANGEROUS_PERMISSIONS, 'dangerous_permissions': DANGEROUS_PERMISSIONS,
}) })
@@ -769,6 +774,21 @@ class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
tenant=tenant, 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): 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). 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: Supports:
- GET /api/staff/ - List staff members - GET /api/staff/ - List staff members
@@ -851,14 +871,13 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
""" """
Return staff members for the current tenant. 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 from django.db.models import Q
# Set base queryset to staff roles only # Set base queryset to staff roles only
self.queryset = User.objects.filter( self.queryset = User.objects.filter(
Q(role=User.Role.TENANT_OWNER) | Q(role=User.Role.TENANT_OWNER) |
Q(role=User.Role.TENANT_MANAGER) |
Q(role=User.Role.TENANT_STAFF) Q(role=User.Role.TENANT_STAFF)
) )
@@ -890,24 +909,31 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
""" """
Update staff member. 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. 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() instance = self.get_object()
# TODO: Add permission checks when authentication is enabled # Permission check: staff can only edit other staff, not owners
# current_user = request.user current_user = request.user
# if current_user.role == User.Role.TENANT_MANAGER: if current_user.role == User.Role.TENANT_STAFF:
# if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]: # Staff can only edit if they have can_access_staff permission
# return Response( if not current_user.has_staff_permission('can_access_staff'):
# {'error': 'Managers cannot edit owners or other managers.'}, return Response(
# status=status.HTTP_403_FORBIDDEN {'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 # 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} 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) 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." '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): class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
""" """