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:
483
frontend/src/pages/settings/StaffRolesSettings.tsx
Normal file
483
frontend/src/pages/settings/StaffRolesSettings.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user