- Add can_edit_staff and can_edit_customers dangerous permissions - Move Site Builder, Services, Locations, Time Blocks, Payments to Settings permissions - Link Edit Others' Schedules and Edit Own Schedule permissions - Add permission checks to StaffViewSet (partial_update, toggle_active, verify_email) - Add permission checks to CustomerViewSet (update, partial_update, verify_email) - Fix CustomerViewSet permission key mismatch (can_access_customers) - Hide Edit/Verify buttons on Staff and Customers pages without permission - Make dangerous permissions section more visually distinct (darker red) - Fix StaffDashboard links to use correct paths (/dashboard/my-schedule) - Disable settings sub-permissions when Access Settings is unchecked 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/**
|
|
* Shared Role Permission Components
|
|
*
|
|
* Reusable components for displaying and editing staff role permissions.
|
|
* Used in both StaffRolesSettings and the Invite Staff modal.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { PermissionDefinition } from '../../types';
|
|
|
|
export interface PermissionSectionProps {
|
|
title: string;
|
|
description: string;
|
|
permissions: Record<string, PermissionDefinition>;
|
|
values: Record<string, boolean>;
|
|
onChange: (key: string, value: boolean) => void;
|
|
onSelectAll?: () => void;
|
|
onClearAll?: () => void;
|
|
variant?: 'default' | 'settings' | 'dangerous';
|
|
readOnly?: boolean;
|
|
columns?: 1 | 2;
|
|
lockedPermissions?: Record<string, string>; // key -> reason (forced on)
|
|
disabledPermissions?: Record<string, string>; // key -> reason (grayed out until parent enabled)
|
|
}
|
|
|
|
/**
|
|
* A section of permission checkboxes with header and select/clear all buttons
|
|
*/
|
|
export const PermissionSection: React.FC<PermissionSectionProps> = ({
|
|
title,
|
|
description,
|
|
permissions,
|
|
values,
|
|
onChange,
|
|
onSelectAll,
|
|
onClearAll,
|
|
variant = 'default',
|
|
readOnly = false,
|
|
columns = 2,
|
|
lockedPermissions = {},
|
|
disabledPermissions = {},
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
|
|
const variantStyles = {
|
|
default: {
|
|
container: '',
|
|
checkbox: 'text-brand-600 focus:ring-brand-500',
|
|
hover: 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
|
},
|
|
settings: {
|
|
container: 'p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30',
|
|
checkbox: 'text-blue-600 focus:ring-blue-500',
|
|
hover: 'hover:bg-blue-100/50 dark:hover:bg-blue-900/20',
|
|
},
|
|
dangerous: {
|
|
container: 'p-3 bg-red-100 dark:bg-red-900/30 rounded-lg border border-red-200 dark:border-red-800/50',
|
|
checkbox: 'text-red-600 focus:ring-red-500',
|
|
hover: 'hover:bg-red-200/70 dark:hover:bg-red-900/40',
|
|
},
|
|
};
|
|
|
|
const styles = variantStyles[variant];
|
|
const gridCols = columns === 1 ? 'grid-cols-1' : 'grid-cols-2';
|
|
|
|
return (
|
|
<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">
|
|
{title}
|
|
{variant === 'dangerous' && (
|
|
<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">{description}</p>
|
|
</div>
|
|
{!readOnly && onSelectAll && onClearAll && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onSelectAll}
|
|
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={onClearAll}
|
|
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
|
>
|
|
{t('common.clearAll', 'Clear All')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`grid ${gridCols} gap-2 ${styles.container}`}>
|
|
{Object.entries(permissions).map(([key, def]) => (
|
|
<PermissionCheckbox
|
|
key={key}
|
|
permissionKey={key}
|
|
definition={def}
|
|
checked={values[key] || false}
|
|
onChange={(value) => onChange(key, value)}
|
|
checkboxClass={styles.checkbox}
|
|
hoverClass={styles.hover}
|
|
readOnly={readOnly}
|
|
locked={!!lockedPermissions[key]}
|
|
lockedReason={lockedPermissions[key]}
|
|
disabled={!!disabledPermissions[key]}
|
|
disabledReason={disabledPermissions[key]}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface PermissionCheckboxProps {
|
|
permissionKey: string;
|
|
definition: PermissionDefinition;
|
|
checked: boolean;
|
|
onChange: (value: boolean) => void;
|
|
checkboxClass?: string;
|
|
hoverClass?: string;
|
|
readOnly?: boolean;
|
|
locked?: boolean;
|
|
lockedReason?: string;
|
|
disabled?: boolean;
|
|
disabledReason?: string;
|
|
}
|
|
|
|
/**
|
|
* Individual permission checkbox with label and description
|
|
*/
|
|
export const PermissionCheckbox: React.FC<PermissionCheckboxProps> = ({
|
|
permissionKey,
|
|
definition,
|
|
checked,
|
|
onChange,
|
|
checkboxClass = 'text-brand-600 focus:ring-brand-500',
|
|
hoverClass = 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
|
readOnly = false,
|
|
locked = false,
|
|
lockedReason,
|
|
disabled = false,
|
|
disabledReason,
|
|
}) => {
|
|
const isDisabled = readOnly || locked || disabled;
|
|
const tooltipReason = locked ? lockedReason : disabled ? disabledReason : undefined;
|
|
|
|
return (
|
|
<label
|
|
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer ${isDisabled ? 'opacity-60 cursor-default' : hoverClass}`}
|
|
title={tooltipReason}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
disabled={isDisabled}
|
|
className={`w-4 h-4 border-gray-300 dark:border-gray-600 rounded ${checkboxClass} disabled:opacity-50`}
|
|
/>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
|
|
{definition.label}
|
|
{locked && (
|
|
<span className="text-[10px] text-gray-400 dark:text-gray-500 font-normal">
|
|
(required)
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{definition.description}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
);
|
|
};
|
|
|
|
interface RolePermissionsEditorProps {
|
|
permissions: Record<string, boolean>;
|
|
onChange: (permissions: Record<string, boolean>) => void;
|
|
availablePermissions: {
|
|
menu: Record<string, PermissionDefinition>;
|
|
settings: Record<string, PermissionDefinition>;
|
|
dangerous: Record<string, PermissionDefinition>;
|
|
};
|
|
readOnly?: boolean;
|
|
columns?: 1 | 2;
|
|
}
|
|
|
|
/**
|
|
* Full role permissions editor with all three sections
|
|
*/
|
|
export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
|
|
permissions,
|
|
onChange,
|
|
availablePermissions,
|
|
readOnly = false,
|
|
columns = 2,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
|
|
const togglePermission = (key: string, value: boolean) => {
|
|
const updates: Record<string, boolean> = { [key]: value };
|
|
|
|
// If enabling any settings sub-permission, also enable the main settings access
|
|
if (value && key.startsWith('can_access_settings_')) {
|
|
updates['can_access_settings'] = true;
|
|
}
|
|
|
|
// If disabling the main settings access, disable all sub-permissions
|
|
if (!value && key === 'can_access_settings') {
|
|
Object.keys(availablePermissions.settings).forEach((settingKey) => {
|
|
if (settingKey !== 'can_access_settings') {
|
|
updates[settingKey] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Schedule editing permissions are linked:
|
|
// - If enabling "edit others' schedules", also enable "edit own schedule"
|
|
// - "edit own schedule" can be disabled independently only if "edit others'" is off
|
|
if (value && key === 'can_edit_others_schedules') {
|
|
updates['can_edit_own_schedule'] = true;
|
|
}
|
|
|
|
// Prevent disabling "edit own schedule" if "edit others' schedules" is enabled
|
|
if (!value && key === 'can_edit_own_schedule') {
|
|
if (permissions['can_edit_others_schedules']) {
|
|
// Keep it enabled - can't disable own schedule editing while others' is enabled
|
|
updates['can_edit_own_schedule'] = true;
|
|
}
|
|
}
|
|
|
|
onChange({ ...permissions, ...updates });
|
|
};
|
|
|
|
const toggleAllInCategory = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
|
|
const categoryPerms = availablePermissions[category];
|
|
const updates: Record<string, boolean> = {};
|
|
Object.keys(categoryPerms).forEach((key) => {
|
|
updates[key] = enable;
|
|
});
|
|
|
|
// If enabling settings permissions, ensure main settings access is also enabled
|
|
if (category === 'settings' && enable) {
|
|
updates['can_access_settings'] = true;
|
|
}
|
|
|
|
// If enabling menu permissions including edit_others, ensure edit_own is also enabled
|
|
if (category === 'menu' && enable && updates['can_edit_others_schedules']) {
|
|
updates['can_edit_own_schedule'] = true;
|
|
}
|
|
|
|
onChange({ ...permissions, ...updates });
|
|
};
|
|
|
|
// Calculate which permissions are locked (cannot be unchecked due to dependencies)
|
|
const lockedPermissions: Record<string, string> = {};
|
|
|
|
// "Edit Own Schedule" is locked when "Edit Others' Schedules" is enabled
|
|
if (permissions['can_edit_others_schedules']) {
|
|
lockedPermissions['can_edit_own_schedule'] = t(
|
|
'settings.staffRoles.lockedByEditOthers',
|
|
'Required when "Edit Others\' Schedules" is enabled'
|
|
);
|
|
}
|
|
|
|
// Calculate which settings permissions are disabled (require "Access Settings" to be enabled first)
|
|
const disabledSettingsPermissions: Record<string, string> = {};
|
|
if (!permissions['can_access_settings']) {
|
|
// Disable all settings sub-permissions when main settings access is off
|
|
Object.keys(availablePermissions.settings).forEach((key) => {
|
|
if (key !== 'can_access_settings') {
|
|
disabledSettingsPermissions[key] = t(
|
|
'settings.staffRoles.requiresAccessSettings',
|
|
'Enable "Access Settings" first'
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Menu Permissions */}
|
|
<PermissionSection
|
|
title={t('settings.staffRoles.menuPermissions', 'Menu Access')}
|
|
description={t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
|
|
permissions={availablePermissions.menu}
|
|
values={permissions}
|
|
onChange={togglePermission}
|
|
onSelectAll={() => toggleAllInCategory('menu', true)}
|
|
onClearAll={() => toggleAllInCategory('menu', false)}
|
|
variant="default"
|
|
readOnly={readOnly}
|
|
columns={columns}
|
|
lockedPermissions={lockedPermissions}
|
|
/>
|
|
|
|
{/* Settings Permissions */}
|
|
<PermissionSection
|
|
title={t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
|
|
description={t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
|
|
permissions={availablePermissions.settings}
|
|
values={permissions}
|
|
onChange={togglePermission}
|
|
onSelectAll={() => toggleAllInCategory('settings', true)}
|
|
onClearAll={() => toggleAllInCategory('settings', false)}
|
|
variant="settings"
|
|
readOnly={readOnly}
|
|
columns={columns}
|
|
lockedPermissions={lockedPermissions}
|
|
disabledPermissions={disabledSettingsPermissions}
|
|
/>
|
|
|
|
{/* Dangerous Permissions */}
|
|
<PermissionSection
|
|
title={t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
|
|
description={t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
|
|
permissions={availablePermissions.dangerous}
|
|
values={permissions}
|
|
onChange={togglePermission}
|
|
onSelectAll={() => toggleAllInCategory('dangerous', true)}
|
|
onClearAll={() => toggleAllInCategory('dangerous', false)}
|
|
variant="dangerous"
|
|
lockedPermissions={lockedPermissions}
|
|
readOnly={readOnly}
|
|
columns={columns}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RolePermissionsEditor;
|