Add staff permission controls for editing staff and customers

- 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>
This commit is contained in:
poduck
2025-12-29 17:38:48 -05:00
parent d7700a68fd
commit 47657e7076
105 changed files with 29709 additions and 873 deletions

View File

@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import UserProfileDropdown from '../UserProfileDropdown';
import { User } from '../../types';
// Mock react-router-dom BEFORE imports
vi.mock('react-router-dom', () => ({
Link: ({ to, children, ...props }: any) =>
React.createElement('a', { ...props, href: to }, children),
useLocation: () => ({ pathname: '/dashboard' }),
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
User: () => React.createElement('div', { 'data-testid': 'user-icon' }),
Settings: () => React.createElement('div', { 'data-testid': 'settings-icon' }),
LogOut: () => React.createElement('div', { 'data-testid': 'logout-icon' }),
ChevronDown: () => React.createElement('div', { 'data-testid': 'chevron-icon' }),
}));
// Mock useAuth hook
const mockLogout = vi.fn();
vi.mock('../../hooks/useAuth', () => ({
useLogout: () => ({
mutate: mockLogout,
isPending: false,
}),
}));
describe('UserProfileDropdown', () => {
const mockUser: User = {
id: 1,
email: 'john@example.com',
name: 'John Doe',
role: 'owner' as any,
phone: '',
isActive: true,
permissions: {},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders user name', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('renders formatted role', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.getByText('Owner')).toBeInTheDocument();
});
it('renders formatted role with underscores replaced', () => {
const staffUser = { ...mockUser, role: 'platform_manager' as any };
render(React.createElement(UserProfileDropdown, { user: staffUser }));
expect(screen.getByText('Platform Manager')).toBeInTheDocument();
});
it('renders user avatar when avatarUrl is provided', () => {
const userWithAvatar = { ...mockUser, avatarUrl: 'https://example.com/avatar.jpg' };
const { container } = render(React.createElement(UserProfileDropdown, { user: userWithAvatar }));
const img = container.querySelector('img[alt="John Doe"]');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg');
});
it('renders user initials when no avatar', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.getByText('JD')).toBeInTheDocument();
});
it('renders single letter initial for single name', () => {
const singleNameUser = { ...mockUser, name: 'Madonna' };
render(React.createElement(UserProfileDropdown, { user: singleNameUser }));
expect(screen.getByText('M')).toBeInTheDocument();
});
it('renders first two initials for multi-word name', () => {
const multiNameUser = { ...mockUser, name: 'John Paul Jones' };
render(React.createElement(UserProfileDropdown, { user: multiNameUser }));
expect(screen.getByText('JP')).toBeInTheDocument();
});
});
describe('dropdown interaction', () => {
it('is closed by default', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
});
it('opens dropdown when button clicked', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
});
it('shows user email in dropdown header', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('closes dropdown when clicking outside', async () => {
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
// Click outside
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
});
});
it('closes dropdown on escape key', async () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
});
});
it('sets aria-expanded attribute correctly', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
});
describe('navigation', () => {
it('links to /profile for non-platform routes', () => {
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const link = container.querySelector('a[href="/profile"]');
expect(link).toBeInTheDocument();
});
it('profile settings link renders correctly', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
});
it('closes dropdown when profile link is clicked', async () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const profileLink = screen.getByText('Profile Settings');
fireEvent.click(profileLink);
await waitFor(() => {
expect(screen.queryByText('Sign Out')).not.toBeInTheDocument();
});
});
});
describe('sign out', () => {
it('renders sign out button', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Sign Out')).toBeInTheDocument();
});
it('calls logout when sign out clicked', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const signOutButton = screen.getByText('Sign Out');
fireEvent.click(signOutButton);
expect(mockLogout).toHaveBeenCalled();
});
it('sign out button is functional', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const signOutButton = screen.getByText('Sign Out').closest('button');
expect(signOutButton).not.toBeDisabled();
});
});
describe('variants', () => {
it('applies default variant styles', () => {
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = container.querySelector('button');
expect(button?.className).toContain('border-gray-200');
});
it('applies light variant styles', () => {
const { container } = render(
React.createElement(UserProfileDropdown, {
user: mockUser,
variant: 'light',
})
);
const button = container.querySelector('button');
expect(button?.className).toContain('border-white/20');
});
it('shows white text in light variant', () => {
const { container } = render(
React.createElement(UserProfileDropdown, {
user: mockUser,
variant: 'light',
})
);
const userName = screen.getByText('John Doe');
expect(userName.className).toContain('text-white');
});
});
});