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 isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api' && currentHostname !== baseDomain;
|
||||||
|
|
||||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||||
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
|
const isBusinessUser = ['owner', 'staff', 'resource'].includes(user.role);
|
||||||
const isCustomer = user.role === 'customer';
|
const isCustomer = user.role === 'customer';
|
||||||
|
|
||||||
// RULE: Platform users on business subdomains should be redirected to platform subdomain
|
// RULE: Platform users on business subdomains should be redirected to platform subdomain
|
||||||
@@ -510,6 +510,15 @@ const AppContent: React.FC = () => {
|
|||||||
// Helper to check access based on roles
|
// Helper to check access based on roles
|
||||||
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
||||||
|
|
||||||
|
// Helper to check permission-based access (owner always has access, staff uses effective_permissions)
|
||||||
|
const canAccess = (permissionKey: string): boolean => {
|
||||||
|
if (user.role === 'owner') return true;
|
||||||
|
if (user.role === 'staff') {
|
||||||
|
return user.effective_permissions?.[permissionKey] === true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
if (isPlatformUser) {
|
if (isPlatformUser) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
@@ -658,8 +667,8 @@ const AppContent: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business users (owner, manager, staff, resource)
|
// Business users (owner, staff, resource)
|
||||||
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
|
if (['owner', 'staff', 'resource'].includes(user.role)) {
|
||||||
// Check if email verification is required
|
// Check if email verification is required
|
||||||
if (!user.email_verified) {
|
if (!user.email_verified) {
|
||||||
return (
|
return (
|
||||||
@@ -799,7 +808,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/automations/marketplace"
|
path="/dashboard/automations/marketplace"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_automations') ? (
|
||||||
<AutomationMarketplace />
|
<AutomationMarketplace />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -809,7 +818,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/automations/my-automations"
|
path="/dashboard/automations/my-automations"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_automations') ? (
|
||||||
<MyAutomations />
|
<MyAutomations />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -819,7 +828,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/automations/create"
|
path="/dashboard/automations/create"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_automations') ? (
|
||||||
<CreateAutomation />
|
<CreateAutomation />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -829,7 +838,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/tasks"
|
path="/dashboard/tasks"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_tasks') ? (
|
||||||
<Tasks />
|
<Tasks />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -841,7 +850,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/customers"
|
path="/dashboard/customers"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_customers') ? (
|
||||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -851,7 +860,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/services"
|
path="/dashboard/services"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_services') ? (
|
||||||
<Services />
|
<Services />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -861,7 +870,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/resources"
|
path="/dashboard/resources"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_resources') ? (
|
||||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -871,7 +880,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/staff"
|
path="/dashboard/staff"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_staff') ? (
|
||||||
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -881,7 +890,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/time-blocks"
|
path="/dashboard/time-blocks"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_time_blocks') ? (
|
||||||
<TimeBlocks />
|
<TimeBlocks />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -891,7 +900,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/locations"
|
path="/dashboard/locations"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_locations') ? (
|
||||||
<Locations />
|
<Locations />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -911,7 +920,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/contracts"
|
path="/dashboard/contracts"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
canAccess('can_access_contracts') && canUse('contracts') ? (
|
||||||
<Contracts />
|
<Contracts />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -921,7 +930,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/contracts/templates"
|
path="/dashboard/contracts/templates"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
canAccess('can_access_contracts') && canUse('contracts') ? (
|
||||||
<ContractTemplates />
|
<ContractTemplates />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -931,13 +940,13 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/payments"
|
path="/dashboard/payments"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
|
canAccess('can_access_payments') ? <Payments /> : <Navigate to="/dashboard" />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/messages"
|
path="/dashboard/messages"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
|
canAccess('can_access_messages') ? (
|
||||||
<Messages />
|
<Messages />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -947,7 +956,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/site-editor"
|
path="/dashboard/site-editor"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_site_editor') ? (
|
||||||
<PageEditor />
|
<PageEditor />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -967,7 +976,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/dashboard/gallery"
|
path="/dashboard/gallery"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
canAccess('can_access_gallery') ? (
|
||||||
<MediaGalleryPage />
|
<MediaGalleryPage />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/dashboard" />
|
<Navigate to="/dashboard" />
|
||||||
@@ -975,7 +984,8 @@ const AppContent: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Settings Routes with Nested Layout */}
|
{/* Settings Routes with Nested Layout */}
|
||||||
{hasAccess(['owner']) ? (
|
{/* Owners have full access, staff need can_access_settings permission */}
|
||||||
|
{canAccess('can_access_settings') ? (
|
||||||
<Route path="/dashboard/settings" element={<SettingsLayout />}>
|
<Route path="/dashboard/settings" element={<SettingsLayout />}>
|
||||||
<Route index element={<Navigate to="/dashboard/settings/general" replace />} />
|
<Route index element={<Navigate to="/dashboard/settings/general" replace />} />
|
||||||
<Route path="general" element={<GeneralSettings />} />
|
<Route path="general" element={<GeneralSettings />} />
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export interface User {
|
|||||||
business_name?: string;
|
business_name?: string;
|
||||||
business_subdomain?: string;
|
business_subdomain?: string;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
|
effective_permissions?: Record<string, boolean>;
|
||||||
|
staff_role_id?: number | null;
|
||||||
|
staff_role_name?: string | null;
|
||||||
can_invite_staff?: boolean;
|
can_invite_staff?: boolean;
|
||||||
can_access_tickets?: boolean;
|
can_access_tickets?: boolean;
|
||||||
can_edit_schedule?: boolean;
|
can_edit_schedule?: boolean;
|
||||||
|
|||||||
@@ -55,18 +55,18 @@ const testUsers: TestUser[] = [
|
|||||||
category: 'business',
|
category: 'business',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: 'manager@demo.com',
|
email: 'staff@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_MANAGER',
|
role: 'TENANT_STAFF',
|
||||||
label: 'Business Manager',
|
label: 'Staff (Full Access)',
|
||||||
color: 'bg-pink-600 hover:bg-pink-700',
|
color: 'bg-pink-600 hover:bg-pink-700',
|
||||||
category: 'business',
|
category: 'business',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: 'staff@demo.com',
|
email: 'limited-staff@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_STAFF',
|
role: 'TENANT_STAFF',
|
||||||
label: 'Staff Member',
|
label: 'Staff (Limited)',
|
||||||
color: 'bg-teal-600 hover:bg-teal-700',
|
color: 'bg-teal-600 hover:bg-teal-700',
|
||||||
category: 'business',
|
category: 'business',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
const { canUse } = usePlanFeatures();
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
// Helper to check if user has a specific staff permission
|
// Helper to check if user has a specific staff permission
|
||||||
// Owners and managers always have all permissions
|
// Owners always have all permissions
|
||||||
// Staff members check their effective_permissions (role + user overrides)
|
// Staff members check their effective_permissions (role + user overrides)
|
||||||
const hasPermission = (permissionKey: string): boolean => {
|
const hasPermission = (permissionKey: string): boolean => {
|
||||||
if (role === 'owner' || role === 'manager') {
|
if (role === 'owner') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (role === 'staff') {
|
if (role === 'staff') {
|
||||||
@@ -59,10 +59,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
// Admin/management access is based on effective permissions for staff
|
||||||
const canViewManagementPages = role === 'owner' || role === 'manager';
|
const canViewAdminPages = role === 'owner' || hasPermission('can_access_staff');
|
||||||
|
const canViewManagementPages = role === 'owner' || hasPermission('can_access_scheduler');
|
||||||
const isStaff = role === 'staff';
|
const isStaff = role === 'staff';
|
||||||
const canViewSettings = role === 'owner';
|
const canViewSettings = role === 'owner' || hasPermission('can_access_settings');
|
||||||
const canViewTickets = hasPermission('can_access_tickets');
|
const canViewTickets = hasPermission('can_access_tickets');
|
||||||
const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true;
|
const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true;
|
||||||
|
|
||||||
@@ -191,7 +192,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
icon={Users}
|
icon={Users}
|
||||||
label={t('nav.customers')}
|
label={t('nav.customers')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
badgeElement={<UnfinishedBadge />}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasPermission('can_access_services') && (
|
{hasPermission('can_access_services') && (
|
||||||
@@ -216,7 +216,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
icon={Users}
|
icon={Users}
|
||||||
label={t('nav.staff')}
|
label={t('nav.staff')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
badgeElement={<UnfinishedBadge />}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasPermission('can_access_contracts') && canUse('contracts') && (
|
{hasPermission('can_access_contracts') && canUse('contracts') && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
export interface PermissionConfig {
|
export interface PermissionConfig {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -8,20 +9,134 @@ export interface PermissionConfig {
|
|||||||
hintKey: string;
|
hintKey: string;
|
||||||
hintDefault: string;
|
hintDefault: string;
|
||||||
defaultValue: boolean;
|
defaultValue: boolean;
|
||||||
roles: ('manager' | 'staff')[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Business Settings sub-permissions
|
||||||
|
export const SETTINGS_PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_general',
|
||||||
|
labelKey: 'staff.canAccessSettingsGeneral',
|
||||||
|
labelDefault: 'General Settings',
|
||||||
|
hintKey: 'staff.canAccessSettingsGeneralHint',
|
||||||
|
hintDefault: 'Business name, timezone, and basic configuration',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_business_hours',
|
||||||
|
labelKey: 'staff.canAccessSettingsBusinessHours',
|
||||||
|
labelDefault: 'Business Hours',
|
||||||
|
hintKey: 'staff.canAccessSettingsBusinessHoursHint',
|
||||||
|
hintDefault: 'Set regular operating hours',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_branding',
|
||||||
|
labelKey: 'staff.canAccessSettingsBranding',
|
||||||
|
labelDefault: 'Branding',
|
||||||
|
hintKey: 'staff.canAccessSettingsBrandingHint',
|
||||||
|
hintDefault: 'Logo, colors, and visual identity',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_booking',
|
||||||
|
labelKey: 'staff.canAccessSettingsBooking',
|
||||||
|
labelDefault: 'Booking Settings',
|
||||||
|
hintKey: 'staff.canAccessSettingsBookingHint',
|
||||||
|
hintDefault: 'Booking policies and rules',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_communication',
|
||||||
|
labelKey: 'staff.canAccessSettingsCommunication',
|
||||||
|
labelDefault: 'Communication',
|
||||||
|
hintKey: 'staff.canAccessSettingsCommunicationHint',
|
||||||
|
hintDefault: 'Notification preferences and reminders',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_embed_widget',
|
||||||
|
labelKey: 'staff.canAccessSettingsEmbedWidget',
|
||||||
|
labelDefault: 'Embed Widget',
|
||||||
|
hintKey: 'staff.canAccessSettingsEmbedWidgetHint',
|
||||||
|
hintDefault: 'Configure booking widget for websites',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_email_templates',
|
||||||
|
labelKey: 'staff.canAccessSettingsEmailTemplates',
|
||||||
|
labelDefault: 'Email Templates',
|
||||||
|
hintKey: 'staff.canAccessSettingsEmailTemplatesHint',
|
||||||
|
hintDefault: 'Customize automated emails',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_staff_roles',
|
||||||
|
labelKey: 'staff.canAccessSettingsStaffRoles',
|
||||||
|
labelDefault: 'Staff Roles',
|
||||||
|
hintKey: 'staff.canAccessSettingsStaffRolesHint',
|
||||||
|
hintDefault: 'Create and manage permission roles',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_resource_types',
|
||||||
|
labelKey: 'staff.canAccessSettingsResourceTypes',
|
||||||
|
labelDefault: 'Resource Types',
|
||||||
|
hintKey: 'staff.canAccessSettingsResourceTypesHint',
|
||||||
|
hintDefault: 'Configure resource categories',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_api',
|
||||||
|
labelKey: 'staff.canAccessSettingsApi',
|
||||||
|
labelDefault: 'API & Integrations',
|
||||||
|
hintKey: 'staff.canAccessSettingsApiHint',
|
||||||
|
hintDefault: 'Manage API tokens and webhooks',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_custom_domains',
|
||||||
|
labelKey: 'staff.canAccessSettingsCustomDomains',
|
||||||
|
labelDefault: 'Custom Domains',
|
||||||
|
hintKey: 'staff.canAccessSettingsCustomDomainsHint',
|
||||||
|
hintDefault: 'Configure custom domain settings',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_authentication',
|
||||||
|
labelKey: 'staff.canAccessSettingsAuthentication',
|
||||||
|
labelDefault: 'Authentication',
|
||||||
|
hintKey: 'staff.canAccessSettingsAuthenticationHint',
|
||||||
|
hintDefault: 'OAuth and social login configuration',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_email',
|
||||||
|
labelKey: 'staff.canAccessSettingsEmail',
|
||||||
|
labelDefault: 'Email Setup',
|
||||||
|
hintKey: 'staff.canAccessSettingsEmailHint',
|
||||||
|
hintDefault: 'Configure email addresses for tickets',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'can_access_settings_sms_calling',
|
||||||
|
labelKey: 'staff.canAccessSettingsSmsCalling',
|
||||||
|
labelDefault: 'SMS & Calling',
|
||||||
|
hintKey: 'staff.canAccessSettingsSmsCallingHint',
|
||||||
|
hintDefault: 'Manage credits and phone numbers',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Define all available permissions in one place
|
// Define all available permissions in one place
|
||||||
|
// All permissions are now available to staff (via staff roles)
|
||||||
export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||||
// Manager-only permissions
|
|
||||||
{
|
{
|
||||||
key: 'can_invite_staff',
|
key: 'can_invite_staff',
|
||||||
labelKey: 'staff.canInviteStaff',
|
labelKey: 'staff.canInviteStaff',
|
||||||
labelDefault: 'Can invite new staff members',
|
labelDefault: 'Can invite new staff members',
|
||||||
hintKey: 'staff.canInviteStaffHint',
|
hintKey: 'staff.canInviteStaffHint',
|
||||||
hintDefault: 'Allow this manager to send invitations to new staff members',
|
hintDefault: 'Allow this staff member to send invitations to new staff members',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
roles: ['manager'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'can_manage_resources',
|
key: 'can_manage_resources',
|
||||||
@@ -29,8 +144,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
labelDefault: 'Can manage resources',
|
labelDefault: 'Can manage resources',
|
||||||
hintKey: 'staff.canManageResourcesHint',
|
hintKey: 'staff.canManageResourcesHint',
|
||||||
hintDefault: 'Create, edit, and delete bookable resources',
|
hintDefault: 'Create, edit, and delete bookable resources',
|
||||||
defaultValue: true,
|
defaultValue: false,
|
||||||
roles: ['manager'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'can_manage_services',
|
key: 'can_manage_services',
|
||||||
@@ -38,8 +152,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
labelDefault: 'Can manage services',
|
labelDefault: 'Can manage services',
|
||||||
hintKey: 'staff.canManageServicesHint',
|
hintKey: 'staff.canManageServicesHint',
|
||||||
hintDefault: 'Create, edit, and delete service offerings',
|
hintDefault: 'Create, edit, and delete service offerings',
|
||||||
defaultValue: true,
|
defaultValue: false,
|
||||||
roles: ['manager'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'can_view_reports',
|
key: 'can_view_reports',
|
||||||
@@ -47,17 +160,7 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
labelDefault: 'Can view reports',
|
labelDefault: 'Can view reports',
|
||||||
hintKey: 'staff.canViewReportsHint',
|
hintKey: 'staff.canViewReportsHint',
|
||||||
hintDefault: 'Access business analytics and financial reports',
|
hintDefault: 'Access business analytics and financial reports',
|
||||||
defaultValue: true,
|
|
||||||
roles: ['manager'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'can_access_settings',
|
|
||||||
labelKey: 'staff.canAccessSettings',
|
|
||||||
labelDefault: 'Can access business settings',
|
|
||||||
hintKey: 'staff.canAccessSettingsHint',
|
|
||||||
hintDefault: 'Modify business profile, branding, and configuration',
|
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
roles: ['manager'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'can_refund_payments',
|
key: 'can_refund_payments',
|
||||||
@@ -66,7 +169,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
hintKey: 'staff.canRefundPaymentsHint',
|
hintKey: 'staff.canRefundPaymentsHint',
|
||||||
hintDefault: 'Process refunds for customer payments',
|
hintDefault: 'Process refunds for customer payments',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
roles: ['manager'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'can_send_messages',
|
key: 'can_send_messages',
|
||||||
@@ -74,10 +176,8 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
labelDefault: 'Can send broadcast messages',
|
labelDefault: 'Can send broadcast messages',
|
||||||
hintKey: 'staff.canSendMessagesHint',
|
hintKey: 'staff.canSendMessagesHint',
|
||||||
hintDefault: 'Send messages to groups of staff and customers',
|
hintDefault: 'Send messages to groups of staff and customers',
|
||||||
defaultValue: true,
|
defaultValue: false,
|
||||||
roles: ['manager'],
|
|
||||||
},
|
},
|
||||||
// Staff-only permissions
|
|
||||||
{
|
{
|
||||||
key: 'can_view_all_schedules',
|
key: 'can_view_all_schedules',
|
||||||
labelKey: 'staff.canViewAllSchedules',
|
labelKey: 'staff.canViewAllSchedules',
|
||||||
@@ -85,7 +185,6 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
hintKey: 'staff.canViewAllSchedulesHint',
|
hintKey: 'staff.canViewAllSchedulesHint',
|
||||||
hintDefault: 'View schedules of other staff members (otherwise only their own)',
|
hintDefault: 'View schedules of other staff members (otherwise only their own)',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
roles: ['staff'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'can_manage_own_appointments',
|
key: 'can_manage_own_appointments',
|
||||||
@@ -94,112 +193,132 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
hintKey: 'staff.canManageOwnAppointmentsHint',
|
hintKey: 'staff.canManageOwnAppointmentsHint',
|
||||||
hintDefault: 'Create, reschedule, and cancel their own appointments',
|
hintDefault: 'Create, reschedule, and cancel their own appointments',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
roles: ['staff'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'can_self_approve_time_off',
|
key: 'can_self_approve_time_off',
|
||||||
labelKey: 'staff.canSelfApproveTimeOff',
|
labelKey: 'staff.canSelfApproveTimeOff',
|
||||||
labelDefault: 'Can self-approve time off',
|
labelDefault: 'Can self-approve time off',
|
||||||
hintKey: 'staff.canSelfApproveTimeOffHint',
|
hintKey: 'staff.canSelfApproveTimeOffHint',
|
||||||
hintDefault: 'Add time off without requiring manager/owner approval',
|
hintDefault: 'Add time off without requiring owner approval',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
roles: ['staff'],
|
|
||||||
},
|
},
|
||||||
// Shared permissions (both manager and staff)
|
|
||||||
{
|
{
|
||||||
key: 'can_access_tickets',
|
key: 'can_access_tickets',
|
||||||
labelKey: 'staff.canAccessTickets',
|
labelKey: 'staff.canAccessTickets',
|
||||||
labelDefault: 'Can access support tickets',
|
labelDefault: 'Can access support tickets',
|
||||||
hintKey: 'staff.canAccessTicketsHint',
|
hintKey: 'staff.canAccessTicketsHint',
|
||||||
hintDefault: 'View and manage customer support tickets',
|
hintDefault: 'View and manage customer support tickets',
|
||||||
defaultValue: true, // Default for managers; staff will override to false
|
defaultValue: false,
|
||||||
roles: ['manager', 'staff'],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get default permissions for a role
|
// Get default permissions for staff
|
||||||
export const getDefaultPermissions = (role: 'manager' | 'staff'): Record<string, boolean> => {
|
export const getDefaultPermissions = (): Record<string, boolean> => {
|
||||||
const defaults: Record<string, boolean> = {};
|
const defaults: Record<string, boolean> = {};
|
||||||
PERMISSION_CONFIGS.forEach((config) => {
|
PERMISSION_CONFIGS.forEach((config) => {
|
||||||
if (config.roles.includes(role)) {
|
defaults[config.key] = config.defaultValue;
|
||||||
// Staff members have ticket access disabled by default
|
|
||||||
if (role === 'staff' && config.key === 'can_access_tickets') {
|
|
||||||
defaults[config.key] = false;
|
|
||||||
} else {
|
|
||||||
defaults[config.key] = config.defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||||
|
defaults[config.key] = config.defaultValue;
|
||||||
|
});
|
||||||
|
defaults['can_access_settings'] = false;
|
||||||
return defaults;
|
return defaults;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StaffPermissionsProps {
|
interface StaffPermissionsProps {
|
||||||
role: 'manager' | 'staff';
|
role: 'staff';
|
||||||
permissions: Record<string, boolean>;
|
permissions: Record<string, boolean>;
|
||||||
onChange: (permissions: Record<string, boolean>) => void;
|
onChange: (permissions: Record<string, boolean>) => void;
|
||||||
variant?: 'invite' | 'edit';
|
variant?: 'invite' | 'edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
const StaffPermissions: React.FC<StaffPermissionsProps> = ({
|
const StaffPermissions: React.FC<StaffPermissionsProps> = ({
|
||||||
role,
|
|
||||||
permissions,
|
permissions,
|
||||||
onChange,
|
onChange,
|
||||||
variant = 'edit',
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [settingsExpanded, setSettingsExpanded] = useState(false);
|
||||||
// Filter permissions for this role
|
|
||||||
const rolePermissions = PERMISSION_CONFIGS.filter((config) =>
|
|
||||||
config.roles.includes(role)
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggle = (key: string, checked: boolean) => {
|
|
||||||
onChange({ ...permissions, [key]: checked });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the current value, falling back to default
|
// Get the current value, falling back to default
|
||||||
const getValue = (config: PermissionConfig): boolean => {
|
const getValue = (key: string, defaultValue: boolean = false): boolean => {
|
||||||
if (permissions[config.key] !== undefined) {
|
if (permissions[key] !== undefined) {
|
||||||
return permissions[config.key];
|
return permissions[key];
|
||||||
}
|
}
|
||||||
// Staff have ticket access disabled by default
|
return defaultValue;
|
||||||
if (role === 'staff' && config.key === 'can_access_tickets') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return config.defaultValue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Different styling for manager vs staff permissions
|
const hasSettingsAccess = getValue('can_access_settings', false);
|
||||||
const isManagerPermission = (config: PermissionConfig) =>
|
|
||||||
config.roles.includes('manager') && !config.roles.includes('staff');
|
|
||||||
|
|
||||||
const getPermissionStyle = (config: PermissionConfig) => {
|
// Auto-expand settings section if any settings permissions are enabled
|
||||||
if (isManagerPermission(config) || role === 'manager') {
|
useEffect(() => {
|
||||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30';
|
if (hasSettingsAccess) {
|
||||||
|
const hasAnySettingEnabled = SETTINGS_PERMISSION_CONFIGS.some(
|
||||||
|
(config) => getValue(config.key, false)
|
||||||
|
);
|
||||||
|
if (hasAnySettingEnabled) {
|
||||||
|
setSettingsExpanded(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700';
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = (key: string, checked: boolean) => {
|
||||||
|
const newPermissions = { ...permissions, [key]: checked };
|
||||||
|
|
||||||
|
// If turning off main settings access, turn off all sub-settings
|
||||||
|
if (key === 'can_access_settings' && !checked) {
|
||||||
|
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||||
|
newPermissions[config.key] = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(newPermissions);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rolePermissions.length === 0) {
|
const handleSettingsMainToggle = (checked: boolean) => {
|
||||||
return null;
|
const newPermissions = { ...permissions, can_access_settings: checked };
|
||||||
}
|
|
||||||
|
// If turning off, disable all sub-settings
|
||||||
|
if (!checked) {
|
||||||
|
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||||
|
newPermissions[config.key] = false;
|
||||||
|
});
|
||||||
|
setSettingsExpanded(false);
|
||||||
|
} else {
|
||||||
|
// If turning on, expand the section
|
||||||
|
setSettingsExpanded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(newPermissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAllSettings = (selectAll: boolean) => {
|
||||||
|
const newPermissions = { ...permissions };
|
||||||
|
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||||
|
newPermissions[config.key] = selectAll;
|
||||||
|
});
|
||||||
|
onChange(newPermissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count how many settings sub-permissions are enabled
|
||||||
|
const enabledSettingsCount = SETTINGS_PERMISSION_CONFIGS.filter((config) =>
|
||||||
|
getValue(config.key, false)
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
{role === 'manager'
|
{t('staff.staffPermissions', 'Staff Permissions')}
|
||||||
? t('staff.managerPermissions', 'Manager Permissions')
|
|
||||||
: t('staff.staffPermissions', 'Staff Permissions')}
|
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{rolePermissions.map((config) => (
|
{/* Regular permissions */}
|
||||||
|
{PERMISSION_CONFIGS.map((config) => (
|
||||||
<label
|
<label
|
||||||
key={config.key}
|
key={config.key}
|
||||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${getPermissionStyle(config)}`}
|
className="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={getValue(config)}
|
checked={getValue(config.key, config.defaultValue)}
|
||||||
onChange={(e) => handleToggle(config.key, e.target.checked)}
|
onChange={(e) => handleToggle(config.key, e.target.checked)}
|
||||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
@@ -213,6 +332,113 @@ const StaffPermissions: React.FC<StaffPermissionsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Business Settings Section */}
|
||||||
|
<div className="border rounded-lg border-gray-200 dark:border-gray-600 overflow-hidden">
|
||||||
|
{/* Main Business Settings Toggle */}
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-3 p-3 cursor-pointer transition-colors ${
|
||||||
|
hasSettingsAccess
|
||||||
|
? 'bg-brand-50 dark:bg-brand-900/20'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasSettingsAccess}
|
||||||
|
onChange={(e) => handleSettingsMainToggle(e.target.checked)}
|
||||||
|
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => hasSettingsAccess && setSettingsExpanded(!settingsExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{t('staff.canAccessSettings', 'Can access business settings')}
|
||||||
|
</span>
|
||||||
|
{hasSettingsAccess && enabledSettingsCount > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-brand-600 dark:text-brand-400">
|
||||||
|
({enabledSettingsCount}/{SETTINGS_PERMISSION_CONFIGS.length} enabled)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasSettingsAccess && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSettingsExpanded(!settingsExpanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settingsExpanded ? (
|
||||||
|
<ChevronDown size={16} className="text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{t(
|
||||||
|
'staff.canAccessSettingsHint',
|
||||||
|
'Access to business settings pages (select specific pages below)'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-permissions (collapsible) */}
|
||||||
|
{hasSettingsAccess && settingsExpanded && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-600 bg-gray-25 dark:bg-gray-800/50">
|
||||||
|
{/* Select All / None buttons */}
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-600 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectAllSettings(true)}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
|
||||||
|
>
|
||||||
|
{t('staff.selectAll', 'Select All')}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectAllSettings(false)}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
||||||
|
>
|
||||||
|
{t('staff.selectNone', 'Select None')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Individual settings permissions */}
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{SETTINGS_PERMISSION_CONFIGS.map((config) => (
|
||||||
|
<label
|
||||||
|
key={config.key}
|
||||||
|
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={getValue(config.key, config.defaultValue)}
|
||||||
|
onChange={(e) => handleToggle(config.key, e.target.checked)}
|
||||||
|
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{t(config.labelKey, config.labelDefault)}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{t(config.hintKey, config.hintDefault)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe('MasqueradeBanner', () => {
|
|||||||
it('shows return to previous user text when previousUser exists', () => {
|
it('shows return to previous user text when previousUser exists', () => {
|
||||||
const propsWithPrevious = {
|
const propsWithPrevious = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
|
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'owner' as const },
|
||||||
};
|
};
|
||||||
render(<MasqueradeBanner {...propsWithPrevious} />);
|
render(<MasqueradeBanner {...propsWithPrevious} />);
|
||||||
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
|
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -518,8 +518,8 @@ describe('TopBar', () => {
|
|||||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render for manager role', () => {
|
it('should render for staff with permissions', () => {
|
||||||
const user = createMockUser({ role: 'manager' });
|
const user = createMockUser({ role: 'staff' });
|
||||||
|
|
||||||
renderWithRouter(
|
renderWithRouter(
|
||||||
<TopBar
|
<TopBar
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ describe('useInvitations hooks', () => {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
role: 'TENANT_MANAGER',
|
role: 'TENANT_STAFF',
|
||||||
role_display: 'Manager',
|
role_display: 'Staff',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
invited_by: 5,
|
invited_by: 5,
|
||||||
invited_by_name: 'Admin User',
|
invited_by_name: 'Admin User',
|
||||||
@@ -205,10 +205,10 @@ describe('useInvitations hooks', () => {
|
|||||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData);
|
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates manager invitation with permissions', async () => {
|
it('creates staff invitation with permissions', async () => {
|
||||||
const invitationData: CreateInvitationData = {
|
const invitationData: CreateInvitationData = {
|
||||||
email: 'manager@example.com',
|
email: 'staff@example.com',
|
||||||
role: 'TENANT_MANAGER',
|
role: 'TENANT_STAFF',
|
||||||
permissions: {
|
permissions: {
|
||||||
can_invite_staff: true,
|
can_invite_staff: true,
|
||||||
can_manage_resources: true,
|
can_manage_resources: true,
|
||||||
@@ -219,7 +219,7 @@ describe('useInvitations hooks', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockResponse = { id: 5, email: 'manager@example.com', role: 'TENANT_MANAGER' };
|
const mockResponse = { id: 5, email: 'staff@example.com', role: 'TENANT_STAFF' };
|
||||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
const { result } = renderHook(() => useCreateInvitation(), {
|
const { result } = renderHook(() => useCreateInvitation(), {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe('useStaff hooks', () => {
|
|||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
phone: '555-1234',
|
phone: '555-1234',
|
||||||
role: 'TENANT_MANAGER',
|
role: 'TENANT_STAFF',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
permissions: { can_invite_staff: true },
|
permissions: { can_invite_staff: true },
|
||||||
can_invite_staff: true,
|
can_invite_staff: true,
|
||||||
@@ -79,7 +79,7 @@ describe('useStaff hooks', () => {
|
|||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
phone: '555-1234',
|
phone: '555-1234',
|
||||||
role: 'TENANT_MANAGER',
|
role: 'TENANT_STAFF',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
permissions: { can_invite_staff: true },
|
permissions: { can_invite_staff: true },
|
||||||
can_invite_staff: true,
|
can_invite_staff: true,
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ export const useIsAuthenticated = (): boolean => {
|
|||||||
/**
|
/**
|
||||||
* Get the redirect path based on user role
|
* Get the redirect path based on user role
|
||||||
* Tenant users go to /dashboard/, platform users go to /
|
* Tenant users go to /dashboard/, platform users go to /
|
||||||
* Note: Backend maps tenant_owner -> owner, tenant_manager -> manager, etc.
|
* Note: Backend maps tenant_owner -> owner, tenant_staff -> staff, etc.
|
||||||
*/
|
*/
|
||||||
const getRedirectPathForRole = (role: string): string => {
|
const getRedirectPathForRole = (role: string): string => {
|
||||||
// Tenant roles (as returned by backend after role mapping)
|
// Tenant roles (as returned by backend after role mapping)
|
||||||
const tenantRoles = ['owner', 'manager', 'staff', 'customer'];
|
const tenantRoles = ['owner', 'staff', 'customer'];
|
||||||
if (tenantRoles.includes(role)) {
|
if (tenantRoles.includes(role)) {
|
||||||
return '/dashboard/';
|
return '/dashboard/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const transformCustomer = (c: any): Customer => ({
|
|||||||
paymentMethods: [],
|
paymentMethods: [],
|
||||||
user_data: c.user_data,
|
user_data: c.user_data,
|
||||||
notes: c.notes || '',
|
notes: c.notes || '',
|
||||||
|
email_verified: c.email_verified ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,3 +209,20 @@ export const useDeleteCustomer = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to verify a customer's email address
|
||||||
|
*/
|
||||||
|
export const useVerifyCustomerEmail = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { data } = await apiClient.post(`/customers/${id}/verify_email/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import apiClient from '../api/client';
|
|||||||
export interface StaffInvitation {
|
export interface StaffInvitation {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
role: 'TENANT_STAFF';
|
||||||
role_display: string;
|
role_display: string;
|
||||||
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED';
|
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED';
|
||||||
invited_by: number | null;
|
invited_by: number | null;
|
||||||
@@ -50,7 +50,7 @@ export interface StaffPermissions {
|
|||||||
|
|
||||||
export interface CreateInvitationData {
|
export interface CreateInvitationData {
|
||||||
email: string;
|
email: string;
|
||||||
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
role: 'TENANT_STAFF';
|
||||||
create_bookable_resource?: boolean;
|
create_bookable_resource?: boolean;
|
||||||
resource_name?: string;
|
resource_name?: string;
|
||||||
permissions?: StaffPermissions;
|
permissions?: StaffPermissions;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface StaffPermissions {
|
|||||||
export interface StaffMember {
|
export interface StaffMember {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -22,6 +24,7 @@ export interface StaffMember {
|
|||||||
staff_role_id: number | null;
|
staff_role_id: number | null;
|
||||||
staff_role_name: string | null;
|
staff_role_name: string | null;
|
||||||
effective_permissions: Record<string, boolean>;
|
effective_permissions: Record<string, boolean>;
|
||||||
|
email_verified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StaffFilters {
|
interface StaffFilters {
|
||||||
@@ -30,7 +33,7 @@ interface StaffFilters {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch staff members with optional filters
|
* Hook to fetch staff members with optional filters
|
||||||
* Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF
|
* Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF
|
||||||
*/
|
*/
|
||||||
export const useStaff = (filters?: StaffFilters) => {
|
export const useStaff = (filters?: StaffFilters) => {
|
||||||
return useQuery<StaffMember[]>({
|
return useQuery<StaffMember[]>({
|
||||||
@@ -46,6 +49,8 @@ export const useStaff = (filters?: StaffFilters) => {
|
|||||||
return data.map((s: any) => ({
|
return data.map((s: any) => ({
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
||||||
|
first_name: s.first_name || '',
|
||||||
|
last_name: s.last_name || '',
|
||||||
email: s.email || '',
|
email: s.email || '',
|
||||||
phone: s.phone || '',
|
phone: s.phone || '',
|
||||||
role: s.role || 'staff',
|
role: s.role || 'staff',
|
||||||
@@ -55,14 +60,27 @@ export const useStaff = (filters?: StaffFilters) => {
|
|||||||
staff_role_id: s.staff_role_id ?? null,
|
staff_role_id: s.staff_role_id ?? null,
|
||||||
staff_role_name: s.staff_role_name ?? null,
|
staff_role_name: s.staff_role_name ?? null,
|
||||||
effective_permissions: s.effective_permissions || {},
|
effective_permissions: s.effective_permissions || {},
|
||||||
|
email_verified: s.email_verified ?? false,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface StaffProfileUpdate {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffUpdate extends StaffProfileUpdate {
|
||||||
|
is_active?: boolean;
|
||||||
|
permissions?: StaffPermissions;
|
||||||
|
staff_role_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to update a staff member's settings
|
* Hook to update a staff member's settings and profile
|
||||||
*/
|
*/
|
||||||
export const useUpdateStaff = () => {
|
export const useUpdateStaff = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -73,7 +91,7 @@ export const useUpdateStaff = () => {
|
|||||||
updates,
|
updates,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null };
|
updates: StaffUpdate;
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = await apiClient.patch(`/staff/${id}/`, updates);
|
const { data } = await apiClient.patch(`/staff/${id}/`, updates);
|
||||||
return data;
|
return data;
|
||||||
@@ -102,3 +120,38 @@ export const useToggleStaffActive = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to verify a staff member's email address
|
||||||
|
*/
|
||||||
|
export const useVerifyStaffEmail = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { data } = await apiClient.post(`/staff/${id}/verify_email/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to send a password reset email to a staff member
|
||||||
|
*/
|
||||||
|
export const useSendStaffPasswordReset = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { data } = await apiClient.post(`/staff/${id}/send_password_reset/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -917,7 +917,19 @@
|
|||||||
"noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.",
|
"noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.",
|
||||||
"addNewCustomer": "Neuen Kunden Hinzufügen",
|
"addNewCustomer": "Neuen Kunden Hinzufügen",
|
||||||
"createCustomer": "Kunden Erstellen",
|
"createCustomer": "Kunden Erstellen",
|
||||||
"errorLoading": "Fehler beim Laden der Kunden"
|
"errorLoading": "Fehler beim Laden der Kunden",
|
||||||
|
"password": "Passwort",
|
||||||
|
"newPassword": "Neues Passwort",
|
||||||
|
"passwordPlaceholder": "Leer lassen, um das aktuelle Passwort zu behalten",
|
||||||
|
"accountInfo": "Kontoinformationen",
|
||||||
|
"contactDetails": "Kontaktdetails",
|
||||||
|
"verifyEmail": "E-Mail bestätigen",
|
||||||
|
"unverifyEmail": "E-Mail nicht bestätigen",
|
||||||
|
"emailVerified": "Bestätigt",
|
||||||
|
"verifyEmailTitle": "E-Mail-Adresse bestätigen",
|
||||||
|
"unverifyEmailTitle": "E-Mail-Bestätigung aufheben",
|
||||||
|
"verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?",
|
||||||
|
"unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?"
|
||||||
},
|
},
|
||||||
"staff": {
|
"staff": {
|
||||||
"title": "Personal & Management",
|
"title": "Personal & Management",
|
||||||
@@ -930,7 +942,15 @@
|
|||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"errorLoading": "Fehler beim Laden des Personals",
|
"errorLoading": "Fehler beim Laden des Personals",
|
||||||
"inviteModalTitle": "Personal Einladen",
|
"inviteModalTitle": "Personal Einladen",
|
||||||
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein."
|
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein.",
|
||||||
|
"verifyEmail": "E-Mail bestätigen",
|
||||||
|
"emailVerified": "Bestätigt",
|
||||||
|
"emailStatus": "E-Mail-Status",
|
||||||
|
"unverifyEmail": "E-Mail nicht bestätigen",
|
||||||
|
"verifyEmailTitle": "E-Mail-Adresse bestätigen",
|
||||||
|
"unverifyEmailTitle": "E-Mail-Bestätigung aufheben",
|
||||||
|
"verifyEmailConfirm": "Möchten Sie {{email}} wirklich als bestätigt markieren?",
|
||||||
|
"unverifyEmailConfirm": "Möchten Sie die Bestätigung von {{email}} wirklich aufheben?"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"title": "Ressourcen",
|
"title": "Ressourcen",
|
||||||
|
|||||||
@@ -936,9 +936,8 @@
|
|||||||
"emailPlaceholder": "colleague@example.com",
|
"emailPlaceholder": "colleague@example.com",
|
||||||
"roleLabel": "Role",
|
"roleLabel": "Role",
|
||||||
"roleStaff": "Staff Member",
|
"roleStaff": "Staff Member",
|
||||||
"roleManager": "Manager",
|
"roleOwner": "Owner",
|
||||||
"managerRoleHint": "Managers can manage staff, resources, and view reports",
|
"staffRoleHint": "Staff permissions are determined by their assigned role",
|
||||||
"staffRoleHint": "Staff members can manage their own schedule and appointments",
|
|
||||||
"makeBookableHint": "Create a bookable resource so customers can schedule appointments with this person",
|
"makeBookableHint": "Create a bookable resource so customers can schedule appointments with this person",
|
||||||
"resourceName": "Display Name (optional)",
|
"resourceName": "Display Name (optional)",
|
||||||
"resourceNamePlaceholder": "Defaults to person's name",
|
"resourceNamePlaceholder": "Defaults to person's name",
|
||||||
@@ -958,7 +957,7 @@
|
|||||||
"canSendMessagesHint": "Send messages to groups of staff and customers",
|
"canSendMessagesHint": "Send messages to groups of staff and customers",
|
||||||
"deactivate": "Deactivate",
|
"deactivate": "Deactivate",
|
||||||
"canInviteStaff": "Can invite new staff members",
|
"canInviteStaff": "Can invite new staff members",
|
||||||
"canInviteStaffHint": "Allow this manager to send invitations to new staff members",
|
"canInviteStaffHint": "Allow this staff member to send invitations to new staff members",
|
||||||
"canManageResources": "Can manage resources",
|
"canManageResources": "Can manage resources",
|
||||||
"canManageResourcesHint": "Create, edit, and delete bookable resources",
|
"canManageResourcesHint": "Create, edit, and delete bookable resources",
|
||||||
"canManageServices": "Can manage services",
|
"canManageServices": "Can manage services",
|
||||||
@@ -966,7 +965,37 @@
|
|||||||
"canViewReports": "Can view reports",
|
"canViewReports": "Can view reports",
|
||||||
"canViewReportsHint": "Access business analytics and financial reports",
|
"canViewReportsHint": "Access business analytics and financial reports",
|
||||||
"canAccessSettings": "Can access business settings",
|
"canAccessSettings": "Can access business settings",
|
||||||
"canAccessSettingsHint": "Modify business profile, branding, and configuration",
|
"canAccessSettingsHint": "Access to business settings pages (select specific pages below)",
|
||||||
|
"canAccessSettingsGeneral": "General Settings",
|
||||||
|
"canAccessSettingsGeneralHint": "Business name, timezone, and basic configuration",
|
||||||
|
"canAccessSettingsBusinessHours": "Business Hours",
|
||||||
|
"canAccessSettingsBusinessHoursHint": "Set regular operating hours",
|
||||||
|
"canAccessSettingsBranding": "Branding",
|
||||||
|
"canAccessSettingsBrandingHint": "Logo, colors, and visual identity",
|
||||||
|
"canAccessSettingsBooking": "Booking Settings",
|
||||||
|
"canAccessSettingsBookingHint": "Booking policies and rules",
|
||||||
|
"canAccessSettingsCommunication": "Communication",
|
||||||
|
"canAccessSettingsCommunicationHint": "Notification preferences and reminders",
|
||||||
|
"canAccessSettingsEmbedWidget": "Embed Widget",
|
||||||
|
"canAccessSettingsEmbedWidgetHint": "Configure booking widget for websites",
|
||||||
|
"canAccessSettingsEmailTemplates": "Email Templates",
|
||||||
|
"canAccessSettingsEmailTemplatesHint": "Customize automated emails",
|
||||||
|
"canAccessSettingsStaffRoles": "Staff Roles",
|
||||||
|
"canAccessSettingsStaffRolesHint": "Create and manage permission roles",
|
||||||
|
"canAccessSettingsResourceTypes": "Resource Types",
|
||||||
|
"canAccessSettingsResourceTypesHint": "Configure resource categories",
|
||||||
|
"canAccessSettingsApi": "API & Integrations",
|
||||||
|
"canAccessSettingsApiHint": "Manage API tokens and webhooks",
|
||||||
|
"canAccessSettingsCustomDomains": "Custom Domains",
|
||||||
|
"canAccessSettingsCustomDomainsHint": "Configure custom domain settings",
|
||||||
|
"canAccessSettingsAuthentication": "Authentication",
|
||||||
|
"canAccessSettingsAuthenticationHint": "OAuth and social login configuration",
|
||||||
|
"canAccessSettingsEmail": "Email Setup",
|
||||||
|
"canAccessSettingsEmailHint": "Configure email addresses for tickets",
|
||||||
|
"canAccessSettingsSmsCalling": "SMS & Calling",
|
||||||
|
"canAccessSettingsSmsCallingHint": "Manage credits and phone numbers",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"selectNone": "Select None",
|
||||||
"canRefundPayments": "Can refund payments",
|
"canRefundPayments": "Can refund payments",
|
||||||
"canRefundPaymentsHint": "Process refunds for customer payments",
|
"canRefundPaymentsHint": "Process refunds for customer payments",
|
||||||
"canViewAllSchedules": "Can view all schedules",
|
"canViewAllSchedules": "Can view all schedules",
|
||||||
@@ -974,16 +1003,41 @@
|
|||||||
"canManageOwnAppointments": "Can manage own appointments",
|
"canManageOwnAppointments": "Can manage own appointments",
|
||||||
"canManageOwnAppointmentsHint": "Create, reschedule, and cancel their own appointments",
|
"canManageOwnAppointmentsHint": "Create, reschedule, and cancel their own appointments",
|
||||||
"canSelfApproveTimeOff": "Can self-approve time off",
|
"canSelfApproveTimeOff": "Can self-approve time off",
|
||||||
"canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval",
|
"canSelfApproveTimeOffHint": "Add time off without requiring owner approval",
|
||||||
"canAccessTickets": "Can access support tickets",
|
"canAccessTickets": "Can access support tickets",
|
||||||
"canAccessTicketsHint": "View and manage customer support tickets",
|
"canAccessTicketsHint": "View and manage customer support tickets",
|
||||||
"managerPermissions": "Manager Permissions",
|
"staffPermissions": "Staff Permissions",
|
||||||
"staffPermissions": "Staff Permissions"
|
"verifyEmail": "Verify Email",
|
||||||
|
"unverifyEmail": "Unverify Email",
|
||||||
|
"emailVerified": "Verified",
|
||||||
|
"emailStatus": "Email Status",
|
||||||
|
"verifyEmailTitle": "Verify Email Address",
|
||||||
|
"unverifyEmailTitle": "Unverify Email Address",
|
||||||
|
"verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?",
|
||||||
|
"unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?",
|
||||||
|
"profileInformation": "Profile Information",
|
||||||
|
"firstName": "First Name",
|
||||||
|
"firstNamePlaceholder": "Enter first name",
|
||||||
|
"lastName": "Last Name",
|
||||||
|
"lastNamePlaceholder": "Enter last name",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"phonePlaceholder": "Enter phone number",
|
||||||
|
"verified": "Verified",
|
||||||
|
"verify": "Verify",
|
||||||
|
"roleAndPermissions": "Role & Permissions",
|
||||||
|
"accountSecurity": "Account Security",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"resetPasswordHint": "Send a password reset email to this staff member",
|
||||||
|
"sendResetEmail": "Send Reset Email",
|
||||||
|
"confirmPasswordReset": "Send a password reset email to {{email}}? They will receive a temporary password.",
|
||||||
|
"passwordResetSent": "Password reset email sent successfully",
|
||||||
|
"passwordResetFailed": "Failed to send password reset email"
|
||||||
},
|
},
|
||||||
"staffDashboard": {
|
"staffDashboard": {
|
||||||
"welcomeTitle": "Welcome, {{name}}!",
|
"welcomeTitle": "Welcome, {{name}}!",
|
||||||
"weekOverview": "Here's your week at a glance",
|
"weekOverview": "Here's your week at a glance",
|
||||||
"noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.",
|
"noResourceLinked": "Your account is not linked to a resource yet. Please contact the business owner to set up your schedule.",
|
||||||
"currentAppointment": "Current Appointment",
|
"currentAppointment": "Current Appointment",
|
||||||
"nextAppointment": "Next Appointment",
|
"nextAppointment": "Next Appointment",
|
||||||
"viewSchedule": "View Schedule",
|
"viewSchedule": "View Schedule",
|
||||||
@@ -1478,7 +1532,14 @@
|
|||||||
"newPassword": "New Password",
|
"newPassword": "New Password",
|
||||||
"passwordPlaceholder": "Leave blank to keep current password",
|
"passwordPlaceholder": "Leave blank to keep current password",
|
||||||
"accountInfo": "Account Information",
|
"accountInfo": "Account Information",
|
||||||
"contactDetails": "Contact Details"
|
"contactDetails": "Contact Details",
|
||||||
|
"verifyEmail": "Verify Email",
|
||||||
|
"unverifyEmail": "Unverify Email",
|
||||||
|
"emailVerified": "Verified",
|
||||||
|
"verifyEmailTitle": "Verify Email Address",
|
||||||
|
"unverifyEmailTitle": "Unverify Email Address",
|
||||||
|
"verifyEmailConfirm": "Are you sure you want to mark {{email}} as verified?",
|
||||||
|
"unverifyEmailConfirm": "Are you sure you want to mark {{email}} as unverified?"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"title": "Resources",
|
"title": "Resources",
|
||||||
@@ -1742,6 +1803,7 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
"noPermission": "You do not have permission to access these settings.",
|
||||||
"businessSettings": "Business Settings",
|
"businessSettings": "Business Settings",
|
||||||
"businessSettingsDescription": "Manage your branding, domain, and policies.",
|
"businessSettingsDescription": "Manage your branding, domain, and policies.",
|
||||||
"domainIdentity": "Domain & Identity",
|
"domainIdentity": "Domain & Identity",
|
||||||
@@ -1921,6 +1983,12 @@
|
|||||||
"roleDescriptionPlaceholder": "Brief description of this role's responsibilities",
|
"roleDescriptionPlaceholder": "Brief description of this role's responsibilities",
|
||||||
"permissions": "Permissions",
|
"permissions": "Permissions",
|
||||||
"menuAccess": "Menu Access",
|
"menuAccess": "Menu Access",
|
||||||
|
"menuPermissions": "Menu Access",
|
||||||
|
"menuPermissionsDescription": "Control which pages staff can see in the sidebar.",
|
||||||
|
"settingsPermissions": "Business Settings Access",
|
||||||
|
"settingsPermissionsDescription": "Control which settings pages staff can access.",
|
||||||
|
"dangerousPermissions": "Dangerous Operations",
|
||||||
|
"dangerousPermissionsDescription": "Allow staff to perform destructive or sensitive actions.",
|
||||||
"dangerousOperations": "Dangerous Operations",
|
"dangerousOperations": "Dangerous Operations",
|
||||||
"staffAssigned": "{{count}} staff assigned",
|
"staffAssigned": "{{count}} staff assigned",
|
||||||
"noStaffAssigned": "No staff assigned",
|
"noStaffAssigned": "No staff assigned",
|
||||||
@@ -3406,7 +3474,7 @@
|
|||||||
"title": "My Availability",
|
"title": "My Availability",
|
||||||
"subtitle": "Manage your time off and unavailability",
|
"subtitle": "Manage your time off and unavailability",
|
||||||
"noResource": "No Resource Linked",
|
"noResource": "No Resource Linked",
|
||||||
"noResourceDesc": "Your account is not linked to a resource. Please contact your manager to set up your availability.",
|
"noResourceDesc": "Your account is not linked to a resource. Please contact the business owner to set up your availability.",
|
||||||
"addBlock": "Block Time",
|
"addBlock": "Block Time",
|
||||||
"businessBlocks": "Business Closures",
|
"businessBlocks": "Business Closures",
|
||||||
"businessBlocksInfo": "These blocks are set by your business and apply to everyone.",
|
"businessBlocksInfo": "These blocks are set by your business and apply to everyone.",
|
||||||
@@ -3717,14 +3785,12 @@
|
|||||||
"staffRoles": "Staff Roles",
|
"staffRoles": "Staff Roles",
|
||||||
"ownerRole": "Owner",
|
"ownerRole": "Owner",
|
||||||
"ownerRoleDesc": "Full access to everything including billing and settings. Cannot be removed.",
|
"ownerRoleDesc": "Full access to everything including billing and settings. Cannot be removed.",
|
||||||
"managerRole": "Manager",
|
|
||||||
"managerRoleDesc": "Can manage staff, customers, services, and appointments. No billing access.",
|
|
||||||
"staffRole": "Staff",
|
"staffRole": "Staff",
|
||||||
"staffRoleDesc": "Basic access. Can view scheduler and manage own appointments if bookable.",
|
"staffRoleDesc": "Access is controlled by their assigned staff role. Create custom roles in Settings > Staff Roles.",
|
||||||
"invitingStaff": "Inviting Staff",
|
"invitingStaff": "Inviting Staff",
|
||||||
"inviteStep1": "Click the Invite Staff button",
|
"inviteStep1": "Click the Invite Staff button",
|
||||||
"inviteStep2": "Enter their email address",
|
"inviteStep2": "Enter their email address",
|
||||||
"inviteStep3": "Select a role (Manager or Staff)",
|
"inviteStep3": "Select a staff role to assign",
|
||||||
"inviteStep4": "Click Send Invitation",
|
"inviteStep4": "Click Send Invitation",
|
||||||
"inviteStep5": "They'll receive an email with a link to join",
|
"inviteStep5": "They'll receive an email with a link to join",
|
||||||
"makeBookable": "Make Bookable",
|
"makeBookable": "Make Bookable",
|
||||||
|
|||||||
@@ -967,6 +967,13 @@
|
|||||||
"lastVisit": "Última Visita",
|
"lastVisit": "Última Visita",
|
||||||
"nextAppointment": "Próxima Cita",
|
"nextAppointment": "Próxima Cita",
|
||||||
"contactInfo": "Información de Contacto",
|
"contactInfo": "Información de Contacto",
|
||||||
|
"verifyEmail": "Verificar correo",
|
||||||
|
"unverifyEmail": "Anular verificación",
|
||||||
|
"emailVerified": "Verificado",
|
||||||
|
"verifyEmailTitle": "Verificar correo electrónico",
|
||||||
|
"unverifyEmailTitle": "Anular verificación de correo",
|
||||||
|
"verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?",
|
||||||
|
"unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"active": "Activo",
|
"active": "Activo",
|
||||||
"inactive": "Inactivo",
|
"inactive": "Inactivo",
|
||||||
@@ -987,6 +994,14 @@
|
|||||||
"role": "Rol",
|
"role": "Rol",
|
||||||
"bookableResource": "Recurso Reservable",
|
"bookableResource": "Recurso Reservable",
|
||||||
"makeBookable": "Hacer Reservable",
|
"makeBookable": "Hacer Reservable",
|
||||||
|
"verifyEmail": "Verificar correo",
|
||||||
|
"unverifyEmail": "Anular verificación",
|
||||||
|
"emailVerified": "Verificado",
|
||||||
|
"emailStatus": "Estado del correo",
|
||||||
|
"verifyEmailTitle": "Verificar correo electrónico",
|
||||||
|
"unverifyEmailTitle": "Anular verificación de correo",
|
||||||
|
"verifyEmailConfirm": "¿Está seguro de que desea marcar {{email}} como verificado?",
|
||||||
|
"unverifyEmailConfirm": "¿Está seguro de que desea anular la verificación de {{email}}?",
|
||||||
"yes": "Sí",
|
"yes": "Sí",
|
||||||
"errorLoading": "Error al cargar personal",
|
"errorLoading": "Error al cargar personal",
|
||||||
"inviteModalTitle": "Invitar Personal",
|
"inviteModalTitle": "Invitar Personal",
|
||||||
|
|||||||
@@ -907,6 +907,13 @@
|
|||||||
"lastVisit": "Dernière Visite",
|
"lastVisit": "Dernière Visite",
|
||||||
"nextAppointment": "Prochain Rendez-vous",
|
"nextAppointment": "Prochain Rendez-vous",
|
||||||
"contactInfo": "Informations de Contact",
|
"contactInfo": "Informations de Contact",
|
||||||
|
"verifyEmail": "Vérifier l'e-mail",
|
||||||
|
"unverifyEmail": "Annuler la vérification",
|
||||||
|
"emailVerified": "Vérifié",
|
||||||
|
"verifyEmailTitle": "Vérifier l'adresse e-mail",
|
||||||
|
"unverifyEmailTitle": "Annuler la vérification de l'e-mail",
|
||||||
|
"verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?",
|
||||||
|
"unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"active": "Actif",
|
"active": "Actif",
|
||||||
"inactive": "Inactif",
|
"inactive": "Inactif",
|
||||||
@@ -930,7 +937,17 @@
|
|||||||
"yes": "Oui",
|
"yes": "Oui",
|
||||||
"errorLoading": "Erreur lors du chargement du personnel",
|
"errorLoading": "Erreur lors du chargement du personnel",
|
||||||
"inviteModalTitle": "Inviter du Personnel",
|
"inviteModalTitle": "Inviter du Personnel",
|
||||||
"inviteModalDescription": "Le flux d'invitation utilisateur irait ici."
|
"inviteModalDescription": "Le flux d'invitation utilisateur irait ici.",
|
||||||
|
"managerPermissions": "Permissions du Gestionnaire",
|
||||||
|
"staffPermissions": "Permissions du Personnel",
|
||||||
|
"verifyEmail": "Vérifier l'e-mail",
|
||||||
|
"unverifyEmail": "Annuler la vérification",
|
||||||
|
"verifyEmailTitle": "Vérifier l'adresse e-mail",
|
||||||
|
"unverifyEmailTitle": "Annuler la vérification de l'e-mail",
|
||||||
|
"verifyEmailConfirm": "Êtes-vous sûr de vouloir marquer {{email}} comme vérifié ?",
|
||||||
|
"unverifyEmailConfirm": "Êtes-vous sûr de vouloir annuler la vérification de {{email}} ?",
|
||||||
|
"emailVerified": "Vérifié",
|
||||||
|
"emailStatus": "Statut de l'e-mail"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"title": "Ressources",
|
"title": "Ressources",
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ const SettingsLayout: React.FC = () => {
|
|||||||
|
|
||||||
// Get context from parent route (BusinessLayout)
|
// Get context from parent route (BusinessLayout)
|
||||||
const parentContext = useOutletContext<ParentContext>();
|
const parentContext = useOutletContext<ParentContext>();
|
||||||
|
const { user } = parentContext || {};
|
||||||
|
const isOwner = user?.role === 'owner';
|
||||||
|
|
||||||
|
// Check if staff has access to a specific settings page
|
||||||
|
const hasSettingsPermission = (permissionKey: string): boolean => {
|
||||||
|
// Owners always have all permissions
|
||||||
|
if (isOwner) return true;
|
||||||
|
// Staff need the specific permission
|
||||||
|
return user?.effective_permissions?.[permissionKey] === true;
|
||||||
|
};
|
||||||
|
|
||||||
// Check if a feature is locked (returns true if locked)
|
// Check if a feature is locked (returns true if locked)
|
||||||
const isLocked = (feature: FeatureKey | undefined): boolean => {
|
const isLocked = (feature: FeatureKey | undefined): boolean => {
|
||||||
@@ -92,123 +102,167 @@ const SettingsLayout: React.FC = () => {
|
|||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
|
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
|
||||||
{/* Business Section */}
|
{/* Business Section */}
|
||||||
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
|
{(hasSettingsPermission('can_access_settings_general') ||
|
||||||
<SettingsSidebarItem
|
hasSettingsPermission('can_access_settings_resource_types') ||
|
||||||
to="/dashboard/settings/general"
|
hasSettingsPermission('can_access_settings_booking') ||
|
||||||
icon={Building2}
|
hasSettingsPermission('can_access_settings_business_hours')) && (
|
||||||
label={t('settings.general.title', 'General')}
|
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
|
||||||
description={t('settings.general.description', 'Name, timezone, contact')}
|
{hasSettingsPermission('can_access_settings_general') && (
|
||||||
/>
|
<SettingsSidebarItem
|
||||||
<SettingsSidebarItem
|
to="/dashboard/settings/general"
|
||||||
to="/dashboard/settings/resource-types"
|
icon={Building2}
|
||||||
icon={Layers}
|
label={t('settings.general.title', 'General')}
|
||||||
label={t('settings.resourceTypes.title', 'Resource Types')}
|
description={t('settings.general.description', 'Name, timezone, contact')}
|
||||||
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
/>
|
||||||
/>
|
)}
|
||||||
<SettingsSidebarItem
|
{hasSettingsPermission('can_access_settings_resource_types') && (
|
||||||
to="/dashboard/settings/booking"
|
<SettingsSidebarItem
|
||||||
icon={Calendar}
|
to="/dashboard/settings/resource-types"
|
||||||
label={t('settings.booking.title', 'Booking')}
|
icon={Layers}
|
||||||
description={t('settings.booking.description', 'Booking URL, redirects')}
|
label={t('settings.resourceTypes.title', 'Resource Types')}
|
||||||
/>
|
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
||||||
<SettingsSidebarItem
|
/>
|
||||||
to="/dashboard/settings/business-hours"
|
)}
|
||||||
icon={Clock}
|
{hasSettingsPermission('can_access_settings_booking') && (
|
||||||
label={t('settings.businessHours.title', 'Business Hours')}
|
<SettingsSidebarItem
|
||||||
description={t('settings.businessHours.description', 'Operating hours')}
|
to="/dashboard/settings/booking"
|
||||||
/>
|
icon={Calendar}
|
||||||
</SettingsSidebarSection>
|
label={t('settings.booking.title', 'Booking')}
|
||||||
|
description={t('settings.booking.description', 'Booking URL, redirects')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasSettingsPermission('can_access_settings_business_hours') && (
|
||||||
|
<SettingsSidebarItem
|
||||||
|
to="/dashboard/settings/business-hours"
|
||||||
|
icon={Clock}
|
||||||
|
label={t('settings.businessHours.title', 'Business Hours')}
|
||||||
|
description={t('settings.businessHours.description', 'Operating hours')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Branding Section */}
|
{/* Branding Section */}
|
||||||
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
|
{(hasSettingsPermission('can_access_settings_branding') ||
|
||||||
<SettingsSidebarItem
|
hasSettingsPermission('can_access_settings_email_templates') ||
|
||||||
to="/dashboard/settings/branding"
|
hasSettingsPermission('can_access_settings_custom_domains') ||
|
||||||
icon={Palette}
|
hasSettingsPermission('can_access_settings_embed_widget')) && (
|
||||||
label={t('settings.appearance.title', 'Appearance')}
|
<SettingsSidebarSection title={t('settings.sections.branding', 'Branding')}>
|
||||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
{hasSettingsPermission('can_access_settings_branding') && (
|
||||||
locked={isLocked('remove_branding')}
|
<SettingsSidebarItem
|
||||||
/>
|
to="/dashboard/settings/branding"
|
||||||
<SettingsSidebarItem
|
icon={Palette}
|
||||||
to="/dashboard/settings/email-templates"
|
label={t('settings.appearance.title', 'Appearance')}
|
||||||
icon={Mail}
|
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||||
label={t('settings.emailTemplates.title', 'Email Templates')}
|
locked={isLocked('remove_branding')}
|
||||||
description={t('settings.emailTemplates.description', 'Customize automated emails')}
|
/>
|
||||||
/>
|
)}
|
||||||
<SettingsSidebarItem
|
{hasSettingsPermission('can_access_settings_email_templates') && (
|
||||||
to="/dashboard/settings/custom-domains"
|
<SettingsSidebarItem
|
||||||
icon={Globe}
|
to="/dashboard/settings/email-templates"
|
||||||
label={t('settings.customDomains.title', 'Custom Domains')}
|
icon={Mail}
|
||||||
description={t('settings.customDomains.description', 'Use your own domain')}
|
label={t('settings.emailTemplates.title', 'Email Templates')}
|
||||||
locked={isLocked('custom_domain')}
|
description={t('settings.emailTemplates.description', 'Customize automated emails')}
|
||||||
/>
|
/>
|
||||||
<SettingsSidebarItem
|
)}
|
||||||
to="/dashboard/settings/embed-widget"
|
{hasSettingsPermission('can_access_settings_custom_domains') && (
|
||||||
icon={Code2}
|
<SettingsSidebarItem
|
||||||
label={t('settings.embedWidget.title', 'Embed Widget')}
|
to="/dashboard/settings/custom-domains"
|
||||||
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')}
|
icon={Globe}
|
||||||
/>
|
label={t('settings.customDomains.title', 'Custom Domains')}
|
||||||
</SettingsSidebarSection>
|
description={t('settings.customDomains.description', 'Use your own domain')}
|
||||||
|
locked={isLocked('custom_domain')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasSettingsPermission('can_access_settings_embed_widget') && (
|
||||||
|
<SettingsSidebarItem
|
||||||
|
to="/dashboard/settings/embed-widget"
|
||||||
|
icon={Code2}
|
||||||
|
label={t('settings.embedWidget.title', 'Embed Widget')}
|
||||||
|
description={t('settings.embedWidget.sidebarDescription', 'Add booking to your site')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Integrations Section */}
|
{/* Integrations Section */}
|
||||||
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
|
{hasSettingsPermission('can_access_settings_api') && (
|
||||||
<SettingsSidebarItem
|
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
|
||||||
to="/dashboard/settings/api"
|
<SettingsSidebarItem
|
||||||
icon={Key}
|
to="/dashboard/settings/api"
|
||||||
label={t('settings.api.title', 'API & Webhooks')}
|
icon={Key}
|
||||||
description={t('settings.api.description', 'API tokens, webhooks')}
|
label={t('settings.api.title', 'API & Webhooks')}
|
||||||
locked={isLocked('api_access')}
|
description={t('settings.api.description', 'API tokens, webhooks')}
|
||||||
/>
|
locked={isLocked('api_access')}
|
||||||
</SettingsSidebarSection>
|
/>
|
||||||
|
</SettingsSidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Access Section */}
|
{/* Access Section */}
|
||||||
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
|
{(hasSettingsPermission('can_access_settings_staff_roles') ||
|
||||||
<SettingsSidebarItem
|
hasSettingsPermission('can_access_settings_authentication')) && (
|
||||||
to="/dashboard/settings/staff-roles"
|
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
|
||||||
icon={Users}
|
{hasSettingsPermission('can_access_settings_staff_roles') && (
|
||||||
label={t('settings.staffRoles.title', 'Staff Roles')}
|
<SettingsSidebarItem
|
||||||
description={t('settings.staffRoles.description', 'Role permissions')}
|
to="/dashboard/settings/staff-roles"
|
||||||
/>
|
icon={Users}
|
||||||
<SettingsSidebarItem
|
label={t('settings.staffRoles.title', 'Staff Roles')}
|
||||||
to="/dashboard/settings/authentication"
|
description={t('settings.staffRoles.description', 'Role permissions')}
|
||||||
icon={Lock}
|
/>
|
||||||
label={t('settings.authentication.title', 'Authentication')}
|
)}
|
||||||
description={t('settings.authentication.description', 'OAuth, social login')}
|
{hasSettingsPermission('can_access_settings_authentication') && (
|
||||||
locked={isLocked('custom_oauth')}
|
<SettingsSidebarItem
|
||||||
/>
|
to="/dashboard/settings/authentication"
|
||||||
</SettingsSidebarSection>
|
icon={Lock}
|
||||||
|
label={t('settings.authentication.title', 'Authentication')}
|
||||||
|
description={t('settings.authentication.description', 'OAuth, social login')}
|
||||||
|
locked={isLocked('custom_oauth')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Communication Section */}
|
{/* Communication Section */}
|
||||||
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
|
{(hasSettingsPermission('can_access_settings_email') ||
|
||||||
<SettingsSidebarItem
|
hasSettingsPermission('can_access_settings_sms_calling')) && (
|
||||||
to="/dashboard/settings/email"
|
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
|
||||||
icon={Mail}
|
{hasSettingsPermission('can_access_settings_email') && (
|
||||||
label={t('settings.email.title', 'Email Setup')}
|
<SettingsSidebarItem
|
||||||
description={t('settings.email.description', 'Email addresses for tickets')}
|
to="/dashboard/settings/email"
|
||||||
/>
|
icon={Mail}
|
||||||
<SettingsSidebarItem
|
label={t('settings.email.title', 'Email Setup')}
|
||||||
to="/dashboard/settings/sms-calling"
|
description={t('settings.email.description', 'Email addresses for tickets')}
|
||||||
icon={Phone}
|
/>
|
||||||
label={t('settings.smsCalling.title', 'SMS & Calling')}
|
)}
|
||||||
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
|
{hasSettingsPermission('can_access_settings_sms_calling') && (
|
||||||
locked={isLocked('sms_reminders')}
|
<SettingsSidebarItem
|
||||||
/>
|
to="/dashboard/settings/sms-calling"
|
||||||
</SettingsSidebarSection>
|
icon={Phone}
|
||||||
|
label={t('settings.smsCalling.title', 'SMS & Calling')}
|
||||||
|
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
|
||||||
|
locked={isLocked('sms_reminders')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Billing Section */}
|
{/* Billing Section - Owner only */}
|
||||||
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
|
{isOwner && (
|
||||||
<SettingsSidebarItem
|
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
|
||||||
to="/dashboard/settings/billing"
|
<SettingsSidebarItem
|
||||||
icon={CreditCard}
|
to="/dashboard/settings/billing"
|
||||||
label={t('settings.billing.title', 'Plan & Billing')}
|
icon={CreditCard}
|
||||||
description={t('settings.billing.description', 'Subscription, invoices')}
|
label={t('settings.billing.title', 'Plan & Billing')}
|
||||||
/>
|
description={t('settings.billing.description', 'Subscription, invoices')}
|
||||||
<SettingsSidebarItem
|
/>
|
||||||
to="/dashboard/settings/quota"
|
<SettingsSidebarItem
|
||||||
icon={AlertTriangle}
|
to="/dashboard/settings/quota"
|
||||||
label={t('settings.quota.title', 'Quota Management')}
|
icon={AlertTriangle}
|
||||||
description={t('settings.quota.description', 'Usage limits, archiving')}
|
label={t('settings.quota.title', 'Quota Management')}
|
||||||
/>
|
description={t('settings.quota.description', 'Usage limits, archiving')}
|
||||||
</SettingsSidebarSection>
|
/>
|
||||||
|
</SettingsSidebarSection>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Customer, User } from '../types';
|
import { Customer, User } from '../types';
|
||||||
import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer } from '../hooks/useCustomers';
|
import { useCustomersInfinite, useCreateCustomer, useUpdateCustomer, useVerifyCustomerEmail } from '../hooks/useCustomers';
|
||||||
import { useAppointments } from '../hooks/useAppointments';
|
import { useAppointments } from '../hooks/useAppointments';
|
||||||
import { useServices } from '../hooks/useServices';
|
import { useServices } from '../hooks/useServices';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
History,
|
History,
|
||||||
Save
|
Save,
|
||||||
|
BadgeCheck,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
|
|
||||||
@@ -68,6 +70,9 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verify email confirmation modal state
|
||||||
|
const [verifyEmailTarget, setVerifyEmailTarget] = useState<Customer | null>(null);
|
||||||
|
|
||||||
// Infinite scroll for customers
|
// Infinite scroll for customers
|
||||||
const {
|
const {
|
||||||
data: customersData,
|
data: customersData,
|
||||||
@@ -81,6 +86,7 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
const { data: services = [] } = useServices();
|
const { data: services = [] } = useServices();
|
||||||
const createCustomerMutation = useCreateCustomer();
|
const createCustomerMutation = useCreateCustomer();
|
||||||
const updateCustomerMutation = useUpdateCustomer();
|
const updateCustomerMutation = useUpdateCustomer();
|
||||||
|
const verifyEmailMutation = useVerifyCustomerEmail();
|
||||||
|
|
||||||
// Transform paginated data to flat array
|
// Transform paginated data to flat array
|
||||||
const customers: Customer[] = useMemo(() => {
|
const customers: Customer[] = useMemo(() => {
|
||||||
@@ -222,6 +228,20 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifyEmailClick = (customer: Customer) => {
|
||||||
|
setVerifyEmailTarget(customer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyEmailConfirm = async () => {
|
||||||
|
if (!verifyEmailTarget) return;
|
||||||
|
try {
|
||||||
|
await verifyEmailMutation.mutateAsync(verifyEmailTarget.id);
|
||||||
|
setVerifyEmailTarget(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to toggle email verification:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getServiceName = (serviceId: string) => {
|
const getServiceName = (serviceId: string) => {
|
||||||
const service = services.find(s => String(s.id) === serviceId);
|
const service = services.find(s => String(s.id) === serviceId);
|
||||||
return service?.name || t('customers.unknownService');
|
return service?.name || t('customers.unknownService');
|
||||||
@@ -381,6 +401,22 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
<td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td>
|
<td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleVerifyEmailClick(customer); }}
|
||||||
|
disabled={verifyEmailMutation.isPending}
|
||||||
|
className={`p-1.5 border rounded-lg transition-colors ${
|
||||||
|
customer.email_verified
|
||||||
|
? 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-900/30'
|
||||||
|
: 'text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-900/30'
|
||||||
|
}`}
|
||||||
|
title={customer.email_verified ? t('customers.emailVerified') : t('customers.verifyEmail')}
|
||||||
|
>
|
||||||
|
{verifyEmailMutation.isPending ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<BadgeCheck size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleEditClick(customer); }}
|
onClick={(e) => { e.stopPropagation(); handleEditClick(customer); }}
|
||||||
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
@@ -807,6 +843,48 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Verify Email Confirmation Modal */}
|
||||||
|
{verifyEmailTarget && (
|
||||||
|
<Portal>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{verifyEmailTarget.email_verified ? t('customers.unverifyEmailTitle') : t('customers.verifyEmailTitle')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{verifyEmailTarget.email_verified
|
||||||
|
? t('customers.unverifyEmailConfirm', { email: verifyEmailTarget.email })
|
||||||
|
: t('customers.verifyEmailConfirm', { email: verifyEmailTarget.email })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setVerifyEmailTarget(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyEmailConfirm}
|
||||||
|
disabled={verifyEmailMutation.isPending}
|
||||||
|
className={`px-4 py-2 text-sm font-medium text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
|
||||||
|
verifyEmailTarget.email_verified
|
||||||
|
? 'bg-amber-600 hover:bg-amber-700'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{verifyEmailMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||||
|
{verifyEmailTarget.email_verified ? t('customers.unverifyEmail') : t('customers.verifyEmail')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ const Messages: React.FC = () => {
|
|||||||
// Computed
|
// Computed
|
||||||
const roleOptions = [
|
const roleOptions = [
|
||||||
{ value: 'owner', label: 'Owners', icon: Users, description: 'Business owners' },
|
{ value: 'owner', label: 'Owners', icon: Users, description: 'Business owners' },
|
||||||
{ value: 'manager', label: 'Managers', icon: Users, description: 'Team leads' },
|
|
||||||
{ value: 'staff', label: 'Staff', icon: Users, description: 'Employees' },
|
{ value: 'staff', label: 'Staff', icon: Users, description: 'Employees' },
|
||||||
{ value: 'customer', label: 'Customers', icon: Users, description: 'Clients' },
|
{ value: 'customer', label: 'Customers', icon: Users, description: 'Clients' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const Payments: React.FC = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>();
|
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>();
|
||||||
|
|
||||||
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'manager';
|
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'staff';
|
||||||
const isCustomer = effectiveUser.role === 'customer';
|
const isCustomer = effectiveUser.role === 'customer';
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { useCreateResource, useResources } from '../hooks/useBusiness';
|
import { useCreateResource, useResources } from '../hooks/useBusiness';
|
||||||
import { useStaff, useToggleStaffActive, useUpdateStaff, StaffMember } from '../hooks/useStaff';
|
import { useStaff, useToggleStaffActive, useUpdateStaff, useVerifyStaffEmail, useSendStaffPasswordReset, StaffMember } from '../hooks/useStaff';
|
||||||
import {
|
import {
|
||||||
useInvitations,
|
useInvitations,
|
||||||
useCreateInvitation,
|
useCreateInvitation,
|
||||||
@@ -30,9 +30,13 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
UserX,
|
UserX,
|
||||||
Power,
|
Power,
|
||||||
|
BadgeCheck,
|
||||||
|
Key,
|
||||||
|
Phone,
|
||||||
|
Eye,
|
||||||
|
ArrowUpDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
import StaffPermissions from '../components/StaffPermissions';
|
|
||||||
|
|
||||||
interface StaffProps {
|
interface StaffProps {
|
||||||
onMasquerade: (user: User) => void;
|
onMasquerade: (user: User) => void;
|
||||||
@@ -51,10 +55,13 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
const resendInvitationMutation = useResendInvitation();
|
const resendInvitationMutation = useResendInvitation();
|
||||||
const toggleActiveMutation = useToggleStaffActive();
|
const toggleActiveMutation = useToggleStaffActive();
|
||||||
const updateStaffMutation = useUpdateStaff();
|
const updateStaffMutation = useUpdateStaff();
|
||||||
|
const verifyEmailMutation = useVerifyStaffEmail();
|
||||||
|
const passwordResetMutation = useSendStaffPasswordReset();
|
||||||
|
|
||||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF');
|
// All invitations are for TENANT_STAFF - manager role removed
|
||||||
|
const inviteRole = 'TENANT_STAFF';
|
||||||
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
|
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
|
||||||
const [createBookableResource, setCreateBookableResource] = useState(false);
|
const [createBookableResource, setCreateBookableResource] = useState(false);
|
||||||
const [resourceName, setResourceName] = useState('');
|
const [resourceName, setResourceName] = useState('');
|
||||||
@@ -66,16 +73,55 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
// Edit modal state
|
// Edit modal state
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
|
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
|
||||||
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
|
|
||||||
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
|
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
|
||||||
|
const [editFirstName, setEditFirstName] = useState('');
|
||||||
|
const [editLastName, setEditLastName] = useState('');
|
||||||
|
const [editPhone, setEditPhone] = useState('');
|
||||||
const [editError, setEditError] = useState('');
|
const [editError, setEditError] = useState('');
|
||||||
const [editSuccess, setEditSuccess] = useState('');
|
const [editSuccess, setEditSuccess] = useState('');
|
||||||
|
|
||||||
// Check if user can invite managers (only owners can)
|
// Verify email confirmation modal state
|
||||||
const canInviteManagers = effectiveUser.role === 'owner';
|
const [verifyEmailTarget, setVerifyEmailTarget] = useState<StaffMember | null>(null);
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'role'; direction: 'asc' | 'desc' }>({
|
||||||
|
key: 'name',
|
||||||
|
direction: 'asc'
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (key: 'name' | 'role') => {
|
||||||
|
setSortConfig(current => ({
|
||||||
|
key,
|
||||||
|
direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate active and inactive staff, then sort
|
||||||
|
const activeStaff = useMemo(() => {
|
||||||
|
const active = staffMembers.filter((s) => s.is_active);
|
||||||
|
return [...active].sort((a, b) => {
|
||||||
|
let aValue: string;
|
||||||
|
let bValue: string;
|
||||||
|
|
||||||
|
if (sortConfig.key === 'name') {
|
||||||
|
aValue = (a.name || a.email || '').toLowerCase();
|
||||||
|
bValue = (b.name || b.email || '').toLowerCase();
|
||||||
|
} else {
|
||||||
|
// Sort by role: owners first, then by staff_role_name
|
||||||
|
const aIsOwner = a.role === 'owner';
|
||||||
|
const bIsOwner = b.role === 'owner';
|
||||||
|
if (aIsOwner && !bIsOwner) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (!aIsOwner && bIsOwner) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
aValue = (a.staff_role_name || '').toLowerCase();
|
||||||
|
bValue = (b.staff_role_name || '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [staffMembers, sortConfig]);
|
||||||
|
|
||||||
// Separate active and inactive staff
|
|
||||||
const activeStaff = staffMembers.filter((s) => s.is_active);
|
|
||||||
const inactiveStaff = staffMembers.filter((s) => !s.is_active);
|
const inactiveStaff = staffMembers.filter((s) => !s.is_active);
|
||||||
|
|
||||||
// Helper to check if a user is already linked to a resource
|
// Helper to check if a user is already linked to a resource
|
||||||
@@ -151,7 +197,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
|
|
||||||
const openInviteModal = () => {
|
const openInviteModal = () => {
|
||||||
setInviteEmail('');
|
setInviteEmail('');
|
||||||
setInviteRole('TENANT_STAFF');
|
|
||||||
setInviteStaffRoleId(null);
|
setInviteStaffRoleId(null);
|
||||||
setCreateBookableResource(false);
|
setCreateBookableResource(false);
|
||||||
setResourceName('');
|
setResourceName('');
|
||||||
@@ -196,8 +241,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
|
|
||||||
const openEditModal = (staff: StaffMember) => {
|
const openEditModal = (staff: StaffMember) => {
|
||||||
setEditingStaff(staff);
|
setEditingStaff(staff);
|
||||||
setEditPermissions(staff.permissions || {});
|
|
||||||
setEditStaffRoleId(staff.staff_role_id);
|
setEditStaffRoleId(staff.staff_role_id);
|
||||||
|
setEditFirstName(staff.first_name);
|
||||||
|
setEditLastName(staff.last_name);
|
||||||
|
setEditPhone(staff.phone || '');
|
||||||
setEditError('');
|
setEditError('');
|
||||||
setEditSuccess('');
|
setEditSuccess('');
|
||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
@@ -206,8 +253,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
const closeEditModal = () => {
|
const closeEditModal = () => {
|
||||||
setIsEditModalOpen(false);
|
setIsEditModalOpen(false);
|
||||||
setEditingStaff(null);
|
setEditingStaff(null);
|
||||||
setEditPermissions({});
|
|
||||||
setEditStaffRoleId(null);
|
setEditStaffRoleId(null);
|
||||||
|
setEditFirstName('');
|
||||||
|
setEditLastName('');
|
||||||
|
setEditPhone('');
|
||||||
setEditError('');
|
setEditError('');
|
||||||
setEditSuccess('');
|
setEditSuccess('');
|
||||||
};
|
};
|
||||||
@@ -217,10 +266,17 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
|
|
||||||
setEditError('');
|
setEditError('');
|
||||||
try {
|
try {
|
||||||
const updates: { permissions: Record<string, boolean>; staff_role_id?: number | null } = {
|
const updates: {
|
||||||
permissions: editPermissions,
|
staff_role_id?: number | null;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
} = {
|
||||||
|
first_name: editFirstName,
|
||||||
|
last_name: editLastName,
|
||||||
|
phone: editPhone,
|
||||||
};
|
};
|
||||||
// Only include staff_role_id for staff users (not owners/managers)
|
// Only include staff_role_id for staff users (not owners)
|
||||||
if (editingStaff.role === 'staff') {
|
if (editingStaff.role === 'staff') {
|
||||||
updates.staff_role_id = editStaffRoleId;
|
updates.staff_role_id = editStaffRoleId;
|
||||||
}
|
}
|
||||||
@@ -237,6 +293,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendPasswordReset = async () => {
|
||||||
|
if (!editingStaff) return;
|
||||||
|
|
||||||
|
if (!confirm(t('staff.confirmPasswordReset', { email: editingStaff.email }))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await passwordResetMutation.mutateAsync(editingStaff.id);
|
||||||
|
setEditSuccess(t('staff.passwordResetSent'));
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditError(err.response?.data?.error || t('staff.passwordResetFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeactivateFromModal = async () => {
|
const handleDeactivateFromModal = async () => {
|
||||||
if (!editingStaff) return;
|
if (!editingStaff) return;
|
||||||
|
|
||||||
@@ -251,6 +322,20 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifyEmailClick = (user: StaffMember) => {
|
||||||
|
setVerifyEmailTarget(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyEmailConfirm = async () => {
|
||||||
|
if (!verifyEmailTarget) return;
|
||||||
|
try {
|
||||||
|
await verifyEmailMutation.mutateAsync(verifyEmailTarget.id);
|
||||||
|
setVerifyEmailTarget(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to toggle email verification:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -325,9 +410,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
<table className="w-full text-sm text-left">
|
<table className="w-full text-sm text-left">
|
||||||
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
|
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('name')}>
|
||||||
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
|
<div className="flex items-center gap-1">{t('staff.name')} <ArrowUpDown size={14} className="text-gray-400" /></div>
|
||||||
<th className="px-6 py-4 font-medium">{t('staff.staffRole')}</th>
|
</th>
|
||||||
|
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('role')}>
|
||||||
|
<div className="flex items-center gap-1">{t('staff.role')} <ArrowUpDown size={14} className="text-gray-400" /></div>
|
||||||
|
</th>
|
||||||
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
|
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
|
||||||
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -356,34 +444,19 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
user.role === 'owner'
|
user.role === 'owner'
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
: user.role === 'manager'
|
: user.staff_role_name === 'Manager'
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.role === 'owner' && <Shield size={12} />}
|
{user.role === 'owner' && <Shield size={12} />}
|
||||||
{user.role === 'manager' && <Briefcase size={12} />}
|
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
|
||||||
{user.role}
|
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
|
||||||
{user.role === 'staff' ? (
|
|
||||||
user.staff_role_name ? (
|
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
{user.staff_role_name}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
|
||||||
{t('staff.noRoleAssigned')}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{linkedResource ? (
|
{linkedResource ? (
|
||||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
|
||||||
@@ -401,21 +474,38 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleVerifyEmailClick(user)}
|
||||||
|
disabled={verifyEmailMutation.isPending}
|
||||||
|
className={`p-1.5 border rounded-lg transition-colors ${
|
||||||
|
user.email_verified
|
||||||
|
? 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-900/30'
|
||||||
|
: 'text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-900/30'
|
||||||
|
}`}
|
||||||
|
title={user.email_verified ? t('staff.emailVerified') : t('staff.verifyEmail')}
|
||||||
|
>
|
||||||
|
{verifyEmailMutation.isPending ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<BadgeCheck size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(user)}
|
||||||
|
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title={t('common.edit')}
|
||||||
|
>
|
||||||
|
<Pencil size={14} /> {t('common.edit')}
|
||||||
|
</button>
|
||||||
{canMasquerade && (
|
{canMasquerade && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onMasquerade(user)}
|
onClick={() => onMasquerade(user)}
|
||||||
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
|
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
|
||||||
|
title={t('common.masqueradeAsUser')}
|
||||||
>
|
>
|
||||||
{t('common.masquerade')}
|
<Eye size={14} /> {t('common.masquerade')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(user)}
|
|
||||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
title={t('common.edit')}
|
|
||||||
>
|
|
||||||
<Pencil size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -473,10 +563,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
{user.role === 'owner' && <Shield size={12} />}
|
{user.role === 'owner' && <Shield size={12} />}
|
||||||
{user.role === 'manager' && <Briefcase size={12} />}
|
{user.staff_role_name === 'Manager' && <Briefcase size={12} />}
|
||||||
{user.role}
|
{user.role === 'owner' ? t('staff.roleOwner') : (user.staff_role_name || t('staff.noRoleAssigned'))}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -545,33 +635,11 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Role Selector */}
|
{/* Staff Role Selector */}
|
||||||
<div>
|
{staffRoles.length > 0 && (
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{t('staff.roleLabel')} *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={inviteRole}
|
|
||||||
onChange={(e) => setInviteRole(e.target.value as 'TENANT_MANAGER' | 'TENANT_STAFF')}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
|
||||||
>
|
|
||||||
<option value="TENANT_STAFF">{t('staff.roleStaff')}</option>
|
|
||||||
{canInviteManagers && (
|
|
||||||
<option value="TENANT_MANAGER">{t('staff.roleManager')}</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{inviteRole === 'TENANT_MANAGER'
|
|
||||||
? t('staff.managerRoleHint')
|
|
||||||
: t('staff.staffRoleHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Staff Role Selector (only for staff invitations) */}
|
|
||||||
{inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('staff.staffRole')}
|
{t('staff.roleLabel')} *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={inviteStaffRoleId ?? ''}
|
value={inviteStaffRoleId ?? ''}
|
||||||
@@ -592,23 +660,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Permissions - Using shared component */}
|
{/* Permissions - Using shared component */}
|
||||||
{inviteRole === 'TENANT_MANAGER' && (
|
<StaffPermissions
|
||||||
<StaffPermissions
|
role="staff"
|
||||||
role="manager"
|
permissions={invitePermissions}
|
||||||
permissions={invitePermissions}
|
onChange={setInvitePermissions}
|
||||||
onChange={setInvitePermissions}
|
variant="invite"
|
||||||
variant="invite"
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{inviteRole === 'TENANT_STAFF' && (
|
|
||||||
<StaffPermissions
|
|
||||||
role="staff"
|
|
||||||
permissions={invitePermissions}
|
|
||||||
onChange={setInvitePermissions}
|
|
||||||
variant="invite"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Make Bookable Option */}
|
{/* Make Bookable Option */}
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
@@ -692,8 +749,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
{isEditModalOpen && editingStaff && (
|
{isEditModalOpen && editingStaff && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden max-h-[90vh] flex flex-col">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center flex-shrink-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{t('staff.editStaff')}
|
{t('staff.editStaff')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -705,80 +762,157 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||||
{/* Staff Info */}
|
{/* Profile Information Section */}
|
||||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
<div>
|
||||||
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 font-medium text-lg">
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||||
{editingStaff.name.charAt(0).toUpperCase()}
|
<UserIcon size={16} />
|
||||||
|
{t('staff.profileInformation', 'Profile Information')}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('staff.firstName', 'First Name')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editFirstName}
|
||||||
|
onChange={(e) => setEditFirstName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder={t('staff.firstNamePlaceholder', 'Enter first name')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('staff.lastName', 'Last Name')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editLastName}
|
||||||
|
onChange={(e) => setEditLastName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder={t('staff.lastNamePlaceholder', 'Enter last name')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="mt-4">
|
||||||
<div className="font-medium text-gray-900 dark:text-white">{editingStaff.name}</div>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">{editingStaff.email}</div>
|
{t('staff.email', 'Email')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={editingStaff.email}
|
||||||
|
disabled
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleVerifyEmailClick(editingStaff)}
|
||||||
|
disabled={verifyEmailMutation.isPending}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
editingStaff.email_verified
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50'
|
||||||
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{verifyEmailMutation.isPending ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<BadgeCheck size={12} />
|
||||||
|
)}
|
||||||
|
{editingStaff.email_verified ? t('staff.verified', 'Verified') : t('staff.verify', 'Verify')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('staff.phone', 'Phone')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={editPhone}
|
||||||
|
onChange={(e) => setEditPhone(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder={t('staff.phonePlaceholder', 'Enter phone number')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
className={`ml-auto inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
|
||||||
editingStaff.role === 'owner'
|
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
|
||||||
: editingStaff.role === 'manager'
|
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
|
||||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{editingStaff.role === 'owner' && <Shield size={12} />}
|
|
||||||
{editingStaff.role === 'manager' && <Briefcase size={12} />}
|
|
||||||
{editingStaff.role}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Staff Role Selector (only for staff users) */}
|
{/* Role Section */}
|
||||||
{editingStaff.role === 'staff' && staffRoles.length > 0 && (
|
{editingStaff.role !== 'owner' && staffRoles.length > 0 && (
|
||||||
<div>
|
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||||
{t('staff.staffRole')}
|
<Shield size={16} />
|
||||||
</label>
|
{t('staff.staffRole', 'Staff Role')}
|
||||||
<select
|
</h4>
|
||||||
value={editStaffRoleId ?? ''}
|
|
||||||
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
{/* Staff Role Selector */}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
<div>
|
||||||
>
|
<select
|
||||||
<option value="">{t('staff.selectRole')}</option>
|
value={editStaffRoleId ?? ''}
|
||||||
{staffRoles.map((role) => (
|
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
||||||
<option key={role.id} value={role.id}>
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
{role.name}
|
>
|
||||||
</option>
|
<option value="">{t('staff.selectRole')}</option>
|
||||||
))}
|
{staffRoles.map((role) => (
|
||||||
</select>
|
<option key={role.id} value={role.id}>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
{role.name}
|
||||||
{t('staff.staffRoleSelectHint')}
|
</option>
|
||||||
</p>
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('staff.staffRoleSelectHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Permissions - Using shared component */}
|
{/* Owner info banner */}
|
||||||
{editingStaff.role === 'manager' && (
|
|
||||||
<StaffPermissions
|
|
||||||
role="manager"
|
|
||||||
permissions={editPermissions}
|
|
||||||
onChange={setEditPermissions}
|
|
||||||
variant="edit"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editingStaff.role === 'staff' && (
|
|
||||||
<StaffPermissions
|
|
||||||
role="staff"
|
|
||||||
permissions={editPermissions}
|
|
||||||
onChange={setEditPermissions}
|
|
||||||
variant="edit"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No permissions for owners */}
|
|
||||||
{editingStaff.role === 'owner' && (
|
{editingStaff.role === 'owner' && (
|
||||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||||
<p className="text-sm text-purple-700 dark:text-purple-300">
|
<div className="flex items-center gap-2">
|
||||||
{t('staff.ownerFullAccess')}
|
<Shield size={16} className="text-purple-600 dark:text-purple-400" />
|
||||||
</p>
|
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
{t('staff.ownerFullAccess')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account Security Section - Password Reset */}
|
||||||
|
{editingStaff.role !== 'owner' && (
|
||||||
|
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||||
|
<Key size={16} />
|
||||||
|
{t('staff.accountSecurity', 'Account Security')}
|
||||||
|
</h4>
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{t('staff.resetPassword', 'Reset Password')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{t('staff.resetPasswordHint', 'Send a password reset email to this staff member')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSendPasswordReset}
|
||||||
|
disabled={passwordResetMutation.isPending}
|
||||||
|
className="ml-4 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 flex-shrink-0 text-brand-600 border border-brand-300 hover:bg-brand-50 dark:text-brand-400 dark:border-brand-700 dark:hover:bg-brand-900/30"
|
||||||
|
>
|
||||||
|
{passwordResetMutation.isPending ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mail size={14} />
|
||||||
|
)}
|
||||||
|
{t('staff.sendResetEmail', 'Send Reset Email')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -838,29 +972,69 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons - Fixed footer */}
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeEditModal}
|
onClick={closeEditModal}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
{editingStaff.role !== 'owner' && (
|
<button
|
||||||
<button
|
onClick={handleSaveStaffSettings}
|
||||||
onClick={handleSaveStaffSettings}
|
disabled={updateStaffMutation.isPending || !!editSuccess}
|
||||||
disabled={updateStaffMutation.isPending || !!editSuccess}
|
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
>
|
||||||
>
|
{updateStaffMutation.isPending ? (
|
||||||
{updateStaffMutation.isPending ? (
|
<Loader2 size={16} className="animate-spin" />
|
||||||
<Loader2 size={16} className="animate-spin" />
|
) : null}
|
||||||
) : null}
|
{t('common.save')}
|
||||||
{t('common.save')}
|
</button>
|
||||||
</button>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verify Email Confirmation Modal */}
|
||||||
|
{verifyEmailTarget && (
|
||||||
|
<Portal>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{verifyEmailTarget.email_verified ? t('staff.unverifyEmailTitle') : t('staff.verifyEmailTitle')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{verifyEmailTarget.email_verified
|
||||||
|
? t('staff.unverifyEmailConfirm', { email: verifyEmailTarget.email })
|
||||||
|
: t('staff.verifyEmailConfirm', { email: verifyEmailTarget.email })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setVerifyEmailTarget(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyEmailConfirm}
|
||||||
|
disabled={verifyEmailMutation.isPending}
|
||||||
|
className={`px-4 py-2 text-sm font-medium text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
|
||||||
|
verifyEmailTarget.email_verified
|
||||||
|
? 'bg-amber-600 hover:bg-amber-700'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{verifyEmailMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||||
|
{verifyEmailTarget.email_verified ? t('staff.unverifyEmail') : t('staff.verifyEmail')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ const Tickets: React.FC = () => {
|
|||||||
setIsTicketModalOpen(false);
|
setIsTicketModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOwnerOrManager = currentUser?.role === 'owner' || currentUser?.role === 'manager';
|
// Owner and staff with can_access_tickets permission have full access
|
||||||
|
const hasFullTicketAccess = currentUser?.role === 'owner' ||
|
||||||
|
(currentUser?.role === 'staff' && currentUser?.effective_permissions?.can_access_tickets);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +165,7 @@ const Tickets: React.FC = () => {
|
|||||||
{t('tickets.title', 'Support Tickets')}
|
{t('tickets.title', 'Support Tickets')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{isOwnerOrManager
|
{hasFullTicketAccess
|
||||||
? t('tickets.descriptionOwner', 'Manage support tickets for your business')
|
? t('tickets.descriptionOwner', 'Manage support tickets for your business')
|
||||||
: t('tickets.descriptionStaff', 'View and create support tickets')}
|
: t('tickets.descriptionStaff', 'View and create support tickets')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ const ApiSettings: React.FC = () => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_api === true;
|
||||||
const { canUse } = usePlanFeatures();
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Only the business owner can access these settings.
|
You do not have permission to access these settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const AuthenticationSettings: React.FC = () => {
|
|||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_authentication === true;
|
||||||
const { canUse } = usePlanFeatures();
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
// Update OAuth settings when data loads
|
// Update OAuth settings when data loads
|
||||||
@@ -147,11 +148,11 @@ const AuthenticationSettings: React.FC = () => {
|
|||||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Only the business owner can access these settings.
|
You do not have permission to access these settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const BookingSettings: React.FC = () => {
|
|||||||
const [returnUrlSaving, setReturnUrlSaving] = useState(false);
|
const [returnUrlSaving, setReturnUrlSaving] = useState(false);
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_booking === true;
|
||||||
|
|
||||||
const handleSaveReturnUrl = async () => {
|
const handleSaveReturnUrl = async () => {
|
||||||
setReturnUrlSaving(true);
|
setReturnUrlSaving(true);
|
||||||
@@ -40,11 +41,11 @@ const BookingSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{t('settings.booking.onlyOwnerCanAccess', 'Only the business owner can access these settings.')}
|
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -139,12 +139,13 @@ const BrandingSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_branding === true;
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Only the business owner can access these settings.
|
You do not have permission to access these settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks';
|
import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks';
|
||||||
import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui';
|
import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui';
|
||||||
import { BlockPurpose, TimeBlock } from '../../types';
|
import { BlockPurpose, TimeBlock, Business, User } from '../../types';
|
||||||
|
|
||||||
interface DayHours {
|
interface DayHours {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -58,11 +59,19 @@ const DEFAULT_HOURS: BusinessHours = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BusinessHoursSettings: React.FC = () => {
|
const BusinessHoursSettings: React.FC = () => {
|
||||||
|
const { user } = useOutletContext<{
|
||||||
|
business: Business;
|
||||||
|
user: User;
|
||||||
|
}>();
|
||||||
|
|
||||||
const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS);
|
const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [success, setSuccess] = useState<string>('');
|
const [success, setSuccess] = useState<string>('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_business_hours === true;
|
||||||
|
|
||||||
// Fetch existing business hours time blocks
|
// Fetch existing business hours time blocks
|
||||||
const { data: timeBlocks, isLoading } = useTimeBlocks({
|
const { data: timeBlocks, isLoading } = useTimeBlocks({
|
||||||
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
||||||
@@ -248,6 +257,16 @@ const BusinessHoursSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
You do not have permission to access these settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const CommunicationSettings: React.FC = () => {
|
|||||||
const [wizardAvailableNumbers, setWizardAvailableNumbers] = useState<AvailablePhoneNumber[]>([]);
|
const [wizardAvailableNumbers, setWizardAvailableNumbers] = useState<AvailablePhoneNumber[]>([]);
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_sms_calling === true;
|
||||||
const { canUse } = usePlanFeatures();
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
// Update settings form when credits data loads
|
// Update settings form when credits data loads
|
||||||
@@ -249,11 +250,11 @@ const CommunicationSettings: React.FC = () => {
|
|||||||
setWizardStep(4);
|
setWizardStep(4);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Only the business owner can access these settings.
|
You do not have permission to access these settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const CustomDomainsSettings: React.FC = () => {
|
|||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_custom_domains === true;
|
||||||
const { canUse } = usePlanFeatures();
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
const handleAddDomain = () => {
|
const handleAddDomain = () => {
|
||||||
@@ -104,11 +105,11 @@ const CustomDomainsSettings: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Only the business owner can access these settings.
|
You do not have permission to access these settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ const EmailSettings: React.FC = () => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email === true;
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Only the business owner can access these settings.
|
You do not have permission to access these settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const EmbedWidgetSettings: React.FC = () => {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_embed_widget === true;
|
||||||
|
|
||||||
// Build the embed URL
|
// Build the embed URL
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
@@ -86,11 +87,11 @@ const EmbedWidgetSettings: React.FC = () => {
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{t('settings.embedWidget.onlyOwnerCanAccess', 'Only the business owner can access these settings.')}
|
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -170,12 +170,13 @@ const GeneralSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_general === true;
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{t('settings.ownerOnly', 'Only the business owner can access these settings.')}
|
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const ResourceTypesSettings: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_resource_types === true;
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
setEditingType(null);
|
setEditingType(null);
|
||||||
@@ -83,11 +84,11 @@ const ResourceTypesSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Only the business owner can access these settings.
|
You do not have permission to access these settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,14 +41,15 @@ const StaffRolesSettings: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isOwner = user.role === 'owner';
|
const isOwner = user.role === 'owner';
|
||||||
const isManager = user.role === 'manager';
|
// Only owners can manage roles (staff with permissions can view but not edit)
|
||||||
const canManageRoles = isOwner || isManager;
|
const canManageRoles = isOwner;
|
||||||
|
|
||||||
// Merge menu and dangerous permissions for display
|
// Merge menu, settings, and dangerous permissions for display
|
||||||
const allPermissions = useMemo(() => {
|
const allPermissions = useMemo(() => {
|
||||||
if (!availablePermissions) return { menu: {}, dangerous: {} };
|
if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
|
||||||
return {
|
return {
|
||||||
menu: availablePermissions.menu_permissions || {},
|
menu: availablePermissions.menu_permissions || {},
|
||||||
|
settings: availablePermissions.settings_permissions || {},
|
||||||
dangerous: availablePermissions.dangerous_permissions || {},
|
dangerous: availablePermissions.dangerous_permissions || {},
|
||||||
};
|
};
|
||||||
}, [availablePermissions]);
|
}, [availablePermissions]);
|
||||||
@@ -82,21 +83,50 @@ const StaffRolesSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const togglePermission = (key: string) => {
|
const togglePermission = (key: string) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => {
|
||||||
...prev,
|
const newValue = !prev.permissions[key];
|
||||||
permissions: {
|
const updates: Record<string, boolean> = { [key]: newValue };
|
||||||
...prev.permissions,
|
|
||||||
[key]: !prev.permissions[key],
|
// If enabling any settings sub-permission, also enable the main settings access
|
||||||
},
|
if (newValue && key.startsWith('can_access_settings_')) {
|
||||||
}));
|
updates['can_access_settings'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If disabling the main settings access, disable all sub-permissions
|
||||||
|
if (!newValue && key === 'can_access_settings') {
|
||||||
|
Object.keys(allPermissions.settings).forEach((settingKey) => {
|
||||||
|
if (settingKey !== 'can_access_settings') {
|
||||||
|
updates[settingKey] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
permissions: {
|
||||||
|
...prev.permissions,
|
||||||
|
...updates,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => {
|
const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
|
||||||
const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous;
|
const permissions = category === 'menu'
|
||||||
|
? allPermissions.menu
|
||||||
|
: category === 'settings'
|
||||||
|
? allPermissions.settings
|
||||||
|
: allPermissions.dangerous;
|
||||||
const updates: Record<string, boolean> = {};
|
const updates: Record<string, boolean> = {};
|
||||||
Object.keys(permissions).forEach((key) => {
|
Object.keys(permissions).forEach((key) => {
|
||||||
updates[key] = enable;
|
updates[key] = enable;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If enabling any settings permissions, ensure main settings access is also enabled
|
||||||
|
if (category === 'settings' && enable) {
|
||||||
|
updates['can_access_settings'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -160,7 +190,7 @@ const StaffRolesSettings: React.FC = () => {
|
|||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')}
|
{t('settings.staffRoles.noAccess', 'Only the business owner can manage staff roles.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -324,8 +354,7 @@ const StaffRolesSettings: React.FC = () => {
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
required
|
required
|
||||||
disabled={editingRole?.is_default}
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
|
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,6 +427,60 @@ const StaffRolesSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Business Settings Permissions */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAllPermissions('settings', true)}
|
||||||
|
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t('common.selectAll', 'Select All')}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAllPermissions('settings', false)}
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t('common.clearAll', 'Clear All')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30">
|
||||||
|
{Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100/50 dark:hover:bg-blue-900/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.permissions[key] || false}
|
||||||
|
onChange={() => togglePermission(key)}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{def.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{def.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dangerous Permissions */}
|
{/* Dangerous Permissions */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Puck, Render } from '@measured/puck';
|
import { Puck, Render } from '@measured/puck';
|
||||||
import '@measured/puck/puck.css';
|
import '@measured/puck/puck.css';
|
||||||
@@ -39,6 +40,8 @@ import {
|
|||||||
SystemEmailTag,
|
SystemEmailTag,
|
||||||
SystemEmailCategory,
|
SystemEmailCategory,
|
||||||
SystemEmailType,
|
SystemEmailType,
|
||||||
|
Business,
|
||||||
|
User,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
// Category metadata
|
// Category metadata
|
||||||
@@ -86,6 +89,14 @@ const CATEGORY_ORDER: SystemEmailCategory[] = [
|
|||||||
const SystemEmailTemplates: React.FC = () => {
|
const SystemEmailTemplates: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useOutletContext<{
|
||||||
|
business: Business;
|
||||||
|
user: User;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOwner = user.role === 'owner';
|
||||||
|
const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email_templates === true;
|
||||||
|
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<SystemEmailCategory>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<SystemEmailCategory>>(
|
||||||
new Set(CATEGORY_ORDER)
|
new Set(CATEGORY_ORDER)
|
||||||
);
|
);
|
||||||
@@ -343,6 +354,16 @@ const SystemEmailTemplates: React.FC = () => {
|
|||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export interface Business {
|
|||||||
planPermissions?: PlanPermissions;
|
planPermissions?: PlanPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer';
|
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'staff' | 'resource' | 'customer';
|
||||||
|
|
||||||
export interface NotificationPreferences {
|
export interface NotificationPreferences {
|
||||||
email: boolean;
|
email: boolean;
|
||||||
@@ -163,6 +163,7 @@ export interface PermissionDefinition {
|
|||||||
|
|
||||||
export interface AvailablePermissions {
|
export interface AvailablePermissions {
|
||||||
menu_permissions: Record<string, PermissionDefinition>;
|
menu_permissions: Record<string, PermissionDefinition>;
|
||||||
|
settings_permissions: Record<string, PermissionDefinition>;
|
||||||
dangerous_permissions: Record<string, PermissionDefinition>;
|
dangerous_permissions: Record<string, PermissionDefinition>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +274,14 @@ export interface Customer {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
paymentMethods: PaymentMethod[];
|
paymentMethods: PaymentMethod[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
user_data?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Service {
|
export interface Service {
|
||||||
|
|||||||
@@ -88,13 +88,13 @@ def get_platform_support_team():
|
|||||||
|
|
||||||
|
|
||||||
def get_tenant_managers(tenant):
|
def get_tenant_managers(tenant):
|
||||||
"""Get all owners and managers for a tenant."""
|
"""Get all owners for a tenant (formerly owners and managers)."""
|
||||||
try:
|
try:
|
||||||
if not tenant:
|
if not tenant:
|
||||||
return User.objects.none()
|
return User.objects.none()
|
||||||
return User.objects.filter(
|
return User.objects.filter(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
role=User.Role.TENANT_OWNER,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class TestGetTenantManagers:
|
|||||||
"""Test the get_tenant_managers() helper function."""
|
"""Test the get_tenant_managers() helper function."""
|
||||||
|
|
||||||
def test_returns_tenant_managers(self):
|
def test_returns_tenant_managers(self):
|
||||||
"""Should return owners and managers for a tenant."""
|
"""Should return owners for a tenant (formerly owners and managers)."""
|
||||||
mock_tenant = Mock(id=1)
|
mock_tenant = Mock(id=1)
|
||||||
mock_queryset = Mock()
|
mock_queryset = Mock()
|
||||||
mock_filtered = Mock()
|
mock_filtered = Mock()
|
||||||
@@ -149,7 +149,7 @@ class TestGetTenantManagers:
|
|||||||
|
|
||||||
mock_queryset.filter.assert_called_once_with(
|
mock_queryset.filter.assert_called_once_with(
|
||||||
tenant=mock_tenant,
|
tenant=mock_tenant,
|
||||||
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
role=User.Role.TENANT_OWNER,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
assert result == mock_filtered
|
assert result == mock_filtered
|
||||||
|
|||||||
@@ -803,8 +803,8 @@ class TicketEmailAddressViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
# Business users see only their own email addresses
|
# Business users see only their own email addresses
|
||||||
if hasattr(user, 'tenant') and user.tenant:
|
if hasattr(user, 'tenant') and user.tenant:
|
||||||
# Only owners and managers can view/manage email addresses
|
# Only owners can view/manage email addresses
|
||||||
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
if user.role == User.Role.TENANT_OWNER:
|
||||||
return TicketEmailAddress.objects.filter(tenant=user.tenant)
|
return TicketEmailAddress.objects.filter(tenant=user.tenant)
|
||||||
|
|
||||||
return TicketEmailAddress.objects.none()
|
return TicketEmailAddress.objects.none()
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ class BroadcastMessageViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
if message.target_owners:
|
if message.target_owners:
|
||||||
role_filters |= Q(role=User.Role.TENANT_OWNER)
|
role_filters |= Q(role=User.Role.TENANT_OWNER)
|
||||||
if message.target_managers:
|
# Note: target_managers now targets no one (managers migrated to staff)
|
||||||
role_filters |= Q(role=User.Role.TENANT_MANAGER)
|
# Kept for backwards compatibility - messages sent to managers will just have no recipients
|
||||||
if message.target_staff:
|
if message.target_staff:
|
||||||
role_filters |= Q(role=User.Role.TENANT_STAFF)
|
role_filters |= Q(role=User.Role.TENANT_STAFF)
|
||||||
if message.target_customers:
|
if message.target_customers:
|
||||||
@@ -251,7 +251,8 @@ class BroadcastMessageViewSet(viewsets.ModelViewSet):
|
|||||||
base_query = User.objects.filter(tenant=tenant, is_active=True).exclude(id=user.id)
|
base_query = User.objects.filter(tenant=tenant, is_active=True).exclude(id=user.id)
|
||||||
|
|
||||||
owner_count = base_query.filter(role=User.Role.TENANT_OWNER).count()
|
owner_count = base_query.filter(role=User.Role.TENANT_OWNER).count()
|
||||||
manager_count = base_query.filter(role=User.Role.TENANT_MANAGER).count()
|
# manager_count is always 0 (managers migrated to staff with permissions)
|
||||||
|
manager_count = 0
|
||||||
staff_count = base_query.filter(role=User.Role.TENANT_STAFF).count()
|
staff_count = base_query.filter(role=User.Role.TENANT_STAFF).count()
|
||||||
customer_count = base_query.filter(role=User.Role.CUSTOMER).count()
|
customer_count = base_query.filter(role=User.Role.CUSTOMER).count()
|
||||||
|
|
||||||
@@ -357,16 +358,13 @@ class InboxViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class IsOwnerOrManager(BasePermission):
|
class IsOwnerOrManager(BasePermission):
|
||||||
"""Only owners and managers can manage email templates."""
|
"""Only owners can manage email templates."""
|
||||||
message = "You must be an owner or manager to manage email templates."
|
message = "You must be an owner to manage email templates."
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
return request.user.role in [
|
return request.user.role == User.Role.TENANT_OWNER
|
||||||
User.Role.TENANT_OWNER,
|
|
||||||
User.Role.TENANT_MANAGER,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ class StatusMachine:
|
|||||||
"""
|
"""
|
||||||
from smoothschedule.identity.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
# Owners and managers can always change status
|
# Owners can always change status
|
||||||
if self.user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
if self.user.role == User.Role.TENANT_OWNER:
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
# Staff must be assigned to the event
|
# Staff must be assigned to the event
|
||||||
|
|||||||
@@ -266,18 +266,6 @@ class TestStatusMachine:
|
|||||||
assert can_change is True
|
assert can_change is True
|
||||||
assert reason == ""
|
assert reason == ""
|
||||||
|
|
||||||
def test_can_user_change_status_manager_allowed(self):
|
|
||||||
"""Test can_user_change_status allows TENANT_MANAGER."""
|
|
||||||
mock_user = Mock()
|
|
||||||
mock_user.role = User.Role.TENANT_MANAGER
|
|
||||||
|
|
||||||
machine = StatusMachine(tenant=Mock(), user=mock_user)
|
|
||||||
mock_event = Mock()
|
|
||||||
|
|
||||||
can_change, reason = machine.can_user_change_status(mock_event)
|
|
||||||
|
|
||||||
assert can_change is True
|
|
||||||
|
|
||||||
def test_can_user_change_status_staff_assigned(self):
|
def test_can_user_change_status_staff_assigned(self):
|
||||||
"""Test can_user_change_status allows assigned TENANT_STAFF."""
|
"""Test can_user_change_status allows assigned TENANT_STAFF."""
|
||||||
mock_user = Mock()
|
mock_user = Mock()
|
||||||
|
|||||||
@@ -70,13 +70,6 @@ class TestHelperFunctions:
|
|||||||
|
|
||||||
assert is_field_employee(mock_user) is True
|
assert is_field_employee(mock_user) is True
|
||||||
|
|
||||||
def test_is_field_employee_with_manager_role(self):
|
|
||||||
"""Test is_field_employee returns True for TENANT_MANAGER."""
|
|
||||||
mock_user = Mock()
|
|
||||||
mock_user.role = User.Role.TENANT_MANAGER
|
|
||||||
|
|
||||||
assert is_field_employee(mock_user) is True
|
|
||||||
|
|
||||||
def test_is_field_employee_with_owner_role(self):
|
def test_is_field_employee_with_owner_role(self):
|
||||||
"""Test is_field_employee returns True for TENANT_OWNER."""
|
"""Test is_field_employee returns True for TENANT_OWNER."""
|
||||||
mock_user = Mock()
|
mock_user = Mock()
|
||||||
|
|||||||
@@ -56,10 +56,9 @@ def get_tenant_from_user(user):
|
|||||||
|
|
||||||
|
|
||||||
def is_field_employee(user):
|
def is_field_employee(user):
|
||||||
"""Check if user is a field employee (staff role)."""
|
"""Check if user is a field employee (staff or owner role)."""
|
||||||
return user.role in [
|
return user.role in [
|
||||||
User.Role.TENANT_STAFF,
|
User.Role.TENANT_STAFF,
|
||||||
User.Role.TENANT_MANAGER,
|
|
||||||
User.Role.TENANT_OWNER,
|
User.Role.TENANT_OWNER,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ from smoothschedule.identity.users.models import User
|
|||||||
|
|
||||||
|
|
||||||
def is_owner_or_manager(user):
|
def is_owner_or_manager(user):
|
||||||
"""Check if user is a tenant owner or manager."""
|
"""Check if user is a tenant owner or staff with management permissions."""
|
||||||
return user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]
|
if user.role == User.Role.TENANT_OWNER:
|
||||||
|
return True
|
||||||
|
if user.role == User.Role.TENANT_STAFF:
|
||||||
|
# Staff with can_manage_users permission has equivalent access
|
||||||
|
return user.has_staff_permission('can_manage_users')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ def can_hijack(hijacker, hijacked):
|
|||||||
│ Hijacker Role │ Can Hijack │
|
│ Hijacker Role │ Can Hijack │
|
||||||
├──────────────────────┼─────────────────────────────────────────────────┤
|
├──────────────────────┼─────────────────────────────────────────────────┤
|
||||||
│ SUPERUSER │ Anyone (full god mode) │
|
│ SUPERUSER │ Anyone (full god mode) │
|
||||||
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF │
|
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_STAFF, CUSTOMER │
|
||||||
│ PLATFORM_SALES │ Only users with is_temporary=True │
|
│ PLATFORM_SALES │ Only users with is_temporary=True │
|
||||||
│ TENANT_OWNER │ TENANT_STAFF in same tenant only │
|
│ TENANT_OWNER │ TENANT_STAFF, CUSTOMER in same tenant only │
|
||||||
│ Others │ Nobody │
|
│ Others │ Nobody │
|
||||||
└──────────────────────┴─────────────────────────────────────────────────┘
|
└──────────────────────┴─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ def can_hijack(hijacker, hijacked):
|
|||||||
if hijacker.role == User.Role.PLATFORM_SUPPORT:
|
if hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||||
return hijacked.role in [
|
return hijacked.role in [
|
||||||
User.Role.TENANT_OWNER,
|
User.Role.TENANT_OWNER,
|
||||||
User.Role.TENANT_MANAGER,
|
|
||||||
User.Role.TENANT_STAFF,
|
User.Role.TENANT_STAFF,
|
||||||
User.Role.CUSTOMER,
|
User.Role.CUSTOMER,
|
||||||
]
|
]
|
||||||
@@ -60,7 +59,7 @@ def can_hijack(hijacker, hijacked):
|
|||||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||||
return hijacked.is_temporary
|
return hijacked.is_temporary
|
||||||
|
|
||||||
# Rule 4: TENANT_OWNER can hijack managers, staff, and customers within their own tenant
|
# Rule 4: TENANT_OWNER can hijack staff and customers within their own tenant
|
||||||
if hijacker.role == User.Role.TENANT_OWNER:
|
if hijacker.role == User.Role.TENANT_OWNER:
|
||||||
# Must be in same tenant
|
# Must be in same tenant
|
||||||
if not hijacker.tenant or not hijacked.tenant:
|
if not hijacker.tenant or not hijacked.tenant:
|
||||||
@@ -68,9 +67,8 @@ def can_hijack(hijacker, hijacked):
|
|||||||
if hijacker.tenant.id != hijacked.tenant.id:
|
if hijacker.tenant.id != hijacked.tenant.id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Can hijack managers, staff, and customers (not other owners)
|
# Can hijack staff and customers (not other owners)
|
||||||
return hijacked.role in [
|
return hijacked.role in [
|
||||||
User.Role.TENANT_MANAGER,
|
|
||||||
User.Role.TENANT_STAFF,
|
User.Role.TENANT_STAFF,
|
||||||
User.Role.CUSTOMER,
|
User.Role.CUSTOMER,
|
||||||
]
|
]
|
||||||
@@ -127,7 +125,6 @@ def get_hijackable_users(hijacker):
|
|||||||
# Can hijack all tenant-level users
|
# Can hijack all tenant-level users
|
||||||
return qs.filter(role__in=[
|
return qs.filter(role__in=[
|
||||||
User.Role.TENANT_OWNER,
|
User.Role.TENANT_OWNER,
|
||||||
User.Role.TENANT_MANAGER,
|
|
||||||
User.Role.TENANT_STAFF,
|
User.Role.TENANT_STAFF,
|
||||||
User.Role.CUSTOMER,
|
User.Role.CUSTOMER,
|
||||||
])
|
])
|
||||||
@@ -137,13 +134,13 @@ def get_hijackable_users(hijacker):
|
|||||||
return qs.filter(is_temporary=True)
|
return qs.filter(is_temporary=True)
|
||||||
|
|
||||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||||
# Managers, staff, and customers in same tenant
|
# Staff and customers in same tenant
|
||||||
if not hijacker.tenant:
|
if not hijacker.tenant:
|
||||||
return qs.none()
|
return qs.none()
|
||||||
|
|
||||||
return qs.filter(
|
return qs.filter(
|
||||||
tenant=hijacker.tenant,
|
tenant=hijacker.tenant,
|
||||||
role__in=[User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -31,25 +31,28 @@ class TestIsOwnerOrManagerHelper:
|
|||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_returns_true_for_manager(self):
|
def test_returns_true_for_staff_with_permission(self):
|
||||||
"""Should return True for tenant manager."""
|
"""Should return True for staff with can_manage_users permission."""
|
||||||
from smoothschedule.identity.core.api_views import is_owner_or_manager
|
|
||||||
from smoothschedule.identity.users.models import User
|
|
||||||
|
|
||||||
mock_user = Mock()
|
|
||||||
mock_user.role = User.Role.TENANT_MANAGER
|
|
||||||
|
|
||||||
result = is_owner_or_manager(mock_user)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_returns_false_for_staff(self):
|
|
||||||
"""Should return False for staff role."""
|
|
||||||
from smoothschedule.identity.core.api_views import is_owner_or_manager
|
from smoothschedule.identity.core.api_views import is_owner_or_manager
|
||||||
from smoothschedule.identity.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
mock_user = Mock()
|
mock_user = Mock()
|
||||||
mock_user.role = User.Role.TENANT_STAFF
|
mock_user.role = User.Role.TENANT_STAFF
|
||||||
|
mock_user.has_staff_permission.return_value = True
|
||||||
|
|
||||||
|
result = is_owner_or_manager(mock_user)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_user.has_staff_permission.assert_called_once_with('can_manage_users')
|
||||||
|
|
||||||
|
def test_returns_false_for_staff_without_permission(self):
|
||||||
|
"""Should return False for staff without can_manage_users permission."""
|
||||||
|
from smoothschedule.identity.core.api_views import is_owner_or_manager
|
||||||
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
|
mock_user = Mock()
|
||||||
|
mock_user.role = User.Role.TENANT_STAFF
|
||||||
|
mock_user.has_staff_permission.return_value = False
|
||||||
|
|
||||||
result = is_owner_or_manager(mock_user)
|
result = is_owner_or_manager(mock_user)
|
||||||
|
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ class TestDenyStaffAllAccessPermission:
|
|||||||
request.method = 'GET'
|
request.method = 'GET'
|
||||||
request.user = Mock()
|
request.user = Mock()
|
||||||
request.user.is_authenticated = True
|
request.user.is_authenticated = True
|
||||||
request.user.role = 'TENANT_MANAGER'
|
request.user.role = 'TENANT_OWNER'
|
||||||
|
|
||||||
view = Mock()
|
view = Mock()
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ class TestCanHijack:
|
|||||||
'PLATFORM_SUPPORT',
|
'PLATFORM_SUPPORT',
|
||||||
'PLATFORM_SALES',
|
'PLATFORM_SALES',
|
||||||
'TENANT_OWNER',
|
'TENANT_OWNER',
|
||||||
'TENANT_MANAGER',
|
|
||||||
'TENANT_STAFF',
|
'TENANT_STAFF',
|
||||||
'CUSTOMER',
|
'CUSTOMER',
|
||||||
]
|
]
|
||||||
@@ -70,7 +69,7 @@ class TestCanHijack:
|
|||||||
"""Should allow platform support to hijack tenant-level users."""
|
"""Should allow platform support to hijack tenant-level users."""
|
||||||
hijacker = Mock(id=1, role='PLATFORM_SUPPORT')
|
hijacker = Mock(id=1, role='PLATFORM_SUPPORT')
|
||||||
|
|
||||||
allowed_roles = ['TENANT_OWNER', 'TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER']
|
allowed_roles = ['TENANT_OWNER', 'TENANT_STAFF', 'CUSTOMER']
|
||||||
|
|
||||||
for role in allowed_roles:
|
for role in allowed_roles:
|
||||||
hijacked = Mock(id=2, role=role)
|
hijacked = Mock(id=2, role=role)
|
||||||
@@ -105,7 +104,7 @@ class TestCanHijack:
|
|||||||
tenant = Mock(id=1)
|
tenant = Mock(id=1)
|
||||||
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant)
|
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant)
|
||||||
|
|
||||||
allowed_roles = ['TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER']
|
allowed_roles = ['TENANT_STAFF', 'CUSTOMER']
|
||||||
|
|
||||||
for role in allowed_roles:
|
for role in allowed_roles:
|
||||||
hijacked = Mock(id=2, role=role, tenant=tenant)
|
hijacked = Mock(id=2, role=role, tenant=tenant)
|
||||||
@@ -146,7 +145,7 @@ class TestCanHijack:
|
|||||||
|
|
||||||
def test_other_roles_cannot_hijack(self):
|
def test_other_roles_cannot_hijack(self):
|
||||||
"""Should deny hijack for roles without permission."""
|
"""Should deny hijack for roles without permission."""
|
||||||
forbidden_roles = ['TENANT_MANAGER', 'TENANT_STAFF', 'CUSTOMER']
|
forbidden_roles = ['TENANT_STAFF', 'CUSTOMER']
|
||||||
|
|
||||||
for role in forbidden_roles:
|
for role in forbidden_roles:
|
||||||
hijacker = Mock(id=1, role=role)
|
hijacker = Mock(id=1, role=role)
|
||||||
@@ -206,7 +205,6 @@ class TestGetHijackableUsers:
|
|||||||
hijacker = Mock(id=1, role='PLATFORM_SUPPORT')
|
hijacker = Mock(id=1, role='PLATFORM_SUPPORT')
|
||||||
mock_user_model.Role.PLATFORM_SUPPORT = 'PLATFORM_SUPPORT'
|
mock_user_model.Role.PLATFORM_SUPPORT = 'PLATFORM_SUPPORT'
|
||||||
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER'
|
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER'
|
||||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
|
||||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||||
mock_user_model.Role.CUSTOMER = 'CUSTOMER'
|
mock_user_model.Role.CUSTOMER = 'CUSTOMER'
|
||||||
|
|
||||||
@@ -223,7 +221,6 @@ class TestGetHijackableUsers:
|
|||||||
assert 'role__in' in filter_kwargs
|
assert 'role__in' in filter_kwargs
|
||||||
roles = filter_kwargs['role__in']
|
roles = filter_kwargs['role__in']
|
||||||
assert 'TENANT_OWNER' in roles
|
assert 'TENANT_OWNER' in roles
|
||||||
assert 'TENANT_MANAGER' in roles
|
|
||||||
assert 'TENANT_STAFF' in roles
|
assert 'TENANT_STAFF' in roles
|
||||||
assert 'CUSTOMER' in roles
|
assert 'CUSTOMER' in roles
|
||||||
|
|
||||||
@@ -249,7 +246,6 @@ class TestGetHijackableUsers:
|
|||||||
tenant = Mock(id=1)
|
tenant = Mock(id=1)
|
||||||
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant)
|
hijacker = Mock(id=1, role='TENANT_OWNER', tenant=tenant)
|
||||||
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER'
|
mock_user_model.Role.TENANT_OWNER = 'TENANT_OWNER'
|
||||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
|
||||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||||
mock_user_model.Role.CUSTOMER = 'CUSTOMER'
|
mock_user_model.Role.CUSTOMER = 'CUSTOMER'
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ class UserAdmin(HijackUserAdminMixin, BaseUserAdmin):
|
|||||||
'PLATFORM_SALES': '#fbc02d', # Yellow
|
'PLATFORM_SALES': '#fbc02d', # Yellow
|
||||||
'PLATFORM_SUPPORT': '#7cb342', # Light green
|
'PLATFORM_SUPPORT': '#7cb342', # Light green
|
||||||
'TENANT_OWNER': '#1976d2', # Blue
|
'TENANT_OWNER': '#1976d2', # Blue
|
||||||
'TENANT_MANAGER': '#0288d1', # Light blue
|
|
||||||
'TENANT_STAFF': '#0097a7', # Cyan
|
'TENANT_STAFF': '#0097a7', # Cyan
|
||||||
'CUSTOMER': '#5e35b1', # Purple
|
'CUSTOMER': '#5e35b1', # Purple
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ def current_user_view(request):
|
|||||||
else:
|
else:
|
||||||
business_subdomain = user.tenant.schema_name
|
business_subdomain = user.tenant.schema_name
|
||||||
|
|
||||||
# Check for active quota overages (for owners and managers)
|
# Check for active quota overages (for owners and staff with management permissions)
|
||||||
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
if user.role == User.Role.TENANT_OWNER or (user.role == User.Role.TENANT_STAFF and user.has_staff_permission('can_manage_users')):
|
||||||
from smoothschedule.identity.core.quota_service import QuotaService
|
from smoothschedule.identity.core.quota_service import QuotaService
|
||||||
try:
|
try:
|
||||||
service = QuotaService(user.tenant)
|
service = QuotaService(user.tenant)
|
||||||
@@ -153,10 +153,10 @@ def current_user_view(request):
|
|||||||
}
|
}
|
||||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||||
|
|
||||||
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
|
# Get linked resource info for tenant users (staff and owners can be linked to resources)
|
||||||
linked_resource_id = None
|
linked_resource_id = None
|
||||||
can_edit_schedule = False
|
can_edit_schedule = False
|
||||||
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
|
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]:
|
||||||
try:
|
try:
|
||||||
with schema_context(user.tenant.schema_name):
|
with schema_context(user.tenant.schema_name):
|
||||||
linked_resource = Resource.objects.filter(user=user).first()
|
linked_resource = Resource.objects.filter(user=user).first()
|
||||||
@@ -183,6 +183,9 @@ def current_user_view(request):
|
|||||||
'business_name': business_name,
|
'business_name': business_name,
|
||||||
'business_subdomain': business_subdomain,
|
'business_subdomain': business_subdomain,
|
||||||
'permissions': user.permissions,
|
'permissions': user.permissions,
|
||||||
|
'effective_permissions': user.get_effective_permissions(),
|
||||||
|
'staff_role_id': user.staff_role_id,
|
||||||
|
'staff_role_name': user.staff_role.name if user.staff_role else None,
|
||||||
'can_invite_staff': user.can_invite_staff(),
|
'can_invite_staff': user.can_invite_staff(),
|
||||||
'can_access_tickets': user.can_access_tickets(),
|
'can_access_tickets': user.can_access_tickets(),
|
||||||
'can_send_messages': user.can_send_messages(),
|
'can_send_messages': user.can_send_messages(),
|
||||||
@@ -316,11 +319,11 @@ def _get_user_data(user):
|
|||||||
}
|
}
|
||||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||||
|
|
||||||
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
|
# Get linked resource info for tenant users (staff and owners can be linked to resources)
|
||||||
linked_resource_id = None
|
linked_resource_id = None
|
||||||
linked_resource_name = None
|
linked_resource_name = None
|
||||||
can_edit_schedule = False
|
can_edit_schedule = False
|
||||||
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
|
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_OWNER]:
|
||||||
try:
|
try:
|
||||||
with schema_context(user.tenant.schema_name):
|
with schema_context(user.tenant.schema_name):
|
||||||
linked_resource = Resource.objects.filter(user=user).first()
|
linked_resource = Resource.objects.filter(user=user).first()
|
||||||
@@ -519,7 +522,6 @@ class StaffInvitationSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def get_role_display(self, obj):
|
def get_role_display(self, obj):
|
||||||
role_map = {
|
role_map = {
|
||||||
'TENANT_MANAGER': 'Manager',
|
|
||||||
'TENANT_STAFF': 'Staff',
|
'TENANT_STAFF': 'Staff',
|
||||||
}
|
}
|
||||||
return role_map.get(obj.role, obj.role)
|
return role_map.get(obj.role, obj.role)
|
||||||
@@ -572,21 +574,13 @@ def staff_invitations_view(request):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate role - only allow manager and staff roles
|
# Validate role - only allow staff role
|
||||||
if role not in [User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF]:
|
if role != User.Role.TENANT_STAFF:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Invalid role. Must be 'TENANT_MANAGER' or 'TENANT_STAFF'."},
|
{"error": "Invalid role. Must be 'TENANT_STAFF'."},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Managers can only invite staff, not other managers
|
|
||||||
# TODO: Add owner control to allow/disallow managers inviting managers
|
|
||||||
if user.role == User.Role.TENANT_MANAGER and role == User.Role.TENANT_MANAGER:
|
|
||||||
return Response(
|
|
||||||
{"error": "Managers can only invite staff members, not other managers."},
|
|
||||||
status=status.HTTP_403_FORBIDDEN
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if user already exists in this tenant
|
# Check if user already exists in this tenant
|
||||||
existing_user = User.objects.filter(
|
existing_user = User.objects.filter(
|
||||||
email=email,
|
email=email,
|
||||||
@@ -708,7 +702,6 @@ def invitation_details_view(request, token):
|
|||||||
|
|
||||||
# Return limited info for the acceptance page
|
# Return limited info for the acceptance page
|
||||||
role_map = {
|
role_map = {
|
||||||
'TENANT_MANAGER': 'Manager',
|
|
||||||
'TENANT_STAFF': 'Staff',
|
'TENANT_STAFF': 'Staff',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,7 +860,6 @@ def _send_invitation_email(invitation):
|
|||||||
invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}"
|
invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}"
|
||||||
|
|
||||||
role_map = {
|
role_map = {
|
||||||
'TENANT_MANAGER': 'Manager',
|
|
||||||
'TENANT_STAFF': 'Staff Member',
|
'TENANT_STAFF': 'Staff Member',
|
||||||
}
|
}
|
||||||
role_display = role_map.get(invitation.role, 'team member')
|
role_display = role_map.get(invitation.role, 'team member')
|
||||||
|
|||||||
@@ -68,15 +68,6 @@ class Command(BaseCommand):
|
|||||||
'last_name': 'Owner',
|
'last_name': 'Owner',
|
||||||
'tenant': demo_tenant,
|
'tenant': demo_tenant,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'username': 'manager@demo.com',
|
|
||||||
'email': 'manager@demo.com',
|
|
||||||
'password': 'test123',
|
|
||||||
'role': User.Role.TENANT_MANAGER,
|
|
||||||
'first_name': 'Business',
|
|
||||||
'last_name': 'Manager',
|
|
||||||
'tenant': demo_tenant,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
'username': 'staff@demo.com',
|
'username': 'staff@demo.com',
|
||||||
'email': 'staff@demo.com',
|
'email': 'staff@demo.com',
|
||||||
|
|||||||
@@ -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-level roles (access within single tenant)
|
||||||
TENANT_OWNER = 'TENANT_OWNER', _('Tenant Owner')
|
TENANT_OWNER = 'TENANT_OWNER', _('Tenant Owner')
|
||||||
TENANT_MANAGER = 'TENANT_MANAGER', _('Tenant Manager')
|
# TENANT_MANAGER removed - use TENANT_STAFF with "Full Access Staff" role instead
|
||||||
TENANT_STAFF = 'TENANT_STAFF', _('Tenant Staff')
|
TENANT_STAFF = 'TENANT_STAFF', _('Tenant Staff')
|
||||||
|
|
||||||
# Customer role (end users of the tenant)
|
# Customer role (end users of the tenant)
|
||||||
@@ -199,19 +199,22 @@ class User(AbstractUser):
|
|||||||
"""Check if user is tenant-scoped"""
|
"""Check if user is tenant-scoped"""
|
||||||
return self.role in [
|
return self.role in [
|
||||||
self.Role.TENANT_OWNER,
|
self.Role.TENANT_OWNER,
|
||||||
self.Role.TENANT_MANAGER,
|
|
||||||
self.Role.TENANT_STAFF,
|
self.Role.TENANT_STAFF,
|
||||||
self.Role.CUSTOMER,
|
self.Role.CUSTOMER,
|
||||||
]
|
]
|
||||||
|
|
||||||
def can_manage_users(self):
|
def can_manage_users(self):
|
||||||
"""Check if user can manage other users"""
|
"""Check if user can manage other users"""
|
||||||
return self.role in [
|
if self.role in [
|
||||||
self.Role.SUPERUSER,
|
self.Role.SUPERUSER,
|
||||||
self.Role.PLATFORM_MANAGER,
|
self.Role.PLATFORM_MANAGER,
|
||||||
self.Role.TENANT_OWNER,
|
self.Role.TENANT_OWNER,
|
||||||
self.Role.TENANT_MANAGER,
|
]:
|
||||||
]
|
return True
|
||||||
|
# Staff can manage users if they have the permission
|
||||||
|
if self.role == self.Role.TENANT_STAFF:
|
||||||
|
return self.has_staff_permission('can_manage_users')
|
||||||
|
return False
|
||||||
|
|
||||||
def can_access_billing(self):
|
def can_access_billing(self):
|
||||||
"""Check if user can access billing information"""
|
"""Check if user can access billing information"""
|
||||||
@@ -226,9 +229,9 @@ class User(AbstractUser):
|
|||||||
# Owners can always invite
|
# Owners can always invite
|
||||||
if self.role == self.Role.TENANT_OWNER:
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
return True
|
return True
|
||||||
# Managers can invite if they have the permission
|
# Staff can invite if they have the permission
|
||||||
if self.role == self.Role.TENANT_MANAGER:
|
if self.role == self.Role.TENANT_STAFF:
|
||||||
return self.permissions.get('can_invite_staff', False)
|
return self.has_staff_permission('can_invite_staff')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def can_access_tickets(self):
|
def can_access_tickets(self):
|
||||||
@@ -236,12 +239,12 @@ class User(AbstractUser):
|
|||||||
# Platform users can always access
|
# Platform users can always access
|
||||||
if self.is_platform_user():
|
if self.is_platform_user():
|
||||||
return True
|
return True
|
||||||
# Owners and managers can always access
|
# Owners can always access
|
||||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
return True
|
return True
|
||||||
# Staff can access if granted permission (default: False)
|
# Staff can access if granted permission via staff role
|
||||||
if self.role == self.Role.TENANT_STAFF:
|
if self.role == self.Role.TENANT_STAFF:
|
||||||
return self.permissions.get('can_access_tickets', False)
|
return self.has_staff_permission('can_access_tickets')
|
||||||
# Customers can create tickets
|
# Customers can create tickets
|
||||||
if self.role == self.Role.CUSTOMER:
|
if self.role == self.Role.CUSTOMER:
|
||||||
return True
|
return True
|
||||||
@@ -280,41 +283,39 @@ class User(AbstractUser):
|
|||||||
"""
|
"""
|
||||||
Check if user can self-approve time off requests.
|
Check if user can self-approve time off requests.
|
||||||
Owners can always self-approve.
|
Owners can always self-approve.
|
||||||
Managers can self-approve by default but can be denied.
|
Staff need explicit permission via staff role.
|
||||||
Staff need explicit permission.
|
|
||||||
"""
|
"""
|
||||||
# Owners can always self-approve
|
# Owners can always self-approve
|
||||||
if self.role == self.Role.TENANT_OWNER:
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
return True
|
return True
|
||||||
# Managers can self-approve by default, but can be denied
|
# Staff can self-approve if granted permission via staff role
|
||||||
if self.role == self.Role.TENANT_MANAGER:
|
|
||||||
return self.permissions.get('can_self_approve_time_off', True)
|
|
||||||
# Staff can self-approve if granted permission (default: False)
|
|
||||||
if self.role == self.Role.TENANT_STAFF:
|
if self.role == self.Role.TENANT_STAFF:
|
||||||
return self.permissions.get('can_self_approve_time_off', False)
|
return self.has_staff_permission('can_self_approve_time_off')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def can_review_time_off_requests(self):
|
def can_review_time_off_requests(self):
|
||||||
"""
|
"""
|
||||||
Check if user can review (approve/deny) time off requests from others.
|
Check if user can review (approve/deny) time off requests from others.
|
||||||
Only owners and managers can review.
|
Owners can always review. Staff need explicit permission.
|
||||||
"""
|
"""
|
||||||
return self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
|
return True
|
||||||
|
if self.role == self.Role.TENANT_STAFF:
|
||||||
|
return self.has_staff_permission('can_review_time_off')
|
||||||
|
return False
|
||||||
|
|
||||||
def can_send_messages(self):
|
def can_send_messages(self):
|
||||||
"""
|
"""
|
||||||
Check if user can send broadcast messages to staff/customers.
|
Check if user can send broadcast messages to staff/customers.
|
||||||
Owners can always send messages.
|
Owners can always send messages.
|
||||||
Managers can by default but can be revoked.
|
Staff need explicit permission via staff role.
|
||||||
Staff cannot send messages.
|
|
||||||
"""
|
"""
|
||||||
# Owners can always send messages
|
# Owners can always send messages
|
||||||
if self.role == self.Role.TENANT_OWNER:
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
return True
|
return True
|
||||||
# Managers can send by default, but can be revoked
|
# Staff can send if they have the permission via staff role
|
||||||
if self.role == self.Role.TENANT_MANAGER:
|
if self.role == self.Role.TENANT_STAFF:
|
||||||
return self.permissions.get('can_send_messages', True)
|
return self.has_staff_permission('can_access_messages')
|
||||||
# Staff and others cannot send messages
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has_staff_permission(self, permission_key):
|
def has_staff_permission(self, permission_key):
|
||||||
@@ -322,7 +323,7 @@ class User(AbstractUser):
|
|||||||
Check if staff member has a specific permission.
|
Check if staff member has a specific permission.
|
||||||
|
|
||||||
Permission Resolution Order:
|
Permission Resolution Order:
|
||||||
1. Owners and Managers always have all permissions (return True)
|
1. Owners always have all permissions (return True)
|
||||||
2. For staff: User-level override takes priority
|
2. For staff: User-level override takes priority
|
||||||
3. Then check staff role permissions
|
3. Then check staff role permissions
|
||||||
4. Default: False
|
4. Default: False
|
||||||
@@ -333,8 +334,8 @@ class User(AbstractUser):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: Whether the user has the permission
|
bool: Whether the user has the permission
|
||||||
"""
|
"""
|
||||||
# Owners and managers have all permissions
|
# Owners have all permissions
|
||||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# For staff, check permissions
|
# For staff, check permissions
|
||||||
@@ -356,8 +357,8 @@ class User(AbstractUser):
|
|||||||
Returns:
|
Returns:
|
||||||
dict: All effective permissions for this user
|
dict: All effective permissions for this user
|
||||||
"""
|
"""
|
||||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
# Return all permissions as True for owner/manager
|
# Return all permissions as True for owner
|
||||||
from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS
|
from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS
|
||||||
return {k: True for k in ALL_PERMISSIONS.keys()}
|
return {k: True for k in ALL_PERMISSIONS.keys()}
|
||||||
|
|
||||||
@@ -684,7 +685,7 @@ class StaffInvitation(models.Model):
|
|||||||
Invitation for new staff members to join a business.
|
Invitation for new staff members to join a business.
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. Owner/Manager creates invitation with email and role
|
1. Owner/Staff with invite permission creates invitation with email and staff role
|
||||||
2. System sends email with unique token link
|
2. System sends email with unique token link
|
||||||
3. Invitee clicks link, creates account, and is added to tenant
|
3. Invitee clicks link, creates account, and is added to tenant
|
||||||
"""
|
"""
|
||||||
@@ -701,7 +702,6 @@ class StaffInvitation(models.Model):
|
|||||||
role = models.CharField(
|
role = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=[
|
||||||
(User.Role.TENANT_MANAGER, _('Manager')),
|
|
||||||
(User.Role.TENANT_STAFF, _('Staff')),
|
(User.Role.TENANT_STAFF, _('Staff')),
|
||||||
],
|
],
|
||||||
default=User.Role.TENANT_STAFF,
|
default=User.Role.TENANT_STAFF,
|
||||||
@@ -833,7 +833,7 @@ class StaffInvitation(models.Model):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: Email address to invite
|
email: Email address to invite
|
||||||
role: Role for the invited user (TENANT_MANAGER or TENANT_STAFF)
|
role: Role for the invited user (TENANT_STAFF)
|
||||||
tenant: Tenant/business the user is being invited to
|
tenant: Tenant/business the user is being invited to
|
||||||
invited_by: User sending the invitation
|
invited_by: User sending the invitation
|
||||||
create_bookable_resource: Whether to create a bookable resource when accepted
|
create_bookable_resource: Whether to create a bookable resource when accepted
|
||||||
|
|||||||
@@ -106,6 +106,86 @@ MENU_PERMISSIONS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Business Settings Permissions
|
||||||
|
# These control access to individual settings pages
|
||||||
|
SETTINGS_PERMISSIONS = {
|
||||||
|
'can_access_settings': {
|
||||||
|
'label': 'Access Settings',
|
||||||
|
'description': 'View Business Settings menu (required for any settings access)',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_general': {
|
||||||
|
'label': 'General Settings',
|
||||||
|
'description': 'Business name, timezone, and basic configuration',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_business_hours': {
|
||||||
|
'label': 'Business Hours',
|
||||||
|
'description': 'Set regular operating hours',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_branding': {
|
||||||
|
'label': 'Branding',
|
||||||
|
'description': 'Logo, colors, and visual identity',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_booking': {
|
||||||
|
'label': 'Booking Settings',
|
||||||
|
'description': 'Booking policies and rules',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_communication': {
|
||||||
|
'label': 'Communication',
|
||||||
|
'description': 'Notification preferences and reminders',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_embed_widget': {
|
||||||
|
'label': 'Embed Widget',
|
||||||
|
'description': 'Configure booking widget for websites',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_email_templates': {
|
||||||
|
'label': 'Email Templates',
|
||||||
|
'description': 'Customize automated emails',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_staff_roles': {
|
||||||
|
'label': 'Staff Roles',
|
||||||
|
'description': 'Create and manage permission roles',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_resource_types': {
|
||||||
|
'label': 'Resource Types',
|
||||||
|
'description': 'Configure resource categories',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_api': {
|
||||||
|
'label': 'API & Integrations',
|
||||||
|
'description': 'Manage API tokens and webhooks',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_custom_domains': {
|
||||||
|
'label': 'Custom Domains',
|
||||||
|
'description': 'Configure custom domain settings',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_authentication': {
|
||||||
|
'label': 'Authentication',
|
||||||
|
'description': 'OAuth and social login configuration',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_email': {
|
||||||
|
'label': 'Email Setup',
|
||||||
|
'description': 'Configure email addresses for tickets',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_access_settings_sms_calling': {
|
||||||
|
'label': 'SMS & Calling',
|
||||||
|
'description': 'Manage credits and phone numbers',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Dangerous Operation Permissions
|
# Dangerous Operation Permissions
|
||||||
# These control specific destructive or sensitive operations at the API level
|
# These control specific destructive or sensitive operations at the API level
|
||||||
DANGEROUS_PERMISSIONS = {
|
DANGEROUS_PERMISSIONS = {
|
||||||
@@ -149,10 +229,20 @@ DANGEROUS_PERMISSIONS = {
|
|||||||
'description': 'Approve own time off requests without manager approval',
|
'description': 'Approve own time off requests without manager approval',
|
||||||
'default': False,
|
'default': False,
|
||||||
},
|
},
|
||||||
|
'can_manage_users': {
|
||||||
|
'label': 'Manage Users',
|
||||||
|
'description': 'Invite and manage staff members',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
'can_review_time_off': {
|
||||||
|
'label': 'Review Time Off',
|
||||||
|
'description': 'Approve or deny time off requests from other staff',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# All permissions combined for easy iteration
|
# All permissions combined for easy iteration
|
||||||
ALL_PERMISSIONS = {**MENU_PERMISSIONS, **DANGEROUS_PERMISSIONS}
|
ALL_PERMISSIONS = {**MENU_PERMISSIONS, **SETTINGS_PERMISSIONS, **DANGEROUS_PERMISSIONS}
|
||||||
|
|
||||||
|
|
||||||
def get_default_permissions_for_role(role_name: str) -> dict:
|
def get_default_permissions_for_role(role_name: str) -> dict:
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ class TestGetUserData:
|
|||||||
(User.Role.PLATFORM_SALES, 'platform_sales'),
|
(User.Role.PLATFORM_SALES, 'platform_sales'),
|
||||||
(User.Role.PLATFORM_SUPPORT, 'platform_support'),
|
(User.Role.PLATFORM_SUPPORT, 'platform_support'),
|
||||||
(User.Role.TENANT_OWNER, 'owner'),
|
(User.Role.TENANT_OWNER, 'owner'),
|
||||||
(User.Role.TENANT_MANAGER, 'manager'),
|
|
||||||
(User.Role.TENANT_STAFF, 'staff'),
|
(User.Role.TENANT_STAFF, 'staff'),
|
||||||
(User.Role.CUSTOMER, 'customer'),
|
(User.Role.CUSTOMER, 'customer'),
|
||||||
]
|
]
|
||||||
@@ -1104,29 +1103,6 @@ class TestStaffInvitationsView:
|
|||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert 'Invalid role' in response.data['error']
|
assert 'Invalid role' in response.data['error']
|
||||||
|
|
||||||
@patch('smoothschedule.identity.users.api_views.User')
|
|
||||||
def test_post_manager_cannot_invite_manager(self, mock_user_model):
|
|
||||||
factory = APIRequestFactory()
|
|
||||||
request = factory.post('/api/staff/invitations/', {
|
|
||||||
'email': 'manager@test.com',
|
|
||||||
'role': User.Role.TENANT_MANAGER
|
|
||||||
})
|
|
||||||
|
|
||||||
mock_tenant = Mock()
|
|
||||||
mock_user = Mock()
|
|
||||||
mock_user.can_invite_staff.return_value = True
|
|
||||||
mock_user.tenant = mock_tenant
|
|
||||||
mock_user.role = User.Role.TENANT_MANAGER
|
|
||||||
request.user = mock_user
|
|
||||||
|
|
||||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
|
||||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
|
||||||
|
|
||||||
response = api_views.staff_invitations_view(request)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert 'Managers can only invite staff' in response.data['error']
|
|
||||||
|
|
||||||
@patch('smoothschedule.identity.users.api_views.User')
|
@patch('smoothschedule.identity.users.api_views.User')
|
||||||
def test_post_rejects_existing_user(self, mock_user_model):
|
def test_post_rejects_existing_user(self, mock_user_model):
|
||||||
factory = APIRequestFactory()
|
factory = APIRequestFactory()
|
||||||
@@ -1142,7 +1118,6 @@ class TestStaffInvitationsView:
|
|||||||
mock_user.role = User.Role.TENANT_OWNER
|
mock_user.role = User.Role.TENANT_OWNER
|
||||||
request.user = mock_user
|
request.user = mock_user
|
||||||
|
|
||||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
|
||||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||||
|
|
||||||
# User already exists
|
# User already exists
|
||||||
@@ -1178,7 +1153,6 @@ class TestStaffInvitationsView:
|
|||||||
mock_user.role = User.Role.TENANT_OWNER
|
mock_user.role = User.Role.TENANT_OWNER
|
||||||
request.user = mock_user
|
request.user = mock_user
|
||||||
|
|
||||||
mock_user_model.Role.TENANT_MANAGER = 'TENANT_MANAGER'
|
|
||||||
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
mock_user_model.Role.TENANT_STAFF = 'TENANT_STAFF'
|
||||||
mock_user_model.objects.filter.return_value.first.return_value = None
|
mock_user_model.objects.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ class TestRoleClassification:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.is_tenant_user() is True
|
assert user.is_tenant_user() is True
|
||||||
|
|
||||||
def test_is_tenant_user_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.is_tenant_user() is True
|
|
||||||
|
|
||||||
def test_is_tenant_user_returns_true_for_tenant_staff(self):
|
def test_is_tenant_user_returns_true_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -116,9 +113,6 @@ class TestCanManageUsers:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_manage_users() is True
|
assert user.can_manage_users() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_manage_users() is True
|
|
||||||
|
|
||||||
def test_returns_false_for_tenant_staff(self):
|
def test_returns_false_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -148,9 +142,6 @@ class TestCanAccessBilling:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_access_billing() is True
|
assert user.can_access_billing() is True
|
||||||
|
|
||||||
def test_returns_false_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_access_billing() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_tenant_staff(self):
|
def test_returns_false_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -168,17 +159,6 @@ class TestCanInviteStaff:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_invite_staff() is True
|
assert user.can_invite_staff() is True
|
||||||
|
|
||||||
def test_returns_true_for_manager_with_permission(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': True})
|
|
||||||
assert user.can_invite_staff() is True
|
|
||||||
|
|
||||||
def test_returns_false_for_manager_without_permission(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_invite_staff() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_manager_with_explicit_false_permission(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER, permissions={'can_invite_staff': False})
|
|
||||||
assert user.can_invite_staff() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_tenant_staff(self):
|
def test_returns_false_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -204,9 +184,6 @@ class TestCanAccessTickets:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_access_tickets() is True
|
assert user.can_access_tickets() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_access_tickets() is True
|
|
||||||
|
|
||||||
def test_returns_true_for_staff_with_permission(self):
|
def test_returns_true_for_staff_with_permission(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_access_tickets': True})
|
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_access_tickets': True})
|
||||||
@@ -292,9 +269,6 @@ class TestCanSelfApproveTimeOff:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_self_approve_time_off() is True
|
assert user.can_self_approve_time_off() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_self_approve_time_off() is True
|
|
||||||
|
|
||||||
def test_returns_true_for_staff_with_permission(self):
|
def test_returns_true_for_staff_with_permission(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_self_approve_time_off': True})
|
user = create_user_instance(User.Role.TENANT_STAFF, permissions={'can_self_approve_time_off': True})
|
||||||
@@ -320,9 +294,6 @@ class TestCanReviewTimeOffRequests:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_review_time_off_requests() is True
|
assert user.can_review_time_off_requests() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_review_time_off_requests() is True
|
|
||||||
|
|
||||||
def test_returns_false_for_tenant_staff(self):
|
def test_returns_false_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -579,16 +550,6 @@ class TestUserCanSendMessages:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_send_messages() is True
|
assert user.can_send_messages() is True
|
||||||
|
|
||||||
def test_manager_can_send_by_default(self):
|
|
||||||
"""Tenant manager can send messages by default."""
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_send_messages() is True
|
|
||||||
|
|
||||||
def test_manager_cannot_send_when_revoked(self):
|
|
||||||
"""Tenant manager cannot send when permission revoked."""
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
user.permissions = {'can_send_messages': False}
|
|
||||||
assert user.can_send_messages() is False
|
|
||||||
|
|
||||||
def test_staff_cannot_send_messages(self):
|
def test_staff_cannot_send_messages(self):
|
||||||
"""Staff should not be able to send messages."""
|
"""Staff should not be able to send messages."""
|
||||||
|
|||||||
@@ -68,19 +68,7 @@ class TestUserHasStaffPermission:
|
|||||||
mock_user.role = 'TENANT_OWNER'
|
mock_user.role = 'TENANT_OWNER'
|
||||||
|
|
||||||
# Simulate the has_staff_permission logic
|
# Simulate the has_staff_permission logic
|
||||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
if mock_user.role == 'TENANT_OWNER':
|
||||||
result = True
|
|
||||||
else:
|
|
||||||
result = False
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_manager_always_has_permission(self):
|
|
||||||
"""Managers have all permissions"""
|
|
||||||
mock_user = Mock()
|
|
||||||
mock_user.role = 'TENANT_MANAGER'
|
|
||||||
|
|
||||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
|
||||||
result = True
|
result = True
|
||||||
else:
|
else:
|
||||||
result = False
|
result = False
|
||||||
@@ -97,7 +85,7 @@ class TestUserHasStaffPermission:
|
|||||||
|
|
||||||
# Simulate permission resolution
|
# Simulate permission resolution
|
||||||
permission_key = 'can_access_scheduler'
|
permission_key = 'can_access_scheduler'
|
||||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
if mock_user.role == 'TENANT_OWNER':
|
||||||
result = True
|
result = True
|
||||||
elif mock_user.role == 'TENANT_STAFF':
|
elif mock_user.role == 'TENANT_STAFF':
|
||||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||||
@@ -120,7 +108,7 @@ class TestUserHasStaffPermission:
|
|||||||
mock_user.staff_role.permissions = {'can_access_scheduler': True}
|
mock_user.staff_role.permissions = {'can_access_scheduler': True}
|
||||||
|
|
||||||
permission_key = 'can_access_scheduler'
|
permission_key = 'can_access_scheduler'
|
||||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
if mock_user.role == 'TENANT_OWNER':
|
||||||
result = True
|
result = True
|
||||||
elif mock_user.role == 'TENANT_STAFF':
|
elif mock_user.role == 'TENANT_STAFF':
|
||||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||||
@@ -142,7 +130,7 @@ class TestUserHasStaffPermission:
|
|||||||
mock_user.staff_role = None
|
mock_user.staff_role = None
|
||||||
|
|
||||||
permission_key = 'can_access_scheduler'
|
permission_key = 'can_access_scheduler'
|
||||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
if mock_user.role == 'TENANT_OWNER':
|
||||||
result = True
|
result = True
|
||||||
elif mock_user.role == 'TENANT_STAFF':
|
elif mock_user.role == 'TENANT_STAFF':
|
||||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||||
@@ -163,7 +151,7 @@ class TestUserHasStaffPermission:
|
|||||||
mock_user.permissions = {'can_access_scheduler': True} # Even if set
|
mock_user.permissions = {'can_access_scheduler': True} # Even if set
|
||||||
|
|
||||||
permission_key = 'can_access_scheduler'
|
permission_key = 'can_access_scheduler'
|
||||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
if mock_user.role == 'TENANT_OWNER':
|
||||||
result = True
|
result = True
|
||||||
elif mock_user.role == 'TENANT_STAFF':
|
elif mock_user.role == 'TENANT_STAFF':
|
||||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||||
|
|||||||
@@ -71,9 +71,6 @@ class TestIsPlatformUser:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.is_platform_user() is False
|
assert user.is_platform_user() is False
|
||||||
|
|
||||||
def test_returns_false_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.is_platform_user() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_tenant_staff(self):
|
def test_returns_false_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -107,9 +104,6 @@ class TestIsTenantUser:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.is_tenant_user() is True
|
assert user.is_tenant_user() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.is_tenant_user() is True
|
|
||||||
|
|
||||||
def test_returns_true_for_tenant_staff(self):
|
def test_returns_true_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -139,9 +133,6 @@ class TestCanManageUsers:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_manage_users() is True
|
assert user.can_manage_users() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_manage_users() is True
|
|
||||||
|
|
||||||
def test_returns_false_for_platform_sales(self):
|
def test_returns_false_for_platform_sales(self):
|
||||||
user = create_user_instance(User.Role.PLATFORM_SALES)
|
user = create_user_instance(User.Role.PLATFORM_SALES)
|
||||||
@@ -187,9 +178,6 @@ class TestCanAccessBilling:
|
|||||||
user = create_user_instance(User.Role.PLATFORM_SUPPORT)
|
user = create_user_instance(User.Role.PLATFORM_SUPPORT)
|
||||||
assert user.can_access_billing() is False
|
assert user.can_access_billing() is False
|
||||||
|
|
||||||
def test_returns_false_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_access_billing() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_tenant_staff(self):
|
def test_returns_false_for_tenant_staff(self):
|
||||||
user = create_user_instance(User.Role.TENANT_STAFF)
|
user = create_user_instance(User.Role.TENANT_STAFF)
|
||||||
@@ -211,23 +199,6 @@ class TestCanInviteStaff:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_invite_staff() is True
|
assert user.can_invite_staff() is True
|
||||||
|
|
||||||
def test_returns_true_for_manager_with_permission(self):
|
|
||||||
user = create_user_instance(
|
|
||||||
User.Role.TENANT_MANAGER,
|
|
||||||
permissions={'can_invite_staff': True}
|
|
||||||
)
|
|
||||||
assert user.can_invite_staff() is True
|
|
||||||
|
|
||||||
def test_returns_false_for_manager_without_permission(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_invite_staff() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_manager_with_explicit_false_permission(self):
|
|
||||||
user = create_user_instance(
|
|
||||||
User.Role.TENANT_MANAGER,
|
|
||||||
permissions={'can_invite_staff': False}
|
|
||||||
)
|
|
||||||
assert user.can_invite_staff() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_superuser(self):
|
def test_returns_false_for_superuser(self):
|
||||||
user = create_user_instance(User.Role.SUPERUSER)
|
user = create_user_instance(User.Role.SUPERUSER)
|
||||||
@@ -273,9 +244,6 @@ class TestCanAccessTickets:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_access_tickets() is True
|
assert user.can_access_tickets() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_access_tickets() is True
|
|
||||||
|
|
||||||
def test_returns_true_for_staff_with_permission(self):
|
def test_returns_true_for_staff_with_permission(self):
|
||||||
user = create_user_instance(
|
user = create_user_instance(
|
||||||
@@ -341,9 +309,6 @@ class TestCanApprovePlugins:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_approve_plugins() is False
|
assert user.can_approve_plugins() is False
|
||||||
|
|
||||||
def test_returns_false_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_approve_plugins() is False
|
|
||||||
|
|
||||||
def test_returns_false_for_customer(self):
|
def test_returns_false_for_customer(self):
|
||||||
user = create_user_instance(User.Role.CUSTOMER)
|
user = create_user_instance(User.Role.CUSTOMER)
|
||||||
@@ -407,9 +372,6 @@ class TestCanSelfApproveTimeOff:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_self_approve_time_off() is True
|
assert user.can_self_approve_time_off() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_self_approve_time_off() is True
|
|
||||||
|
|
||||||
def test_returns_true_for_staff_with_permission(self):
|
def test_returns_true_for_staff_with_permission(self):
|
||||||
user = create_user_instance(
|
user = create_user_instance(
|
||||||
@@ -449,9 +411,6 @@ class TestCanReviewTimeOffRequests:
|
|||||||
user = create_user_instance(User.Role.TENANT_OWNER)
|
user = create_user_instance(User.Role.TENANT_OWNER)
|
||||||
assert user.can_review_time_off_requests() is True
|
assert user.can_review_time_off_requests() is True
|
||||||
|
|
||||||
def test_returns_true_for_tenant_manager(self):
|
|
||||||
user = create_user_instance(User.Role.TENANT_MANAGER)
|
|
||||||
assert user.can_review_time_off_requests() is True
|
|
||||||
|
|
||||||
def test_returns_false_for_superuser(self):
|
def test_returns_false_for_superuser(self):
|
||||||
user = create_user_instance(User.Role.SUPERUSER)
|
user = create_user_instance(User.Role.SUPERUSER)
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class APITokenViewSet(viewsets.ViewSet):
|
|||||||
self._check_api_access_permission(tenant)
|
self._check_api_access_permission(tenant)
|
||||||
|
|
||||||
# Only owners can manage API tokens (roles are uppercase in DB)
|
# Only owners can manage API tokens (roles are uppercase in DB)
|
||||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER']
|
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER']
|
||||||
if user.role.upper() not in allowed_roles:
|
if user.role.upper() not in allowed_roles:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'forbidden', 'message': 'Only business owners can manage API tokens'},
|
{'error': 'forbidden', 'message': 'Only business owners can manage API tokens'},
|
||||||
@@ -200,7 +200,7 @@ class APITokenViewSet(viewsets.ViewSet):
|
|||||||
# Check API access permission
|
# Check API access permission
|
||||||
self._check_api_access_permission(tenant)
|
self._check_api_access_permission(tenant)
|
||||||
|
|
||||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER']
|
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER']
|
||||||
if user.role.upper() not in allowed_roles:
|
if user.role.upper() not in allowed_roles:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'forbidden', 'message': 'Only business owners can create API tokens'},
|
{'error': 'forbidden', 'message': 'Only business owners can create API tokens'},
|
||||||
@@ -261,7 +261,7 @@ class APITokenViewSet(viewsets.ViewSet):
|
|||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER']
|
allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER']
|
||||||
if user.role.upper() not in allowed_roles:
|
if user.role.upper() not in allowed_roles:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'forbidden', 'message': 'Only business owners can revoke API tokens'},
|
{'error': 'forbidden', 'message': 'Only business owners can revoke API tokens'},
|
||||||
|
|||||||
@@ -229,13 +229,13 @@ class Command(BaseCommand):
|
|||||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||||
self.stdout.write(f" {status} {owner.email} (Owner)")
|
self.stdout.write(f" {status} {owner.email} (Owner)")
|
||||||
|
|
||||||
# Manager
|
# Manager (now TENANT_STAFF with Full Access Staff role)
|
||||||
manager_data = {
|
manager_data = {
|
||||||
"username": "manager@demo.com",
|
"username": "manager@demo.com",
|
||||||
"email": "manager@demo.com",
|
"email": "manager@demo.com",
|
||||||
"first_name": "Marcus",
|
"first_name": "Marcus",
|
||||||
"last_name": "Chen",
|
"last_name": "Chen",
|
||||||
"role": User.Role.TENANT_MANAGER,
|
"role": User.Role.TENANT_STAFF,
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"phone": "555-100-0002",
|
"phone": "555-100-0002",
|
||||||
}
|
}
|
||||||
@@ -246,10 +246,18 @@ class Command(BaseCommand):
|
|||||||
if created:
|
if created:
|
||||||
manager.set_password("test123")
|
manager.set_password("test123")
|
||||||
manager.save()
|
manager.save()
|
||||||
|
# Assign Full Access Staff role
|
||||||
|
full_access_role = StaffRole.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
name="Full Access Staff"
|
||||||
|
).first()
|
||||||
|
if full_access_role and manager.staff_role != full_access_role:
|
||||||
|
manager.staff_role = full_access_role
|
||||||
|
manager.save(update_fields=['staff_role'])
|
||||||
users["manager"] = manager
|
users["manager"] = manager
|
||||||
if not self.quiet:
|
if not self.quiet:
|
||||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||||
self.stdout.write(f" {status} {manager.email} (Manager)")
|
self.stdout.write(f" {status} {manager.email} (Full Access Staff)")
|
||||||
|
|
||||||
# Staff members (stylists and spa therapists)
|
# Staff members (stylists and spa therapists)
|
||||||
staff_data = [
|
staff_data = [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from django.utils import timezone
|
|||||||
from django_tenants.utils import schema_context, tenant_context
|
from django_tenants.utils import schema_context, tenant_context
|
||||||
|
|
||||||
from smoothschedule.identity.core.models import Tenant, Domain
|
from smoothschedule.identity.core.models import Tenant, Domain
|
||||||
from smoothschedule.identity.users.models import User
|
from smoothschedule.identity.users.models import User, StaffRole
|
||||||
from smoothschedule.scheduling.schedule.models import (
|
from smoothschedule.scheduling.schedule.models import (
|
||||||
Event,
|
Event,
|
||||||
Participant,
|
Participant,
|
||||||
@@ -254,11 +254,12 @@ class Command(BaseCommand):
|
|||||||
"username": "manager@demo.com",
|
"username": "manager@demo.com",
|
||||||
"email": "manager@demo.com",
|
"email": "manager@demo.com",
|
||||||
"password": "test123",
|
"password": "test123",
|
||||||
"role": User.Role.TENANT_MANAGER,
|
"role": User.Role.TENANT_STAFF,
|
||||||
"first_name": "Business",
|
"first_name": "Business",
|
||||||
"last_name": "Manager",
|
"last_name": "Manager",
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"phone": "555-100-0002",
|
"phone": "555-100-0002",
|
||||||
|
"_assign_full_access": True, # Flag to assign Full Access Staff role
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "staff@demo.com",
|
"username": "staff@demo.com",
|
||||||
@@ -273,8 +274,10 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
|
|
||||||
created_users = {}
|
created_users = {}
|
||||||
|
manager_user = None # Track manager user separately
|
||||||
for user_data in tenant_users:
|
for user_data in tenant_users:
|
||||||
password = user_data.pop("password")
|
password = user_data.pop("password")
|
||||||
|
assign_full_access = user_data.pop("_assign_full_access", False)
|
||||||
user, created = User.objects.get_or_create(
|
user, created = User.objects.get_or_create(
|
||||||
username=user_data["username"],
|
username=user_data["username"],
|
||||||
defaults=user_data,
|
defaults=user_data,
|
||||||
@@ -285,9 +288,24 @@ class Command(BaseCommand):
|
|||||||
status = self.style.SUCCESS("CREATED")
|
status = self.style.SUCCESS("CREATED")
|
||||||
else:
|
else:
|
||||||
status = self.style.WARNING("EXISTS")
|
status = self.style.WARNING("EXISTS")
|
||||||
|
|
||||||
|
# Assign Full Access Staff role if flagged
|
||||||
|
if assign_full_access:
|
||||||
|
full_access_role = StaffRole.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
name="Full Access Staff"
|
||||||
|
).first()
|
||||||
|
if full_access_role and user.staff_role != full_access_role:
|
||||||
|
user.staff_role = full_access_role
|
||||||
|
user.save(update_fields=['staff_role'])
|
||||||
|
manager_user = user # Track for resource creation
|
||||||
|
|
||||||
self.stdout.write(f" {status} {user.email} ({user.get_role_display()})")
|
self.stdout.write(f" {status} {user.email} ({user.get_role_display()})")
|
||||||
created_users[user_data["role"]] = user
|
created_users[user_data["role"]] = user
|
||||||
|
|
||||||
|
# Store manager user under a special key for backward compatibility
|
||||||
|
created_users["_manager"] = manager_user
|
||||||
|
|
||||||
return created_users
|
return created_users
|
||||||
|
|
||||||
def create_resource_types(self):
|
def create_resource_types(self):
|
||||||
@@ -405,7 +423,7 @@ class Command(BaseCommand):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Business Manager",
|
"name": "Business Manager",
|
||||||
"user": tenant_users.get(User.Role.TENANT_MANAGER),
|
"user": tenant_users.get("_manager"),
|
||||||
"description": "Business manager - handles VIP appointments",
|
"description": "Business manager - handles VIP appointments",
|
||||||
"resource_type": staff_type,
|
"resource_type": staff_type,
|
||||||
"type": Resource.Type.STAFF,
|
"type": Resource.Type.STAFF,
|
||||||
|
|||||||
@@ -172,9 +172,9 @@ class CustomerSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
|
'id', 'name', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
|
||||||
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
||||||
'user_id', 'user_data', 'notes',
|
'user_id', 'user_data', 'notes', 'email_verified',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id', 'email_verified']
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
"""Create a customer with email as username"""
|
"""Create a customer with email as username"""
|
||||||
@@ -260,8 +260,9 @@ class StaffSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'username', 'name', 'email', 'phone', 'role',
|
'id', 'username', 'name', 'email', 'phone', 'role',
|
||||||
'is_active', 'permissions', 'can_invite_staff',
|
'is_active', 'permissions', 'can_invite_staff',
|
||||||
'staff_role_id', 'staff_role_name', 'effective_permissions',
|
'staff_role_id', 'staff_role_name', 'effective_permissions',
|
||||||
|
'email_verified',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff', 'effective_permissions']
|
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff', 'effective_permissions', 'email_verified']
|
||||||
|
|
||||||
def get_name(self, obj):
|
def get_name(self, obj):
|
||||||
return obj.full_name
|
return obj.full_name
|
||||||
@@ -270,7 +271,6 @@ class StaffSerializer(serializers.ModelSerializer):
|
|||||||
# Map database roles to frontend roles
|
# Map database roles to frontend roles
|
||||||
role_mapping = {
|
role_mapping = {
|
||||||
'TENANT_OWNER': 'owner',
|
'TENANT_OWNER': 'owner',
|
||||||
'TENANT_MANAGER': 'manager',
|
|
||||||
'TENANT_STAFF': 'staff',
|
'TENANT_STAFF': 'staff',
|
||||||
}
|
}
|
||||||
return role_mapping.get(obj.role, obj.role.lower())
|
return role_mapping.get(obj.role, obj.role.lower())
|
||||||
|
|||||||
@@ -675,13 +675,26 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
|
|||||||
f"for resource '{instance.resource.name}'"
|
f"for resource '{instance.resource.name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find all managers and owners to notify
|
# Find all users who can review time off requests to notify
|
||||||
from smoothschedule.identity.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
|
# Get owners (always have permission) + staff with can_review_time_off permission
|
||||||
reviewers = User.objects.filter(
|
reviewers = User.objects.filter(
|
||||||
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
role=User.Role.TENANT_OWNER,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
|
# Also include staff who have the permission (via role or override)
|
||||||
|
# Note: This is a simplified query - for proper permission checking,
|
||||||
|
# we'd need to check each staff's effective_permissions
|
||||||
|
staff_reviewers = User.objects.filter(
|
||||||
|
role=User.Role.TENANT_STAFF,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
# Filter staff to those who can review time off
|
||||||
|
reviewers = list(reviewers) + [
|
||||||
|
staff for staff in staff_reviewers
|
||||||
|
if staff.has_staff_permission('can_review_time_off')
|
||||||
|
]
|
||||||
|
|
||||||
# Create in-app notifications for each reviewer
|
# Create in-app notifications for each reviewer
|
||||||
for reviewer in reviewers:
|
for reviewer in reviewers:
|
||||||
|
|||||||
@@ -193,19 +193,6 @@ class TestStaffSerializer:
|
|||||||
# Assert
|
# Assert
|
||||||
assert role == 'owner'
|
assert role == 'owner'
|
||||||
|
|
||||||
def test_get_role_maps_tenant_manager(self):
|
|
||||||
"""Test that TENANT_MANAGER maps to manager."""
|
|
||||||
# Arrange
|
|
||||||
mock_user = Mock()
|
|
||||||
mock_user.role = 'TENANT_MANAGER'
|
|
||||||
|
|
||||||
serializer = StaffSerializer()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
role = serializer.get_role(mock_user)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert role == 'manager'
|
|
||||||
|
|
||||||
def test_get_role_maps_tenant_staff(self):
|
def test_get_role_maps_tenant_staff(self):
|
||||||
"""Test that TENANT_STAFF maps to staff."""
|
"""Test that TENANT_STAFF maps to staff."""
|
||||||
@@ -1682,14 +1669,6 @@ class TestStaffSerializerMethodFields:
|
|||||||
result = serializer.get_name(mock_obj)
|
result = serializer.get_name(mock_obj)
|
||||||
assert result == 'Jane Smith'
|
assert result == 'Jane Smith'
|
||||||
|
|
||||||
def test_get_role_maps_tenant_manager_to_manager(self):
|
|
||||||
"""Test get_role maps TENANT_MANAGER to manager."""
|
|
||||||
serializer = StaffSerializer()
|
|
||||||
mock_obj = Mock()
|
|
||||||
mock_obj.role = 'TENANT_MANAGER'
|
|
||||||
|
|
||||||
result = serializer.get_role(mock_obj)
|
|
||||||
assert result == 'manager'
|
|
||||||
|
|
||||||
|
|
||||||
class TestResourceSerializerFields:
|
class TestResourceSerializerFields:
|
||||||
|
|||||||
@@ -159,10 +159,15 @@ class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
This endpoint provides the frontend with the full list of permission
|
This endpoint provides the frontend with the full list of permission
|
||||||
keys that can be configured on a staff role.
|
keys that can be configured on a staff role.
|
||||||
"""
|
"""
|
||||||
from smoothschedule.identity.users.staff_permissions import MENU_PERMISSIONS, DANGEROUS_PERMISSIONS
|
from smoothschedule.identity.users.staff_permissions import (
|
||||||
|
MENU_PERMISSIONS,
|
||||||
|
SETTINGS_PERMISSIONS,
|
||||||
|
DANGEROUS_PERMISSIONS,
|
||||||
|
)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'menu_permissions': MENU_PERMISSIONS,
|
'menu_permissions': MENU_PERMISSIONS,
|
||||||
|
'settings_permissions': SETTINGS_PERMISSIONS,
|
||||||
'dangerous_permissions': DANGEROUS_PERMISSIONS,
|
'dangerous_permissions': DANGEROUS_PERMISSIONS,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -769,6 +774,21 @@ class CustomerViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
|||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def verify_email(self, request, pk=None):
|
||||||
|
"""Toggle a customer's email verification status."""
|
||||||
|
customer = self.get_object()
|
||||||
|
|
||||||
|
customer.email_verified = not customer.email_verified
|
||||||
|
customer.save(update_fields=['email_verified'])
|
||||||
|
|
||||||
|
action = 'verified' if customer.email_verified else 'unverified'
|
||||||
|
return Response({
|
||||||
|
'id': customer.id,
|
||||||
|
'email_verified': customer.email_verified,
|
||||||
|
'message': f'Email {action} successfully.'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class ServiceViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
class ServiceViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -828,7 +848,7 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
API endpoint for managing staff members (Users who can be assigned to resources).
|
API endpoint for managing staff members (Users who can be assigned to resources).
|
||||||
|
|
||||||
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
Staff members are Users with roles: TENANT_OWNER, TENANT_STAFF.
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- GET /api/staff/ - List staff members
|
- GET /api/staff/ - List staff members
|
||||||
@@ -851,14 +871,13 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
Return staff members for the current tenant.
|
Return staff members for the current tenant.
|
||||||
|
|
||||||
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
Staff are Users with roles: TENANT_OWNER, TENANT_STAFF.
|
||||||
"""
|
"""
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
# Set base queryset to staff roles only
|
# Set base queryset to staff roles only
|
||||||
self.queryset = User.objects.filter(
|
self.queryset = User.objects.filter(
|
||||||
Q(role=User.Role.TENANT_OWNER) |
|
Q(role=User.Role.TENANT_OWNER) |
|
||||||
Q(role=User.Role.TENANT_MANAGER) |
|
|
||||||
Q(role=User.Role.TENANT_STAFF)
|
Q(role=User.Role.TENANT_STAFF)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -890,24 +909,31 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
Update staff member.
|
Update staff member.
|
||||||
|
|
||||||
Allowed fields: is_active, permissions
|
Allowed fields: is_active, permissions, staff_role_id, first_name, last_name, phone
|
||||||
|
|
||||||
Owners can edit any staff member.
|
Owners can edit any staff member.
|
||||||
Managers can only edit staff (not other managers or owners).
|
Staff with can_access_staff permission can edit other staff (not owners).
|
||||||
"""
|
"""
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
|
||||||
# TODO: Add permission checks when authentication is enabled
|
# Permission check: staff can only edit other staff, not owners
|
||||||
# current_user = request.user
|
current_user = request.user
|
||||||
# if current_user.role == User.Role.TENANT_MANAGER:
|
if current_user.role == User.Role.TENANT_STAFF:
|
||||||
# if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
# Staff can only edit if they have can_access_staff permission
|
||||||
# return Response(
|
if not current_user.has_staff_permission('can_access_staff'):
|
||||||
# {'error': 'Managers cannot edit owners or other managers.'},
|
return Response(
|
||||||
# status=status.HTTP_403_FORBIDDEN
|
{'error': 'You do not have permission to edit staff members.'},
|
||||||
# )
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
# Staff cannot edit owner accounts
|
||||||
|
if instance.role == User.Role.TENANT_OWNER:
|
||||||
|
return Response(
|
||||||
|
{'error': 'You cannot edit owner accounts.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
# Only allow updating specific fields
|
# Only allow updating specific fields
|
||||||
allowed_fields = {'is_active', 'permissions'}
|
allowed_fields = {'is_active', 'permissions', 'staff_role_id', 'first_name', 'last_name', 'phone'}
|
||||||
update_data = {k: v for k, v in request.data.items() if k in allowed_fields}
|
update_data = {k: v for k, v in request.data.items() if k in allowed_fields}
|
||||||
|
|
||||||
serializer = self.get_serializer(instance, data=update_data, partial=True)
|
serializer = self.get_serializer(instance, data=update_data, partial=True)
|
||||||
@@ -938,6 +964,98 @@ class StaffViewSet(UserTenantFilteredMixin, viewsets.ModelViewSet):
|
|||||||
'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully."
|
'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def verify_email(self, request, pk=None):
|
||||||
|
"""Toggle a staff member's email verification status."""
|
||||||
|
staff = self.get_object()
|
||||||
|
|
||||||
|
staff.email_verified = not staff.email_verified
|
||||||
|
staff.save(update_fields=['email_verified'])
|
||||||
|
|
||||||
|
action = 'verified' if staff.email_verified else 'unverified'
|
||||||
|
return Response({
|
||||||
|
'id': staff.id,
|
||||||
|
'email_verified': staff.email_verified,
|
||||||
|
'message': f'Email {action} successfully.'
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def send_password_reset(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Send a password reset email to the staff member.
|
||||||
|
|
||||||
|
Owners or staff with can_access_staff permission can trigger password resets.
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from smoothschedule.communication.messaging.email_service import send_plain_email
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# Only owners or staff with can_access_staff permission can send password resets
|
||||||
|
can_manage = (
|
||||||
|
request.user.role == User.Role.TENANT_OWNER or
|
||||||
|
request.user.has_staff_permission('can_access_staff')
|
||||||
|
)
|
||||||
|
if not can_manage:
|
||||||
|
return Response(
|
||||||
|
{'error': 'You do not have permission to reset passwords.'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
staff = self.get_object()
|
||||||
|
|
||||||
|
# Generate a secure random password
|
||||||
|
temp_password = secrets.token_urlsafe(12)
|
||||||
|
|
||||||
|
# Set the temporary password
|
||||||
|
staff.set_password(temp_password)
|
||||||
|
staff.save(update_fields=['password'])
|
||||||
|
|
||||||
|
# Build login URL
|
||||||
|
port = ':5173' if settings.DEBUG else ''
|
||||||
|
subdomain = ''
|
||||||
|
if staff.tenant:
|
||||||
|
primary_domain = staff.tenant.domains.filter(is_primary=True).first()
|
||||||
|
if primary_domain:
|
||||||
|
subdomain = primary_domain.domain.split('.')[0] + '.'
|
||||||
|
|
||||||
|
base_domain = 'lvh.me' if settings.DEBUG else 'smoothschedule.com'
|
||||||
|
login_url = f"https://{subdomain}{base_domain}{port}/login"
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
subject = "Password Reset - SmoothSchedule"
|
||||||
|
message = f"""Hi {staff.full_name},
|
||||||
|
|
||||||
|
Your password has been reset by the business owner.
|
||||||
|
|
||||||
|
Your temporary password is: {temp_password}
|
||||||
|
|
||||||
|
Please log in at {login_url} and change your password immediately.
|
||||||
|
|
||||||
|
If you did not expect this email, please contact your business administrator.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
The SmoothSchedule Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_plain_email(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||||
|
[staff.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Failed to send password reset email: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'id': staff.id,
|
||||||
|
'message': f'Password reset email sent to {staff.email}.'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user