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:
241
frontend/src/components/__tests__/UserProfileDropdown.test.tsx
Normal file
241
frontend/src/components/__tests__/UserProfileDropdown.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user