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,132 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
hintKey: 'staff.canManageOwnAppointmentsHint',
hintDefault: 'Create, reschedule, and cancel their own appointments',
defaultValue: true,
roles: ['staff'],
},
{
key: 'can_self_approve_time_off',
labelKey: 'staff.canSelfApproveTimeOff',
labelDefault: 'Can self-approve time off',
hintKey: 'staff.canSelfApproveTimeOffHint',
hintDefault: 'Add time off without requiring manager/owner approval',
hintDefault: 'Add time off without requiring owner approval',
defaultValue: false,
roles: ['staff'],
},
// Shared permissions (both manager and staff)
{
key: 'can_access_tickets',
labelKey: 'staff.canAccessTickets',
labelDefault: 'Can access support tickets',
hintKey: 'staff.canAccessTicketsHint',
hintDefault: 'View and manage customer support tickets',
defaultValue: true, // Default for managers; staff will override to false
roles: ['manager', 'staff'],
defaultValue: false,
},
];
// Get default permissions for a role
export const getDefaultPermissions = (role: 'manager' | 'staff'): Record<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;
}
}
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"
/>
@@ -213,6 +332,113 @@ const StaffPermissions: React.FC<StaffPermissionsProps> = ({
</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"
/>
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{t(config.labelKey, config.labelDefault)}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t(config.hintKey, config.hintDefault)}
</p>
</div>
</label>
))}
</div>
</div>
)}
</div>
</div>
);
};

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,123 +102,167 @@ const SettingsLayout: React.FC = () => {
{/* Navigation */}
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
{/* Business Section */}
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
<SettingsSidebarItem
to="/dashboard/settings/general"
icon={Building2}
label={t('settings.general.title', 'General')}
description={t('settings.general.description', 'Name, timezone, contact')}
/>
<SettingsSidebarItem
to="/dashboard/settings/resource-types"
icon={Layers}
label={t('settings.resourceTypes.title', 'Resource Types')}
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
/>
<SettingsSidebarItem
to="/dashboard/settings/booking"
icon={Calendar}
label={t('settings.booking.title', 'Booking')}
description={t('settings.booking.description', 'Booking URL, redirects')}
/>
<SettingsSidebarItem
to="/dashboard/settings/business-hours"
icon={Clock}
label={t('settings.businessHours.title', 'Business Hours')}
description={t('settings.businessHours.description', 'Operating hours')}
/>
</SettingsSidebarSection>
{(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 */}
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
<SettingsSidebarItem
to="/dashboard/settings/branding"
icon={Palette}
label={t('settings.appearance.title', 'Appearance')}
description={t('settings.appearance.description', 'Logo, colors, theme')}
locked={isLocked('remove_branding')}
/>
<SettingsSidebarItem
to="/dashboard/settings/email-templates"
icon={Mail}
label={t('settings.emailTemplates.title', 'Email Templates')}
description={t('settings.emailTemplates.description', 'Customize automated emails')}
/>
<SettingsSidebarItem
to="/dashboard/settings/custom-domains"
icon={Globe}
label={t('settings.customDomains.title', 'Custom Domains')}
description={t('settings.customDomains.description', 'Use your own domain')}
locked={isLocked('custom_domain')}
/>
<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>
{(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}
label={t('settings.appearance.title', 'Appearance')}
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}
label={t('settings.customDomains.title', 'Custom Domains')}
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 */}
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
<SettingsSidebarItem
to="/dashboard/settings/api"
icon={Key}
label={t('settings.api.title', 'API & Webhooks')}
description={t('settings.api.description', 'API tokens, webhooks')}
locked={isLocked('api_access')}
/>
</SettingsSidebarSection>
{hasSettingsPermission('can_access_settings_api') && (
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
<SettingsSidebarItem
to="/dashboard/settings/api"
icon={Key}
label={t('settings.api.title', 'API & Webhooks')}
description={t('settings.api.description', 'API tokens, webhooks')}
locked={isLocked('api_access')}
/>
</SettingsSidebarSection>
)}
{/* Access Section */}
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
<SettingsSidebarItem
to="/dashboard/settings/staff-roles"
icon={Users}
label={t('settings.staffRoles.title', 'Staff Roles')}
description={t('settings.staffRoles.description', 'Role permissions')}
/>
<SettingsSidebarItem
to="/dashboard/settings/authentication"
icon={Lock}
label={t('settings.authentication.title', 'Authentication')}
description={t('settings.authentication.description', 'OAuth, social login')}
locked={isLocked('custom_oauth')}
/>
</SettingsSidebarSection>
{(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}
label={t('settings.authentication.title', 'Authentication')}
description={t('settings.authentication.description', 'OAuth, social login')}
locked={isLocked('custom_oauth')}
/>
)}
</SettingsSidebarSection>
)}
{/* Communication Section */}
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
<SettingsSidebarItem
to="/dashboard/settings/email"
icon={Mail}
label={t('settings.email.title', 'Email Setup')}
description={t('settings.email.description', 'Email addresses for tickets')}
/>
<SettingsSidebarItem
to="/dashboard/settings/sms-calling"
icon={Phone}
label={t('settings.smsCalling.title', 'SMS & Calling')}
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
locked={isLocked('sms_reminders')}
/>
</SettingsSidebarSection>
{(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}
label={t('settings.smsCalling.title', 'SMS & Calling')}
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
locked={isLocked('sms_reminders')}
/>
)}
</SettingsSidebarSection>
)}
{/* Billing Section */}
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
<SettingsSidebarItem
to="/dashboard/settings/billing"
icon={CreditCard}
label={t('settings.billing.title', 'Plan & Billing')}
description={t('settings.billing.description', 'Subscription, invoices')}
/>
<SettingsSidebarItem
to="/dashboard/settings/quota"
icon={AlertTriangle}
label={t('settings.quota.title', 'Quota Management')}
description={t('settings.quota.description', 'Usage limits, archiving')}
/>
</SettingsSidebarSection>
{/* Billing Section - Owner only */}
{isOwner && (
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
<SettingsSidebarItem
to="/dashboard/settings/billing"
icon={CreditCard}
label={t('settings.billing.title', 'Plan & Billing')}
description={t('settings.billing.description', 'Subscription, invoices')}
/>
<SettingsSidebarItem
to="/dashboard/settings/quota"
icon={AlertTriangle}
label={t('settings.quota.title', 'Quota Management')}
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,33 +635,11 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
/>
</div>
{/* Role Selector */}
<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 && (
{/* 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.staffRole')}
{t('staff.roleLabel')} *
</label>
<select
value={inviteStaffRoleId ?? ''}
@@ -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"
/>
)}
<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,80 +762,157 @@ 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 className="p-6 space-y-6 overflow-y-auto flex-1">
{/* Profile Information Section */}
<div>
<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.firstName', 'First Name')}
</label>
<input
type="text"
value={editFirstName}
onChange={(e) => setEditFirstName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={t('staff.firstNamePlaceholder', 'Enter first name')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.lastName', 'Last Name')}
</label>
<input
type="text"
value={editLastName}
onChange={(e) => setEditLastName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={t('staff.lastNamePlaceholder', 'Enter last name')}
/>
</div>
</div>
<div>
<div 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 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>
<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 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.staffRole')}
</label>
<select
value={editStaffRoleId ?? ''}
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
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="">{t('staff.selectRole')}</option>
{staffRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('staff.staffRoleSelectHint')}
</p>
{/* 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)}
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="">{t('staff.selectRole')}</option>
{staffRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{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">
<p className="text-sm text-purple-700 dark:text-purple-300">
{t('staff.ownerFullAccess')}
</p>
<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>
)}
@@ -838,29 +972,69 @@ 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">
<button
type="button"
onClick={closeEditModal}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
{editingStaff.role !== 'owner' && (
<button
onClick={handleSaveStaffSettings}
disabled={updateStaffMutation.isPending || !!editSuccess}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{updateStaffMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : null}
{t('common.save')}
</button>
)}
</div>
{/* 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}
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={handleSaveStaffSettings}
disabled={updateStaffMutation.isPending || !!editSuccess}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{updateStaffMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : 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>

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) => ({
...prev,
permissions: {
...prev.permissions,
[key]: !prev.permissions[key],
},
}));
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,
...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 {