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

View File

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

View File

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

View File

@@ -46,10 +46,10 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { canUse } = usePlanFeatures();
// Helper to check if user has a specific staff permission
// Owners and managers always have all permissions
// Owners always have all permissions
// Staff members check their effective_permissions (role + user overrides)
const hasPermission = (permissionKey: string): boolean => {
if (role === 'owner' || role === 'manager') {
if (role === 'owner') {
return true;
}
if (role === 'staff') {
@@ -59,10 +59,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
return false;
};
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager';
// Admin/management access is based on effective permissions for staff
const canViewAdminPages = role === 'owner' || hasPermission('can_access_staff');
const canViewManagementPages = role === 'owner' || hasPermission('can_access_scheduler');
const isStaff = role === 'staff';
const canViewSettings = role === 'owner';
const canViewSettings = role === 'owner' || hasPermission('can_access_settings');
const canViewTickets = hasPermission('can_access_tickets');
const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true;
@@ -191,7 +192,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{hasPermission('can_access_services') && (
@@ -216,7 +216,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{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 { ChevronDown, ChevronRight } from 'lucide-react';
export interface PermissionConfig {
key: string;
@@ -8,20 +9,134 @@ export interface PermissionConfig {
hintKey: string;
hintDefault: string;
defaultValue: boolean;
roles: ('manager' | 'staff')[];
}
// Business Settings sub-permissions
export const SETTINGS_PERMISSION_CONFIGS: PermissionConfig[] = [
{
key: 'can_access_settings_general',
labelKey: 'staff.canAccessSettingsGeneral',
labelDefault: 'General Settings',
hintKey: 'staff.canAccessSettingsGeneralHint',
hintDefault: 'Business name, timezone, and basic configuration',
defaultValue: false,
},
{
key: 'can_access_settings_business_hours',
labelKey: 'staff.canAccessSettingsBusinessHours',
labelDefault: 'Business Hours',
hintKey: 'staff.canAccessSettingsBusinessHoursHint',
hintDefault: 'Set regular operating hours',
defaultValue: false,
},
{
key: 'can_access_settings_branding',
labelKey: 'staff.canAccessSettingsBranding',
labelDefault: 'Branding',
hintKey: 'staff.canAccessSettingsBrandingHint',
hintDefault: 'Logo, colors, and visual identity',
defaultValue: false,
},
{
key: 'can_access_settings_booking',
labelKey: 'staff.canAccessSettingsBooking',
labelDefault: 'Booking Settings',
hintKey: 'staff.canAccessSettingsBookingHint',
hintDefault: 'Booking policies and rules',
defaultValue: false,
},
{
key: 'can_access_settings_communication',
labelKey: 'staff.canAccessSettingsCommunication',
labelDefault: 'Communication',
hintKey: 'staff.canAccessSettingsCommunicationHint',
hintDefault: 'Notification preferences and reminders',
defaultValue: false,
},
{
key: 'can_access_settings_embed_widget',
labelKey: 'staff.canAccessSettingsEmbedWidget',
labelDefault: 'Embed Widget',
hintKey: 'staff.canAccessSettingsEmbedWidgetHint',
hintDefault: 'Configure booking widget for websites',
defaultValue: false,
},
{
key: 'can_access_settings_email_templates',
labelKey: 'staff.canAccessSettingsEmailTemplates',
labelDefault: 'Email Templates',
hintKey: 'staff.canAccessSettingsEmailTemplatesHint',
hintDefault: 'Customize automated emails',
defaultValue: false,
},
{
key: 'can_access_settings_staff_roles',
labelKey: 'staff.canAccessSettingsStaffRoles',
labelDefault: 'Staff Roles',
hintKey: 'staff.canAccessSettingsStaffRolesHint',
hintDefault: 'Create and manage permission roles',
defaultValue: false,
},
{
key: 'can_access_settings_resource_types',
labelKey: 'staff.canAccessSettingsResourceTypes',
labelDefault: 'Resource Types',
hintKey: 'staff.canAccessSettingsResourceTypesHint',
hintDefault: 'Configure resource categories',
defaultValue: false,
},
{
key: 'can_access_settings_api',
labelKey: 'staff.canAccessSettingsApi',
labelDefault: 'API & Integrations',
hintKey: 'staff.canAccessSettingsApiHint',
hintDefault: 'Manage API tokens and webhooks',
defaultValue: false,
},
{
key: 'can_access_settings_custom_domains',
labelKey: 'staff.canAccessSettingsCustomDomains',
labelDefault: 'Custom Domains',
hintKey: 'staff.canAccessSettingsCustomDomainsHint',
hintDefault: 'Configure custom domain settings',
defaultValue: false,
},
{
key: 'can_access_settings_authentication',
labelKey: 'staff.canAccessSettingsAuthentication',
labelDefault: 'Authentication',
hintKey: 'staff.canAccessSettingsAuthenticationHint',
hintDefault: 'OAuth and social login configuration',
defaultValue: false,
},
{
key: 'can_access_settings_email',
labelKey: 'staff.canAccessSettingsEmail',
labelDefault: 'Email Setup',
hintKey: 'staff.canAccessSettingsEmailHint',
hintDefault: 'Configure email addresses for tickets',
defaultValue: false,
},
{
key: 'can_access_settings_sms_calling',
labelKey: 'staff.canAccessSettingsSmsCalling',
labelDefault: 'SMS & Calling',
hintKey: 'staff.canAccessSettingsSmsCallingHint',
hintDefault: 'Manage credits and phone numbers',
defaultValue: false,
},
];
// Define all available permissions in one place
// All permissions are now available to staff (via staff roles)
export const PERMISSION_CONFIGS: PermissionConfig[] = [
// Manager-only permissions
{
key: 'can_invite_staff',
labelKey: 'staff.canInviteStaff',
labelDefault: 'Can invite new staff members',
hintKey: 'staff.canInviteStaffHint',
hintDefault: 'Allow this manager to send invitations to new staff members',
hintDefault: 'Allow this staff member to send invitations to new staff members',
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_manage_resources',
@@ -29,8 +144,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can manage resources',
hintKey: 'staff.canManageResourcesHint',
hintDefault: 'Create, edit, and delete bookable resources',
defaultValue: true,
roles: ['manager'],
defaultValue: false,
},
{
key: 'can_manage_services',
@@ -38,8 +152,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can manage services',
hintKey: 'staff.canManageServicesHint',
hintDefault: 'Create, edit, and delete service offerings',
defaultValue: true,
roles: ['manager'],
defaultValue: false,
},
{
key: 'can_view_reports',
@@ -47,17 +160,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can view reports',
hintKey: 'staff.canViewReportsHint',
hintDefault: 'Access business analytics and financial reports',
defaultValue: true,
roles: ['manager'],
},
{
key: 'can_access_settings',
labelKey: 'staff.canAccessSettings',
labelDefault: 'Can access business settings',
hintKey: 'staff.canAccessSettingsHint',
hintDefault: 'Modify business profile, branding, and configuration',
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_refund_payments',
@@ -66,7 +169,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canRefundPaymentsHint',
hintDefault: 'Process refunds for customer payments',
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_send_messages',
@@ -74,10 +176,8 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
labelDefault: 'Can send broadcast messages',
hintKey: 'staff.canSendMessagesHint',
hintDefault: 'Send messages to groups of staff and customers',
defaultValue: true,
roles: ['manager'],
defaultValue: false,
},
// Staff-only permissions
{
key: 'can_view_all_schedules',
labelKey: 'staff.canViewAllSchedules',
@@ -85,7 +185,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canViewAllSchedulesHint',
hintDefault: 'View schedules of other staff members (otherwise only their own)',
defaultValue: false,
roles: ['staff'],
},
{
key: 'can_manage_own_appointments',
@@ -94,112 +193,235 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canManageOwnAppointmentsHint',
hintDefault: 'Create, reschedule, and cancel their own appointments',
defaultValue: true,
roles: ['staff'],
},
{
key: 'can_self_approve_time_off',
labelKey: 'staff.canSelfApproveTimeOff',
labelDefault: 'Can self-approve time off',
hintKey: 'staff.canSelfApproveTimeOffHint',
hintDefault: 'Add time off without requiring manager/owner approval',
hintDefault: 'Add time off without requiring owner approval',
defaultValue: false,
roles: ['staff'],
},
// Shared permissions (both manager and staff)
{
key: 'can_access_tickets',
labelKey: 'staff.canAccessTickets',
labelDefault: 'Can access support tickets',
hintKey: 'staff.canAccessTicketsHint',
hintDefault: 'View and manage customer support tickets',
defaultValue: true, // Default for managers; staff will override to false
roles: ['manager', 'staff'],
defaultValue: false,
},
];
// Get default permissions for a role
export const getDefaultPermissions = (role: 'manager' | 'staff'): Record<string, boolean> => {
// Get default permissions for staff
export const getDefaultPermissions = (): Record<string, boolean> => {
const defaults: Record<string, boolean> = {};
PERMISSION_CONFIGS.forEach((config) => {
if (config.roles.includes(role)) {
// Staff members have ticket access disabled by default
if (role === 'staff' && config.key === 'can_access_tickets') {
defaults[config.key] = false;
} else {
defaults[config.key] = config.defaultValue;
}
}
});
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
defaults[config.key] = config.defaultValue;
});
defaults['can_access_settings'] = false;
return defaults;
};
interface StaffPermissionsProps {
role: 'manager' | 'staff';
role: 'staff';
permissions: Record<string, boolean>;
onChange: (permissions: Record<string, boolean>) => void;
variant?: 'invite' | 'edit';
}
const StaffPermissions: React.FC<StaffPermissionsProps> = ({
role,
permissions,
onChange,
variant = 'edit',
}) => {
const { t } = useTranslation();
// Filter permissions for this role
const rolePermissions = PERMISSION_CONFIGS.filter((config) =>
config.roles.includes(role)
);
const handleToggle = (key: string, checked: boolean) => {
onChange({ ...permissions, [key]: checked });
};
const [settingsExpanded, setSettingsExpanded] = useState(false);
// Get the current value, falling back to default
const getValue = (config: PermissionConfig): boolean => {
if (permissions[config.key] !== undefined) {
return permissions[config.key];
const getValue = (key: string, defaultValue: boolean = false): boolean => {
if (permissions[key] !== undefined) {
return permissions[key];
}
// Staff have ticket access disabled by default
if (role === 'staff' && config.key === 'can_access_tickets') {
return false;
}
return config.defaultValue;
return defaultValue;
};
// Different styling for manager vs staff permissions
const isManagerPermission = (config: PermissionConfig) =>
config.roles.includes('manager') && !config.roles.includes('staff');
const hasSettingsAccess = getValue('can_access_settings', false);
const getPermissionStyle = (config: PermissionConfig) => {
if (isManagerPermission(config) || role === 'manager') {
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30';
// Auto-expand settings section if any settings permissions are enabled
useEffect(() => {
if (hasSettingsAccess) {
const hasAnySettingEnabled = SETTINGS_PERMISSION_CONFIGS.some(
(config) => getValue(config.key, false)
);
if (hasAnySettingEnabled) {
setSettingsExpanded(true);
}
return 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700';
}
}, []);
const handleToggle = (key: string, checked: boolean) => {
const newPermissions = { ...permissions, [key]: checked };
// If turning off main settings access, turn off all sub-settings
if (key === 'can_access_settings' && !checked) {
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
newPermissions[config.key] = false;
});
}
onChange(newPermissions);
};
if (rolePermissions.length === 0) {
return null;
const handleSettingsMainToggle = (checked: boolean) => {
const newPermissions = { ...permissions, can_access_settings: checked };
// If turning off, disable all sub-settings
if (!checked) {
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
newPermissions[config.key] = false;
});
setSettingsExpanded(false);
} else {
// If turning on, expand the section
setSettingsExpanded(true);
}
onChange(newPermissions);
};
const handleSelectAllSettings = (selectAll: boolean) => {
const newPermissions = { ...permissions };
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
newPermissions[config.key] = selectAll;
});
onChange(newPermissions);
};
// Count how many settings sub-permissions are enabled
const enabledSettingsCount = SETTINGS_PERMISSION_CONFIGS.filter((config) =>
getValue(config.key, false)
).length;
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{role === 'manager'
? t('staff.managerPermissions', 'Manager Permissions')
: t('staff.staffPermissions', 'Staff Permissions')}
{t('staff.staffPermissions', 'Staff Permissions')}
</h4>
{rolePermissions.map((config) => (
{/* Regular permissions */}
{PERMISSION_CONFIGS.map((config) => (
<label
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
type="checkbox"
checked={getValue(config)}
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>
))}
{/* 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"
/>
@@ -214,6 +436,10 @@ const StaffPermissions: React.FC<StaffPermissionsProps> = ({
</label>
))}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -50,7 +50,7 @@ describe('MasqueradeBanner', () => {
it('shows return to previous user text when previousUser exists', () => {
const propsWithPrevious = {
...defaultProps,
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'owner' as const },
};
render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ const transformCustomer = (c: any): Customer => ({
paymentMethods: [],
user_data: c.user_data,
notes: c.notes || '',
email_verified: c.email_verified ?? false,
});
/**
@@ -208,3 +209,20 @@ export const useDeleteCustomer = () => {
},
});
};
/**
* Hook to verify a customer's email address
*/
export const useVerifyCustomerEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await apiClient.post(`/customers/${id}/verify_email/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};

View File

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

View File

@@ -13,6 +13,8 @@ export interface StaffPermissions {
export interface StaffMember {
id: string;
name: string;
first_name: string;
last_name: string;
email: string;
phone?: string;
role: string;
@@ -22,6 +24,7 @@ export interface StaffMember {
staff_role_id: number | null;
staff_role_name: string | null;
effective_permissions: Record<string, boolean>;
email_verified: boolean;
}
interface StaffFilters {
@@ -30,7 +33,7 @@ interface StaffFilters {
/**
* Hook to fetch staff members with optional filters
* Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF
* Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF
*/
export const useStaff = (filters?: StaffFilters) => {
return useQuery<StaffMember[]>({
@@ -46,6 +49,8 @@ export const useStaff = (filters?: StaffFilters) => {
return data.map((s: any) => ({
id: String(s.id),
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
first_name: s.first_name || '',
last_name: s.last_name || '',
email: s.email || '',
phone: s.phone || '',
role: s.role || 'staff',
@@ -55,14 +60,27 @@ export const useStaff = (filters?: StaffFilters) => {
staff_role_id: s.staff_role_id ?? null,
staff_role_name: s.staff_role_name ?? null,
effective_permissions: s.effective_permissions || {},
email_verified: s.email_verified ?? false,
}));
},
retry: false,
});
};
export interface StaffProfileUpdate {
first_name?: string;
last_name?: string;
phone?: string;
}
export interface StaffUpdate extends StaffProfileUpdate {
is_active?: boolean;
permissions?: StaffPermissions;
staff_role_id?: number | null;
}
/**
* Hook to update a staff member's settings
* Hook to update a staff member's settings and profile
*/
export const useUpdateStaff = () => {
const queryClient = useQueryClient();
@@ -73,7 +91,7 @@ export const useUpdateStaff = () => {
updates,
}: {
id: string;
updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null };
updates: StaffUpdate;
}) => {
const { data } = await apiClient.patch(`/staff/${id}/`, updates);
return data;
@@ -102,3 +120,38 @@ export const useToggleStaffActive = () => {
},
});
};
/**
* Hook to verify a staff member's email address
*/
export const useVerifyStaffEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await apiClient.post(`/staff/${id}/verify_email/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
},
});
};
/**
* Hook to send a password reset email to a staff member
*/
export const useSendStaffPasswordReset = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await apiClient.post(`/staff/${id}/send_password_reset/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
},
});
};

View File

@@ -917,7 +917,19 @@
"noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.",
"addNewCustomer": "Neuen Kunden Hinzufügen",
"createCustomer": "Kunden Erstellen",
"errorLoading": "Fehler beim Laden der Kunden"
"errorLoading": "Fehler beim Laden der Kunden",
"password": "Passwort",
"newPassword": "Neues Passwort",
"passwordPlaceholder": "Leer lassen, um das aktuelle Passwort zu behalten",
"accountInfo": "Kontoinformationen",
"contactDetails": "Kontaktdetails",
"verifyEmail": "E-Mail bestätigen",
"unverifyEmail": "E-Mail nicht bestätigen",
"emailVerified": "Bestätigt",
"verifyEmailTitle": "E-Mail-Adresse bestätigen",
"unverifyEmailTitle": "E-Mail-Bestätigung aufheben",
"verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?",
"unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?"
},
"staff": {
"title": "Personal & Management",
@@ -930,7 +942,15 @@
"yes": "Ja",
"errorLoading": "Fehler beim Laden des Personals",
"inviteModalTitle": "Personal Einladen",
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein."
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein.",
"verifyEmail": "E-Mail bestätigen",
"emailVerified": "Bestätigt",
"emailStatus": "E-Mail-Status",
"unverifyEmail": "E-Mail nicht bestätigen",
"verifyEmailTitle": "E-Mail-Adresse bestätigen",
"unverifyEmailTitle": "E-Mail-Bestätigung aufheben",
"verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?",
"unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?"
},
"resources": {
"title": "Ressourcen",

View File

@@ -936,9 +936,8 @@
"emailPlaceholder": "colleague@example.com",
"roleLabel": "Role",
"roleStaff": "Staff Member",
"roleManager": "Manager",
"managerRoleHint": "Managers can manage staff, resources, and view reports",
"staffRoleHint": "Staff members can manage their own schedule and appointments",
"roleOwner": "Owner",
"staffRoleHint": "Staff permissions are determined by their assigned role",
"makeBookableHint": "Create a bookable resource so customers can schedule appointments with this person",
"resourceName": "Display Name (optional)",
"resourceNamePlaceholder": "Defaults to person's name",
@@ -958,7 +957,7 @@
"canSendMessagesHint": "Send messages to groups of staff and customers",
"deactivate": "Deactivate",
"canInviteStaff": "Can invite new staff members",
"canInviteStaffHint": "Allow this manager to send invitations to new staff members",
"canInviteStaffHint": "Allow this staff member to send invitations to new staff members",
"canManageResources": "Can manage resources",
"canManageResourcesHint": "Create, edit, and delete bookable resources",
"canManageServices": "Can manage services",
@@ -966,7 +965,37 @@
"canViewReports": "Can view reports",
"canViewReportsHint": "Access business analytics and financial reports",
"canAccessSettings": "Can access business settings",
"canAccessSettingsHint": "Modify business profile, branding, and configuration",
"canAccessSettingsHint": "Access to business settings pages (select specific pages below)",
"canAccessSettingsGeneral": "General Settings",
"canAccessSettingsGeneralHint": "Business name, timezone, and basic configuration",
"canAccessSettingsBusinessHours": "Business Hours",
"canAccessSettingsBusinessHoursHint": "Set regular operating hours",
"canAccessSettingsBranding": "Branding",
"canAccessSettingsBrandingHint": "Logo, colors, and visual identity",
"canAccessSettingsBooking": "Booking Settings",
"canAccessSettingsBookingHint": "Booking policies and rules",
"canAccessSettingsCommunication": "Communication",
"canAccessSettingsCommunicationHint": "Notification preferences and reminders",
"canAccessSettingsEmbedWidget": "Embed Widget",
"canAccessSettingsEmbedWidgetHint": "Configure booking widget for websites",
"canAccessSettingsEmailTemplates": "Email Templates",
"canAccessSettingsEmailTemplatesHint": "Customize automated emails",
"canAccessSettingsStaffRoles": "Staff Roles",
"canAccessSettingsStaffRolesHint": "Create and manage permission roles",
"canAccessSettingsResourceTypes": "Resource Types",
"canAccessSettingsResourceTypesHint": "Configure resource categories",
"canAccessSettingsApi": "API & Integrations",
"canAccessSettingsApiHint": "Manage API tokens and webhooks",
"canAccessSettingsCustomDomains": "Custom Domains",
"canAccessSettingsCustomDomainsHint": "Configure custom domain settings",
"canAccessSettingsAuthentication": "Authentication",
"canAccessSettingsAuthenticationHint": "OAuth and social login configuration",
"canAccessSettingsEmail": "Email Setup",
"canAccessSettingsEmailHint": "Configure email addresses for tickets",
"canAccessSettingsSmsCalling": "SMS & Calling",
"canAccessSettingsSmsCallingHint": "Manage credits and phone numbers",
"selectAll": "Select All",
"selectNone": "Select None",
"canRefundPayments": "Can refund payments",
"canRefundPaymentsHint": "Process refunds for customer payments",
"canViewAllSchedules": "Can view all schedules",
@@ -974,16 +1003,41 @@
"canManageOwnAppointments": "Can manage own appointments",
"canManageOwnAppointmentsHint": "Create, reschedule, and cancel their own appointments",
"canSelfApproveTimeOff": "Can self-approve time off",
"canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval",
"canSelfApproveTimeOffHint": "Add time off without requiring owner approval",
"canAccessTickets": "Can access support tickets",
"canAccessTicketsHint": "View and manage customer support tickets",
"managerPermissions": "Manager Permissions",
"staffPermissions": "Staff Permissions"
"staffPermissions": "Staff Permissions",
"verifyEmail": "Verify Email",
"unverifyEmail": "Unverify Email",
"emailVerified": "Verified",
"emailStatus": "Email Status",
"verifyEmailTitle": "Verify Email Address",
"unverifyEmailTitle": "Unverify Email Address",
"verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?",
"unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?",
"profileInformation": "Profile Information",
"firstName": "First Name",
"firstNamePlaceholder": "Enter first name",
"lastName": "Last Name",
"lastNamePlaceholder": "Enter last name",
"email": "Email",
"phone": "Phone",
"phonePlaceholder": "Enter phone number",
"verified": "Verified",
"verify": "Verify",
"roleAndPermissions": "Role & Permissions",
"accountSecurity": "Account Security",
"resetPassword": "Reset Password",
"resetPasswordHint": "Send a password reset email to this staff member",
"sendResetEmail": "Send Reset Email",
"confirmPasswordReset": "Send a password reset email to {{email}}? They will receive a temporary password.",
"passwordResetSent": "Password reset email sent successfully",
"passwordResetFailed": "Failed to send password reset email"
},
"staffDashboard": {
"welcomeTitle": "Welcome, {{name}}!",
"weekOverview": "Here's your week at a glance",
"noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.",
"noResourceLinked": "Your account is not linked to a resource yet. Please contact the business owner to set up your schedule.",
"currentAppointment": "Current Appointment",
"nextAppointment": "Next Appointment",
"viewSchedule": "View Schedule",
@@ -1478,7 +1532,14 @@
"newPassword": "New Password",
"passwordPlaceholder": "Leave blank to keep current password",
"accountInfo": "Account Information",
"contactDetails": "Contact Details"
"contactDetails": "Contact Details",
"verifyEmail": "Verify Email",
"unverifyEmail": "Unverify Email",
"emailVerified": "Verified",
"verifyEmailTitle": "Verify Email Address",
"unverifyEmailTitle": "Unverify Email Address",
"verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?",
"unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?"
},
"resources": {
"title": "Resources",
@@ -1742,6 +1803,7 @@
},
"settings": {
"title": "Settings",
"noPermission": "You do not have permission to access these settings.",
"businessSettings": "Business Settings",
"businessSettingsDescription": "Manage your branding, domain, and policies.",
"domainIdentity": "Domain & Identity",
@@ -1921,6 +1983,12 @@
"roleDescriptionPlaceholder": "Brief description of this role's responsibilities",
"permissions": "Permissions",
"menuAccess": "Menu Access",
"menuPermissions": "Menu Access",
"menuPermissionsDescription": "Control which pages staff can see in the sidebar.",
"settingsPermissions": "Business Settings Access",
"settingsPermissionsDescription": "Control which settings pages staff can access.",
"dangerousPermissions": "Dangerous Operations",
"dangerousPermissionsDescription": "Allow staff to perform destructive or sensitive actions.",
"dangerousOperations": "Dangerous Operations",
"staffAssigned": "{{count}} staff assigned",
"noStaffAssigned": "No staff assigned",
@@ -3406,7 +3474,7 @@
"title": "My Availability",
"subtitle": "Manage your time off and unavailability",
"noResource": "No Resource Linked",
"noResourceDesc": "Your account is not linked to a resource. Please contact your manager to set up your availability.",
"noResourceDesc": "Your account is not linked to a resource. Please contact the business owner to set up your availability.",
"addBlock": "Block Time",
"businessBlocks": "Business Closures",
"businessBlocksInfo": "These blocks are set by your business and apply to everyone.",
@@ -3717,14 +3785,12 @@
"staffRoles": "Staff Roles",
"ownerRole": "Owner",
"ownerRoleDesc": "Full access to everything including billing and settings. Cannot be removed.",
"managerRole": "Manager",
"managerRoleDesc": "Can manage staff, customers, services, and appointments. No billing access.",
"staffRole": "Staff",
"staffRoleDesc": "Basic access. Can view scheduler and manage own appointments if bookable.",
"staffRoleDesc": "Access is controlled by their assigned staff role. Create custom roles in Settings > Staff Roles.",
"invitingStaff": "Inviting Staff",
"inviteStep1": "Click the Invite Staff button",
"inviteStep2": "Enter their email address",
"inviteStep3": "Select a role (Manager or Staff)",
"inviteStep3": "Select a staff role to assign",
"inviteStep4": "Click Send Invitation",
"inviteStep5": "They'll receive an email with a link to join",
"makeBookable": "Make Bookable",

View File

@@ -967,6 +967,13 @@
"lastVisit": "Última Visita",
"nextAppointment": "Próxima Cita",
"contactInfo": "Información de Contacto",
"verifyEmail": "Verificar correo",
"unverifyEmail": "Anular verificación",
"emailVerified": "Verificado",
"verifyEmailTitle": "Verificar correo electrónico",
"unverifyEmailTitle": "Anular verificación de correo",
"verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?",
"unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?",
"status": "Estado",
"active": "Activo",
"inactive": "Inactivo",
@@ -987,6 +994,14 @@
"role": "Rol",
"bookableResource": "Recurso Reservable",
"makeBookable": "Hacer Reservable",
"verifyEmail": "Verificar correo",
"unverifyEmail": "Anular verificación",
"emailVerified": "Verificado",
"emailStatus": "Estado del correo",
"verifyEmailTitle": "Verificar correo electrónico",
"unverifyEmailTitle": "Anular verificación de correo",
"verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?",
"unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?",
"yes": "Sí",
"errorLoading": "Error al cargar personal",
"inviteModalTitle": "Invitar Personal",

View File

@@ -907,6 +907,13 @@
"lastVisit": "Dernière Visite",
"nextAppointment": "Prochain Rendez-vous",
"contactInfo": "Informations de Contact",
"verifyEmail": "Vérifier l'e-mail",
"unverifyEmail": "Annuler la vérification",
"emailVerified": "Vérifié",
"verifyEmailTitle": "Vérifier l'adresse e-mail",
"unverifyEmailTitle": "Annuler la vérification de l'e-mail",
"verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?",
"unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?",
"status": "Statut",
"active": "Actif",
"inactive": "Inactif",
@@ -930,7 +937,17 @@
"yes": "Oui",
"errorLoading": "Erreur lors du chargement du personnel",
"inviteModalTitle": "Inviter du Personnel",
"inviteModalDescription": "Le flux d'invitation utilisateur irait ici."
"inviteModalDescription": "Le flux d'invitation utilisateur irait ici.",
"managerPermissions": "Permissions du Gestionnaire",
"staffPermissions": "Permissions du Personnel",
"verifyEmail": "Vérifier l'e-mail",
"unverifyEmail": "Annuler la vérification",
"verifyEmailTitle": "Vérifier l'adresse e-mail",
"unverifyEmailTitle": "Annuler la vérification de l'e-mail",
"verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?",
"unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?",
"emailVerified": "Vérifié",
"emailStatus": "Statut de l'e-mail"
},
"resources": {
"title": "Ressources",

View File

@@ -56,6 +56,16 @@ const SettingsLayout: React.FC = () => {
// Get context from parent route (BusinessLayout)
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)
const isLocked = (feature: FeatureKey | undefined): boolean => {
@@ -92,35 +102,53 @@ const SettingsLayout: React.FC = () => {
{/* Navigation */}
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
{/* Business Section */}
{(hasSettingsPermission('can_access_settings_general') ||
hasSettingsPermission('can_access_settings_resource_types') ||
hasSettingsPermission('can_access_settings_booking') ||
hasSettingsPermission('can_access_settings_business_hours')) && (
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
{hasSettingsPermission('can_access_settings_general') && (
<SettingsSidebarItem
to="/dashboard/settings/general"
icon={Building2}
label={t('settings.general.title', 'General')}
description={t('settings.general.description', 'Name, timezone, contact')}
/>
)}
{hasSettingsPermission('can_access_settings_resource_types') && (
<SettingsSidebarItem
to="/dashboard/settings/resource-types"
icon={Layers}
label={t('settings.resourceTypes.title', 'Resource Types')}
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
/>
)}
{hasSettingsPermission('can_access_settings_booking') && (
<SettingsSidebarItem
to="/dashboard/settings/booking"
icon={Calendar}
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 */}
{(hasSettingsPermission('can_access_settings_branding') ||
hasSettingsPermission('can_access_settings_email_templates') ||
hasSettingsPermission('can_access_settings_custom_domains') ||
hasSettingsPermission('can_access_settings_embed_widget')) && (
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
{hasSettingsPermission('can_access_settings_branding') && (
<SettingsSidebarItem
to="/dashboard/settings/branding"
icon={Palette}
@@ -128,12 +156,16 @@ const SettingsLayout: React.FC = () => {
description={t('settings.appearance.description', 'Logo, colors, theme')}
locked={isLocked('remove_branding')}
/>
)}
{hasSettingsPermission('can_access_settings_email_templates') && (
<SettingsSidebarItem
to="/dashboard/settings/email-templates"
icon={Mail}
label={t('settings.emailTemplates.title', 'Email Templates')}
description={t('settings.emailTemplates.description', 'Customize automated emails')}
/>
)}
{hasSettingsPermission('can_access_settings_custom_domains') && (
<SettingsSidebarItem
to="/dashboard/settings/custom-domains"
icon={Globe}
@@ -141,15 +173,20 @@ const SettingsLayout: React.FC = () => {
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 */}
{hasSettingsPermission('can_access_settings_api') && (
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
<SettingsSidebarItem
to="/dashboard/settings/api"
@@ -159,15 +196,21 @@ const SettingsLayout: React.FC = () => {
locked={isLocked('api_access')}
/>
</SettingsSidebarSection>
)}
{/* Access Section */}
{(hasSettingsPermission('can_access_settings_staff_roles') ||
hasSettingsPermission('can_access_settings_authentication')) && (
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
{hasSettingsPermission('can_access_settings_staff_roles') && (
<SettingsSidebarItem
to="/dashboard/settings/staff-roles"
icon={Users}
label={t('settings.staffRoles.title', 'Staff Roles')}
description={t('settings.staffRoles.description', 'Role permissions')}
/>
)}
{hasSettingsPermission('can_access_settings_authentication') && (
<SettingsSidebarItem
to="/dashboard/settings/authentication"
icon={Lock}
@@ -175,16 +218,23 @@ const SettingsLayout: React.FC = () => {
description={t('settings.authentication.description', 'OAuth, social login')}
locked={isLocked('custom_oauth')}
/>
)}
</SettingsSidebarSection>
)}
{/* Communication Section */}
{(hasSettingsPermission('can_access_settings_email') ||
hasSettingsPermission('can_access_settings_sms_calling')) && (
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
{hasSettingsPermission('can_access_settings_email') && (
<SettingsSidebarItem
to="/dashboard/settings/email"
icon={Mail}
label={t('settings.email.title', 'Email Setup')}
description={t('settings.email.description', 'Email addresses for tickets')}
/>
)}
{hasSettingsPermission('can_access_settings_sms_calling') && (
<SettingsSidebarItem
to="/dashboard/settings/sms-calling"
icon={Phone}
@@ -192,9 +242,12 @@ const SettingsLayout: React.FC = () => {
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
locked={isLocked('sms_reminders')}
/>
)}
</SettingsSidebarSection>
)}
{/* Billing Section */}
{/* Billing Section - Owner only */}
{isOwner && (
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
<SettingsSidebarItem
to="/dashboard/settings/billing"
@@ -209,6 +262,7 @@ const SettingsLayout: React.FC = () => {
description={t('settings.quota.description', 'Usage limits, archiving')}
/>
</SettingsSidebarSection>
)}
</nav>
</aside>

View File

@@ -3,7 +3,7 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Customer, User } from '../types';
import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer } from '../hooks/useCustomers';
import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer, useVerifyCustomerEmail } from '../hooks/useCustomers';
import { useAppointments } from '../hooks/useAppointments';
import { useServices } from '../hooks/useServices';
import {
@@ -26,7 +26,9 @@ import {
FileText,
StickyNote,
History,
Save
Save,
BadgeCheck,
Loader2,
} from 'lucide-react';
import Portal from '../components/Portal';
@@ -68,6 +70,9 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
isActive: true
});
// Verify email confirmation modal state
const [verifyEmailTarget, setVerifyEmailTarget] = useState<Customer | null>(null);
// Infinite scroll for customers
const {
data: customersData,
@@ -81,6 +86,7 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
const { data: services = [] } = useServices();
const createCustomerMutation = useCreateCustomer();
const updateCustomerMutation = useUpdateCustomer();
const verifyEmailMutation = useVerifyCustomerEmail();
// Transform paginated data to flat array
const customers: Customer[] = useMemo(() => {
@@ -222,6 +228,20 @@ const Customers: React.FC<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 service = services.find(s => String(s.id) === serviceId);
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">
<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
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"
@@ -807,6 +843,48 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
</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('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>
);
};

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '../types';
import { useCreateResource, useResources } from '../hooks/useBusiness';
import { useStaff, useToggleStaffActive, useUpdateStaff, StaffMember } from '../hooks/useStaff';
import { useStaff, useToggleStaffActive, useUpdateStaff, useVerifyStaffEmail, useSendStaffPasswordReset, StaffMember } from '../hooks/useStaff';
import {
useInvitations,
useCreateInvitation,
@@ -30,9 +30,13 @@ import {
ChevronRight,
UserX,
Power,
BadgeCheck,
Key,
Phone,
Eye,
ArrowUpDown,
} from 'lucide-react';
import Portal from '../components/Portal';
import StaffPermissions from '../components/StaffPermissions';
interface StaffProps {
onMasquerade: (user: User) => void;
@@ -51,10 +55,13 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const resendInvitationMutation = useResendInvitation();
const toggleActiveMutation = useToggleStaffActive();
const updateStaffMutation = useUpdateStaff();
const verifyEmailMutation = useVerifyStaffEmail();
const passwordResetMutation = useSendStaffPasswordReset();
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF');
// All invitations are for TENANT_STAFF - manager role removed
const inviteRole = 'TENANT_STAFF';
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
const [createBookableResource, setCreateBookableResource] = useState(false);
const [resourceName, setResourceName] = useState('');
@@ -66,16 +73,55 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
// Edit modal state
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
const [editFirstName, setEditFirstName] = useState('');
const [editLastName, setEditLastName] = useState('');
const [editPhone, setEditPhone] = useState('');
const [editError, setEditError] = useState('');
const [editSuccess, setEditSuccess] = useState('');
// Check if user can invite managers (only owners can)
const canInviteManagers = effectiveUser.role === 'owner';
// Verify email confirmation modal state
const [verifyEmailTarget, setVerifyEmailTarget] = useState<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);
// 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 = () => {
setInviteEmail('');
setInviteRole('TENANT_STAFF');
setInviteStaffRoleId(null);
setCreateBookableResource(false);
setResourceName('');
@@ -196,8 +241,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const openEditModal = (staff: StaffMember) => {
setEditingStaff(staff);
setEditPermissions(staff.permissions || {});
setEditStaffRoleId(staff.staff_role_id);
setEditFirstName(staff.first_name);
setEditLastName(staff.last_name);
setEditPhone(staff.phone || '');
setEditError('');
setEditSuccess('');
setIsEditModalOpen(true);
@@ -206,8 +253,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const closeEditModal = () => {
setIsEditModalOpen(false);
setEditingStaff(null);
setEditPermissions({});
setEditStaffRoleId(null);
setEditFirstName('');
setEditLastName('');
setEditPhone('');
setEditError('');
setEditSuccess('');
};
@@ -217,10 +266,17 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setEditError('');
try {
const updates: { permissions: Record<string, boolean>; staff_role_id?: number | null } = {
permissions: editPermissions,
const updates: {
staff_role_id?: number | null;
first_name?: string;
last_name?: string;
phone?: string;
} = {
first_name: editFirstName,
last_name: editLastName,
phone: editPhone,
};
// Only include staff_role_id for staff users (not owners/managers)
// Only include staff_role_id for staff users (not owners)
if (editingStaff.role === 'staff') {
updates.staff_role_id = editStaffRoleId;
}
@@ -237,6 +293,21 @@ const Staff: React.FC<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 () => {
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 (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
@@ -325,9 +410,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<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">
<tr>
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
<th className="px-6 py-4 font-medium">{t('staff.staffRole')}</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')}>
<div className="flex items-center gap-1">{t('staff.name')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</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 text-right">{t('common.actions')}</th>
</tr>
@@ -356,34 +444,19 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</td>
<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 ${
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === 'owner'
? '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-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{user.role === 'owner' && <Shield size={12} />}
{user.role === 'manager' && <Briefcase size={12} />}
{user.role}
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
</span>
</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">
{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">
@@ -401,21 +474,38 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</td>
<td className="px-6 py-4 text-right">
<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 && (
<button
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"
title={t('common.masqueradeAsUser')}
>
{t('common.masquerade')}
<Eye size={14} /> {t('common.masquerade')}
</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>
</td>
</tr>
@@ -473,10 +563,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</div>
</td>
<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 === 'manager' && <Briefcase size={12} />}
{user.role}
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
</span>
</td>
<td className="px-6 py-4">
@@ -545,34 +635,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
/>
</div>
{/* Role Selector */}
{/* Staff Role Selector */}
{staffRoles.length > 0 && (
<div>
<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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.staffRole')}
</label>
<select
value={inviteStaffRoleId ?? ''}
onChange={(e) => setInviteStaffRoleId(e.target.value ? Number(e.target.value) : null)}
@@ -592,23 +660,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
)}
{/* Permissions - Using shared component */}
{inviteRole === 'TENANT_MANAGER' && (
<StaffPermissions
role="manager"
permissions={invitePermissions}
onChange={setInvitePermissions}
variant="invite"
/>
)}
{inviteRole === 'TENANT_STAFF' && (
<StaffPermissions
role="staff"
permissions={invitePermissions}
onChange={setInvitePermissions}
variant="invite"
/>
)}
{/* 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">
@@ -692,8 +749,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{isEditModalOpen && editingStaff && (
<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-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<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 flex-shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('staff.editStaff')}
</h3>
@@ -705,37 +762,95 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</button>
</div>
<div className="p-6 space-y-4">
{/* Staff Info */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<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">
{editingStaff.name.charAt(0).toUpperCase()}
</div>
<div className="p-6 space-y-6 overflow-y-auto flex-1">
{/* Profile Information Section */}
<div>
<div className="font-medium text-gray-900 dark:text-white">{editingStaff.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{editingStaff.email}</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>
{/* Staff Role Selector (only for staff users) */}
{editingStaff.role === 'staff' && staffRoles.length > 0 && (
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<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.staffRole')}
{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 className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{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>
{/* Role Section */}
{editingStaff.role !== 'owner' && staffRoles.length > 0 && (
<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">
<Shield size={16} />
{t('staff.staffRole', 'Staff Role')}
</h4>
{/* Staff Role Selector */}
<div>
<select
value={editStaffRoleId ?? ''}
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
@@ -752,34 +867,53 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{t('staff.staffRoleSelectHint')}
</p>
</div>
</div>
)}
{/* Permissions - Using shared component */}
{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 */}
{/* Owner info banner */}
{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="flex items-center gap-2">
<Shield size={16} className="text-purple-600 dark:text-purple-400" />
<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>
)}
{/* Error Message */}
@@ -838,9 +972,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</div>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
{/* Action Buttons - Fixed footer */}
<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
type="button"
onClick={closeEditModal}
@@ -848,7 +983,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
>
{t('common.cancel')}
</button>
{editingStaff.role !== 'owner' && (
<button
onClick={handleSaveStaffSettings}
disabled={updateStaffMutation.isPending || !!editSuccess}
@@ -859,10 +993,50 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
) : null}
{t('common.save')}
</button>
)}
</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>
</Portal>
)}

View File

@@ -122,7 +122,9 @@ const Tickets: React.FC = () => {
setIsTicketModalOpen(false);
};
const isOwnerOrManager = currentUser?.role === 'owner' || currentUser?.role === 'manager';
// Owner and staff with can_access_tickets permission have full access
const hasFullTicketAccess = currentUser?.role === 'owner' ||
(currentUser?.role === 'staff' && currentUser?.effective_permissions?.can_access_tickets);
if (isLoading) {
return (
@@ -163,7 +165,7 @@ const Tickets: React.FC = () => {
{t('tickets.title', 'Support Tickets')}
</h2>
<p className="text-gray-500 dark:text-gray-400">
{isOwnerOrManager
{hasFullTicketAccess
? t('tickets.descriptionOwner', 'Manage support tickets for your business')
: t('tickets.descriptionStaff', 'View and create support tickets')}
</p>

View File

@@ -21,13 +21,14 @@ const ApiSettings: React.FC = () => {
}>();
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_api === true;
const { canUse } = usePlanFeatures();
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -59,6 +59,7 @@ const AuthenticationSettings: React.FC = () => {
const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_authentication === true;
const { canUse } = usePlanFeatures();
// Update OAuth settings when data loads
@@ -147,11 +148,11 @@ const AuthenticationSettings: React.FC = () => {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
};
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -26,6 +26,7 @@ const BookingSettings: React.FC = () => {
const [returnUrlSaving, setReturnUrlSaving] = useState(false);
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_booking === true;
const handleSaveReturnUrl = async () => {
setReturnUrlSaving(true);
@@ -40,11 +41,11 @@ const BookingSettings: React.FC = () => {
}
};
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -139,12 +139,13 @@ const BrandingSettings: React.FC = () => {
};
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_branding === true;
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -6,9 +6,10 @@
*/
import React, { useState, useEffect } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks';
import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui';
import { BlockPurpose, TimeBlock } from '../../types';
import { BlockPurpose, TimeBlock, Business, User } from '../../types';
interface DayHours {
enabled: boolean;
@@ -58,11 +59,19 @@ const DEFAULT_HOURS: BusinessHours = {
};
const BusinessHoursSettings: React.FC = () => {
const { user } = useOutletContext<{
business: Business;
user: User;
}>();
const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS);
const [error, setError] = useState<string>('');
const [success, setSuccess] = useState<string>('');
const [isSaving, setIsSaving] = useState(false);
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_business_hours === true;
// Fetch existing business hours time blocks
const { data: timeBlocks, isLoading } = useTimeBlocks({
purpose: 'BUSINESS_HOURS' as BlockPurpose,
@@ -248,6 +257,16 @@ const BusinessHoursSettings: React.FC = () => {
}
};
if (!hasPermission) {
return (
<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) {
return (
<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 isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_sms_calling === true;
const { canUse } = usePlanFeatures();
// Update settings form when credits data loads
@@ -249,11 +250,11 @@ const CommunicationSettings: React.FC = () => {
setWizardStep(4);
};
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -43,6 +43,7 @@ const CustomDomainsSettings: React.FC = () => {
const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_custom_domains === true;
const { canUse } = usePlanFeatures();
const handleAddDomain = () => {
@@ -104,11 +105,11 @@ const CustomDomainsSettings: React.FC = () => {
});
};
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -19,12 +19,13 @@ const EmailSettings: React.FC = () => {
}>();
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email === true;
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -37,6 +37,7 @@ const EmbedWidgetSettings: React.FC = () => {
const [copied, setCopied] = useState(false);
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_embed_widget === true;
// Build the embed URL
const embedUrl = useMemo(() => {
@@ -86,11 +87,11 @@ const EmbedWidgetSettings: React.FC = () => {
setTimeout(() => setCopied(false), 2000);
};
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -170,12 +170,13 @@ const GeneralSettings: React.FC = () => {
};
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_general === true;
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -33,6 +33,7 @@ const ResourceTypesSettings: React.FC = () => {
});
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_resource_types === true;
const openCreateModal = () => {
setEditingType(null);
@@ -83,11 +84,11 @@ const ResourceTypesSettings: React.FC = () => {
}
};
if (!isOwner) {
if (!hasPermission) {
return (
<div className="text-center py-12">
<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>
</div>
);

View File

@@ -41,14 +41,15 @@ const StaffRolesSettings: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const isOwner = user.role === 'owner';
const isManager = user.role === 'manager';
const canManageRoles = isOwner || isManager;
// Only owners can manage roles (staff with permissions can view but not edit)
const canManageRoles = isOwner;
// Merge menu and dangerous permissions for display
// Merge menu, settings, and dangerous permissions for display
const allPermissions = useMemo(() => {
if (!availablePermissions) return { menu: {}, dangerous: {} };
if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
return {
menu: availablePermissions.menu_permissions || {},
settings: availablePermissions.settings_permissions || {},
dangerous: availablePermissions.dangerous_permissions || {},
};
}, [availablePermissions]);
@@ -82,21 +83,50 @@ const StaffRolesSettings: React.FC = () => {
};
const togglePermission = (key: string) => {
setFormData((prev) => ({
setFormData((prev) => {
const newValue = !prev.permissions[key];
const updates: Record<string, boolean> = { [key]: newValue };
// If enabling any settings sub-permission, also enable the main settings access
if (newValue && key.startsWith('can_access_settings_')) {
updates['can_access_settings'] = true;
}
// If disabling the main settings access, disable all sub-permissions
if (!newValue && key === 'can_access_settings') {
Object.keys(allPermissions.settings).forEach((settingKey) => {
if (settingKey !== 'can_access_settings') {
updates[settingKey] = false;
}
});
}
return {
...prev,
permissions: {
...prev.permissions,
[key]: !prev.permissions[key],
...updates,
},
}));
};
});
};
const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => {
const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous;
const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
const permissions = category === 'menu'
? allPermissions.menu
: category === 'settings'
? allPermissions.settings
: allPermissions.dangerous;
const updates: Record<string, boolean> = {};
Object.keys(permissions).forEach((key) => {
updates[key] = enable;
});
// If enabling any settings permissions, ensure main settings access is also enabled
if (category === 'settings' && enable) {
updates['can_access_settings'] = true;
}
setFormData((prev) => ({
...prev,
permissions: {
@@ -160,7 +190,7 @@ const StaffRolesSettings: React.FC = () => {
<div className="text-center py-12">
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<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>
</div>
);
@@ -324,8 +354,7 @@ const StaffRolesSettings: React.FC = () => {
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
disabled={editingRole?.is_default}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
/>
</div>
@@ -398,6 +427,60 @@ const StaffRolesSettings: React.FC = () => {
</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 */}
<div>
<div className="flex items-center justify-between mb-3">

View File

@@ -7,6 +7,7 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Puck, Render } from '@measured/puck';
import '@measured/puck/puck.css';
@@ -39,6 +40,8 @@ import {
SystemEmailTag,
SystemEmailCategory,
SystemEmailType,
Business,
User,
} from '../../types';
// Category metadata
@@ -86,6 +89,14 @@ const CATEGORY_ORDER: SystemEmailCategory[] = [
const SystemEmailTemplates: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { user } = useOutletContext<{
business: Business;
user: User;
}>();
const isOwner = user.role === 'owner';
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email_templates === true;
const [expandedCategories, setExpandedCategories] = useState<Set<SystemEmailCategory>>(
new Set(CATEGORY_ORDER)
);
@@ -343,6 +354,16 @@ const SystemEmailTemplates: React.FC = () => {
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) {
return (
<div className="flex items-center justify-center h-64">

View File

@@ -94,7 +94,7 @@ export interface Business {
planPermissions?: PlanPermissions;
}
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer';
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'staff' | 'resource' | 'customer';
export interface NotificationPreferences {
email: boolean;
@@ -163,6 +163,7 @@ export interface PermissionDefinition {
export interface AvailablePermissions {
menu_permissions: Record<string, PermissionDefinition>;
settings_permissions: Record<string, PermissionDefinition>;
dangerous_permissions: Record<string, PermissionDefinition>;
}
@@ -273,6 +274,14 @@ export interface Customer {
userId?: string;
paymentMethods: PaymentMethod[];
notes?: string;
email_verified?: boolean;
user_data?: {
id: number;
username: string;
name: string;
email: string;
role: string;
};
}
export interface Service {

View File

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

View File

@@ -138,7 +138,7 @@ class TestGetTenantManagers:
"""Test the get_tenant_managers() helper function."""
def test_returns_tenant_managers(self):
"""Should return owners and managers for a tenant."""
"""Should return owners for a tenant (formerly owners and managers)."""
mock_tenant = Mock(id=1)
mock_queryset = Mock()
mock_filtered = Mock()
@@ -149,7 +149,7 @@ class TestGetTenantManagers:
mock_queryset.filter.assert_called_once_with(
tenant=mock_tenant,
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
role=User.Role.TENANT_OWNER,
is_active=True
)
assert result == mock_filtered

View File

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

View File

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

View File

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

View File

@@ -266,18 +266,6 @@ class TestStatusMachine:
assert can_change is True
assert reason == ""
def test_can_user_change_status_manager_allowed(self):
"""Test can_user_change_status allows TENANT_MANAGER."""
mock_user = Mock()
mock_user.role = User.Role.TENANT_MANAGER
machine = StatusMachine(tenant=Mock(), user=mock_user)
mock_event = Mock()
can_change, reason = machine.can_user_change_status(mock_event)
assert can_change is True
def test_can_user_change_status_staff_assigned(self):
"""Test can_user_change_status allows assigned TENANT_STAFF."""
mock_user = Mock()

View File

@@ -70,13 +70,6 @@ class TestHelperFunctions:
assert is_field_employee(mock_user) is True
def test_is_field_employee_with_manager_role(self):
"""Test is_field_employee returns True for TENANT_MANAGER."""
mock_user = Mock()
mock_user.role = User.Role.TENANT_MANAGER
assert is_field_employee(mock_user) is True
def test_is_field_employee_with_owner_role(self):
"""Test is_field_employee returns True for TENANT_OWNER."""
mock_user = Mock()

View File

@@ -56,10 +56,9 @@ def get_tenant_from_user(user):
def is_field_employee(user):
"""Check if user is a field employee (staff role)."""
"""Check if user is a field employee (staff or owner role)."""
return user.role in [
User.Role.TENANT_STAFF,
User.Role.TENANT_MANAGER,
User.Role.TENANT_OWNER,
]

View File

@@ -13,8 +13,13 @@ from smoothschedule.identity.users.models import User
def is_owner_or_manager(user):
"""Check if user is a tenant owner or manager."""
return user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]
"""Check if user is a tenant owner or staff with management permissions."""
if user.role == User.Role.TENANT_OWNER:
return True
if user.role == User.Role.TENANT_STAFF:
# Staff with can_manage_users permission has equivalent access
return user.has_staff_permission('can_manage_users')
return False
@api_view(['GET'])

View File

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

View File

@@ -31,25 +31,28 @@ class TestIsOwnerOrManagerHelper:
assert result is True
def test_returns_true_for_manager(self):
"""Should return True for tenant manager."""
from smoothschedule.identity.core.api_views import is_owner_or_manager
from smoothschedule.identity.users.models import User
mock_user = Mock()
mock_user.role = User.Role.TENANT_MANAGER
result = is_owner_or_manager(mock_user)
assert result is True
def test_returns_false_for_staff(self):
"""Should return False for staff role."""
def test_returns_true_for_staff_with_permission(self):
"""Should return True for staff with can_manage_users permission."""
from smoothschedule.identity.core.api_views import is_owner_or_manager
from smoothschedule.identity.users.models import User
mock_user = Mock()
mock_user.role = User.Role.TENANT_STAFF
mock_user.has_staff_permission.return_value = True
result = is_owner_or_manager(mock_user)
assert result is True
mock_user.has_staff_permission.assert_called_once_with('can_manage_users')
def test_returns_false_for_staff_without_permission(self):
"""Should return False for staff without can_manage_users permission."""
from smoothschedule.identity.core.api_views import is_owner_or_manager
from smoothschedule.identity.users.models import User
mock_user = Mock()
mock_user.role = User.Role.TENANT_STAFF
mock_user.has_staff_permission.return_value = False
result = is_owner_or_manager(mock_user)

View File

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

View File

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

View File

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

View File

@@ -130,8 +130,8 @@ def current_user_view(request):
else:
business_subdomain = user.tenant.schema_name
# Check for active quota overages (for owners and managers)
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
# Check for active quota overages (for owners and staff with management permissions)
if user.role == User.Role.TENANT_OWNER or (user.role == User.Role.TENANT_STAFF and user.has_staff_permission('can_manage_users')):
from smoothschedule.identity.core.quota_service import QuotaService
try:
service = QuotaService(user.tenant)
@@ -153,10 +153,10 @@ def current_user_view(request):
}
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
# Get linked resource info for tenant users (staff and owners can be linked to resources)
linked_resource_id = None
can_edit_schedule = False
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]:
try:
with schema_context(user.tenant.schema_name):
linked_resource = Resource.objects.filter(user=user).first()
@@ -183,6 +183,9 @@ def current_user_view(request):
'business_name': business_name,
'business_subdomain': business_subdomain,
'permissions': user.permissions,
'effective_permissions': user.get_effective_permissions(),
'staff_role_id': user.staff_role_id,
'staff_role_name': user.staff_role.name if user.staff_role else None,
'can_invite_staff': user.can_invite_staff(),
'can_access_tickets': user.can_access_tickets(),
'can_send_messages': user.can_send_messages(),
@@ -316,11 +319,11 @@ def _get_user_data(user):
}
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
# Get linked resource info for tenant users (staff and owners can be linked to resources)
linked_resource_id = None
linked_resource_name = None
can_edit_schedule = False
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]:
try:
with schema_context(user.tenant.schema_name):
linked_resource = Resource.objects.filter(user=user).first()
@@ -519,7 +522,6 @@ class StaffInvitationSerializer(serializers.ModelSerializer):
def get_role_display(self, obj):
role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff',
}
return role_map.get(obj.role, obj.role)
@@ -572,21 +574,13 @@ def staff_invitations_view(request):
status=status.HTTP_400_BAD_REQUEST
)
# Validate role - only allow manager and staff roles
if role not in [User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF]:
# Validate role - only allow staff role
if role != User.Role.TENANT_STAFF:
return Response(
{"error": "Invalid role. Must be 'TENANT_MANAGER' or 'TENANT_STAFF'."},
{"error": "Invalid role. Must be 'TENANT_STAFF'."},
status=status.HTTP_400_BAD_REQUEST
)
# Managers can only invite staff, not other managers
# TODO: Add owner control to allow/disallow managers inviting managers
if user.role == User.Role.TENANT_MANAGER and role == User.Role.TENANT_MANAGER:
return Response(
{"error": "Managers can only invite staff members, not other managers."},
status=status.HTTP_403_FORBIDDEN
)
# Check if user already exists in this tenant
existing_user = User.objects.filter(
email=email,
@@ -708,7 +702,6 @@ def invitation_details_view(request, token):
# Return limited info for the acceptance page
role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff',
}
@@ -867,7 +860,6 @@ def _send_invitation_email(invitation):
invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}"
role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff Member',
}
role_display = role_map.get(invitation.role, 'team member')

View File

@@ -68,15 +68,6 @@ class Command(BaseCommand):
'last_name': 'Owner',
'tenant': demo_tenant,
},
{
'username': 'manager@demo.com',
'email': 'manager@demo.com',
'password': 'test123',
'role': User.Role.TENANT_MANAGER,
'first_name': 'Business',
'last_name': 'Manager',
'tenant': demo_tenant,
},
{
'username': 'staff@demo.com',
'email': 'staff@demo.com',

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

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
# These control specific destructive or sensitive operations at the API level
DANGEROUS_PERMISSIONS = {
@@ -149,10 +229,20 @@ DANGEROUS_PERMISSIONS = {
'description': 'Approve own time off requests without manager approval',
'default': False,
},
'can_manage_users': {
'label': 'Manage Users',
'description': 'Invite and manage staff members',
'default': False,
},
'can_review_time_off': {
'label': 'Review Time Off',
'description': 'Approve or deny time off requests from other staff',
'default': False,
},
}
# All permissions combined for easy iteration
ALL_PERMISSIONS = {**MENU_PERMISSIONS, **DANGEROUS_PERMISSIONS}
ALL_PERMISSIONS = {**MENU_PERMISSIONS, **SETTINGS_PERMISSIONS, **DANGEROUS_PERMISSIONS}
def get_default_permissions_for_role(role_name: str) -> dict:

View File

@@ -189,7 +189,6 @@ class TestGetUserData:
(User.Role.PLATFORM_SALES, 'platform_sales'),
(User.Role.PLATFORM_SUPPORT, 'platform_support'),
(User.Role.TENANT_OWNER, 'owner'),
(User.Role.TENANT_MANAGER, 'manager'),
(User.Role.TENANT_STAFF, 'staff'),
(User.Role.CUSTOMER, 'customer'),
]
@@ -1104,29 +1103,6 @@ class TestStaffInvitationsView:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert 'Invalid role' in response.data['error']
@patch('smoothschedule.identity.users.api_views.User')
def test_post_manager_cannot_invite_manager(self, mock_user_model):
factory = APIRequestFactory()
request = factory.post('/api/staff/invitations/', {
'email': 'manager@test.com',
'role': User.Role.TENANT_MANAGER
})
mock_tenant = Mock()
mock_user = Mock()
mock_user.can_invite_staff.return_value = True
mock_user.tenant = mock_tenant
mock_user.role = User.Role.TENANT_MANAGER
request.user = mock_user
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
response = api_views.staff_invitations_view(request)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'Managers can only invite staff' in response.data['error']
@patch('smoothschedule.identity.users.api_views.User')
def test_post_rejects_existing_user(self, mock_user_model):
factory = APIRequestFactory()
@@ -1142,7 +1118,6 @@ class TestStaffInvitationsView:
mock_user.role = User.Role.TENANT_OWNER
request.user = mock_user
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
# User already exists
@@ -1178,7 +1153,6 @@ class TestStaffInvitationsView:
mock_user.role = User.Role.TENANT_OWNER
request.user = mock_user
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
mock_user_model.objects.filter.return_value.first.return_value = None

View File

@@ -80,9 +80,6 @@ class TestRoleClassification:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.is_tenant_user() is True
def test_is_tenant_user_returns_true_for_tenant_manager(self):
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.is_tenant_user() is True
def test_is_tenant_user_returns_true_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF)
@@ -116,9 +113,6 @@ class TestCanManageUsers:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_manage_users() is True
def test_returns_true_for_tenant_manager(self):
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.can_manage_users() is True
def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF)
@@ -148,9 +142,6 @@ class TestCanAccessBilling:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_access_billing() is True
def test_returns_false_for_tenant_manager(self):
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.can_access_billing() is False
def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF)
@@ -168,17 +159,6 @@ class TestCanInviteStaff:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_invite_staff() is True
def test_returns_true_for_manager_with_permission(self):
user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': True})
assert user.can_invite_staff() is True
def test_returns_false_for_manager_without_permission(self):
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.can_invite_staff() is False
def test_returns_false_for_manager_with_explicit_false_permission(self):
user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': False})
assert user.can_invite_staff() is False
def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF)
@@ -204,9 +184,6 @@ class TestCanAccessTickets:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_access_tickets() is True
def test_returns_true_for_tenant_manager(self):
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.can_access_tickets() is True
def test_returns_true_for_staff_with_permission(self):
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_access_tickets': True})
@@ -292,9 +269,6 @@ class TestCanSelfApproveTimeOff:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_self_approve_time_off() is True
def test_returns_true_for_tenant_manager(self):
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.can_self_approve_time_off() is True
def test_returns_true_for_staff_with_permission(self):
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_self_approve_time_off': True})
@@ -320,9 +294,6 @@ class TestCanReviewTimeOffRequests:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_review_time_off_requests() is True
def test_returns_true_for_tenant_manager(self):
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.can_review_time_off_requests() is True
def test_returns_false_for_tenant_staff(self):
user = create_user_instance(User.Role.TENANT_STAFF)
@@ -579,16 +550,6 @@ class TestUserCanSendMessages:
user = create_user_instance(User.Role.TENANT_OWNER)
assert user.can_send_messages() is True
def test_manager_can_send_by_default(self):
"""Tenant manager can send messages by default."""
user = create_user_instance(User.Role.TENANT_MANAGER)
assert user.can_send_messages() is True
def test_manager_cannot_send_when_revoked(self):
"""Tenant manager cannot send when permission revoked."""
user = create_user_instance(User.Role.TENANT_MANAGER)
user.permissions = {'can_send_messages': False}
assert user.can_send_messages() is False
def test_staff_cannot_send_messages(self):
"""Staff should not be able to send messages."""

View File

@@ -68,19 +68,7 @@ class TestUserHasStaffPermission:
mock_user.role = 'TENANT_OWNER'
# Simulate the has_staff_permission logic
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
result = True
else:
result = False
assert result is True
def test_manager_always_has_permission(self):
"""Managers have all permissions"""
mock_user = Mock()
mock_user.role = 'TENANT_MANAGER'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
if mock_user.role == 'TENANT_OWNER':
result = True
else:
result = False
@@ -97,7 +85,7 @@ class TestUserHasStaffPermission:
# Simulate permission resolution
permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
if mock_user.role == 'TENANT_OWNER':
result = True
elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions:
@@ -120,7 +108,7 @@ class TestUserHasStaffPermission:
mock_user.staff_role.permissions = {'can_access_scheduler': True}
permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
if mock_user.role == 'TENANT_OWNER':
result = True
elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions:
@@ -142,7 +130,7 @@ class TestUserHasStaffPermission:
mock_user.staff_role = None
permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
if mock_user.role == 'TENANT_OWNER':
result = True
elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions:
@@ -163,7 +151,7 @@ class TestUserHasStaffPermission:
mock_user.permissions = {'can_access_scheduler': True} # Even if set
permission_key = 'can_access_scheduler'
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
if mock_user.role == 'TENANT_OWNER':
result = True
elif mock_user.role == 'TENANT_STAFF':
if mock_user.permissions and permission_key in mock_user.permissions:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -675,13 +675,26 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
f"for resource '{instance.resource.name}'"
)
# Find all managers and owners to notify
# Find all users who can review time off requests to notify
from smoothschedule.identity.users.models import User
# Get owners (always have permission) + staff with can_review_time_off permission
reviewers = User.objects.filter(
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
role=User.Role.TENANT_OWNER,
is_active=True
)
# Also include staff who have the permission (via role or override)
# Note: This is a simplified query - for proper permission checking,
# we'd need to check each staff's effective_permissions
staff_reviewers = User.objects.filter(
role=User.Role.TENANT_STAFF,
is_active=True
)
# Filter staff to those who can review time off
reviewers = list(reviewers) + [
staff for staff in staff_reviewers
if staff.has_staff_permission('can_review_time_off')
]
# Create in-app notifications for each reviewer
for reviewer in reviewers:

View File

@@ -193,19 +193,6 @@ class TestStaffSerializer:
# Assert
assert role == 'owner'
def test_get_role_maps_tenant_manager(self):
"""Test that TENANT_MANAGER maps to manager."""
# Arrange
mock_user = Mock()
mock_user.role = 'TENANT_MANAGER'
serializer = StaffSerializer()
# Act
role = serializer.get_role(mock_user)
# Assert
assert role == 'manager'
def test_get_role_maps_tenant_staff(self):
"""Test that TENANT_STAFF maps to staff."""
@@ -1682,14 +1669,6 @@ class TestStaffSerializerMethodFields:
result = serializer.get_name(mock_obj)
assert result == 'Jane Smith'
def test_get_role_maps_tenant_manager_to_manager(self):
"""Test get_role maps TENANT_MANAGER to manager."""
serializer = StaffSerializer()
mock_obj = Mock()
mock_obj.role = 'TENANT_MANAGER'
result = serializer.get_role(mock_obj)
assert result == 'manager'
class TestResourceSerializerFields:

View File

@@ -159,10 +159,15 @@ class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
This endpoint provides the frontend with the full list of permission
keys that can be configured on a staff role.
"""
from smoothschedule.identity.users.staff_permissions import MENU_PERMISSIONS, DANGEROUS_PERMISSIONS
from smoothschedule.identity.users.staff_permissions import (
MENU_PERMISSIONS,
SETTINGS_PERMISSIONS,
DANGEROUS_PERMISSIONS,
)
return Response({
'menu_permissions': MENU_PERMISSIONS,
'settings_permissions': SETTINGS_PERMISSIONS,
'dangerous_permissions': DANGEROUS_PERMISSIONS,
})
@@ -769,6 +774,21 @@ class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
tenant=tenant,
)
@action(detail=True, methods=['post'])
def verify_email(self, request, pk=None):
"""Toggle a customer's email verification status."""
customer = self.get_object()
customer.email_verified = not customer.email_verified
customer.save(update_fields=['email_verified'])
action = 'verified' if customer.email_verified else 'unverified'
return Response({
'id': customer.id,
'email_verified': customer.email_verified,
'message': f'Email {action} successfully.'
})
class ServiceViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
"""
@@ -828,7 +848,7 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
"""
API endpoint for managing staff members (Users who can be assigned to resources).
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF.
Supports:
- GET /api/staff/ - List staff members
@@ -851,14 +871,13 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
"""
Return staff members for the current tenant.
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
Staff are Users with roles: TENANT_OWNER, TENANT_STAFF.
"""
from django.db.models import Q
# Set base queryset to staff roles only
self.queryset = User.objects.filter(
Q(role=User.Role.TENANT_OWNER) |
Q(role=User.Role.TENANT_MANAGER) |
Q(role=User.Role.TENANT_STAFF)
)
@@ -890,24 +909,31 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
"""
Update staff member.
Allowed fields: is_active, permissions
Allowed fields: is_active, permissions, staff_role_id, first_name, last_name, phone
Owners can edit any staff member.
Managers can only edit staff (not other managers or owners).
Staff with can_access_staff permission can edit other staff (not owners).
"""
instance = self.get_object()
# TODO: Add permission checks when authentication is enabled
# current_user = request.user
# if current_user.role == User.Role.TENANT_MANAGER:
# if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
# return Response(
# {'error': 'Managers cannot edit owners or other managers.'},
# status=status.HTTP_403_FORBIDDEN
# )
# Permission check: staff can only edit other staff, not owners
current_user = request.user
if current_user.role == User.Role.TENANT_STAFF:
# Staff can only edit if they have can_access_staff permission
if not current_user.has_staff_permission('can_access_staff'):
return Response(
{'error': 'You do not have permission to edit staff members.'},
status=status.HTTP_403_FORBIDDEN
)
# Staff cannot edit owner accounts
if instance.role == User.Role.TENANT_OWNER:
return Response(
{'error': 'You cannot edit owner accounts.'},
status=status.HTTP_403_FORBIDDEN
)
# Only allow updating specific fields
allowed_fields = {'is_active', 'permissions'}
allowed_fields = {'is_active', 'permissions', 'staff_role_id', 'first_name', 'last_name', 'phone'}
update_data = {k: v for k, v in request.data.items() if k in allowed_fields}
serializer = self.get_serializer(instance, data=update_data, partial=True)
@@ -938,6 +964,98 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully."
})
@action(detail=True, methods=['post'])
def verify_email(self, request, pk=None):
"""Toggle a staff member's email verification status."""
staff = self.get_object()
staff.email_verified = not staff.email_verified
staff.save(update_fields=['email_verified'])
action = 'verified' if staff.email_verified else 'unverified'
return Response({
'id': staff.id,
'email_verified': staff.email_verified,
'message': f'Email {action} successfully.'
})
@action(detail=True, methods=['post'])
def send_password_reset(self, request, pk=None):
"""
Send a password reset email to the staff member.
Owners or staff with can_access_staff permission can trigger password resets.
"""
from django.conf import settings
from smoothschedule.communication.messaging.email_service import send_plain_email
import secrets
# Only owners or staff with can_access_staff permission can send password resets
can_manage = (
request.user.role == User.Role.TENANT_OWNER or
request.user.has_staff_permission('can_access_staff')
)
if not can_manage:
return Response(
{'error': 'You do not have permission to reset passwords.'},
status=status.HTTP_403_FORBIDDEN
)
staff = self.get_object()
# Generate a secure random password
temp_password = secrets.token_urlsafe(12)
# Set the temporary password
staff.set_password(temp_password)
staff.save(update_fields=['password'])
# Build login URL
port = ':5173' if settings.DEBUG else ''
subdomain = ''
if staff.tenant:
primary_domain = staff.tenant.domains.filter(is_primary=True).first()
if primary_domain:
subdomain = primary_domain.domain.split('.')[0] + '.'
base_domain = 'lvh.me' if settings.DEBUG else 'smoothschedule.com'
login_url = f"https://{subdomain}{base_domain}{port}/login"
# Send email
subject = "Password Reset - SmoothSchedule"
message = f"""Hi {staff.full_name},
Your password has been reset by the business owner.
Your temporary password is: {temp_password}
Please log in at {login_url} and change your password immediately.
If you did not expect this email, please contact your business administrator.
Thanks,
The SmoothSchedule Team
"""
try:
send_plain_email(
subject,
message,
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
[staff.email],
fail_silently=False,
)
except Exception as e:
return Response(
{'error': f'Failed to send password reset email: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({
'id': staff.id,
'message': f'Password reset email sent to {staff.email}.'
})
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
"""