Update staff roles, documentation, and add tenant API
Staff Roles: - Remove Support Staff default role (now Manager and Staff only) - Add position field for custom role ordering - Update StaffRolesSettings with improved permission UI - Add RolePermissions component for visual permission display Documentation Updates: - HelpStaff: Explain two-tier permission system (User Roles + Staff Roles) - HelpSettingsStaffRoles: Update default roles, add settings access permissions - HelpComprehensive: Update staff roles section with correct role structure - HelpCustomers: Add customer creation and onboarding sections - HelpContracts: Add lifecycle, snapshotting, and signing experience docs - HelpSettingsAppearance: Update with 20 color palettes and navigation text Tenant API: - Add new isolated API at /tenant-api/v1/ for third-party integrations - Token-based authentication with scope permissions - Endpoints: business, services, resources, availability, bookings, customers, webhooks Tests: - Add test coverage for Celery tasks across modules - Reorganize schedule view tests for better maintainability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,18 +5,20 @@
|
||||
* Roles control what menu items and features are accessible to staff members.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useRef } 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 { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check, GripVertical } from 'lucide-react';
|
||||
import { Business, User, StaffRole } from '../../types';
|
||||
import {
|
||||
useStaffRoles,
|
||||
useAvailablePermissions,
|
||||
useCreateStaffRole,
|
||||
useUpdateStaffRole,
|
||||
useDeleteStaffRole,
|
||||
useReorderStaffRoles,
|
||||
} from '../../hooks/useStaffRoles';
|
||||
import { RolePermissionsEditor } from '../../components/staff/RolePermissions';
|
||||
|
||||
const StaffRolesSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,8 +32,12 @@ const StaffRolesSettings: React.FC = () => {
|
||||
const createStaffRole = useCreateStaffRole();
|
||||
const updateStaffRole = useUpdateStaffRole();
|
||||
const deleteStaffRole = useDeleteStaffRole();
|
||||
const reorderStaffRoles = useReorderStaffRoles();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [draggedRoleId, setDraggedRoleId] = useState<number | null>(null);
|
||||
const [dragOverRoleId, setDragOverRoleId] = useState<number | null>(null);
|
||||
const draggedRef = useRef<number | null>(null);
|
||||
const [editingRole, setEditingRole] = useState<StaffRole | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
@@ -82,57 +88,10 @@ const StaffRolesSettings: React.FC = () => {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setFormData((prev) => {
|
||||
const newValue = !prev.permissions[key];
|
||||
const updates: Record<string, boolean> = { [key]: newValue };
|
||||
|
||||
// If enabling any settings sub-permission, also enable the main settings access
|
||||
if (newValue && key.startsWith('can_access_settings_')) {
|
||||
updates['can_access_settings'] = true;
|
||||
}
|
||||
|
||||
// If disabling the main settings access, disable all sub-permissions
|
||||
if (!newValue && key === 'can_access_settings') {
|
||||
Object.keys(allPermissions.settings).forEach((settingKey) => {
|
||||
if (settingKey !== 'can_access_settings') {
|
||||
updates[settingKey] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
...updates,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
|
||||
const permissions = category === 'menu'
|
||||
? allPermissions.menu
|
||||
: category === 'settings'
|
||||
? allPermissions.settings
|
||||
: allPermissions.dangerous;
|
||||
const updates: Record<string, boolean> = {};
|
||||
Object.keys(permissions).forEach((key) => {
|
||||
updates[key] = enable;
|
||||
});
|
||||
|
||||
// If enabling any settings permissions, ensure main settings access is also enabled
|
||||
if (category === 'settings' && enable) {
|
||||
updates['can_access_settings'] = true;
|
||||
}
|
||||
|
||||
const handlePermissionsChange = (newPermissions: Record<string, boolean>) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
...updates,
|
||||
},
|
||||
permissions: newPermissions,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -185,6 +144,67 @@ const StaffRolesSettings: React.FC = () => {
|
||||
return Object.values(permissions).filter(Boolean).length;
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, roleId: number) => {
|
||||
e.dataTransfer.setData('text/plain', String(roleId));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
draggedRef.current = roleId;
|
||||
setDraggedRoleId(roleId);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedRoleId(null);
|
||||
setDragOverRoleId(null);
|
||||
draggedRef.current = null;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, roleId: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (draggedRef.current && draggedRef.current !== roleId) {
|
||||
setDragOverRoleId(roleId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverRoleId(null);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetRoleId: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const draggedId = draggedRef.current;
|
||||
setDragOverRoleId(null);
|
||||
|
||||
if (!draggedId || draggedId === targetRoleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder the roles
|
||||
const currentOrder = staffRoles.map(r => r.id);
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetRoleId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove dragged item and insert at target position
|
||||
const newOrder = [...currentOrder];
|
||||
newOrder.splice(draggedIndex, 1);
|
||||
newOrder.splice(targetIndex, 0, draggedId);
|
||||
|
||||
try {
|
||||
await reorderStaffRoles.mutateAsync(newOrder);
|
||||
} catch (err) {
|
||||
// Silently fail - the UI will remain unchanged
|
||||
}
|
||||
};
|
||||
|
||||
if (!canManageRoles) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
@@ -240,14 +260,36 @@ const StaffRolesSettings: React.FC = () => {
|
||||
<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">
|
||||
<div
|
||||
className="space-y-3"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{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"
|
||||
draggable="true"
|
||||
onDragStart={(e) => handleDragStart(e, role.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, role.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e)}
|
||||
onDrop={(e) => handleDrop(e, role.id)}
|
||||
className={`p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border transition-all select-none ${
|
||||
dragOverRoleId === role.id
|
||||
? 'border-brand-500 border-2 bg-brand-50 dark:bg-brand-900/20'
|
||||
: draggedRoleId === role.id
|
||||
? 'border-gray-300 dark:border-gray-600 opacity-50'
|
||||
: '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">
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
className="w-6 h-10 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
|
||||
title={t('settings.staffRoles.dragToReorder', 'Drag to reorder')}
|
||||
>
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<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'
|
||||
@@ -283,9 +325,10 @@ const StaffRolesSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2" onDragStart={(e) => e.preventDefault()}>
|
||||
<button
|
||||
onClick={() => openEditModal(role)}
|
||||
draggable="false"
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
@@ -293,6 +336,7 @@ const StaffRolesSettings: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(role)}
|
||||
draggable="false"
|
||||
disabled={deleteStaffRole.isPending || !role.can_delete}
|
||||
className={`p-2 transition-colors disabled:opacity-50 ${
|
||||
role.can_delete
|
||||
@@ -373,170 +417,12 @@ const StaffRolesSettings: React.FC = () => {
|
||||
</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>
|
||||
|
||||
{/* Business Settings Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('settings', true)}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.selectAll', 'Select All')}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('settings', false)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear All')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30">
|
||||
{Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100/50 dark:hover:bg-blue-900/20 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions[key] || false}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{def.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{def.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dangerous Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
{/* Permissions Editor */}
|
||||
<RolePermissionsEditor
|
||||
permissions={formData.permissions}
|
||||
onChange={handlePermissionsChange}
|
||||
availablePermissions={allPermissions}
|
||||
/>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user