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:
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface PermissionConfig {
|
||||
key: string;
|
||||
@@ -8,20 +9,134 @@ export interface PermissionConfig {
|
||||
hintKey: string;
|
||||
hintDefault: string;
|
||||
defaultValue: boolean;
|
||||
roles: ('manager' | 'staff')[];
|
||||
}
|
||||
|
||||
// Business Settings sub-permissions
|
||||
export const SETTINGS_PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
{
|
||||
key: 'can_access_settings_general',
|
||||
labelKey: 'staff.canAccessSettingsGeneral',
|
||||
labelDefault: 'General Settings',
|
||||
hintKey: 'staff.canAccessSettingsGeneralHint',
|
||||
hintDefault: 'Business name, timezone, and basic configuration',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_business_hours',
|
||||
labelKey: 'staff.canAccessSettingsBusinessHours',
|
||||
labelDefault: 'Business Hours',
|
||||
hintKey: 'staff.canAccessSettingsBusinessHoursHint',
|
||||
hintDefault: 'Set regular operating hours',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_branding',
|
||||
labelKey: 'staff.canAccessSettingsBranding',
|
||||
labelDefault: 'Branding',
|
||||
hintKey: 'staff.canAccessSettingsBrandingHint',
|
||||
hintDefault: 'Logo, colors, and visual identity',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_booking',
|
||||
labelKey: 'staff.canAccessSettingsBooking',
|
||||
labelDefault: 'Booking Settings',
|
||||
hintKey: 'staff.canAccessSettingsBookingHint',
|
||||
hintDefault: 'Booking policies and rules',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_communication',
|
||||
labelKey: 'staff.canAccessSettingsCommunication',
|
||||
labelDefault: 'Communication',
|
||||
hintKey: 'staff.canAccessSettingsCommunicationHint',
|
||||
hintDefault: 'Notification preferences and reminders',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_embed_widget',
|
||||
labelKey: 'staff.canAccessSettingsEmbedWidget',
|
||||
labelDefault: 'Embed Widget',
|
||||
hintKey: 'staff.canAccessSettingsEmbedWidgetHint',
|
||||
hintDefault: 'Configure booking widget for websites',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_email_templates',
|
||||
labelKey: 'staff.canAccessSettingsEmailTemplates',
|
||||
labelDefault: 'Email Templates',
|
||||
hintKey: 'staff.canAccessSettingsEmailTemplatesHint',
|
||||
hintDefault: 'Customize automated emails',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_staff_roles',
|
||||
labelKey: 'staff.canAccessSettingsStaffRoles',
|
||||
labelDefault: 'Staff Roles',
|
||||
hintKey: 'staff.canAccessSettingsStaffRolesHint',
|
||||
hintDefault: 'Create and manage permission roles',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_resource_types',
|
||||
labelKey: 'staff.canAccessSettingsResourceTypes',
|
||||
labelDefault: 'Resource Types',
|
||||
hintKey: 'staff.canAccessSettingsResourceTypesHint',
|
||||
hintDefault: 'Configure resource categories',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_api',
|
||||
labelKey: 'staff.canAccessSettingsApi',
|
||||
labelDefault: 'API & Integrations',
|
||||
hintKey: 'staff.canAccessSettingsApiHint',
|
||||
hintDefault: 'Manage API tokens and webhooks',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_custom_domains',
|
||||
labelKey: 'staff.canAccessSettingsCustomDomains',
|
||||
labelDefault: 'Custom Domains',
|
||||
hintKey: 'staff.canAccessSettingsCustomDomainsHint',
|
||||
hintDefault: 'Configure custom domain settings',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_authentication',
|
||||
labelKey: 'staff.canAccessSettingsAuthentication',
|
||||
labelDefault: 'Authentication',
|
||||
hintKey: 'staff.canAccessSettingsAuthenticationHint',
|
||||
hintDefault: 'OAuth and social login configuration',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_email',
|
||||
labelKey: 'staff.canAccessSettingsEmail',
|
||||
labelDefault: 'Email Setup',
|
||||
hintKey: 'staff.canAccessSettingsEmailHint',
|
||||
hintDefault: 'Configure email addresses for tickets',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings_sms_calling',
|
||||
labelKey: 'staff.canAccessSettingsSmsCalling',
|
||||
labelDefault: 'SMS & Calling',
|
||||
hintKey: 'staff.canAccessSettingsSmsCallingHint',
|
||||
hintDefault: 'Manage credits and phone numbers',
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Define all available permissions in one place
|
||||
// All permissions are now available to staff (via staff roles)
|
||||
export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
// Manager-only permissions
|
||||
{
|
||||
key: 'can_invite_staff',
|
||||
labelKey: 'staff.canInviteStaff',
|
||||
labelDefault: 'Can invite new staff members',
|
||||
hintKey: 'staff.canInviteStaffHint',
|
||||
hintDefault: 'Allow this manager to send invitations to new staff members',
|
||||
hintDefault: 'Allow this staff member to send invitations to new staff members',
|
||||
defaultValue: false,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_manage_resources',
|
||||
@@ -29,8 +144,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
labelDefault: 'Can manage resources',
|
||||
hintKey: 'staff.canManageResourcesHint',
|
||||
hintDefault: 'Create, edit, and delete bookable resources',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_manage_services',
|
||||
@@ -38,8 +152,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
labelDefault: 'Can manage services',
|
||||
hintKey: 'staff.canManageServicesHint',
|
||||
hintDefault: 'Create, edit, and delete service offerings',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'can_view_reports',
|
||||
@@ -47,17 +160,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
labelDefault: 'Can view reports',
|
||||
hintKey: 'staff.canViewReportsHint',
|
||||
hintDefault: 'Access business analytics and financial reports',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings',
|
||||
labelKey: 'staff.canAccessSettings',
|
||||
labelDefault: 'Can access business settings',
|
||||
hintKey: 'staff.canAccessSettingsHint',
|
||||
hintDefault: 'Modify business profile, branding, and configuration',
|
||||
defaultValue: false,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_refund_payments',
|
||||
@@ -66,7 +169,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
hintKey: 'staff.canRefundPaymentsHint',
|
||||
hintDefault: 'Process refunds for customer payments',
|
||||
defaultValue: false,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_send_messages',
|
||||
@@ -74,10 +176,8 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
labelDefault: 'Can send broadcast messages',
|
||||
hintKey: 'staff.canSendMessagesHint',
|
||||
hintDefault: 'Send messages to groups of staff and customers',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
defaultValue: false,
|
||||
},
|
||||
// Staff-only permissions
|
||||
{
|
||||
key: 'can_view_all_schedules',
|
||||
labelKey: 'staff.canViewAllSchedules',
|
||||
@@ -85,7 +185,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
hintKey: 'staff.canViewAllSchedulesHint',
|
||||
hintDefault: 'View schedules of other staff members (otherwise only their own)',
|
||||
defaultValue: false,
|
||||
roles: ['staff'],
|
||||
},
|
||||
{
|
||||
key: 'can_manage_own_appointments',
|
||||
@@ -94,112 +193,235 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
hintKey: 'staff.canManageOwnAppointmentsHint',
|
||||
hintDefault: 'Create, reschedule, and cancel their own appointments',
|
||||
defaultValue: true,
|
||||
roles: ['staff'],
|
||||
},
|
||||
{
|
||||
key: 'can_self_approve_time_off',
|
||||
labelKey: 'staff.canSelfApproveTimeOff',
|
||||
labelDefault: 'Can self-approve time off',
|
||||
hintKey: 'staff.canSelfApproveTimeOffHint',
|
||||
hintDefault: 'Add time off without requiring manager/owner approval',
|
||||
hintDefault: 'Add time off without requiring owner approval',
|
||||
defaultValue: false,
|
||||
roles: ['staff'],
|
||||
},
|
||||
// Shared permissions (both manager and staff)
|
||||
{
|
||||
key: 'can_access_tickets',
|
||||
labelKey: 'staff.canAccessTickets',
|
||||
labelDefault: 'Can access support tickets',
|
||||
hintKey: 'staff.canAccessTicketsHint',
|
||||
hintDefault: 'View and manage customer support tickets',
|
||||
defaultValue: true, // Default for managers; staff will override to false
|
||||
roles: ['manager', 'staff'],
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Get default permissions for a role
|
||||
export const getDefaultPermissions = (role: 'manager' | 'staff'): Record<string, boolean> => {
|
||||
// Get default permissions for staff
|
||||
export const getDefaultPermissions = (): Record<string, boolean> => {
|
||||
const defaults: Record<string, boolean> = {};
|
||||
PERMISSION_CONFIGS.forEach((config) => {
|
||||
if (config.roles.includes(role)) {
|
||||
// Staff members have ticket access disabled by default
|
||||
if (role === 'staff' && config.key === 'can_access_tickets') {
|
||||
defaults[config.key] = false;
|
||||
} else {
|
||||
defaults[config.key] = config.defaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||
defaults[config.key] = config.defaultValue;
|
||||
});
|
||||
defaults['can_access_settings'] = false;
|
||||
return defaults;
|
||||
};
|
||||
|
||||
interface StaffPermissionsProps {
|
||||
role: 'manager' | 'staff';
|
||||
role: 'staff';
|
||||
permissions: Record<string, boolean>;
|
||||
onChange: (permissions: Record<string, boolean>) => void;
|
||||
variant?: 'invite' | 'edit';
|
||||
}
|
||||
|
||||
const StaffPermissions: React.FC<StaffPermissionsProps> = ({
|
||||
role,
|
||||
permissions,
|
||||
onChange,
|
||||
variant = 'edit',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Filter permissions for this role
|
||||
const rolePermissions = PERMISSION_CONFIGS.filter((config) =>
|
||||
config.roles.includes(role)
|
||||
);
|
||||
|
||||
const handleToggle = (key: string, checked: boolean) => {
|
||||
onChange({ ...permissions, [key]: checked });
|
||||
};
|
||||
const [settingsExpanded, setSettingsExpanded] = useState(false);
|
||||
|
||||
// Get the current value, falling back to default
|
||||
const getValue = (config: PermissionConfig): boolean => {
|
||||
if (permissions[config.key] !== undefined) {
|
||||
return permissions[config.key];
|
||||
const getValue = (key: string, defaultValue: boolean = false): boolean => {
|
||||
if (permissions[key] !== undefined) {
|
||||
return permissions[key];
|
||||
}
|
||||
// Staff have ticket access disabled by default
|
||||
if (role === 'staff' && config.key === 'can_access_tickets') {
|
||||
return false;
|
||||
}
|
||||
return config.defaultValue;
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
// Different styling for manager vs staff permissions
|
||||
const isManagerPermission = (config: PermissionConfig) =>
|
||||
config.roles.includes('manager') && !config.roles.includes('staff');
|
||||
const hasSettingsAccess = getValue('can_access_settings', false);
|
||||
|
||||
const getPermissionStyle = (config: PermissionConfig) => {
|
||||
if (isManagerPermission(config) || role === 'manager') {
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30';
|
||||
// Auto-expand settings section if any settings permissions are enabled
|
||||
useEffect(() => {
|
||||
if (hasSettingsAccess) {
|
||||
const hasAnySettingEnabled = SETTINGS_PERMISSION_CONFIGS.some(
|
||||
(config) => getValue(config.key, false)
|
||||
);
|
||||
if (hasAnySettingEnabled) {
|
||||
setSettingsExpanded(true);
|
||||
}
|
||||
return 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggle = (key: string, checked: boolean) => {
|
||||
const newPermissions = { ...permissions, [key]: checked };
|
||||
|
||||
// If turning off main settings access, turn off all sub-settings
|
||||
if (key === 'can_access_settings' && !checked) {
|
||||
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||
newPermissions[config.key] = false;
|
||||
});
|
||||
}
|
||||
|
||||
onChange(newPermissions);
|
||||
};
|
||||
|
||||
if (rolePermissions.length === 0) {
|
||||
return null;
|
||||
const handleSettingsMainToggle = (checked: boolean) => {
|
||||
const newPermissions = { ...permissions, can_access_settings: checked };
|
||||
|
||||
// If turning off, disable all sub-settings
|
||||
if (!checked) {
|
||||
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||
newPermissions[config.key] = false;
|
||||
});
|
||||
setSettingsExpanded(false);
|
||||
} else {
|
||||
// If turning on, expand the section
|
||||
setSettingsExpanded(true);
|
||||
}
|
||||
|
||||
onChange(newPermissions);
|
||||
};
|
||||
|
||||
const handleSelectAllSettings = (selectAll: boolean) => {
|
||||
const newPermissions = { ...permissions };
|
||||
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||
newPermissions[config.key] = selectAll;
|
||||
});
|
||||
onChange(newPermissions);
|
||||
};
|
||||
|
||||
// Count how many settings sub-permissions are enabled
|
||||
const enabledSettingsCount = SETTINGS_PERMISSION_CONFIGS.filter((config) =>
|
||||
getValue(config.key, false)
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{role === 'manager'
|
||||
? t('staff.managerPermissions', 'Manager Permissions')
|
||||
: t('staff.staffPermissions', 'Staff Permissions')}
|
||||
{t('staff.staffPermissions', 'Staff Permissions')}
|
||||
</h4>
|
||||
|
||||
{rolePermissions.map((config) => (
|
||||
{/* Regular permissions */}
|
||||
{PERMISSION_CONFIGS.map((config) => (
|
||||
<label
|
||||
key={config.key}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${getPermissionStyle(config)}`}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={getValue(config)}
|
||||
checked={getValue(config.key, config.defaultValue)}
|
||||
onChange={(e) => handleToggle(config.key, e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t(config.labelKey, config.labelDefault)}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t(config.hintKey, config.hintDefault)}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Business Settings Section */}
|
||||
<div className="border rounded-lg border-gray-200 dark:border-gray-600 overflow-hidden">
|
||||
{/* Main Business Settings Toggle */}
|
||||
<div
|
||||
className={`flex items-start gap-3 p-3 cursor-pointer transition-colors ${
|
||||
hasSettingsAccess
|
||||
? 'bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasSettingsAccess}
|
||||
onChange={(e) => handleSettingsMainToggle(e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => hasSettingsAccess && setSettingsExpanded(!settingsExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessSettings', 'Can access business settings')}
|
||||
</span>
|
||||
{hasSettingsAccess && enabledSettingsCount > 0 && (
|
||||
<span className="ml-2 text-xs text-brand-600 dark:text-brand-400">
|
||||
({enabledSettingsCount}/{SETTINGS_PERMISSION_CONFIGS.length} enabled)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasSettingsAccess && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSettingsExpanded(!settingsExpanded);
|
||||
}}
|
||||
>
|
||||
{settingsExpanded ? (
|
||||
<ChevronDown size={16} className="text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight size={16} className="text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t(
|
||||
'staff.canAccessSettingsHint',
|
||||
'Access to business settings pages (select specific pages below)'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-permissions (collapsible) */}
|
||||
{hasSettingsAccess && settingsExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 bg-gray-25 dark:bg-gray-800/50">
|
||||
{/* Select All / None buttons */}
|
||||
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-600 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectAllSettings(true)}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
|
||||
>
|
||||
{t('staff.selectAll', 'Select All')}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectAllSettings(false)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
||||
>
|
||||
{t('staff.selectNone', 'Select None')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Individual settings permissions */}
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{SETTINGS_PERMISSION_CONFIGS.map((config) => (
|
||||
<label
|
||||
key={config.key}
|
||||
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={getValue(config.key, config.defaultValue)}
|
||||
onChange={(e) => handleToggle(config.key, e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
@@ -214,6 +436,10 @@ const StaffPermissions: React.FC<StaffPermissionsProps> = ({
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/';
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -56,6 +56,16 @@ const SettingsLayout: React.FC = () => {
|
||||
|
||||
// Get context from parent route (BusinessLayout)
|
||||
const parentContext = useOutletContext<ParentContext>();
|
||||
const { user } = parentContext || {};
|
||||
const isOwner = user?.role === 'owner';
|
||||
|
||||
// Check if staff has access to a specific settings page
|
||||
const hasSettingsPermission = (permissionKey: string): boolean => {
|
||||
// Owners always have all permissions
|
||||
if (isOwner) return true;
|
||||
// Staff need the specific permission
|
||||
return user?.effective_permissions?.[permissionKey] === true;
|
||||
};
|
||||
|
||||
// Check if a feature is locked (returns true if locked)
|
||||
const isLocked = (feature: FeatureKey | undefined): boolean => {
|
||||
@@ -92,35 +102,53 @@ const SettingsLayout: React.FC = () => {
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
|
||||
{/* Business Section */}
|
||||
{(hasSettingsPermission('can_access_settings_general') ||
|
||||
hasSettingsPermission('can_access_settings_resource_types') ||
|
||||
hasSettingsPermission('can_access_settings_booking') ||
|
||||
hasSettingsPermission('can_access_settings_business_hours')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
|
||||
{hasSettingsPermission('can_access_settings_general') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/general"
|
||||
icon={Building2}
|
||||
label={t('settings.general.title', 'General')}
|
||||
description={t('settings.general.description', 'Name, timezone, contact')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_resource_types') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/resource-types"
|
||||
icon={Layers}
|
||||
label={t('settings.resourceTypes.title', 'Resource Types')}
|
||||
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_booking') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/booking"
|
||||
icon={Calendar}
|
||||
label={t('settings.booking.title', 'Booking')}
|
||||
description={t('settings.booking.description', 'Booking URL, redirects')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_business_hours') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/business-hours"
|
||||
icon={Clock}
|
||||
label={t('settings.businessHours.title', 'Business Hours')}
|
||||
description={t('settings.businessHours.description', 'Operating hours')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Branding Section */}
|
||||
{(hasSettingsPermission('can_access_settings_branding') ||
|
||||
hasSettingsPermission('can_access_settings_email_templates') ||
|
||||
hasSettingsPermission('can_access_settings_custom_domains') ||
|
||||
hasSettingsPermission('can_access_settings_embed_widget')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
|
||||
{hasSettingsPermission('can_access_settings_branding') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/branding"
|
||||
icon={Palette}
|
||||
@@ -128,12 +156,16 @@ const SettingsLayout: React.FC = () => {
|
||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||
locked={isLocked('remove_branding')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_email_templates') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/email-templates"
|
||||
icon={Mail}
|
||||
label={t('settings.emailTemplates.title', 'Email Templates')}
|
||||
description={t('settings.emailTemplates.description', 'Customize automated emails')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_custom_domains') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/custom-domains"
|
||||
icon={Globe}
|
||||
@@ -141,15 +173,20 @@ const SettingsLayout: React.FC = () => {
|
||||
description={t('settings.customDomains.description', 'Use your own domain')}
|
||||
locked={isLocked('custom_domain')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_embed_widget') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/embed-widget"
|
||||
icon={Code2}
|
||||
label={t('settings.embedWidget.title', 'Embed Widget')}
|
||||
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Integrations Section */}
|
||||
{hasSettingsPermission('can_access_settings_api') && (
|
||||
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/api"
|
||||
@@ -159,15 +196,21 @@ const SettingsLayout: React.FC = () => {
|
||||
locked={isLocked('api_access')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Access Section */}
|
||||
{(hasSettingsPermission('can_access_settings_staff_roles') ||
|
||||
hasSettingsPermission('can_access_settings_authentication')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
|
||||
{hasSettingsPermission('can_access_settings_staff_roles') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/staff-roles"
|
||||
icon={Users}
|
||||
label={t('settings.staffRoles.title', 'Staff Roles')}
|
||||
description={t('settings.staffRoles.description', 'Role permissions')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_authentication') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/authentication"
|
||||
icon={Lock}
|
||||
@@ -175,16 +218,23 @@ const SettingsLayout: React.FC = () => {
|
||||
description={t('settings.authentication.description', 'OAuth, social login')}
|
||||
locked={isLocked('custom_oauth')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Communication Section */}
|
||||
{(hasSettingsPermission('can_access_settings_email') ||
|
||||
hasSettingsPermission('can_access_settings_sms_calling')) && (
|
||||
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
|
||||
{hasSettingsPermission('can_access_settings_email') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/email"
|
||||
icon={Mail}
|
||||
label={t('settings.email.title', 'Email Setup')}
|
||||
description={t('settings.email.description', 'Email addresses for tickets')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_sms_calling') && (
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/sms-calling"
|
||||
icon={Phone}
|
||||
@@ -192,9 +242,12 @@ const SettingsLayout: React.FC = () => {
|
||||
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
|
||||
locked={isLocked('sms_reminders')}
|
||||
/>
|
||||
)}
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
|
||||
{/* Billing Section */}
|
||||
{/* Billing Section - Owner only */}
|
||||
{isOwner && (
|
||||
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/billing"
|
||||
@@ -209,6 +262,7 @@ const SettingsLayout: React.FC = () => {
|
||||
description={t('settings.quota.description', 'Usage limits, archiving')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { User } from '../types';
|
||||
import { useCreateResource, useResources } from '../hooks/useBusiness';
|
||||
import { useStaff, useToggleStaffActive, useUpdateStaff, StaffMember } from '../hooks/useStaff';
|
||||
import { useStaff, useToggleStaffActive, useUpdateStaff, useVerifyStaffEmail, useSendStaffPasswordReset, StaffMember } from '../hooks/useStaff';
|
||||
import {
|
||||
useInvitations,
|
||||
useCreateInvitation,
|
||||
@@ -30,9 +30,13 @@ import {
|
||||
ChevronRight,
|
||||
UserX,
|
||||
Power,
|
||||
BadgeCheck,
|
||||
Key,
|
||||
Phone,
|
||||
Eye,
|
||||
ArrowUpDown,
|
||||
} from 'lucide-react';
|
||||
import Portal from '../components/Portal';
|
||||
import StaffPermissions from '../components/StaffPermissions';
|
||||
|
||||
interface StaffProps {
|
||||
onMasquerade: (user: User) => void;
|
||||
@@ -51,10 +55,13 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const resendInvitationMutation = useResendInvitation();
|
||||
const toggleActiveMutation = useToggleStaffActive();
|
||||
const updateStaffMutation = useUpdateStaff();
|
||||
const verifyEmailMutation = useVerifyStaffEmail();
|
||||
const passwordResetMutation = useSendStaffPasswordReset();
|
||||
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF');
|
||||
// All invitations are for TENANT_STAFF - manager role removed
|
||||
const inviteRole = 'TENANT_STAFF';
|
||||
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
|
||||
const [createBookableResource, setCreateBookableResource] = useState(false);
|
||||
const [resourceName, setResourceName] = useState('');
|
||||
@@ -66,16 +73,55 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
// Edit modal state
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
|
||||
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
|
||||
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
|
||||
const [editFirstName, setEditFirstName] = useState('');
|
||||
const [editLastName, setEditLastName] = useState('');
|
||||
const [editPhone, setEditPhone] = useState('');
|
||||
const [editError, setEditError] = useState('');
|
||||
const [editSuccess, setEditSuccess] = useState('');
|
||||
|
||||
// Check if user can invite managers (only owners can)
|
||||
const canInviteManagers = effectiveUser.role === 'owner';
|
||||
// Verify email confirmation modal state
|
||||
const [verifyEmailTarget, setVerifyEmailTarget] = useState<StaffMember | null>(null);
|
||||
|
||||
// Sorting state
|
||||
const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'role'; direction: 'asc' | 'desc' }>({
|
||||
key: 'name',
|
||||
direction: 'asc'
|
||||
});
|
||||
|
||||
const handleSort = (key: 'name' | 'role') => {
|
||||
setSortConfig(current => ({
|
||||
key,
|
||||
direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc',
|
||||
}));
|
||||
};
|
||||
|
||||
// Separate active and inactive staff, then sort
|
||||
const activeStaff = useMemo(() => {
|
||||
const active = staffMembers.filter((s) => s.is_active);
|
||||
return [...active].sort((a, b) => {
|
||||
let aValue: string;
|
||||
let bValue: string;
|
||||
|
||||
if (sortConfig.key === 'name') {
|
||||
aValue = (a.name || a.email || '').toLowerCase();
|
||||
bValue = (b.name || b.email || '').toLowerCase();
|
||||
} else {
|
||||
// Sort by role: owners first, then by staff_role_name
|
||||
const aIsOwner = a.role === 'owner';
|
||||
const bIsOwner = b.role === 'owner';
|
||||
if (aIsOwner && !bIsOwner) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (!aIsOwner && bIsOwner) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
aValue = (a.staff_role_name || '').toLowerCase();
|
||||
bValue = (b.staff_role_name || '').toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [staffMembers, sortConfig]);
|
||||
|
||||
// Separate active and inactive staff
|
||||
const activeStaff = staffMembers.filter((s) => s.is_active);
|
||||
const inactiveStaff = staffMembers.filter((s) => !s.is_active);
|
||||
|
||||
// Helper to check if a user is already linked to a resource
|
||||
@@ -151,7 +197,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
const openInviteModal = () => {
|
||||
setInviteEmail('');
|
||||
setInviteRole('TENANT_STAFF');
|
||||
setInviteStaffRoleId(null);
|
||||
setCreateBookableResource(false);
|
||||
setResourceName('');
|
||||
@@ -196,8 +241,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
const openEditModal = (staff: StaffMember) => {
|
||||
setEditingStaff(staff);
|
||||
setEditPermissions(staff.permissions || {});
|
||||
setEditStaffRoleId(staff.staff_role_id);
|
||||
setEditFirstName(staff.first_name);
|
||||
setEditLastName(staff.last_name);
|
||||
setEditPhone(staff.phone || '');
|
||||
setEditError('');
|
||||
setEditSuccess('');
|
||||
setIsEditModalOpen(true);
|
||||
@@ -206,8 +253,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const closeEditModal = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingStaff(null);
|
||||
setEditPermissions({});
|
||||
setEditStaffRoleId(null);
|
||||
setEditFirstName('');
|
||||
setEditLastName('');
|
||||
setEditPhone('');
|
||||
setEditError('');
|
||||
setEditSuccess('');
|
||||
};
|
||||
@@ -217,10 +266,17 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
setEditError('');
|
||||
try {
|
||||
const updates: { permissions: Record<string, boolean>; staff_role_id?: number | null } = {
|
||||
permissions: editPermissions,
|
||||
const updates: {
|
||||
staff_role_id?: number | null;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
} = {
|
||||
first_name: editFirstName,
|
||||
last_name: editLastName,
|
||||
phone: editPhone,
|
||||
};
|
||||
// Only include staff_role_id for staff users (not owners/managers)
|
||||
// Only include staff_role_id for staff users (not owners)
|
||||
if (editingStaff.role === 'staff') {
|
||||
updates.staff_role_id = editStaffRoleId;
|
||||
}
|
||||
@@ -237,6 +293,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendPasswordReset = async () => {
|
||||
if (!editingStaff) return;
|
||||
|
||||
if (!confirm(t('staff.confirmPasswordReset', { email: editingStaff.email }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await passwordResetMutation.mutateAsync(editingStaff.id);
|
||||
setEditSuccess(t('staff.passwordResetSent'));
|
||||
} catch (err: any) {
|
||||
setEditError(err.response?.data?.error || t('staff.passwordResetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivateFromModal = async () => {
|
||||
if (!editingStaff) return;
|
||||
|
||||
@@ -251,6 +322,20 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmailClick = (user: StaffMember) => {
|
||||
setVerifyEmailTarget(user);
|
||||
};
|
||||
|
||||
const handleVerifyEmailConfirm = async () => {
|
||||
if (!verifyEmailTarget) return;
|
||||
try {
|
||||
await verifyEmailMutation.mutateAsync(verifyEmailTarget.id);
|
||||
setVerifyEmailTarget(null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to toggle email verification:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
@@ -325,9 +410,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.staffRole')}</th>
|
||||
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('name')}>
|
||||
<div className="flex items-center gap-1">{t('staff.name')} <ArrowUpDown size={14} className="text-gray-400" /></div>
|
||||
</th>
|
||||
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('role')}>
|
||||
<div className="flex items-center gap-1">{t('staff.role')} <ArrowUpDown size={14} className="text-gray-400" /></div>
|
||||
</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
|
||||
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
||||
</tr>
|
||||
@@ -356,34 +444,19 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: user.role === 'manager'
|
||||
: user.staff_role_name === 'Manager'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{user.role === 'owner' && <Shield size={12} />}
|
||||
{user.role === 'manager' && <Briefcase size={12} />}
|
||||
{user.role}
|
||||
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
|
||||
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.role === 'staff' ? (
|
||||
user.staff_role_name ? (
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{user.staff_role_name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
{t('staff.noRoleAssigned')}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{linkedResource ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
|
||||
@@ -401,21 +474,38 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleVerifyEmailClick(user)}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`p-1.5 border rounded-lg transition-colors ${
|
||||
user.email_verified
|
||||
? 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-900/30'
|
||||
: 'text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-900/30'
|
||||
}`}
|
||||
title={user.email_verified ? t('staff.emailVerified') : t('staff.verifyEmail')}
|
||||
>
|
||||
{verifyEmailMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck size={16} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={14} /> {t('common.edit')}
|
||||
</button>
|
||||
{canMasquerade && (
|
||||
<button
|
||||
onClick={() => onMasquerade(user)}
|
||||
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
|
||||
title={t('common.masqueradeAsUser')}
|
||||
>
|
||||
{t('common.masquerade')}
|
||||
<Eye size={14} /> {t('common.masquerade')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -473,10 +563,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{user.role === 'owner' && <Shield size={12} />}
|
||||
{user.role === 'manager' && <Briefcase size={12} />}
|
||||
{user.role}
|
||||
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
|
||||
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -545,34 +635,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role Selector */}
|
||||
{/* Staff Role Selector */}
|
||||
{staffRoles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.roleLabel')} *
|
||||
</label>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as 'TENANT_MANAGER' | 'TENANT_STAFF')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="TENANT_STAFF">{t('staff.roleStaff')}</option>
|
||||
{canInviteManagers && (
|
||||
<option value="TENANT_MANAGER">{t('staff.roleManager')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{inviteRole === 'TENANT_MANAGER'
|
||||
? t('staff.managerRoleHint')
|
||||
: t('staff.staffRoleHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Staff Role Selector (only for staff invitations) */}
|
||||
{inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.staffRole')}
|
||||
</label>
|
||||
<select
|
||||
value={inviteStaffRoleId ?? ''}
|
||||
onChange={(e) => setInviteStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
||||
@@ -592,23 +660,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{inviteRole === 'TENANT_MANAGER' && (
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
|
||||
{inviteRole === 'TENANT_STAFF' && (
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Make Bookable Option */}
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
@@ -692,8 +749,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
{isEditModalOpen && editingStaff && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden max-h-[90vh] flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staff.editStaff')}
|
||||
</h3>
|
||||
@@ -705,37 +762,95 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Staff Info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 font-medium text-lg">
|
||||
{editingStaff.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||
{/* Profile Information Section */}
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{editingStaff.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{editingStaff.email}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`ml-auto inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
editingStaff.role === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: editingStaff.role === 'manager'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{editingStaff.role === 'owner' && <Shield size={12} />}
|
||||
{editingStaff.role === 'manager' && <Briefcase size={12} />}
|
||||
{editingStaff.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Staff Role Selector (only for staff users) */}
|
||||
{editingStaff.role === 'staff' && staffRoles.length > 0 && (
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<UserIcon size={16} />
|
||||
{t('staff.profileInformation', 'Profile Information')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.staffRole')}
|
||||
{t('staff.firstName', 'First Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editFirstName}
|
||||
onChange={(e) => setEditFirstName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={t('staff.firstNamePlaceholder', 'Enter first name')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.lastName', 'Last Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editLastName}
|
||||
onChange={(e) => setEditLastName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={t('staff.lastNamePlaceholder', 'Enter last name')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.email', 'Email')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={editingStaff.email}
|
||||
disabled
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleVerifyEmailClick(editingStaff)}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
editingStaff.email_verified
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
|
||||
}`}
|
||||
>
|
||||
{verifyEmailMutation.isPending ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck size={12} />
|
||||
)}
|
||||
{editingStaff.email_verified ? t('staff.verified', 'Verified') : t('staff.verify', 'Verify')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.phone', 'Phone')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
value={editPhone}
|
||||
onChange={(e) => setEditPhone(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={t('staff.phonePlaceholder', 'Enter phone number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Section */}
|
||||
{editingStaff.role !== 'owner' && staffRoles.length > 0 && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Shield size={16} />
|
||||
{t('staff.staffRole', 'Staff Role')}
|
||||
</h4>
|
||||
|
||||
{/* Staff Role Selector */}
|
||||
<div>
|
||||
<select
|
||||
value={editStaffRoleId ?? ''}
|
||||
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
||||
@@ -752,34 +867,53 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
{t('staff.staffRoleSelectHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{editingStaff.role === 'manager' && (
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingStaff.role === 'staff' && (
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No permissions for owners */}
|
||||
{/* Owner info banner */}
|
||||
{editingStaff.role === 'owner' && (
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} className="text-purple-600 dark:text-purple-400" />
|
||||
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||
{t('staff.ownerFullAccess')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Security Section - Password Reset */}
|
||||
{editingStaff.role !== 'owner' && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Key size={16} />
|
||||
{t('staff.accountSecurity', 'Account Security')}
|
||||
</h4>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.resetPassword', 'Reset Password')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.resetPasswordHint', 'Send a password reset email to this staff member')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendPasswordReset}
|
||||
disabled={passwordResetMutation.isPending}
|
||||
className="ml-4 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 flex-shrink-0 text-brand-600 border border-brand-300 hover:bg-brand-50 dark:text-brand-400 dark:border-brand-700 dark:hover:bg-brand-900/30"
|
||||
>
|
||||
{passwordResetMutation.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Mail size={14} />
|
||||
)}
|
||||
{t('staff.sendResetEmail', 'Send Reset Email')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
@@ -838,9 +972,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{/* Action Buttons - Fixed footer */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditModal}
|
||||
@@ -848,7 +983,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{editingStaff.role !== 'owner' && (
|
||||
<button
|
||||
onClick={handleSaveStaffSettings}
|
||||
disabled={updateStaffMutation.isPending || !!editSuccess}
|
||||
@@ -859,10 +993,50 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
) : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Verify Email Confirmation Modal */}
|
||||
{verifyEmailTarget && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{verifyEmailTarget.email_verified ? t('staff.unverifyEmailTitle') : t('staff.verifyEmailTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{verifyEmailTarget.email_verified
|
||||
? t('staff.unverifyEmailConfirm', { email: verifyEmailTarget.email })
|
||||
: t('staff.verifyEmailConfirm', { email: verifyEmailTarget.email })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setVerifyEmailTarget(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerifyEmailConfirm}
|
||||
disabled={verifyEmailMutation.isPending}
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
|
||||
verifyEmailTarget.email_verified
|
||||
? 'bg-amber-600 hover:bg-amber-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{verifyEmailMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||
{verifyEmailTarget.email_verified ? t('staff.unverifyEmail') : t('staff.verifyEmail')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -41,14 +41,15 @@ const StaffRolesSettings: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const isManager = user.role === 'manager';
|
||||
const canManageRoles = isOwner || isManager;
|
||||
// Only owners can manage roles (staff with permissions can view but not edit)
|
||||
const canManageRoles = isOwner;
|
||||
|
||||
// Merge menu and dangerous permissions for display
|
||||
// Merge menu, settings, and dangerous permissions for display
|
||||
const allPermissions = useMemo(() => {
|
||||
if (!availablePermissions) return { menu: {}, dangerous: {} };
|
||||
if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
|
||||
return {
|
||||
menu: availablePermissions.menu_permissions || {},
|
||||
settings: availablePermissions.settings_permissions || {},
|
||||
dangerous: availablePermissions.dangerous_permissions || {},
|
||||
};
|
||||
}, [availablePermissions]);
|
||||
@@ -82,21 +83,50 @@ const StaffRolesSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setFormData((prev) => ({
|
||||
setFormData((prev) => {
|
||||
const newValue = !prev.permissions[key];
|
||||
const updates: Record<string, boolean> = { [key]: newValue };
|
||||
|
||||
// If enabling any settings sub-permission, also enable the main settings access
|
||||
if (newValue && key.startsWith('can_access_settings_')) {
|
||||
updates['can_access_settings'] = true;
|
||||
}
|
||||
|
||||
// If disabling the main settings access, disable all sub-permissions
|
||||
if (!newValue && key === 'can_access_settings') {
|
||||
Object.keys(allPermissions.settings).forEach((settingKey) => {
|
||||
if (settingKey !== 'can_access_settings') {
|
||||
updates[settingKey] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
[key]: !prev.permissions[key],
|
||||
...updates,
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => {
|
||||
const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous;
|
||||
const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
|
||||
const permissions = category === 'menu'
|
||||
? allPermissions.menu
|
||||
: category === 'settings'
|
||||
? allPermissions.settings
|
||||
: allPermissions.dangerous;
|
||||
const updates: Record<string, boolean> = {};
|
||||
Object.keys(permissions).forEach((key) => {
|
||||
updates[key] = enable;
|
||||
});
|
||||
|
||||
// If enabling any settings permissions, ensure main settings access is also enabled
|
||||
if (category === 'settings' && enable) {
|
||||
updates['can_access_settings'] = true;
|
||||
}
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
@@ -160,7 +190,7 @@ const StaffRolesSettings: React.FC = () => {
|
||||
<div className="text-center py-12">
|
||||
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')}
|
||||
{t('settings.staffRoles.noAccess', 'Only the business owner can manage staff roles.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -324,8 +354,7 @@ const StaffRolesSettings: React.FC = () => {
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
disabled={editingRole?.is_default}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
|
||||
/>
|
||||
</div>
|
||||
@@ -398,6 +427,60 @@ const StaffRolesSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Settings Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('settings', true)}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.selectAll', 'Select All')}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('settings', false)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear All')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30">
|
||||
{Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100/50 dark:hover:bg-blue-900/20 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions[key] || false}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{def.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{def.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dangerous Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -88,13 +88,13 @@ def get_platform_support_team():
|
||||
|
||||
|
||||
def get_tenant_managers(tenant):
|
||||
"""Get all owners and managers for a tenant."""
|
||||
"""Get all owners for a tenant (formerly owners and managers)."""
|
||||
try:
|
||||
if not tenant:
|
||||
return User.objects.none()
|
||||
return User.objects.filter(
|
||||
tenant=tenant,
|
||||
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
||||
role=User.Role.TENANT_OWNER,
|
||||
is_active=True
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestGetTenantManagers:
|
||||
"""Test the get_tenant_managers() helper function."""
|
||||
|
||||
def test_returns_tenant_managers(self):
|
||||
"""Should return owners and managers for a tenant."""
|
||||
"""Should return owners for a tenant (formerly owners and managers)."""
|
||||
mock_tenant = Mock(id=1)
|
||||
mock_queryset = Mock()
|
||||
mock_filtered = Mock()
|
||||
@@ -149,7 +149,7 @@ class TestGetTenantManagers:
|
||||
|
||||
mock_queryset.filter.assert_called_once_with(
|
||||
tenant=mock_tenant,
|
||||
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
||||
role=User.Role.TENANT_OWNER,
|
||||
is_active=True
|
||||
)
|
||||
assert result == mock_filtered
|
||||
|
||||
@@ -803,8 +803,8 @@ class TicketEmailAddressViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Business users see only their own email addresses
|
||||
if hasattr(user, 'tenant') and user.tenant:
|
||||
# Only owners and managers can view/manage email addresses
|
||||
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||
# Only owners can view/manage email addresses
|
||||
if user.role == User.Role.TENANT_OWNER:
|
||||
return TicketEmailAddress.objects.filter(tenant=user.tenant)
|
||||
|
||||
return TicketEmailAddress.objects.none()
|
||||
|
||||
@@ -204,8 +204,8 @@ class BroadcastMessageViewSet(viewsets.ModelViewSet):
|
||||
|
||||
if message.target_owners:
|
||||
role_filters |= Q(role=User.Role.TENANT_OWNER)
|
||||
if message.target_managers:
|
||||
role_filters |= Q(role=User.Role.TENANT_MANAGER)
|
||||
# Note: target_managers now targets no one (managers migrated to staff)
|
||||
# Kept for backwards compatibility - messages sent to managers will just have no recipients
|
||||
if message.target_staff:
|
||||
role_filters |= Q(role=User.Role.TENANT_STAFF)
|
||||
if message.target_customers:
|
||||
@@ -251,7 +251,8 @@ class BroadcastMessageViewSet(viewsets.ModelViewSet):
|
||||
base_query = User.objects.filter(tenant=tenant, is_active=True).exclude(id=user.id)
|
||||
|
||||
owner_count = base_query.filter(role=User.Role.TENANT_OWNER).count()
|
||||
manager_count = base_query.filter(role=User.Role.TENANT_MANAGER).count()
|
||||
# manager_count is always 0 (managers migrated to staff with permissions)
|
||||
manager_count = 0
|
||||
staff_count = base_query.filter(role=User.Role.TENANT_STAFF).count()
|
||||
customer_count = base_query.filter(role=User.Role.CUSTOMER).count()
|
||||
|
||||
@@ -357,16 +358,13 @@ class InboxViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
# =============================================================================
|
||||
|
||||
class IsOwnerOrManager(BasePermission):
|
||||
"""Only owners and managers can manage email templates."""
|
||||
message = "You must be an owner or manager to manage email templates."
|
||||
"""Only owners can manage email templates."""
|
||||
message = "You must be an owner to manage email templates."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
return request.user.role in [
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
]
|
||||
return request.user.role == User.Role.TENANT_OWNER
|
||||
|
||||
|
||||
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@@ -160,8 +160,8 @@ class StatusMachine:
|
||||
"""
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
# Owners and managers can always change status
|
||||
if self.user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||
# Owners can always change status
|
||||
if self.user.role == User.Role.TENANT_OWNER:
|
||||
return True, ""
|
||||
|
||||
# Staff must be assigned to the event
|
||||
|
||||
@@ -266,18 +266,6 @@ class TestStatusMachine:
|
||||
assert can_change is True
|
||||
assert reason == ""
|
||||
|
||||
def test_can_user_change_status_manager_allowed(self):
|
||||
"""Test can_user_change_status allows TENANT_MANAGER."""
|
||||
mock_user = Mock()
|
||||
mock_user.role = User.Role.TENANT_MANAGER
|
||||
|
||||
machine = StatusMachine(tenant=Mock(), user=mock_user)
|
||||
mock_event = Mock()
|
||||
|
||||
can_change, reason = machine.can_user_change_status(mock_event)
|
||||
|
||||
assert can_change is True
|
||||
|
||||
def test_can_user_change_status_staff_assigned(self):
|
||||
"""Test can_user_change_status allows assigned TENANT_STAFF."""
|
||||
mock_user = Mock()
|
||||
|
||||
@@ -70,13 +70,6 @@ class TestHelperFunctions:
|
||||
|
||||
assert is_field_employee(mock_user) is True
|
||||
|
||||
def test_is_field_employee_with_manager_role(self):
|
||||
"""Test is_field_employee returns True for TENANT_MANAGER."""
|
||||
mock_user = Mock()
|
||||
mock_user.role = User.Role.TENANT_MANAGER
|
||||
|
||||
assert is_field_employee(mock_user) is True
|
||||
|
||||
def test_is_field_employee_with_owner_role(self):
|
||||
"""Test is_field_employee returns True for TENANT_OWNER."""
|
||||
mock_user = Mock()
|
||||
|
||||
@@ -56,10 +56,9 @@ def get_tenant_from_user(user):
|
||||
|
||||
|
||||
def is_field_employee(user):
|
||||
"""Check if user is a field employee (staff role)."""
|
||||
"""Check if user is a field employee (staff or owner role)."""
|
||||
return user.role in [
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_OWNER,
|
||||
]
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@ from smoothschedule.identity.users.models import User
|
||||
|
||||
|
||||
def is_owner_or_manager(user):
|
||||
"""Check if user is a tenant owner or manager."""
|
||||
return user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]
|
||||
"""Check if user is a tenant owner or staff with management permissions."""
|
||||
if user.role == User.Role.TENANT_OWNER:
|
||||
return True
|
||||
if user.role == User.Role.TENANT_STAFF:
|
||||
# Staff with can_manage_users permission has equivalent access
|
||||
return user.has_staff_permission('can_manage_users')
|
||||
return False
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
|
||||
@@ -14,9 +14,9 @@ def can_hijack(hijacker, hijacked):
|
||||
│ Hijacker Role │ Can Hijack │
|
||||
├──────────────────────┼─────────────────────────────────────────────────┤
|
||||
│ SUPERUSER │ Anyone (full god mode) │
|
||||
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF │
|
||||
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_STAFF, CUSTOMER │
|
||||
│ PLATFORM_SALES │ Only users with is_temporary=True │
|
||||
│ TENANT_OWNER │ TENANT_STAFF in same tenant only │
|
||||
│ TENANT_OWNER │ TENANT_STAFF, CUSTOMER in same tenant only │
|
||||
│ Others │ Nobody │
|
||||
└──────────────────────┴─────────────────────────────────────────────────┘
|
||||
|
||||
@@ -51,7 +51,6 @@ def can_hijack(hijacker, hijacked):
|
||||
if hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
@@ -60,7 +59,7 @@ def can_hijack(hijacker, hijacked):
|
||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||
return hijacked.is_temporary
|
||||
|
||||
# Rule 4: TENANT_OWNER can hijack managers, staff, and customers within their own tenant
|
||||
# Rule 4: TENANT_OWNER can hijack staff and customers within their own tenant
|
||||
if hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Must be in same tenant
|
||||
if not hijacker.tenant or not hijacked.tenant:
|
||||
@@ -68,9 +67,8 @@ def can_hijack(hijacker, hijacked):
|
||||
if hijacker.tenant.id != hijacked.tenant.id:
|
||||
return False
|
||||
|
||||
# Can hijack managers, staff, and customers (not other owners)
|
||||
# Can hijack staff and customers (not other owners)
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
@@ -127,7 +125,6 @@ def get_hijackable_users(hijacker):
|
||||
# Can hijack all tenant-level users
|
||||
return qs.filter(role__in=[
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
])
|
||||
@@ -137,13 +134,13 @@ def get_hijackable_users(hijacker):
|
||||
return qs.filter(is_temporary=True)
|
||||
|
||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Managers, staff, and customers in same tenant
|
||||
# Staff and customers in same tenant
|
||||
if not hijacker.tenant:
|
||||
return qs.none()
|
||||
|
||||
return qs.filter(
|
||||
tenant=hijacker.tenant,
|
||||
role__in=[User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -31,25 +31,28 @@ class TestIsOwnerOrManagerHelper:
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_returns_true_for_manager(self):
|
||||
"""Should return True for tenant manager."""
|
||||
from smoothschedule.identity.core.api_views import is_owner_or_manager
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.role = User.Role.TENANT_MANAGER
|
||||
|
||||
result = is_owner_or_manager(mock_user)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_for_staff(self):
|
||||
"""Should return False for staff role."""
|
||||
def test_returns_true_for_staff_with_permission(self):
|
||||
"""Should return True for staff with can_manage_users permission."""
|
||||
from smoothschedule.identity.core.api_views import is_owner_or_manager
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.role = User.Role.TENANT_STAFF
|
||||
mock_user.has_staff_permission.return_value = True
|
||||
|
||||
result = is_owner_or_manager(mock_user)
|
||||
|
||||
assert result is True
|
||||
mock_user.has_staff_permission.assert_called_once_with('can_manage_users')
|
||||
|
||||
def test_returns_false_for_staff_without_permission(self):
|
||||
"""Should return False for staff without can_manage_users permission."""
|
||||
from smoothschedule.identity.core.api_views import is_owner_or_manager
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.role = User.Role.TENANT_STAFF
|
||||
mock_user.has_staff_permission.return_value = False
|
||||
|
||||
result = is_owner_or_manager(mock_user)
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ class TestDenyStaffAllAccessPermission:
|
||||
request.method = 'GET'
|
||||
request.user = Mock()
|
||||
request.user.is_authenticated = True
|
||||
request.user.role = 'TENANT_MANAGER'
|
||||
request.user.role = 'TENANT_OWNER'
|
||||
|
||||
view = Mock()
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ class TestCanHijack:
|
||||
'PLATFORM_SUPPORT',
|
||||
'PLATFORM_SALES',
|
||||
'TENANT_OWNER',
|
||||
'TENANT_MANAGER',
|
||||
'TENANT_STAFF',
|
||||
'CUSTOMER',
|
||||
]
|
||||
@@ -70,7 +69,7 @@ class TestCanHijack:
|
||||
"""Should allow platform support to hijack tenant-level users."""
|
||||
hijacker = Mock(id=1, role='PLATFORM_SUPPORT')
|
||||
|
||||
allowed_roles = ['TENANT_OWNER', 'TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER']
|
||||
allowed_roles = ['TENANT_OWNER', 'TENANT_STAFF', 'CUSTOMER']
|
||||
|
||||
for role in allowed_roles:
|
||||
hijacked = Mock(id=2, role=role)
|
||||
@@ -105,7 +104,7 @@ class TestCanHijack:
|
||||
tenant = Mock(id=1)
|
||||
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant)
|
||||
|
||||
allowed_roles = ['TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER']
|
||||
allowed_roles = ['TENANT_STAFF', 'CUSTOMER']
|
||||
|
||||
for role in allowed_roles:
|
||||
hijacked = Mock(id=2, role=role, tenant=tenant)
|
||||
@@ -146,7 +145,7 @@ class TestCanHijack:
|
||||
|
||||
def test_other_roles_cannot_hijack(self):
|
||||
"""Should deny hijack for roles without permission."""
|
||||
forbidden_roles = ['TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER']
|
||||
forbidden_roles = ['TENANT_STAFF', 'CUSTOMER']
|
||||
|
||||
for role in forbidden_roles:
|
||||
hijacker = Mock(id=1, role=role)
|
||||
@@ -206,7 +205,6 @@ class TestGetHijackableUsers:
|
||||
hijacker = Mock(id=1, role='PLATFORM_SUPPORT')
|
||||
mock_user_model.Role.PLATFORM_SUPPORT = 'PLATFORM_SUPPORT'
|
||||
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER'
|
||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||
mock_user_model.Role.CUSTOMER = 'CUSTOMER'
|
||||
|
||||
@@ -223,7 +221,6 @@ class TestGetHijackableUsers:
|
||||
assert 'role__in' in filter_kwargs
|
||||
roles = filter_kwargs['role__in']
|
||||
assert 'TENANT_OWNER' in roles
|
||||
assert 'TENANT_MANAGER' in roles
|
||||
assert 'TENANT_STAFF' in roles
|
||||
assert 'CUSTOMER' in roles
|
||||
|
||||
@@ -249,7 +246,6 @@ class TestGetHijackableUsers:
|
||||
tenant = Mock(id=1)
|
||||
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant)
|
||||
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER'
|
||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||
mock_user_model.Role.CUSTOMER = 'CUSTOMER'
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ class UserAdmin(HijackUserAdminMixin, BaseUserAdmin):
|
||||
'PLATFORM_SALES': '#fbc02d', # Yellow
|
||||
'PLATFORM_SUPPORT': '#7cb342', # Light green
|
||||
'TENANT_OWNER': '#1976d2', # Blue
|
||||
'TENANT_MANAGER': '#0288d1', # Light blue
|
||||
'TENANT_STAFF': '#0097a7', # Cyan
|
||||
'CUSTOMER': '#5e35b1', # Purple
|
||||
}
|
||||
|
||||
@@ -130,8 +130,8 @@ def current_user_view(request):
|
||||
else:
|
||||
business_subdomain = user.tenant.schema_name
|
||||
|
||||
# Check for active quota overages (for owners and managers)
|
||||
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||
# Check for active quota overages (for owners and staff with management permissions)
|
||||
if user.role == User.Role.TENANT_OWNER or (user.role == User.Role.TENANT_STAFF and user.has_staff_permission('can_manage_users')):
|
||||
from smoothschedule.identity.core.quota_service import QuotaService
|
||||
try:
|
||||
service = QuotaService(user.tenant)
|
||||
@@ -153,10 +153,10 @@ def current_user_view(request):
|
||||
}
|
||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||
|
||||
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
|
||||
# Get linked resource info for tenant users (staff and owners can be linked to resources)
|
||||
linked_resource_id = None
|
||||
can_edit_schedule = False
|
||||
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
|
||||
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]:
|
||||
try:
|
||||
with schema_context(user.tenant.schema_name):
|
||||
linked_resource = Resource.objects.filter(user=user).first()
|
||||
@@ -183,6 +183,9 @@ def current_user_view(request):
|
||||
'business_name': business_name,
|
||||
'business_subdomain': business_subdomain,
|
||||
'permissions': user.permissions,
|
||||
'effective_permissions': user.get_effective_permissions(),
|
||||
'staff_role_id': user.staff_role_id,
|
||||
'staff_role_name': user.staff_role.name if user.staff_role else None,
|
||||
'can_invite_staff': user.can_invite_staff(),
|
||||
'can_access_tickets': user.can_access_tickets(),
|
||||
'can_send_messages': user.can_send_messages(),
|
||||
@@ -316,11 +319,11 @@ def _get_user_data(user):
|
||||
}
|
||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||
|
||||
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
|
||||
# Get linked resource info for tenant users (staff and owners can be linked to resources)
|
||||
linked_resource_id = None
|
||||
linked_resource_name = None
|
||||
can_edit_schedule = False
|
||||
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
|
||||
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]:
|
||||
try:
|
||||
with schema_context(user.tenant.schema_name):
|
||||
linked_resource = Resource.objects.filter(user=user).first()
|
||||
@@ -519,7 +522,6 @@ class StaffInvitationSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_role_display(self, obj):
|
||||
role_map = {
|
||||
'TENANT_MANAGER': 'Manager',
|
||||
'TENANT_STAFF': 'Staff',
|
||||
}
|
||||
return role_map.get(obj.role, obj.role)
|
||||
@@ -572,21 +574,13 @@ def staff_invitations_view(request):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Validate role - only allow manager and staff roles
|
||||
if role not in [User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF]:
|
||||
# Validate role - only allow staff role
|
||||
if role != User.Role.TENANT_STAFF:
|
||||
return Response(
|
||||
{"error": "Invalid role. Must be 'TENANT_MANAGER' or 'TENANT_STAFF'."},
|
||||
{"error": "Invalid role. Must be 'TENANT_STAFF'."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Managers can only invite staff, not other managers
|
||||
# TODO: Add owner control to allow/disallow managers inviting managers
|
||||
if user.role == User.Role.TENANT_MANAGER and role == User.Role.TENANT_MANAGER:
|
||||
return Response(
|
||||
{"error": "Managers can only invite staff members, not other managers."},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Check if user already exists in this tenant
|
||||
existing_user = User.objects.filter(
|
||||
email=email,
|
||||
@@ -708,7 +702,6 @@ def invitation_details_view(request, token):
|
||||
|
||||
# Return limited info for the acceptance page
|
||||
role_map = {
|
||||
'TENANT_MANAGER': 'Manager',
|
||||
'TENANT_STAFF': 'Staff',
|
||||
}
|
||||
|
||||
@@ -867,7 +860,6 @@ def _send_invitation_email(invitation):
|
||||
invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}"
|
||||
|
||||
role_map = {
|
||||
'TENANT_MANAGER': 'Manager',
|
||||
'TENANT_STAFF': 'Staff Member',
|
||||
}
|
||||
role_display = role_map.get(invitation.role, 'team member')
|
||||
|
||||
@@ -68,15 +68,6 @@ class Command(BaseCommand):
|
||||
'last_name': 'Owner',
|
||||
'tenant': demo_tenant,
|
||||
},
|
||||
{
|
||||
'username': 'manager@demo.com',
|
||||
'email': 'manager@demo.com',
|
||||
'password': 'test123',
|
||||
'role': User.Role.TENANT_MANAGER,
|
||||
'first_name': 'Business',
|
||||
'last_name': 'Manager',
|
||||
'tenant': demo_tenant,
|
||||
},
|
||||
{
|
||||
'username': 'staff@demo.com',
|
||||
'email': 'staff@demo.com',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Migration to remove TENANT_MANAGER role.
|
||||
|
||||
Converts all existing TENANT_MANAGER users to TENANT_STAFF with
|
||||
the 'Full Access Staff' role assigned.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_managers_to_staff(apps, schema_editor):
|
||||
"""
|
||||
Convert all TENANT_MANAGER users to TENANT_STAFF.
|
||||
Assign them the 'Full Access Staff' role for their tenant.
|
||||
"""
|
||||
User = apps.get_model('users', 'User')
|
||||
StaffRole = apps.get_model('users', 'StaffRole')
|
||||
|
||||
# Find all managers
|
||||
managers = User.objects.filter(role='TENANT_MANAGER')
|
||||
|
||||
for manager in managers:
|
||||
# Get the Full Access Staff role for this tenant
|
||||
full_access_role = StaffRole.objects.filter(
|
||||
tenant=manager.tenant,
|
||||
name='Full Access Staff'
|
||||
).first()
|
||||
|
||||
if full_access_role:
|
||||
manager.role = 'TENANT_STAFF'
|
||||
manager.staff_role = full_access_role
|
||||
manager.save(update_fields=['role', 'staff_role'])
|
||||
else:
|
||||
# If no Full Access Staff role exists, just convert to staff
|
||||
# They'll need to be assigned a role manually
|
||||
manager.role = 'TENANT_STAFF'
|
||||
manager.save(update_fields=['role'])
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""
|
||||
Reverse: Convert staff with Full Access role back to managers.
|
||||
Note: This is a best-effort reversal - we can't know for sure
|
||||
which staff were originally managers.
|
||||
"""
|
||||
User = apps.get_model('users', 'User')
|
||||
StaffRole = apps.get_model('users', 'StaffRole')
|
||||
|
||||
# Find all Full Access Staff roles
|
||||
full_access_roles = StaffRole.objects.filter(name='Full Access Staff')
|
||||
|
||||
for role in full_access_roles:
|
||||
# Convert users with this role back to managers
|
||||
User.objects.filter(
|
||||
role='TENANT_STAFF',
|
||||
staff_role=role
|
||||
).update(role='TENANT_MANAGER', staff_role=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0013_add_notes_to_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_managers_to_staff,
|
||||
reverse_code=reverse_migration,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-17 16:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0014_remove_tenant_manager_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='staffinvitation',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('TENANT_STAFF', 'Staff')], default='TENANT_STAFF', help_text='Role the invited user will have', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('SUPERUSER', 'Platform Superuser'), ('PLATFORM_MANAGER', 'Platform Manager'), ('PLATFORM_SALES', 'Platform Sales'), ('PLATFORM_SUPPORT', 'Platform Support'), ('TENANT_OWNER', 'Tenant Owner'), ('TENANT_STAFF', 'Tenant Staff'), ('CUSTOMER', 'Customer')], default='CUSTOMER', help_text="User's role in the system hierarchy", max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -26,7 +26,7 @@ class User(AbstractUser):
|
||||
|
||||
# Tenant-level roles (access within single tenant)
|
||||
TENANT_OWNER = 'TENANT_OWNER', _('Tenant Owner')
|
||||
TENANT_MANAGER = 'TENANT_MANAGER', _('Tenant Manager')
|
||||
# TENANT_MANAGER removed - use TENANT_STAFF with "Full Access Staff" role instead
|
||||
TENANT_STAFF = 'TENANT_STAFF', _('Tenant Staff')
|
||||
|
||||
# Customer role (end users of the tenant)
|
||||
@@ -199,19 +199,22 @@ class User(AbstractUser):
|
||||
"""Check if user is tenant-scoped"""
|
||||
return self.role in [
|
||||
self.Role.TENANT_OWNER,
|
||||
self.Role.TENANT_MANAGER,
|
||||
self.Role.TENANT_STAFF,
|
||||
self.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
def can_manage_users(self):
|
||||
"""Check if user can manage other users"""
|
||||
return self.role in [
|
||||
if self.role in [
|
||||
self.Role.SUPERUSER,
|
||||
self.Role.PLATFORM_MANAGER,
|
||||
self.Role.TENANT_OWNER,
|
||||
self.Role.TENANT_MANAGER,
|
||||
]
|
||||
]:
|
||||
return True
|
||||
# Staff can manage users if they have the permission
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
return self.has_staff_permission('can_manage_users')
|
||||
return False
|
||||
|
||||
def can_access_billing(self):
|
||||
"""Check if user can access billing information"""
|
||||
@@ -226,9 +229,9 @@ class User(AbstractUser):
|
||||
# Owners can always invite
|
||||
if self.role == self.Role.TENANT_OWNER:
|
||||
return True
|
||||
# Managers can invite if they have the permission
|
||||
if self.role == self.Role.TENANT_MANAGER:
|
||||
return self.permissions.get('can_invite_staff', False)
|
||||
# Staff can invite if they have the permission
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
return self.has_staff_permission('can_invite_staff')
|
||||
return False
|
||||
|
||||
def can_access_tickets(self):
|
||||
@@ -236,12 +239,12 @@ class User(AbstractUser):
|
||||
# Platform users can always access
|
||||
if self.is_platform_user():
|
||||
return True
|
||||
# Owners and managers can always access
|
||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
||||
# Owners can always access
|
||||
if self.role == self.Role.TENANT_OWNER:
|
||||
return True
|
||||
# Staff can access if granted permission (default: False)
|
||||
# Staff can access if granted permission via staff role
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
return self.permissions.get('can_access_tickets', False)
|
||||
return self.has_staff_permission('can_access_tickets')
|
||||
# Customers can create tickets
|
||||
if self.role == self.Role.CUSTOMER:
|
||||
return True
|
||||
@@ -280,41 +283,39 @@ class User(AbstractUser):
|
||||
"""
|
||||
Check if user can self-approve time off requests.
|
||||
Owners can always self-approve.
|
||||
Managers can self-approve by default but can be denied.
|
||||
Staff need explicit permission.
|
||||
Staff need explicit permission via staff role.
|
||||
"""
|
||||
# Owners can always self-approve
|
||||
if self.role == self.Role.TENANT_OWNER:
|
||||
return True
|
||||
# Managers can self-approve by default, but can be denied
|
||||
if self.role == self.Role.TENANT_MANAGER:
|
||||
return self.permissions.get('can_self_approve_time_off', True)
|
||||
# Staff can self-approve if granted permission (default: False)
|
||||
# Staff can self-approve if granted permission via staff role
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
return self.permissions.get('can_self_approve_time_off', False)
|
||||
return self.has_staff_permission('can_self_approve_time_off')
|
||||
return False
|
||||
|
||||
def can_review_time_off_requests(self):
|
||||
"""
|
||||
Check if user can review (approve/deny) time off requests from others.
|
||||
Only owners and managers can review.
|
||||
Owners can always review. Staff need explicit permission.
|
||||
"""
|
||||
return self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]
|
||||
if self.role == self.Role.TENANT_OWNER:
|
||||
return True
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
return self.has_staff_permission('can_review_time_off')
|
||||
return False
|
||||
|
||||
def can_send_messages(self):
|
||||
"""
|
||||
Check if user can send broadcast messages to staff/customers.
|
||||
Owners can always send messages.
|
||||
Managers can by default but can be revoked.
|
||||
Staff cannot send messages.
|
||||
Staff need explicit permission via staff role.
|
||||
"""
|
||||
# Owners can always send messages
|
||||
if self.role == self.Role.TENANT_OWNER:
|
||||
return True
|
||||
# Managers can send by default, but can be revoked
|
||||
if self.role == self.Role.TENANT_MANAGER:
|
||||
return self.permissions.get('can_send_messages', True)
|
||||
# Staff and others cannot send messages
|
||||
# Staff can send if they have the permission via staff role
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
return self.has_staff_permission('can_access_messages')
|
||||
return False
|
||||
|
||||
def has_staff_permission(self, permission_key):
|
||||
@@ -322,7 +323,7 @@ class User(AbstractUser):
|
||||
Check if staff member has a specific permission.
|
||||
|
||||
Permission Resolution Order:
|
||||
1. Owners and Managers always have all permissions (return True)
|
||||
1. Owners always have all permissions (return True)
|
||||
2. For staff: User-level override takes priority
|
||||
3. Then check staff role permissions
|
||||
4. Default: False
|
||||
@@ -333,8 +334,8 @@ class User(AbstractUser):
|
||||
Returns:
|
||||
bool: Whether the user has the permission
|
||||
"""
|
||||
# Owners and managers have all permissions
|
||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
||||
# Owners have all permissions
|
||||
if self.role == self.Role.TENANT_OWNER:
|
||||
return True
|
||||
|
||||
# For staff, check permissions
|
||||
@@ -356,8 +357,8 @@ class User(AbstractUser):
|
||||
Returns:
|
||||
dict: All effective permissions for this user
|
||||
"""
|
||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
||||
# Return all permissions as True for owner/manager
|
||||
if self.role == self.Role.TENANT_OWNER:
|
||||
# Return all permissions as True for owner
|
||||
from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS
|
||||
return {k: True for k in ALL_PERMISSIONS.keys()}
|
||||
|
||||
@@ -684,7 +685,7 @@ class StaffInvitation(models.Model):
|
||||
Invitation for new staff members to join a business.
|
||||
|
||||
Flow:
|
||||
1. Owner/Manager creates invitation with email and role
|
||||
1. Owner/Staff with invite permission creates invitation with email and staff role
|
||||
2. System sends email with unique token link
|
||||
3. Invitee clicks link, creates account, and is added to tenant
|
||||
"""
|
||||
@@ -701,7 +702,6 @@ class StaffInvitation(models.Model):
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
(User.Role.TENANT_MANAGER, _('Manager')),
|
||||
(User.Role.TENANT_STAFF, _('Staff')),
|
||||
],
|
||||
default=User.Role.TENANT_STAFF,
|
||||
@@ -833,7 +833,7 @@ class StaffInvitation(models.Model):
|
||||
|
||||
Args:
|
||||
email: Email address to invite
|
||||
role: Role for the invited user (TENANT_MANAGER or TENANT_STAFF)
|
||||
role: Role for the invited user (TENANT_STAFF)
|
||||
tenant: Tenant/business the user is being invited to
|
||||
invited_by: User sending the invitation
|
||||
create_bookable_resource: Whether to create a bookable resource when accepted
|
||||
|
||||
@@ -106,6 +106,86 @@ MENU_PERMISSIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
# Business Settings Permissions
|
||||
# These control access to individual settings pages
|
||||
SETTINGS_PERMISSIONS = {
|
||||
'can_access_settings': {
|
||||
'label': 'Access Settings',
|
||||
'description': 'View Business Settings menu (required for any settings access)',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_general': {
|
||||
'label': 'General Settings',
|
||||
'description': 'Business name, timezone, and basic configuration',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_business_hours': {
|
||||
'label': 'Business Hours',
|
||||
'description': 'Set regular operating hours',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_branding': {
|
||||
'label': 'Branding',
|
||||
'description': 'Logo, colors, and visual identity',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_booking': {
|
||||
'label': 'Booking Settings',
|
||||
'description': 'Booking policies and rules',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_communication': {
|
||||
'label': 'Communication',
|
||||
'description': 'Notification preferences and reminders',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_embed_widget': {
|
||||
'label': 'Embed Widget',
|
||||
'description': 'Configure booking widget for websites',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_email_templates': {
|
||||
'label': 'Email Templates',
|
||||
'description': 'Customize automated emails',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_staff_roles': {
|
||||
'label': 'Staff Roles',
|
||||
'description': 'Create and manage permission roles',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_resource_types': {
|
||||
'label': 'Resource Types',
|
||||
'description': 'Configure resource categories',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_api': {
|
||||
'label': 'API & Integrations',
|
||||
'description': 'Manage API tokens and webhooks',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_custom_domains': {
|
||||
'label': 'Custom Domains',
|
||||
'description': 'Configure custom domain settings',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_authentication': {
|
||||
'label': 'Authentication',
|
||||
'description': 'OAuth and social login configuration',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_email': {
|
||||
'label': 'Email Setup',
|
||||
'description': 'Configure email addresses for tickets',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_settings_sms_calling': {
|
||||
'label': 'SMS & Calling',
|
||||
'description': 'Manage credits and phone numbers',
|
||||
'default': False,
|
||||
},
|
||||
}
|
||||
|
||||
# Dangerous Operation Permissions
|
||||
# These control specific destructive or sensitive operations at the API level
|
||||
DANGEROUS_PERMISSIONS = {
|
||||
@@ -149,10 +229,20 @@ DANGEROUS_PERMISSIONS = {
|
||||
'description': 'Approve own time off requests without manager approval',
|
||||
'default': False,
|
||||
},
|
||||
'can_manage_users': {
|
||||
'label': 'Manage Users',
|
||||
'description': 'Invite and manage staff members',
|
||||
'default': False,
|
||||
},
|
||||
'can_review_time_off': {
|
||||
'label': 'Review Time Off',
|
||||
'description': 'Approve or deny time off requests from other staff',
|
||||
'default': False,
|
||||
},
|
||||
}
|
||||
|
||||
# All permissions combined for easy iteration
|
||||
ALL_PERMISSIONS = {**MENU_PERMISSIONS, **DANGEROUS_PERMISSIONS}
|
||||
ALL_PERMISSIONS = {**MENU_PERMISSIONS, **SETTINGS_PERMISSIONS, **DANGEROUS_PERMISSIONS}
|
||||
|
||||
|
||||
def get_default_permissions_for_role(role_name: str) -> dict:
|
||||
|
||||
@@ -189,7 +189,6 @@ class TestGetUserData:
|
||||
(User.Role.PLATFORM_SALES, 'platform_sales'),
|
||||
(User.Role.PLATFORM_SUPPORT, 'platform_support'),
|
||||
(User.Role.TENANT_OWNER, 'owner'),
|
||||
(User.Role.TENANT_MANAGER, 'manager'),
|
||||
(User.Role.TENANT_STAFF, 'staff'),
|
||||
(User.Role.CUSTOMER, 'customer'),
|
||||
]
|
||||
@@ -1104,29 +1103,6 @@ class TestStaffInvitationsView:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Invalid role' in response.data['error']
|
||||
|
||||
@patch('smoothschedule.identity.users.api_views.User')
|
||||
def test_post_manager_cannot_invite_manager(self, mock_user_model):
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/api/staff/invitations/', {
|
||||
'email': 'manager@test.com',
|
||||
'role': User.Role.TENANT_MANAGER
|
||||
})
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.can_invite_staff.return_value = True
|
||||
mock_user.tenant = mock_tenant
|
||||
mock_user.role = User.Role.TENANT_MANAGER
|
||||
request.user = mock_user
|
||||
|
||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||
|
||||
response = api_views.staff_invitations_view(request)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'Managers can only invite staff' in response.data['error']
|
||||
|
||||
@patch('smoothschedule.identity.users.api_views.User')
|
||||
def test_post_rejects_existing_user(self, mock_user_model):
|
||||
factory = APIRequestFactory()
|
||||
@@ -1142,7 +1118,6 @@ class TestStaffInvitationsView:
|
||||
mock_user.role = User.Role.TENANT_OWNER
|
||||
request.user = mock_user
|
||||
|
||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||
|
||||
# User already exists
|
||||
@@ -1178,7 +1153,6 @@ class TestStaffInvitationsView:
|
||||
mock_user.role = User.Role.TENANT_OWNER
|
||||
request.user = mock_user
|
||||
|
||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||
mock_user_model.objects.filter.return_value.first.return_value = None
|
||||
|
||||
|
||||
@@ -80,9 +80,6 @@ class TestRoleClassification:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.is_tenant_user() is True
|
||||
|
||||
def test_is_tenant_user_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.is_tenant_user() is True
|
||||
|
||||
def test_is_tenant_user_returns_true_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -116,9 +113,6 @@ class TestCanManageUsers:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_manage_users() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_manage_users() is True
|
||||
|
||||
def test_returns_false_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -148,9 +142,6 @@ class TestCanAccessBilling:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_access_billing() is True
|
||||
|
||||
def test_returns_false_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_access_billing() is False
|
||||
|
||||
def test_returns_false_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -168,17 +159,6 @@ class TestCanInviteStaff:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_invite_staff() is True
|
||||
|
||||
def test_returns_true_for_manager_with_permission(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': True})
|
||||
assert user.can_invite_staff() is True
|
||||
|
||||
def test_returns_false_for_manager_without_permission(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_invite_staff() is False
|
||||
|
||||
def test_returns_false_for_manager_with_explicit_false_permission(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': False})
|
||||
assert user.can_invite_staff() is False
|
||||
|
||||
def test_returns_false_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -204,9 +184,6 @@ class TestCanAccessTickets:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_access_tickets() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_access_tickets() is True
|
||||
|
||||
def test_returns_true_for_staff_with_permission(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_access_tickets': True})
|
||||
@@ -292,9 +269,6 @@ class TestCanSelfApproveTimeOff:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_self_approve_time_off() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_self_approve_time_off() is True
|
||||
|
||||
def test_returns_true_for_staff_with_permission(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_self_approve_time_off': True})
|
||||
@@ -320,9 +294,6 @@ class TestCanReviewTimeOffRequests:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_review_time_off_requests() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_review_time_off_requests() is True
|
||||
|
||||
def test_returns_false_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -579,16 +550,6 @@ class TestUserCanSendMessages:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_send_messages() is True
|
||||
|
||||
def test_manager_can_send_by_default(self):
|
||||
"""Tenant manager can send messages by default."""
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_send_messages() is True
|
||||
|
||||
def test_manager_cannot_send_when_revoked(self):
|
||||
"""Tenant manager cannot send when permission revoked."""
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
user.permissions = {'can_send_messages': False}
|
||||
assert user.can_send_messages() is False
|
||||
|
||||
def test_staff_cannot_send_messages(self):
|
||||
"""Staff should not be able to send messages."""
|
||||
|
||||
@@ -68,19 +68,7 @@ class TestUserHasStaffPermission:
|
||||
mock_user.role = 'TENANT_OWNER'
|
||||
|
||||
# Simulate the has_staff_permission logic
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
result = True
|
||||
else:
|
||||
result = False
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_manager_always_has_permission(self):
|
||||
"""Managers have all permissions"""
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'TENANT_MANAGER'
|
||||
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
if mock_user.role == 'TENANT_OWNER':
|
||||
result = True
|
||||
else:
|
||||
result = False
|
||||
@@ -97,7 +85,7 @@ class TestUserHasStaffPermission:
|
||||
|
||||
# Simulate permission resolution
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
if mock_user.role == 'TENANT_OWNER':
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
@@ -120,7 +108,7 @@ class TestUserHasStaffPermission:
|
||||
mock_user.staff_role.permissions = {'can_access_scheduler': True}
|
||||
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
if mock_user.role == 'TENANT_OWNER':
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
@@ -142,7 +130,7 @@ class TestUserHasStaffPermission:
|
||||
mock_user.staff_role = None
|
||||
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
if mock_user.role == 'TENANT_OWNER':
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
@@ -163,7 +151,7 @@ class TestUserHasStaffPermission:
|
||||
mock_user.permissions = {'can_access_scheduler': True} # Even if set
|
||||
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
if mock_user.role == 'TENANT_OWNER':
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
|
||||
@@ -71,9 +71,6 @@ class TestIsPlatformUser:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.is_platform_user() is False
|
||||
|
||||
def test_returns_false_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.is_platform_user() is False
|
||||
|
||||
def test_returns_false_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -107,9 +104,6 @@ class TestIsTenantUser:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.is_tenant_user() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.is_tenant_user() is True
|
||||
|
||||
def test_returns_true_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -139,9 +133,6 @@ class TestCanManageUsers:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_manage_users() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_manage_users() is True
|
||||
|
||||
def test_returns_false_for_platform_sales(self):
|
||||
user = create_user_instance(User.Role.PLATFORM_SALES)
|
||||
@@ -187,9 +178,6 @@ class TestCanAccessBilling:
|
||||
user = create_user_instance(User.Role.PLATFORM_SUPPORT)
|
||||
assert user.can_access_billing() is False
|
||||
|
||||
def test_returns_false_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_access_billing() is False
|
||||
|
||||
def test_returns_false_for_tenant_staff(self):
|
||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||
@@ -211,23 +199,6 @@ class TestCanInviteStaff:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_invite_staff() is True
|
||||
|
||||
def test_returns_true_for_manager_with_permission(self):
|
||||
user = create_user_instance(
|
||||
User.Role.TENANT_MANAGER,
|
||||
permissions={'can_invite_staff': True}
|
||||
)
|
||||
assert user.can_invite_staff() is True
|
||||
|
||||
def test_returns_false_for_manager_without_permission(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_invite_staff() is False
|
||||
|
||||
def test_returns_false_for_manager_with_explicit_false_permission(self):
|
||||
user = create_user_instance(
|
||||
User.Role.TENANT_MANAGER,
|
||||
permissions={'can_invite_staff': False}
|
||||
)
|
||||
assert user.can_invite_staff() is False
|
||||
|
||||
def test_returns_false_for_superuser(self):
|
||||
user = create_user_instance(User.Role.SUPERUSER)
|
||||
@@ -273,9 +244,6 @@ class TestCanAccessTickets:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_access_tickets() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_access_tickets() is True
|
||||
|
||||
def test_returns_true_for_staff_with_permission(self):
|
||||
user = create_user_instance(
|
||||
@@ -341,9 +309,6 @@ class TestCanApprovePlugins:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_approve_plugins() is False
|
||||
|
||||
def test_returns_false_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_approve_plugins() is False
|
||||
|
||||
def test_returns_false_for_customer(self):
|
||||
user = create_user_instance(User.Role.CUSTOMER)
|
||||
@@ -407,9 +372,6 @@ class TestCanSelfApproveTimeOff:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_self_approve_time_off() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_self_approve_time_off() is True
|
||||
|
||||
def test_returns_true_for_staff_with_permission(self):
|
||||
user = create_user_instance(
|
||||
@@ -449,9 +411,6 @@ class TestCanReviewTimeOffRequests:
|
||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||
assert user.can_review_time_off_requests() is True
|
||||
|
||||
def test_returns_true_for_tenant_manager(self):
|
||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
||||
assert user.can_review_time_off_requests() is True
|
||||
|
||||
def test_returns_false_for_superuser(self):
|
||||
user = create_user_instance(User.Role.SUPERUSER)
|
||||
|
||||
@@ -172,7 +172,7 @@ class APITokenViewSet(viewsets.ViewSet):
|
||||
self._check_api_access_permission(tenant)
|
||||
|
||||
# Only owners can manage API tokens (roles are uppercase in DB)
|
||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER']
|
||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER']
|
||||
if user.role.upper() not in allowed_roles:
|
||||
return Response(
|
||||
{'error': 'forbidden', 'message': 'Only business owners can manage API tokens'},
|
||||
@@ -200,7 +200,7 @@ class APITokenViewSet(viewsets.ViewSet):
|
||||
# Check API access permission
|
||||
self._check_api_access_permission(tenant)
|
||||
|
||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER']
|
||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER']
|
||||
if user.role.upper() not in allowed_roles:
|
||||
return Response(
|
||||
{'error': 'forbidden', 'message': 'Only business owners can create API tokens'},
|
||||
@@ -261,7 +261,7 @@ class APITokenViewSet(viewsets.ViewSet):
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER']
|
||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER']
|
||||
if user.role.upper() not in allowed_roles:
|
||||
return Response(
|
||||
{'error': 'forbidden', 'message': 'Only business owners can revoke API tokens'},
|
||||
|
||||
@@ -229,13 +229,13 @@ class Command(BaseCommand):
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {owner.email} (Owner)")
|
||||
|
||||
# Manager
|
||||
# Manager (now TENANT_STAFF with Full Access Staff role)
|
||||
manager_data = {
|
||||
"username": "manager@demo.com",
|
||||
"email": "manager@demo.com",
|
||||
"first_name": "Marcus",
|
||||
"last_name": "Chen",
|
||||
"role": User.Role.TENANT_MANAGER,
|
||||
"role": User.Role.TENANT_STAFF,
|
||||
"tenant": tenant,
|
||||
"phone": "555-100-0002",
|
||||
}
|
||||
@@ -246,10 +246,18 @@ class Command(BaseCommand):
|
||||
if created:
|
||||
manager.set_password("test123")
|
||||
manager.save()
|
||||
# Assign Full Access Staff role
|
||||
full_access_role = StaffRole.objects.filter(
|
||||
tenant=tenant,
|
||||
name="Full Access Staff"
|
||||
).first()
|
||||
if full_access_role and manager.staff_role != full_access_role:
|
||||
manager.staff_role = full_access_role
|
||||
manager.save(update_fields=['staff_role'])
|
||||
users["manager"] = manager
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {manager.email} (Manager)")
|
||||
self.stdout.write(f" {status} {manager.email} (Full Access Staff)")
|
||||
|
||||
# Staff members (stylists and spa therapists)
|
||||
staff_data = [
|
||||
|
||||
@@ -21,7 +21,7 @@ from django.utils import timezone
|
||||
from django_tenants.utils import schema_context, tenant_context
|
||||
|
||||
from smoothschedule.identity.core.models import Tenant, Domain
|
||||
from smoothschedule.identity.users.models import User
|
||||
from smoothschedule.identity.users.models import User, StaffRole
|
||||
from smoothschedule.scheduling.schedule.models import (
|
||||
Event,
|
||||
Participant,
|
||||
@@ -254,11 +254,12 @@ class Command(BaseCommand):
|
||||
"username": "manager@demo.com",
|
||||
"email": "manager@demo.com",
|
||||
"password": "test123",
|
||||
"role": User.Role.TENANT_MANAGER,
|
||||
"role": User.Role.TENANT_STAFF,
|
||||
"first_name": "Business",
|
||||
"last_name": "Manager",
|
||||
"tenant": tenant,
|
||||
"phone": "555-100-0002",
|
||||
"_assign_full_access": True, # Flag to assign Full Access Staff role
|
||||
},
|
||||
{
|
||||
"username": "staff@demo.com",
|
||||
@@ -273,8 +274,10 @@ class Command(BaseCommand):
|
||||
]
|
||||
|
||||
created_users = {}
|
||||
manager_user = None # Track manager user separately
|
||||
for user_data in tenant_users:
|
||||
password = user_data.pop("password")
|
||||
assign_full_access = user_data.pop("_assign_full_access", False)
|
||||
user, created = User.objects.get_or_create(
|
||||
username=user_data["username"],
|
||||
defaults=user_data,
|
||||
@@ -285,9 +288,24 @@ class Command(BaseCommand):
|
||||
status = self.style.SUCCESS("CREATED")
|
||||
else:
|
||||
status = self.style.WARNING("EXISTS")
|
||||
|
||||
# Assign Full Access Staff role if flagged
|
||||
if assign_full_access:
|
||||
full_access_role = StaffRole.objects.filter(
|
||||
tenant=tenant,
|
||||
name="Full Access Staff"
|
||||
).first()
|
||||
if full_access_role and user.staff_role != full_access_role:
|
||||
user.staff_role = full_access_role
|
||||
user.save(update_fields=['staff_role'])
|
||||
manager_user = user # Track for resource creation
|
||||
|
||||
self.stdout.write(f" {status} {user.email} ({user.get_role_display()})")
|
||||
created_users[user_data["role"]] = user
|
||||
|
||||
# Store manager user under a special key for backward compatibility
|
||||
created_users["_manager"] = manager_user
|
||||
|
||||
return created_users
|
||||
|
||||
def create_resource_types(self):
|
||||
@@ -405,7 +423,7 @@ class Command(BaseCommand):
|
||||
},
|
||||
{
|
||||
"name": "Business Manager",
|
||||
"user": tenant_users.get(User.Role.TENANT_MANAGER),
|
||||
"user": tenant_users.get("_manager"),
|
||||
"description": "Business manager - handles VIP appointments",
|
||||
"resource_type": staff_type,
|
||||
"type": Resource.Type.STAFF,
|
||||
|
||||
@@ -172,9 +172,9 @@ class CustomerSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
|
||||
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
||||
'user_id', 'user_data', 'notes',
|
||||
'user_id', 'user_data', 'notes', 'email_verified',
|
||||
]
|
||||
read_only_fields = ['id']
|
||||
read_only_fields = ['id', 'email_verified']
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a customer with email as username"""
|
||||
@@ -260,8 +260,9 @@ class StaffSerializer(serializers.ModelSerializer):
|
||||
'id', 'username', 'name', 'email', 'phone', 'role',
|
||||
'is_active', 'permissions', 'can_invite_staff',
|
||||
'staff_role_id', 'staff_role_name', 'effective_permissions',
|
||||
'email_verified',
|
||||
]
|
||||
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff', 'effective_permissions']
|
||||
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff', 'effective_permissions', 'email_verified']
|
||||
|
||||
def get_name(self, obj):
|
||||
return obj.full_name
|
||||
@@ -270,7 +271,6 @@ class StaffSerializer(serializers.ModelSerializer):
|
||||
# Map database roles to frontend roles
|
||||
role_mapping = {
|
||||
'TENANT_OWNER': 'owner',
|
||||
'TENANT_MANAGER': 'manager',
|
||||
'TENANT_STAFF': 'staff',
|
||||
}
|
||||
return role_mapping.get(obj.role, obj.role.lower())
|
||||
|
||||
@@ -675,13 +675,26 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
|
||||
f"for resource '{instance.resource.name}'"
|
||||
)
|
||||
|
||||
# Find all managers and owners to notify
|
||||
# Find all users who can review time off requests to notify
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
# Get owners (always have permission) + staff with can_review_time_off permission
|
||||
reviewers = User.objects.filter(
|
||||
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
||||
role=User.Role.TENANT_OWNER,
|
||||
is_active=True
|
||||
)
|
||||
# Also include staff who have the permission (via role or override)
|
||||
# Note: This is a simplified query - for proper permission checking,
|
||||
# we'd need to check each staff's effective_permissions
|
||||
staff_reviewers = User.objects.filter(
|
||||
role=User.Role.TENANT_STAFF,
|
||||
is_active=True
|
||||
)
|
||||
# Filter staff to those who can review time off
|
||||
reviewers = list(reviewers) + [
|
||||
staff for staff in staff_reviewers
|
||||
if staff.has_staff_permission('can_review_time_off')
|
||||
]
|
||||
|
||||
# Create in-app notifications for each reviewer
|
||||
for reviewer in reviewers:
|
||||
|
||||
@@ -193,19 +193,6 @@ class TestStaffSerializer:
|
||||
# Assert
|
||||
assert role == 'owner'
|
||||
|
||||
def test_get_role_maps_tenant_manager(self):
|
||||
"""Test that TENANT_MANAGER maps to manager."""
|
||||
# Arrange
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'TENANT_MANAGER'
|
||||
|
||||
serializer = StaffSerializer()
|
||||
|
||||
# Act
|
||||
role = serializer.get_role(mock_user)
|
||||
|
||||
# Assert
|
||||
assert role == 'manager'
|
||||
|
||||
def test_get_role_maps_tenant_staff(self):
|
||||
"""Test that TENANT_STAFF maps to staff."""
|
||||
@@ -1682,14 +1669,6 @@ class TestStaffSerializerMethodFields:
|
||||
result = serializer.get_name(mock_obj)
|
||||
assert result == 'Jane Smith'
|
||||
|
||||
def test_get_role_maps_tenant_manager_to_manager(self):
|
||||
"""Test get_role maps TENANT_MANAGER to manager."""
|
||||
serializer = StaffSerializer()
|
||||
mock_obj = Mock()
|
||||
mock_obj.role = 'TENANT_MANAGER'
|
||||
|
||||
result = serializer.get_role(mock_obj)
|
||||
assert result == 'manager'
|
||||
|
||||
|
||||
class TestResourceSerializerFields:
|
||||
|
||||
@@ -159,10 +159,15 @@ class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||
This endpoint provides the frontend with the full list of permission
|
||||
keys that can be configured on a staff role.
|
||||
"""
|
||||
from smoothschedule.identity.users.staff_permissions import MENU_PERMISSIONS, DANGEROUS_PERMISSIONS
|
||||
from smoothschedule.identity.users.staff_permissions import (
|
||||
MENU_PERMISSIONS,
|
||||
SETTINGS_PERMISSIONS,
|
||||
DANGEROUS_PERMISSIONS,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'menu_permissions': MENU_PERMISSIONS,
|
||||
'settings_permissions': SETTINGS_PERMISSIONS,
|
||||
'dangerous_permissions': DANGEROUS_PERMISSIONS,
|
||||
})
|
||||
|
||||
@@ -769,6 +774,21 @@ class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def verify_email(self, request, pk=None):
|
||||
"""Toggle a customer's email verification status."""
|
||||
customer = self.get_object()
|
||||
|
||||
customer.email_verified = not customer.email_verified
|
||||
customer.save(update_fields=['email_verified'])
|
||||
|
||||
action = 'verified' if customer.email_verified else 'unverified'
|
||||
return Response({
|
||||
'id': customer.id,
|
||||
'email_verified': customer.email_verified,
|
||||
'message': f'Email {action} successfully.'
|
||||
})
|
||||
|
||||
|
||||
class ServiceViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
@@ -828,7 +848,7 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing staff members (Users who can be assigned to resources).
|
||||
|
||||
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||
Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF.
|
||||
|
||||
Supports:
|
||||
- GET /api/staff/ - List staff members
|
||||
@@ -851,14 +871,13 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
Return staff members for the current tenant.
|
||||
|
||||
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||
Staff are Users with roles: TENANT_OWNER, TENANT_STAFF.
|
||||
"""
|
||||
from django.db.models import Q
|
||||
|
||||
# Set base queryset to staff roles only
|
||||
self.queryset = User.objects.filter(
|
||||
Q(role=User.Role.TENANT_OWNER) |
|
||||
Q(role=User.Role.TENANT_MANAGER) |
|
||||
Q(role=User.Role.TENANT_STAFF)
|
||||
)
|
||||
|
||||
@@ -890,24 +909,31 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
Update staff member.
|
||||
|
||||
Allowed fields: is_active, permissions
|
||||
Allowed fields: is_active, permissions, staff_role_id, first_name, last_name, phone
|
||||
|
||||
Owners can edit any staff member.
|
||||
Managers can only edit staff (not other managers or owners).
|
||||
Staff with can_access_staff permission can edit other staff (not owners).
|
||||
"""
|
||||
instance = self.get_object()
|
||||
|
||||
# TODO: Add permission checks when authentication is enabled
|
||||
# current_user = request.user
|
||||
# if current_user.role == User.Role.TENANT_MANAGER:
|
||||
# if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||
# return Response(
|
||||
# {'error': 'Managers cannot edit owners or other managers.'},
|
||||
# status=status.HTTP_403_FORBIDDEN
|
||||
# )
|
||||
# Permission check: staff can only edit other staff, not owners
|
||||
current_user = request.user
|
||||
if current_user.role == User.Role.TENANT_STAFF:
|
||||
# Staff can only edit if they have can_access_staff permission
|
||||
if not current_user.has_staff_permission('can_access_staff'):
|
||||
return Response(
|
||||
{'error': 'You do not have permission to edit staff members.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
# Staff cannot edit owner accounts
|
||||
if instance.role == User.Role.TENANT_OWNER:
|
||||
return Response(
|
||||
{'error': 'You cannot edit owner accounts.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Only allow updating specific fields
|
||||
allowed_fields = {'is_active', 'permissions'}
|
||||
allowed_fields = {'is_active', 'permissions', 'staff_role_id', 'first_name', 'last_name', 'phone'}
|
||||
update_data = {k: v for k, v in request.data.items() if k in allowed_fields}
|
||||
|
||||
serializer = self.get_serializer(instance, data=update_data, partial=True)
|
||||
@@ -938,6 +964,98 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
||||
'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully."
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def verify_email(self, request, pk=None):
|
||||
"""Toggle a staff member's email verification status."""
|
||||
staff = self.get_object()
|
||||
|
||||
staff.email_verified = not staff.email_verified
|
||||
staff.save(update_fields=['email_verified'])
|
||||
|
||||
action = 'verified' if staff.email_verified else 'unverified'
|
||||
return Response({
|
||||
'id': staff.id,
|
||||
'email_verified': staff.email_verified,
|
||||
'message': f'Email {action} successfully.'
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def send_password_reset(self, request, pk=None):
|
||||
"""
|
||||
Send a password reset email to the staff member.
|
||||
|
||||
Owners or staff with can_access_staff permission can trigger password resets.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from smoothschedule.communication.messaging.email_service import send_plain_email
|
||||
import secrets
|
||||
|
||||
# Only owners or staff with can_access_staff permission can send password resets
|
||||
can_manage = (
|
||||
request.user.role == User.Role.TENANT_OWNER or
|
||||
request.user.has_staff_permission('can_access_staff')
|
||||
)
|
||||
if not can_manage:
|
||||
return Response(
|
||||
{'error': 'You do not have permission to reset passwords.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
staff = self.get_object()
|
||||
|
||||
# Generate a secure random password
|
||||
temp_password = secrets.token_urlsafe(12)
|
||||
|
||||
# Set the temporary password
|
||||
staff.set_password(temp_password)
|
||||
staff.save(update_fields=['password'])
|
||||
|
||||
# Build login URL
|
||||
port = ':5173' if settings.DEBUG else ''
|
||||
subdomain = ''
|
||||
if staff.tenant:
|
||||
primary_domain = staff.tenant.domains.filter(is_primary=True).first()
|
||||
if primary_domain:
|
||||
subdomain = primary_domain.domain.split('.')[0] + '.'
|
||||
|
||||
base_domain = 'lvh.me' if settings.DEBUG else 'smoothschedule.com'
|
||||
login_url = f"https://{subdomain}{base_domain}{port}/login"
|
||||
|
||||
# Send email
|
||||
subject = "Password Reset - SmoothSchedule"
|
||||
message = f"""Hi {staff.full_name},
|
||||
|
||||
Your password has been reset by the business owner.
|
||||
|
||||
Your temporary password is: {temp_password}
|
||||
|
||||
Please log in at {login_url} and change your password immediately.
|
||||
|
||||
If you did not expect this email, please contact your business administrator.
|
||||
|
||||
Thanks,
|
||||
The SmoothSchedule Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_plain_email(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||
[staff.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to send password reset email: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return Response({
|
||||
'id': staff.id,
|
||||
'message': f'Password reset email sent to {staff.email}.'
|
||||
})
|
||||
|
||||
|
||||
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user