Add demo tenant reseed, staff roles, and fix masquerade redirect

Demo Tenant:
- Add block_emails field to Tenant model for demo accounts
- Add is_email_blocked() and wrapper functions in email_service
- Create reseed_demo management command with salon/spa theme
- Add Celery beat task for daily reseed at midnight UTC
- Create 100 appointments, 20 customers, 13 services, 12 resources

Staff Roles:
- Add StaffRole model with permission toggles
- Create default roles: Full Access, Front Desk, Limited Staff
- Add StaffRolesSettings page and hooks
- Integrate role assignment in Staff management

Bug Fixes:
- Fix masquerade redirect using wrong role names (tenant_owner vs owner)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-16 15:20:59 -05:00
parent cfb626b595
commit 79b76bf2dc
30 changed files with 2973 additions and 100 deletions

View File

@@ -133,6 +133,7 @@ const CommunicationSettings = React.lazy(() => import('./pages/settings/Communic
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
const StaffRolesSettings = React.lazy(() => import('./pages/settings/StaffRolesSettings'));
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
@@ -953,6 +954,7 @@ const AppContent: React.FC = () => {
<Route path="email-templates" element={<SystemEmailTemplates />} />
<Route path="custom-domains" element={<CustomDomainsSettings />} />
<Route path="api" element={<ApiSettings />} />
<Route path="staff-roles" element={<StaffRolesSettings />} />
<Route path="authentication" element={<AuthenticationSettings />} />
<Route path="email" element={<EmailSettings />} />
<Route path="sms-calling" element={<CommunicationSettings />} />

View File

@@ -45,12 +45,26 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const logoutMutation = useLogout();
const { canUse } = usePlanFeatures();
// Helper to check if user has a specific staff permission
// Owners and managers always have all permissions
// Staff members check their effective_permissions (role + user overrides)
const hasPermission = (permissionKey: string): boolean => {
if (role === 'owner' || role === 'manager') {
return true;
}
if (role === 'staff') {
// Check effective_permissions which combines user overrides and staff role
return user.effective_permissions?.[permissionKey] === true;
}
return false;
};
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager';
const isStaff = role === 'staff';
const canViewSettings = role === 'owner';
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
const canSendMessages = user.can_send_messages === true;
const canViewTickets = hasPermission('can_access_tickets');
const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true;
const handleSignOut = () => {
logoutMutation.mutate();
@@ -116,7 +130,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
exact
/>
{!isStaff && (
{hasPermission('can_access_scheduler') && (
<SidebarItem
to="/dashboard/scheduler"
icon={CalendarDays}
@@ -124,7 +138,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
/>
)}
{!isStaff && (
{hasPermission('can_access_tasks') && (
<SidebarItem
to="/dashboard/tasks"
icon={Clock}
@@ -134,7 +148,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
badgeElement={<UnfinishedBadge />}
/>
)}
{isStaff && (
{(isStaff && hasPermission('can_access_my_schedule')) && (
<SidebarItem
to="/dashboard/my-schedule"
icon={CalendarDays}
@@ -142,7 +156,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
/>
)}
{(role === 'staff' || role === 'resource') && (
{(role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability') && (
<SidebarItem
to="/dashboard/my-availability"
icon={CalendarOff}
@@ -152,72 +166,94 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
</SidebarSection>
{/* Manage Section - Staff+ */}
{canViewManagementPages && (
{/* Manage Section - Show if user has any manage-related permission */}
{(canViewManagementPages ||
hasPermission('can_access_site_builder') ||
hasPermission('can_access_gallery') ||
hasPermission('can_access_customers') ||
hasPermission('can_access_services') ||
hasPermission('can_access_resources') ||
hasPermission('can_access_staff') ||
hasPermission('can_access_contracts') ||
hasPermission('can_access_time_blocks') ||
hasPermission('can_access_locations')
) && (
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
<SidebarItem
to="/dashboard/site-editor"
icon={LayoutTemplate}
label={t('nav.siteBuilder', 'Site Builder')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/gallery"
icon={Image}
label={t('nav.gallery', 'Media Gallery')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/customers"
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/dashboard/services"
icon={Briefcase}
label={t('nav.services', 'Services')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/resources"
icon={ClipboardList}
label={t('nav.resources')}
isCollapsed={isCollapsed}
/>
{canViewAdminPages && (
<>
<SidebarItem
to="/dashboard/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
{canUse('contracts') && (
<SidebarItem
to="/dashboard/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
<SidebarItem
to="/dashboard/time-blocks"
icon={CalendarOff}
label={t('nav.timeBlocks', 'Time Blocks')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/locations"
icon={MapPin}
label={t('nav.locations', 'Locations')}
isCollapsed={isCollapsed}
locked={!canUse('multi_location')}
/>
</>
{hasPermission('can_access_site_builder') && (
<SidebarItem
to="/dashboard/site-editor"
icon={LayoutTemplate}
label={t('nav.siteBuilder', 'Site Builder')}
isCollapsed={isCollapsed}
/>
)}
{hasPermission('can_access_gallery') && (
<SidebarItem
to="/dashboard/gallery"
icon={Image}
label={t('nav.gallery', 'Media Gallery')}
isCollapsed={isCollapsed}
/>
)}
{hasPermission('can_access_customers') && (
<SidebarItem
to="/dashboard/customers"
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{hasPermission('can_access_services') && (
<SidebarItem
to="/dashboard/services"
icon={Briefcase}
label={t('nav.services', 'Services')}
isCollapsed={isCollapsed}
/>
)}
{hasPermission('can_access_resources') && (
<SidebarItem
to="/dashboard/resources"
icon={ClipboardList}
label={t('nav.resources')}
isCollapsed={isCollapsed}
/>
)}
{hasPermission('can_access_staff') && (
<SidebarItem
to="/dashboard/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{hasPermission('can_access_contracts') && canUse('contracts') && (
<SidebarItem
to="/dashboard/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{hasPermission('can_access_time_blocks') && (
<SidebarItem
to="/dashboard/time-blocks"
icon={CalendarOff}
label={t('nav.timeBlocks', 'Time Blocks')}
isCollapsed={isCollapsed}
/>
)}
{hasPermission('can_access_locations') && (
<SidebarItem
to="/dashboard/locations"
icon={MapPin}
label={t('nav.locations', 'Locations')}
isCollapsed={isCollapsed}
locked={!canUse('multi_location')}
/>
)}
</SidebarSection>
)}
@@ -245,7 +281,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{/* Money Section - Payments */}
{canViewAdminPages && (
{hasPermission('can_access_payments') && (
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
<SidebarItem
to="/dashboard/payments"
@@ -258,7 +294,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{/* Extend Section - Automations */}
{canViewAdminPages && (
{hasPermission('can_access_automations') && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/dashboard/automations/my-automations"

View File

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

View File

@@ -19,6 +19,8 @@ export interface StaffInvitation {
create_bookable_resource: boolean;
resource_name: string;
permissions: Record<string, boolean>;
staff_role_id: number | null;
staff_role_name: string | null;
}
export interface InvitationDetails {
@@ -52,6 +54,7 @@ export interface CreateInvitationData {
create_bookable_resource?: boolean;
resource_name?: string;
permissions?: StaffPermissions;
staff_role_id?: number | null;
}
/**

View File

@@ -7,6 +7,7 @@ import apiClient from '../api/client';
export interface StaffPermissions {
can_invite_staff?: boolean;
[key: string]: boolean | undefined;
}
export interface StaffMember {
@@ -18,6 +19,9 @@ export interface StaffMember {
is_active: boolean;
permissions: StaffPermissions;
can_invite_staff: boolean;
staff_role_id: number | null;
staff_role_name: string | null;
effective_permissions: Record<string, boolean>;
}
interface StaffFilters {
@@ -48,6 +52,9 @@ export const useStaff = (filters?: StaffFilters) => {
is_active: s.is_active ?? true,
permissions: s.permissions || {},
can_invite_staff: s.can_invite_staff ?? false,
staff_role_id: s.staff_role_id ?? null,
staff_role_name: s.staff_role_name ?? null,
effective_permissions: s.effective_permissions || {},
}));
},
retry: false,
@@ -66,7 +73,7 @@ export const useUpdateStaff = () => {
updates,
}: {
id: string;
updates: { is_active?: boolean; permissions?: StaffPermissions };
updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null };
}) => {
const { data } = await apiClient.patch(`/staff/${id}/`, updates);
return data;

View File

@@ -0,0 +1,95 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { StaffRole, AvailablePermissions } from '../types';
/**
* Hook to fetch all staff roles for the current tenant
*/
export const useStaffRoles = () => {
return useQuery<StaffRole[]>({
queryKey: ['staffRoles'],
queryFn: async () => {
const { data } = await apiClient.get('/staff-roles/');
return data;
},
});
};
/**
* Hook to fetch a single staff role by ID
*/
export const useStaffRole = (id: number | null) => {
return useQuery<StaffRole>({
queryKey: ['staffRoles', id],
queryFn: async () => {
const { data } = await apiClient.get(`/staff-roles/${id}/`);
return data;
},
enabled: id !== null,
});
};
/**
* Hook to fetch available permission keys and their metadata
*/
export const useAvailablePermissions = () => {
return useQuery<AvailablePermissions>({
queryKey: ['staffRoles', 'availablePermissions'],
queryFn: async () => {
const { data } = await apiClient.get('/staff-roles/available_permissions/');
return data;
},
staleTime: 1000 * 60 * 60, // Cache for 1 hour - permissions don't change often
});
};
/**
* Hook to create a new staff role
*/
export const useCreateStaffRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<StaffRole>) => {
const response = await apiClient.post('/staff-roles/', data);
return response.data as StaffRole;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staffRoles'] });
},
});
};
/**
* Hook to update an existing staff role
*/
export const useUpdateStaffRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: { id: number } & Partial<StaffRole>) => {
const response = await apiClient.patch(`/staff-roles/${id}/`, data);
return response.data as StaffRole;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['staffRoles'] });
queryClient.invalidateQueries({ queryKey: ['staffRoles', variables.id] });
},
});
};
/**
* Hook to delete a staff role
*/
export const useDeleteStaffRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/staff-roles/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staffRoles'] });
},
});
};

View File

@@ -496,6 +496,10 @@
"reactivateAccount": "Reactivate Account",
"deactivateHint": "Prevent this user from logging in while keeping their data",
"reactivateHint": "Allow this user to log in again",
"staffRole": "Staff Role",
"noRoleAssigned": "No role assigned",
"selectRole": "Select a role...",
"staffRoleSelectHint": "Assign a role to control which features this staff member can access",
"canSendMessages": "Can send broadcast messages",
"canSendMessagesHint": "Send messages to groups of staff and customers",
"deactivate": "Deactivate",
@@ -1316,6 +1320,97 @@
"copiedToClipboard": "Copied to clipboard",
"failedToSaveReturnUrl": "Failed to save return URL",
"onlyOwnerCanAccess": "Only the business owner can access these settings."
},
"backToApp": "Back to App",
"sections": {
"business": "Business",
"branding": "Branding",
"integrations": "Integrations",
"access": "Access",
"communication": "Communication",
"billing": "Billing"
},
"general": {
"title": "General",
"description": "Name, timezone, contact"
},
"resourceTypes": {
"title": "Resource Types",
"description": "Staff, rooms, equipment"
},
"businessHours": {
"title": "Business Hours",
"description": "Operating hours"
},
"appearance": {
"title": "Appearance",
"description": "Logo, colors, theme"
},
"emailTemplates": {
"title": "Email Templates",
"description": "Customize automated emails"
},
"customDomains": {
"title": "Custom Domains",
"description": "Use your own domain"
},
"api": {
"title": "API & Webhooks",
"description": "API tokens, webhooks"
},
"staffRoles": {
"title": "Staff Roles",
"description": "Role permissions",
"pageTitle": "Staff Roles",
"pageDescription": "Create and manage roles with specific permissions for your staff members.",
"createRole": "Create Role",
"editRole": "Edit Role",
"deleteRole": "Delete Role",
"roleName": "Role Name",
"roleDescription": "Description",
"roleNamePlaceholder": "e.g., Front Desk",
"roleDescriptionPlaceholder": "Brief description of this role's responsibilities",
"permissions": "Permissions",
"menuAccess": "Menu Access",
"dangerousOperations": "Dangerous Operations",
"staffAssigned": "{{count}} staff assigned",
"noStaffAssigned": "No staff assigned",
"defaultRole": "Default",
"cannotDeleteDefault": "Cannot delete default roles",
"cannotDeleteWithStaff": "Cannot delete roles with assigned staff",
"confirmDelete": "Are you sure you want to delete this role?",
"deleteWarning": "This action cannot be undone.",
"noRolesFound": "No staff roles found",
"createFirstRole": "Create your first custom staff role to control what your staff can access.",
"saving": "Saving...",
"save": "Save Role",
"cancel": "Cancel",
"loadingRoles": "Loading roles...",
"loadingPermissions": "Loading permissions...",
"errorLoadingRoles": "Failed to load staff roles",
"errorLoadingPermissions": "Failed to load available permissions",
"roleCreated": "Role created successfully",
"roleUpdated": "Role updated successfully",
"roleDeleted": "Role deleted successfully",
"errorCreating": "Failed to create role",
"errorUpdating": "Failed to update role",
"errorDeleting": "Failed to delete role"
},
"authentication": {
"title": "Authentication",
"description": "OAuth, social login"
},
"email": {
"title": "Email Setup",
"description": "Email addresses for tickets"
},
"smsCalling": {
"title": "SMS & Calling",
"description": "Credits, phone numbers"
},
"billing": {
"title": "Plan & Billing",
"description": "Subscription, invoices"
}
},
"profile": {

View File

@@ -22,6 +22,7 @@ import {
AlertTriangle,
Calendar,
Clock,
Users,
} from 'lucide-react';
import {
SettingsSidebarSection,
@@ -154,6 +155,12 @@ const SettingsLayout: React.FC = () => {
{/* Access Section */}
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
<SettingsSidebarItem
to="/dashboard/settings/staff-roles"
icon={Users}
label={t('settings.staffRoles.title', 'Staff Roles')}
description={t('settings.staffRoles.description', 'Role permissions')}
/>
<SettingsSidebarItem
to="/dashboard/settings/authentication"
icon={Lock}

View File

@@ -11,6 +11,7 @@ import {
StaffInvitation,
CreateInvitationData,
} from '../hooks/useInvitations';
import { useStaffRoles } from '../hooks/useStaffRoles';
import {
Plus,
User as UserIcon,
@@ -43,6 +44,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const { data: staffMembers = [], isLoading, error } = useStaff();
const { data: resources = [] } = useResources();
const { data: invitations = [], isLoading: invitationsLoading } = useInvitations();
const { data: staffRoles = [] } = useStaffRoles();
const createResourceMutation = useCreateResource();
const createInvitationMutation = useCreateInvitation();
const cancelInvitationMutation = useCancelInvitation();
@@ -53,6 +55,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF');
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
const [createBookableResource, setCreateBookableResource] = useState(false);
const [resourceName, setResourceName] = useState('');
const [invitePermissions, setInvitePermissions] = useState<Record<string, boolean>>({});
@@ -64,6 +67,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
const [editError, setEditError] = useState('');
const [editSuccess, setEditSuccess] = useState('');
@@ -106,6 +110,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
create_bookable_resource: createBookableResource,
resource_name: resourceName.trim(),
permissions: invitePermissions,
staff_role_id: inviteRole === 'TENANT_STAFF' ? inviteStaffRoleId : null,
};
await createInvitationMutation.mutateAsync(invitationData);
@@ -114,6 +119,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setCreateBookableResource(false);
setResourceName('');
setInvitePermissions({});
setInviteStaffRoleId(null);
// Close modal after short delay
setTimeout(() => {
setIsInviteModalOpen(false);
@@ -146,6 +152,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const openInviteModal = () => {
setInviteEmail('');
setInviteRole('TENANT_STAFF');
setInviteStaffRoleId(null);
setCreateBookableResource(false);
setResourceName('');
setInvitePermissions({});
@@ -190,6 +197,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const openEditModal = (staff: StaffMember) => {
setEditingStaff(staff);
setEditPermissions(staff.permissions || {});
setEditStaffRoleId(staff.staff_role_id);
setEditError('');
setEditSuccess('');
setIsEditModalOpen(true);
@@ -199,6 +207,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setIsEditModalOpen(false);
setEditingStaff(null);
setEditPermissions({});
setEditStaffRoleId(null);
setEditError('');
setEditSuccess('');
};
@@ -208,9 +217,16 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setEditError('');
try {
const updates: { permissions: Record<string, boolean>; staff_role_id?: number | null } = {
permissions: editPermissions,
};
// Only include staff_role_id for staff users (not owners/managers)
if (editingStaff.role === 'staff') {
updates.staff_role_id = editStaffRoleId;
}
await updateStaffMutation.mutateAsync({
id: editingStaff.id,
updates: { permissions: editPermissions },
updates,
});
setEditSuccess(t('staff.settingsSaved'));
setTimeout(() => {
@@ -272,7 +288,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<div>
<div className="font-medium text-gray-900 dark:text-white text-sm">{invitation.email}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{invitation.role_display} &bull; {t('staff.expires')}{' '}
{invitation.role_display}
{invitation.staff_role_name && ` (${invitation.staff_role_name})`}
{' '}&bull; {t('staff.expires')}{' '}
{new Date(invitation.expires_at).toLocaleDateString()}
</div>
</div>
@@ -309,6 +327,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<tr>
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
<th className="px-6 py-4 font-medium">{t('staff.staffRole')}</th>
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
@@ -350,6 +369,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{user.role}
</span>
</td>
<td className="px-6 py-4">
{user.role === 'staff' ? (
user.staff_role_name ? (
<span className="text-xs text-gray-700 dark:text-gray-300">
{user.staff_role_name}
</span>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
{t('staff.noRoleAssigned')}
</span>
)
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</td>
<td className="px-6 py-4">
{linkedResource ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
@@ -533,6 +567,30 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</p>
</div>
{/* Staff Role Selector (only for staff invitations) */}
{inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.staffRole')}
</label>
<select
value={inviteStaffRoleId ?? ''}
onChange={(e) => setInviteStaffRoleId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="">{t('staff.selectRole')}</option>
{staffRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('staff.staffRoleSelectHint')}
</p>
</div>
)}
{/* Permissions - Using shared component */}
{inviteRole === 'TENANT_MANAGER' && (
<StaffPermissions
@@ -672,6 +730,30 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
</span>
</div>
{/* Staff Role Selector (only for staff users) */}
{editingStaff.role === 'staff' && staffRoles.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.staffRole')}
</label>
<select
value={editStaffRoleId ?? ''}
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="">{t('staff.selectRole')}</option>
{staffRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('staff.staffRoleSelectHint')}
</p>
</div>
)}
{/* Permissions - Using shared component */}
{editingStaff.role === 'manager' && (
<StaffPermissions

View File

@@ -0,0 +1,483 @@
/**
* Staff Roles Settings Page
*
* Create and manage staff roles with granular permissions.
* Roles control what menu items and features are accessible to staff members.
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check } from 'lucide-react';
import { Business, User, StaffRole, PermissionDefinition } from '../../types';
import {
useStaffRoles,
useAvailablePermissions,
useCreateStaffRole,
useUpdateStaffRole,
useDeleteStaffRole,
} from '../../hooks/useStaffRoles';
const StaffRolesSettings: React.FC = () => {
const { t } = useTranslation();
const { user } = useOutletContext<{
business: Business;
user: User;
}>();
const { data: staffRoles = [], isLoading } = useStaffRoles();
const { data: availablePermissions } = useAvailablePermissions();
const createStaffRole = useCreateStaffRole();
const updateStaffRole = useUpdateStaffRole();
const deleteStaffRole = useDeleteStaffRole();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingRole, setEditingRole] = useState<StaffRole | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
permissions: {} as Record<string, boolean>,
});
const [error, setError] = useState<string | null>(null);
const isOwner = user.role === 'owner';
const isManager = user.role === 'manager';
const canManageRoles = isOwner || isManager;
// Merge menu and dangerous permissions for display
const allPermissions = useMemo(() => {
if (!availablePermissions) return { menu: {}, dangerous: {} };
return {
menu: availablePermissions.menu_permissions || {},
dangerous: availablePermissions.dangerous_permissions || {},
};
}, [availablePermissions]);
const openCreateModal = () => {
setEditingRole(null);
setFormData({
name: '',
description: '',
permissions: {},
});
setError(null);
setIsModalOpen(true);
};
const openEditModal = (role: StaffRole) => {
setEditingRole(role);
setFormData({
name: role.name,
description: role.description || '',
permissions: { ...role.permissions },
});
setError(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingRole(null);
setError(null);
};
const togglePermission = (key: string) => {
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
[key]: !prev.permissions[key],
},
}));
};
const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => {
const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous;
const updates: Record<string, boolean> = {};
Object.keys(permissions).forEach((key) => {
updates[key] = enable;
});
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
...updates,
},
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingRole) {
await updateStaffRole.mutateAsync({
id: editingRole.id,
name: formData.name,
description: formData.description,
permissions: formData.permissions,
});
} else {
await createStaffRole.mutateAsync({
name: formData.name,
description: formData.description,
permissions: formData.permissions,
});
}
closeModal();
} catch (err: any) {
const message = err.response?.data?.error || err.response?.data?.name?.[0] || 'Failed to save role';
setError(message);
}
};
const handleDelete = async (role: StaffRole) => {
if (!role.can_delete) {
alert(
role.is_default
? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted.')
: t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff from this role before deleting.')
);
return;
}
if (window.confirm(t('settings.staffRoles.confirmDelete', `Are you sure you want to delete the "${role.name}" role?`))) {
try {
await deleteStaffRole.mutateAsync(role.id);
} catch (err: any) {
alert(err.response?.data?.error || 'Failed to delete role');
}
}
};
const countEnabledPermissions = (permissions: Record<string, boolean>) => {
return Object.values(permissions).filter(Boolean).length;
};
if (!canManageRoles) {
return (
<div className="text-center py-12">
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')}
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Shield className="text-indigo-500" />
{t('settings.staffRoles.title', 'Staff Roles')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('settings.staffRoles.subtitle', 'Create roles to control what staff members can access in your business.')}
</p>
</div>
{/* Roles List */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.staffRoles.yourRoles', 'Your Staff Roles')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('settings.staffRoles.rolesDescription', 'Assign staff members to roles to control their permissions.')}
</p>
</div>
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
>
<Plus size={16} />
{t('settings.staffRoles.createRole', 'Create Role')}
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : staffRoles.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Shield size={40} className="mx-auto mb-2 opacity-30" />
<p>{t('settings.staffRoles.noRoles', 'No staff roles configured.')}</p>
<p className="text-sm mt-1">{t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}</p>
</div>
) : (
<div className="space-y-3">
{staffRoles.map((role) => (
<div
key={role.id}
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
role.is_default
? 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
<Shield size={20} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2 flex-wrap">
{role.name}
{role.is_default && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded">
{t('common.default', 'Default')}
</span>
)}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-3 mt-0.5">
<span className="flex items-center gap-1">
<Users size={14} />
{t('settings.staffRoles.staffAssigned', '{{count}} staff', { count: role.staff_count })}
</span>
<span className="flex items-center gap-1">
<Check size={14} />
{t('settings.staffRoles.permissionsEnabled', '{{count}} permissions', {
count: countEnabledPermissions(role.permissions),
})}
</span>
</p>
{role.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
{role.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<button
onClick={() => openEditModal(role)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(role)}
disabled={deleteStaffRole.isPending || !role.can_delete}
className={`p-2 transition-colors disabled:opacity-50 ${
role.can_delete
? 'text-gray-400 hover:text-red-600 dark:hover:text-red-400'
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
}`}
title={
role.is_default
? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted')
: role.staff_count > 0
? t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff first')
: t('common.delete', 'Delete')
}
>
{role.can_delete ? <Trash2 size={16} /> : <Lock size={16} />}
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
{/* Create/Edit Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingRole
? t('settings.staffRoles.editRole', 'Edit Role')
: t('settings.staffRoles.createRole', 'Create Role')}
</h3>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
{/* Basic Info */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.staffRoles.roleName', 'Role Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
disabled={editingRole?.is_default}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.staffRoles.roleDescription', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
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 resize-none"
placeholder={t('settings.staffRoles.roleDescriptionPlaceholder', 'Describe what this role can do...')}
/>
</div>
</div>
{/* Menu 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.menuPermissions', 'Menu Access')}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('menu', 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('menu', 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">
{Object.entries(allPermissions.menu).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-brand-600 border-gray-300 dark:border-gray-600 rounded focus:ring-brand-500"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Dangerous Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
{t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">
{t('common.caution', 'Caution')}
</span>
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('dangerous', 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('dangerous', 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-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30">
{Object.entries(allPermissions.dangerous).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-red-100/50 dark:hover:bg-red-900/20 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-red-600 border-gray-300 dark:border-gray-600 rounded focus:ring-red-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>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createStaffRole.isPending || updateStaffRole.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{editingRole ? t('common.save', 'Save') : t('common.create', 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default StaffRolesSettings;

View File

@@ -135,6 +135,34 @@ export interface User {
linked_resource_name?: string;
permissions?: Record<string, boolean>;
quota_overages?: QuotaOverage[];
// Staff role fields
staff_role_id?: number | null;
staff_role_name?: string | null;
effective_permissions?: Record<string, boolean>;
}
// Staff Role Types
export interface StaffRole {
id: number;
name: string;
description: string;
permissions: Record<string, boolean>;
is_default: boolean;
staff_count: number;
can_delete: boolean;
created_at: string;
updated_at: string;
}
export interface PermissionDefinition {
label: string;
description: string;
default: boolean;
}
export interface AvailablePermissions {
menu_permissions: Record<string, PermissionDefinition>;
dangerous_permissions: Record<string, PermissionDefinition>;
}
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';