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,373 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import EmailComposer from '../EmailComposer';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { StaffEmail } from '../../../types';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock react-hot-toast
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock hooks
vi.mock('../../../hooks/useStaffEmail', () => ({
useCreateDraft: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
})),
useUpdateDraft: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
})),
useSendEmail: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
})),
useUploadAttachment: vi.fn(() => ({
mutateAsync: vi.fn(),
})),
useContactSearch: vi.fn(() => ({
data: [],
})),
useUserEmailAddresses: vi.fn(() => ({
data: [
{
id: 1,
email_address: 'test@example.com',
display_name: 'Test User',
},
],
})),
}));
describe('EmailComposer', () => {
const defaultProps = {
onClose: vi.fn(),
onSent: vi.fn(),
};
const mockReplyToEmail: StaffEmail = {
id: 1,
owner: 1,
emailAddress: 1,
folder: 1,
fromAddress: 'sender@example.com',
fromName: 'Sender Name',
toAddresses: ['test@example.com'],
ccAddresses: [],
bccAddresses: [],
subject: 'Original Subject',
snippet: 'Email snippet',
bodyText: 'Original email body',
bodyHtml: '<p>Original email body</p>',
messageId: 'message-123',
inReplyTo: null,
references: '',
status: 'SENT',
threadId: 'thread-123',
emailDate: '2025-01-15T10:00:00Z',
isRead: true,
isStarred: false,
isImportant: false,
isAnswered: false,
isPermanentlyDeleted: false,
deletedAt: null,
hasAttachments: false,
attachmentCount: 0,
attachments: [],
labels: [],
createdAt: '2025-01-15T10:00:00Z',
updatedAt: '2025-01-15T10:00:00Z',
};
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders new message mode', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('New Message')).toBeInTheDocument();
});
it('renders reply mode with subject prefixed', () => {
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Reply')).toBeInTheDocument();
expect(screen.getByDisplayValue('Re: Original Subject')).toBeInTheDocument();
});
it('renders forward mode with subject prefixed', () => {
render(<EmailComposer {...defaultProps} forwardFrom={mockReplyToEmail} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Forward')).toBeInTheDocument();
expect(screen.getByDisplayValue('Fwd: Original Subject')).toBeInTheDocument();
});
it('populates recipient in reply mode', () => {
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
wrapper: createWrapper(),
});
expect(screen.getByDisplayValue('sender@example.com')).toBeInTheDocument();
});
it('renders minimized state', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const minimizeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-minimize-2')
);
if (minimizeButton) {
fireEvent.click(minimizeButton);
}
expect(screen.getByText('New Message')).toBeInTheDocument();
});
it('expands from minimized state', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
// Minimize
let buttons = screen.getAllByRole('button');
const minimizeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-minimize-2')
);
if (minimizeButton) {
fireEvent.click(minimizeButton);
}
// Maximize
buttons = screen.getAllByRole('button');
const maximizeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-maximize-2')
);
if (maximizeButton) {
fireEvent.click(maximizeButton);
}
expect(screen.getByPlaceholderText('recipient@example.com')).toBeInTheDocument();
});
it('calls onClose when close button clicked', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const closeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-x')
);
if (closeButton) {
fireEvent.click(closeButton);
}
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('shows Cc field when Cc button clicked', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const ccButton = screen.getByText('Cc');
fireEvent.click(ccButton);
expect(screen.getByPlaceholderText('cc@example.com')).toBeInTheDocument();
});
it('shows Bcc field when Bcc button clicked', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const bccButton = screen.getByText('Bcc');
fireEvent.click(bccButton);
expect(screen.getByPlaceholderText('bcc@example.com')).toBeInTheDocument();
});
it('updates subject field', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const subjectInput = screen.getByPlaceholderText('Email subject');
fireEvent.change(subjectInput, { target: { value: 'Test Subject' } });
expect(subjectInput).toHaveValue('Test Subject');
});
it('updates body field', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
fireEvent.change(bodyTextarea, { target: { value: 'Test email body' } });
expect(bodyTextarea).toHaveValue('Test email body');
});
it('updates to field', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const toInput = screen.getByPlaceholderText('recipient@example.com');
fireEvent.change(toInput, { target: { value: 'test@example.com' } });
expect(toInput).toHaveValue('test@example.com');
});
it('updates cc field when shown', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByText('Cc'));
const ccInput = screen.getByPlaceholderText('cc@example.com');
fireEvent.change(ccInput, { target: { value: 'cc@example.com' } });
expect(ccInput).toHaveValue('cc@example.com');
});
it('updates bcc field when shown', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByText('Bcc'));
const bccInput = screen.getByPlaceholderText('bcc@example.com');
fireEvent.change(bccInput, { target: { value: 'bcc@example.com' } });
expect(bccInput).toHaveValue('bcc@example.com');
});
it('renders send button', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('staffEmail.send')).toBeInTheDocument();
});
it('renders save draft button', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('Save draft')).toBeInTheDocument();
});
it('renders formatting buttons', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const boldButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-bold')
);
const italicButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-italic')
);
const underlineButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-underline')
);
expect(boldButton).toBeInTheDocument();
expect(italicButton).toBeInTheDocument();
expect(underlineButton).toBeInTheDocument();
});
it('renders attachment button', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
// Attachment input is wrapped in a label, not a button
const paperclipIcons = document.querySelectorAll('.lucide-paperclip');
expect(paperclipIcons.length).toBeGreaterThan(0);
});
it('renders discard button', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const discardButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-trash-2')
);
expect(discardButton).toBeInTheDocument();
});
it('renders from address selector', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('populates from address with default value', async () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toHaveValue('1');
});
});
it('does not add Re: prefix if subject already has it', () => {
const emailWithReply = {
...mockReplyToEmail,
subject: 'Re: Already Replied',
};
render(<EmailComposer {...defaultProps} replyTo={emailWithReply} />, {
wrapper: createWrapper(),
});
expect(screen.getByDisplayValue('Re: Already Replied')).toBeInTheDocument();
});
it('does not add Fwd: prefix if subject already has it', () => {
const emailWithFwd = {
...mockReplyToEmail,
subject: 'Fwd: Already Forwarded',
};
render(<EmailComposer {...defaultProps} forwardFrom={emailWithFwd} />, {
wrapper: createWrapper(),
});
expect(screen.getByDisplayValue('Fwd: Already Forwarded')).toBeInTheDocument();
});
it('includes original message in reply body', () => {
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
wrapper: createWrapper(),
});
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
const value = (bodyTextarea as HTMLTextAreaElement).value;
expect(value).toContain('Original email body');
});
it('includes original message in forward body', () => {
render(<EmailComposer {...defaultProps} forwardFrom={mockReplyToEmail} />, {
wrapper: createWrapper(),
});
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
const value = (bodyTextarea as HTMLTextAreaElement).value;
expect(value).toContain('Original email body');
});
it('renders with all header fields', () => {
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.getByText('To:')).toBeInTheDocument();
expect(screen.getByText('Subject:')).toBeInTheDocument();
});
});