From 8be55359cece1531ac02a5ce6f0e13eecd8aed22 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 10 Feb 2026 14:41:04 +0100 Subject: [PATCH] add e2e tests for employee restrictions --- e2e/calendar.spec.ts | 36 ++++ e2e/clients.spec.ts | 90 +++++++++- e2e/command-palette.spec.ts | 66 ++++++++ e2e/dashboard.spec.ts | 119 ++++++++++++++ e2e/import-export.spec.ts | 154 ++++++++++++++++++ e2e/members.spec.ts | 73 +++++++++ e2e/organization.spec.ts | 29 ++++ e2e/projects.spec.ts | 100 +++++++++++- e2e/reporting.spec.ts | 129 +++++++++++++++ e2e/tasks.spec.ts | 61 ++++++- e2e/time.spec.ts | 131 +++++++++++++++ e2e/timetracker.spec.ts | 19 +++ e2e/utils/api.ts | 17 ++ e2e/utils/members.ts | 96 +++++++++++ playwright/fixtures.ts | 22 ++- .../Common/Reporting/ReportingOverview.vue | 20 ++- .../Common/Reporting/ReportingRow.vue | 9 +- .../Common/Reporting/ReportingTabNavbar.vue | 61 +++++-- resources/js/Pages/Projects.vue | 7 +- resources/js/Pages/Teams/Show.vue | 7 +- resources/js/packages/ui/src/Modal.vue | 2 +- 21 files changed, 1211 insertions(+), 37 deletions(-) create mode 100644 e2e/dashboard.spec.ts create mode 100644 e2e/import-export.spec.ts diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 30ea00d0..ec2b5b2b 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -6,6 +6,7 @@ import { createBillableProjectViaApi, createProjectViaApi, createBareTimeEntryViaApi, + createTimeEntryViaApi, } from './utils/api'; async function goToCalendar(page: Page) { @@ -288,3 +289,38 @@ test('test that deleting time entry from calendar modal works', async ({ page, c // Verify the event is removed from the calendar await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Calendar Isolation', () => { + test('employee can only see their own time entries on the calendar', async ({ + ctx, + employee, + }) => { + // Owner creates a time entry for today + const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, ownerDescription, '1h'); + + // Create a time entry for the employee for today + const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description: employeeDescription, duration: '30min' } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); + await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 }); + + // Employee's event IS visible + await expect( + employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first() + ).toBeVisible({ timeout: 10000 }); + + // Owner's event is NOT visible + await expect( + employee.page.locator('.fc-event').filter({ hasText: ownerDescription }) + ).not.toBeVisible(); + }); +}); diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts index 39720e07..761f215e 100644 --- a/e2e/clients.spec.ts +++ b/e2e/clients.spec.ts @@ -2,7 +2,12 @@ import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; -import { createClientViaApi } from './utils/api'; +import { + createClientViaApi, + createProjectMemberViaApi, + createProjectViaApi, + createPublicProjectViaApi, +} from './utils/api'; async function goToClientsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); @@ -125,3 +130,86 @@ test('test that deleting a client via actions menu works', async ({ page, ctx }) await expect(page.getByTestId('client_table')).not.toContainText(clientName); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Clients Restrictions', () => { + test('employee can view clients but cannot create', async ({ ctx, employee }) => { + // Create a client with a public project so the employee can see the client + const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000); + const client = await createClientViaApi(ctx, { name: clientName }); + await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); + await expect(employee.page.getByTestId('clients_view')).toBeVisible({ + timeout: 10000, + }); + + // Employee can see the client + await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); + + // Employee cannot see Create Client button + await expect( + employee.page.getByRole('button', { name: 'Create Client' }) + ).not.toBeVisible(); + }); + + test('employee cannot see edit/delete/archive actions on clients', async ({ + ctx, + employee, + }) => { + const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000); + const client = await createClientViaApi(ctx, { name: clientName }); + await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); + await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); + + // Click the actions dropdown trigger to open the menu + const actionsButton = employee.page.locator( + `[aria-label='Actions for Client ${clientName}']` + ); + await actionsButton.click(); + + // The dropdown menu items (Edit, Archive, Delete) should NOT be visible + await expect( + employee.page.locator(`[aria-label='Edit Client ${clientName}']`) + ).not.toBeVisible(); + await expect( + employee.page.locator(`[aria-label='Archive Client ${clientName}']`) + ).not.toBeVisible(); + await expect( + employee.page.locator(`[aria-label='Delete Client ${clientName}']`) + ).not.toBeVisible(); + }); + + test('employee can see client when they are a member of its private project', async ({ + ctx, + employee, + }) => { + const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000); + const client = await createClientViaApi(ctx, { name: clientName }); + + // Create a private project under this client + const project = await createProjectViaApi(ctx, { + name: 'PrivateProj', + client_id: client.id, + is_public: false, + }); + + // Add the employee as a project member + await createProjectMemberViaApi(ctx, project.id, { + member_id: employee.memberId, + }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients'); + await expect(employee.page.getByTestId('clients_view')).toBeVisible({ + timeout: 10000, + }); + + // Employee can see the client because they are a member of its private project + await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts index 2f0995be..013f0138 100644 --- a/e2e/command-palette.spec.ts +++ b/e2e/command-palette.spec.ts @@ -400,3 +400,69 @@ test.describe('Command Palette', () => { }); }); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Command Palette Restrictions', () => { + test('employee command palette does not show restricted navigation commands', async ({ + employee, + }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Open command palette + await employee.page.getByTestId('command_palette_button').click(); + await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + + // Available navigation commands + await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible(); + await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); + + // Restricted commands should NOT be visible + await expect( + employee.page.getByRole('option', { name: 'Go to Members' }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('option', { name: 'Go to Settings' }) + ).not.toBeVisible(); + }); + + test('employee command palette does not show create commands for restricted entities', async ({ + employee, + }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Open command palette + await employee.page.getByTestId('command_palette_button').click(); + await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + + // Search for "Create" to filter + await employee.page.locator('[role="dialog"] input').fill('Create'); + await employee.page.waitForTimeout(300); + + // Should NOT see create commands for restricted entities + await expect( + employee.page.getByRole('option', { name: 'Create Project' }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('option', { name: 'Create Client' }) + ).not.toBeVisible(); + await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible(); + await expect( + employee.page.getByRole('option', { name: 'Invite Member' }) + ).not.toBeVisible(); + + // Should still see Create Time Entry (employees can create time entries) + await expect( + employee.page.getByRole('option', { name: 'Create Time Entry' }) + ).toBeVisible(); + }); +}); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 00000000..669306c7 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; +import { + assertThatTimerHasStarted, + assertThatTimerIsStopped, + newTimeEntryResponse, + startOrStopTimerWithButton, + stoppedTimeEntryResponse, +} from './utils/currentTimeEntry'; +import { createBareTimeEntryViaApi } from './utils/api'; + +async function goToDashboard(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); +} + +test('test that dashboard loads with all expected sections', async ({ page }) => { + await goToDashboard(page); + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + + // Timer section (scoped to dashboard_timer to avoid matching sidebar timer) + await expect(page.getByTestId('time_entry_description')).toBeVisible(); + await expect(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible(); + + // Dashboard cards + await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible(); + await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible(); + await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible(); + await expect(page.getByText('Team Activity', { exact: true })).toBeVisible(); + + // Weekly overview section + await expect(page.getByText('This Week', { exact: true })).toBeVisible(); +}); + +test('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h'); + + await goToDashboard(page); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); + + // The "Last 7 Days" or "This Week" section should reflect tracked time + await expect(page.getByText('This Week', { exact: true })).toBeVisible(); +}); + +test('test that timer on dashboard can start and stop', async ({ page }) => { + await goToDashboard(page); + await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]); + await assertThatTimerHasStarted(page); + + await page.waitForTimeout(1500); + + await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]); + await assertThatTimerIsStopped(page); +}); + +test('test that weekly overview section displays stat cards', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h'); + + await goToDashboard(page); + + // Verify stat card labels are visible + await expect(page.getByText('Spent Time')).toBeVisible(); + await expect(page.getByText('Billable Time')).toBeVisible(); + await expect(page.getByText('Billable Amount')).toBeVisible(); +}); + +test('test that stopping timer refreshes dashboard data', async ({ page }) => { + await goToDashboard(page); + + // Start timer + await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(1500); + + // Stop timer and verify dashboard queries are refetched + await Promise.all([ + stoppedTimeEntryResponse(page), + page.waitForResponse( + (response) => + response.url().includes('/charts/') && + response.request().method() === 'GET' && + response.status() === 200 + ), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Dashboard Restrictions', () => { + test('employee dashboard loads and timer is functional', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Timer should be available + await expect( + employee.page.getByTestId('dashboard_timer').getByTestId('timer_button') + ).toBeVisible(); + await expect(employee.page.getByTestId('time_entry_description')).toBeEditable(); + }); + + test('employee cannot see Team Activity card', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Other dashboard cards should be visible + await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible(); + + // Team Activity should NOT be visible for employees + await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible(); + }); +}); diff --git a/e2e/import-export.spec.ts b/e2e/import-export.spec.ts new file mode 100644 index 00000000..740274c8 --- /dev/null +++ b/e2e/import-export.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; +import path from 'path'; + +async function goToImportExport(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/import'); +} + +test('test that import page loads with type dropdown and file upload', async ({ page }) => { + await goToImportExport(page); + await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 }); + + // Import section + await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible(); + await expect(page.locator('#importType')).toBeVisible(); + + // Export section + await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible(); +}); + +test('test that selecting an import type shows instructions', async ({ page }) => { + await goToImportExport(page); + + // Select a Toggl import type + await page.getByLabel('Import Type').selectOption({ index: 1 }); + + // Instructions should appear + await expect(page.getByText('Instructions:')).toBeVisible(); +}); + +test('test that importing without selecting type shows error', async ({ page }) => { + await goToImportExport(page); + + // Click Import Data without selecting a type + await page.getByRole('button', { name: 'Import Data' }).click(); + + // Should show an error notification + await expect(page.getByText('Please select the import type')).toBeVisible(); +}); + +test('test that importing without selecting file shows error', async ({ page }) => { + await goToImportExport(page); + + // Select an import type first + await page.getByLabel('Import Type').selectOption({ index: 1 }); + + // Click Import Data without selecting a file + await page.getByRole('button', { name: 'Import Data' }).click(); + + // Should show an error notification + await expect( + page.getByText('Please select the CSV or ZIP file that you want to import') + ).toBeVisible(); +}); + +test('test that export button triggers export and shows success modal', async ({ page }) => { + await goToImportExport(page); + await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible(); + + // Override window.open to prevent the page from navigating away to the + // download URL (the app uses window.open(url, '_self') which would navigate + // away before we can verify the success modal) + await page.evaluate(() => { + window.open = () => null; + }); + + // Click Export Organization Data and wait for the API response + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/export') && + response.request().method() === 'POST' && + response.status() === 200, + { timeout: 60000 } + ), + page.getByRole('button', { name: 'Export Organization Data' }).click(), + ]); + + // Success modal should appear after export completes + await expect(page.getByText('The export was successful!')).toBeVisible(); +}); + +test('test that import type dropdown has multiple options', async ({ page }) => { + await goToImportExport(page); + + // The dropdown should load with options from the API + await page.waitForResponse( + (response) => + response.url().includes('/importers') && + response.request().method() === 'GET' && + response.status() === 200 + ); + + // Verify the select has options besides the default placeholder + const options = page.getByLabel('Import Type').locator('option'); + const count = await options.count(); + // Should have at least the placeholder + some import types + expect(count).toBeGreaterThan(1); +}); + +test('test that importing a generic time entries CSV works', async ({ page }) => { + await goToImportExport(page); + await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 }); + + // Select "Generic Time Entries" import type + await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' }); + await expect(page.getByText('Instructions:')).toBeVisible(); + + // Upload the test CSV file + const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv'); + await page.locator('#file-upload').setInputFiles(csvPath); + + // Click Import and wait for the API response + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/import') && + response.request().method() === 'POST' && + response.status() === 200, + { timeout: 30000 } + ), + page.getByRole('button', { name: 'Import Data' }).click(), + ]); + + // Verify success modal with import results + await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible(); + await expect(page.getByText('The import was successful!')).toBeVisible(); + + // The CSV has 2 time entries, 1 client, 2 projects, 1 task + await expect(page.getByText('Time entries created:').locator('..')).toContainText('2'); + await expect(page.getByText('Projects created:').locator('..')).toContainText('2'); + await expect(page.getByText('Clients created:').locator('..')).toContainText('1'); + await expect(page.getByText('Tasks created:').locator('..')).toContainText('1'); +}); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Import Restrictions', () => { + test('employee does not see Import / Export link in the sidebar', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // The Import / Export link should NOT be visible in the sidebar for employees + await expect( + employee.page.getByRole('link', { name: 'Import / Export' }) + ).not.toBeVisible(); + }); +}); diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 066889ae..b5cf4ec0 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -445,6 +445,33 @@ test('test that invitation can be resent', async ({ page }) => { ]); }); +test('test that admin user cannot transfer ownership', async ({ page, browser }) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `admin+${memberId}@perms.test`; + + // Invite and accept an admin member + await inviteAndAcceptMember( + page, + browser, + 'Admin User ' + memberId, + memberEmail, + 'Administrator' + ); + + // Go to members page and verify the admin exists + await goToMembersPage(page); + const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' }); + await expect(adminRow).toBeVisible(); + + // The owner should still be the owner + const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' }); + await expect(ownerRow).toBeVisible(); + + // Open actions menu for the admin - should NOT have "Transfer Ownership" option + await adminRow.getByRole('button').click(); + await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible(); +}); + test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => { const memberId = Math.round(Math.random() * 100000); const memberEmail = `accepted+${memberId}@invite.test`; @@ -459,3 +486,49 @@ test('test that accepted invitation disappears from invitations tab', async ({ p // The accepted invitation should not be visible await expect(page.getByText(memberEmail)).not.toBeVisible(); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Sidebar Navigation', () => { + test('employee sidebar shows correct navigation links', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Visible links + await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible(); + await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible(); + + // Hidden links + await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible(); + await expect( + employee.page.getByRole('link', { name: 'Settings', exact: true }) + ).not.toBeVisible(); + }); + + test('employee cannot see members list or invite members', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members'); + + // Page loads but the members API returns 403 (no members:view permission) + await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({ + timeout: 10000, + }); + + // Member table is empty — no rows rendered (only headers) + await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount( + 0 + ); + + // Employee should NOT see the Invite Member button + await expect( + employee.page.getByRole('button', { name: 'Invite member' }) + ).not.toBeVisible(); + }); +}); diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts index 69e7d5ca..7e23e72e 100644 --- a/e2e/organization.spec.ts +++ b/e2e/organization.spec.ts @@ -364,3 +364,32 @@ test('test that format settings persist after page reload', async ({ page }) => await page.reload(); await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY'); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Organization Settings Restrictions', () => { + test('employee can see org name but not editable settings', async ({ ctx, employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId); + + // Organization Name section is visible (but inputs are disabled) + await expect( + employee.page.getByRole('heading', { name: 'Organization Name', level: 3 }) + ).toBeVisible({ timeout: 10000 }); + + // Editable settings sections should NOT be visible + await expect( + employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('heading', { name: 'Format Settings', level: 3 }) + ).not.toBeVisible(); + await expect( + employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 }) + ).not.toBeVisible(); + + // Save button should not be visible (employee cannot update) + await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible(); + }); +}); diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 8dad0100..92e35fa4 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -4,7 +4,12 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { formatCentsWithOrganizationDefaults } from './utils/money'; import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money'; -import { createProjectViaApi, createTaskViaApi } from './utils/api'; +import { + createProjectViaApi, + createPublicProjectViaApi, + createTaskViaApi, + updateOrganizationSettingViaApi, +} from './utils/api'; async function goToProjectsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); @@ -502,3 +507,96 @@ test('test that editing a task name on the project detail page works', async ({ await expect(page.getByTestId('task_table')).toContainText(updatedTaskName); await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Projects Restrictions', () => { + test('employee can view public projects but cannot create', async ({ ctx, employee }) => { + const projectName = 'EmpViewProj ' + Math.floor(Math.random() * 10000); + await createPublicProjectViaApi(ctx, { name: projectName }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByTestId('projects_view')).toBeVisible({ + timeout: 10000, + }); + + // Employee can see the public project + await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + + // Employee cannot see Create Project button + await expect( + employee.page.getByRole('button', { name: 'Create Project' }) + ).not.toBeVisible(); + }); + + test('employee cannot see edit/delete/archive actions on projects', async ({ + ctx, + employee, + }) => { + const projectName = 'EmpActionsProj ' + Math.floor(Math.random() * 10000); + await createPublicProjectViaApi(ctx, { name: projectName }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + + // Click the actions dropdown trigger to open the menu + const actionsButton = employee.page.locator( + `[aria-label='Actions for Project ${projectName}']` + ); + await actionsButton.click(); + + // The dropdown menu items (Edit, Archive, Delete) should NOT be visible + await expect( + employee.page.locator(`[aria-label='Edit Project ${projectName}']`) + ).not.toBeVisible(); + await expect( + employee.page.locator(`[aria-label='Archive Project ${projectName}']`) + ).not.toBeVisible(); + await expect( + employee.page.locator(`[aria-label='Delete Project ${projectName}']`) + ).not.toBeVisible(); + }); +}); + +test.describe('Employee Billable Rate Visibility', () => { + test('employee cannot see billable rate column by default', async ({ ctx, employee }) => { + const projectName = 'EmpBillableProj ' + Math.floor(Math.random() * 10000); + await createPublicProjectViaApi(ctx, { + name: projectName, + is_billable: true, + billable_rate: 15000, + }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + + // Billable Rate column should not be visible to employee by default + await expect(employee.page.getByText('Billable Rate')).not.toBeVisible(); + }); + + test('employee can see billable rate column when employees_can_see_billable_rates is enabled', async ({ + ctx, + employee, + }) => { + await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true }); + + const projectName = 'EmpBillableVisProj ' + Math.floor(Math.random() * 10000); + await createPublicProjectViaApi(ctx, { + name: projectName, + is_billable: true, + billable_rate: 20000, + }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + + // Billable Rate column header should be visible + await expect(employee.page.getByText('Billable Rate')).toBeVisible(); + + // The project row should show the formatted billable rate + const projectRow = employee.page.getByRole('row').filter({ hasText: projectName }); + await expect(projectRow).toContainText('200'); + }); +}); diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 7537030f..42745292 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -9,6 +9,9 @@ import { createTimeEntryViaApi, createTimeEntryWithTagViaApi, createTimeEntryWithBillableStatusViaApi, + createBareTimeEntryViaApi, + createPublicProjectViaApi, + updateOrganizationSettingViaApi, } from './utils/api'; // Each test registers a new user and creates test data via API @@ -859,3 +862,129 @@ test('test that export dropdown shows all export options', async ({ page, ctx }) await expect(page.getByRole('menuitem', { name: 'Export as CSV' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Export as ODS' })).toBeVisible(); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Reporting Restrictions', () => { + test('employee can access overview reporting and sees own data', async ({ ctx, employee }) => { + // Owner creates a time entry + await createBareTimeEntryViaApi(ctx, 'Owner report entry', '2h'); + + // Create employee time entry + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description: 'Emp report entry', duration: '1h' } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); + await expect(employee.page.getByTestId('reporting_view')).toBeVisible({ + timeout: 10000, + }); + + // Employee's data should be visible (1h) + await expect( + employee.page.getByTestId('reporting_view').getByText('1h 00min').first() + ).toBeVisible(); + }); + + test('employee can access detailed reporting and sees only own entries', async ({ + ctx, + employee, + }) => { + // Owner creates time entries + const ownerDescription = 'OwnerDetailEntry ' + Math.floor(Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, ownerDescription, '2h'); + + // Create employee time entry + const empDescription = 'EmpDetailEntry ' + Math.floor(Math.random() * 10000); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description: empDescription, duration: '1h' } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed'); + await expect(employee.page.getByTestId('reporting_view')).toBeVisible({ + timeout: 10000, + }); + + // Employee's entry IS visible + await expect( + employee.page.getByTestId('reporting_view').locator(`text=${empDescription}`).first() + ).toBeAttached({ timeout: 10000 }); + + // Owner's entry is NOT visible + await expect( + employee.page.getByTestId('reporting_view').locator(`text=${ownerDescription}`) + ).not.toBeAttached(); + }); + + test('employee cannot see shared reports tab in reporting', async ({ employee }) => { + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); + await expect(employee.page.getByTestId('reporting_view')).toBeVisible({ + timeout: 10000, + }); + + // Overview and Detailed tabs should be visible (scope to main to avoid sidebar matches) + const mainContent = employee.page.getByRole('main'); + await expect(mainContent.getByRole('link', { name: 'Overview' })).toBeVisible(); + await expect(mainContent.getByRole('link', { name: 'Detailed' })).toBeVisible(); + + // Shared tab should NOT be visible for employees + await expect(mainContent.getByRole('link', { name: 'Shared' })).not.toBeVisible(); + }); + + test('employee cannot see Cost column in reporting by default', async ({ ctx, employee }) => { + const project = await createPublicProjectViaApi(ctx, { + name: 'EmpBillProj', + is_billable: true, + billable_rate: 10000, + }); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description: 'Emp cost entry', duration: '1h', projectId: project.id } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); + await expect(employee.page.getByTestId('reporting_view')).toBeVisible({ + timeout: 10000, + }); + + // Cost column header should NOT be visible + await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible(); + }); + + test('employee can see Cost column when employees_can_see_billable_rates is enabled', async ({ + ctx, + employee, + }) => { + await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true }); + + const project = await createPublicProjectViaApi(ctx, { + name: 'EmpBillVisProj', + is_billable: true, + billable_rate: 10000, + }); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { + description: 'Emp cost visible entry', + duration: '1h', + projectId: project.id, + billable: true, + } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); + await expect(employee.page.getByTestId('reporting_view')).toBeVisible({ + timeout: 10000, + }); + + // Cost column header should be visible + await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible(); + + // 1h at 100.00/h billable rate = 100.00 cost (shown in row and total) + await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible(); + }); +}); diff --git a/e2e/tasks.spec.ts b/e2e/tasks.spec.ts index 5e34af33..69ef358b 100644 --- a/e2e/tasks.spec.ts +++ b/e2e/tasks.spec.ts @@ -2,7 +2,13 @@ import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; -import { createProjectViaApi, createTaskViaApi, createClientViaApi } from './utils/api'; +import { + createProjectViaApi, + createPublicProjectViaApi, + createTaskViaApi, + createClientViaApi, + updateOrganizationSettingViaApi, +} from './utils/api'; async function goToProjectsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); @@ -188,3 +194,56 @@ test('test that multiple tasks are displayed on project detail page', async ({ p await expect(page.getByText(taskName1)).toBeVisible(); await expect(page.getByText(taskName2)).toBeVisible(); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Tasks Restrictions', () => { + test('employee cannot see task management actions when employees_can_manage_tasks is disabled', async ({ + ctx, + employee, + }) => { + // Create a public project with a task + const projectName = 'EmpTaskProj ' + Math.floor(Math.random() * 10000); + const taskName = 'EmpTask ' + Math.floor(Math.random() * 10000); + const project = await createPublicProjectViaApi(ctx, { name: projectName }); + await createTaskViaApi(ctx, { name: taskName, project_id: project.id }); + + // Navigate to the project detail page + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + await employee.page.getByText(projectName).first().click(); + await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/); + + // Task should be visible + await expect(employee.page.getByText(taskName)).toBeVisible({ timeout: 10000 }); + + // Create Task button should not be visible + await expect(employee.page.getByRole('button', { name: 'Create Task' })).not.toBeVisible(); + + // Task actions button should not be visible + const actionsButton = employee.page.locator(`[aria-label='Actions for Task ${taskName}']`); + await expect(actionsButton).not.toBeVisible(); + }); + + test('employee can manage tasks when employees_can_manage_tasks is enabled', async ({ + ctx, + employee, + }) => { + // Enable the setting + await updateOrganizationSettingViaApi(ctx, { employees_can_manage_tasks: true }); + + const projectName = 'EmpTaskMgmtProj ' + Math.floor(Math.random() * 10000); + await createPublicProjectViaApi(ctx, { name: projectName }); + + // Navigate to the project detail page + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + await employee.page.getByText(projectName).first().click(); + await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/); + + // Create Task button SHOULD be visible + await expect(employee.page.getByRole('button', { name: 'Create Task' })).toBeVisible(); + }); +}); diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 72385735..c5fa5ab9 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -13,6 +13,8 @@ import { createProjectViaApi, createBillableProjectViaApi, createBareTimeEntryViaApi, + createTimeEntryViaApi, + updateOrganizationCurrencyViaWeb, } from './utils/api'; // Date picker button name patterns for different date formats @@ -602,6 +604,32 @@ test('test that date picker displays date in organization date format', async ({ // TODO: Test that project can be created in the time entry row +test('test that billable icon shows dollar sign for USD currency on time entry row', async ({ + page, + ctx, +}) => { + await updateOrganizationCurrencyViaWeb(ctx, 'USD'); + await goToTimeOverview(page); + await createEmptyTimeEntry(page); + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first(); + await expect(billableButton).toBeVisible(); + await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14'); +}); + +test('test that billable icon shows euro sign for EUR currency on time entry row', async ({ + page, + ctx, +}) => { + await updateOrganizationCurrencyViaWeb(ctx, 'EUR'); + await goToTimeOverview(page); + await createEmptyTimeEntry(page); + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first(); + await expect(billableButton).toBeVisible(); + await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12'); +}); + test('test that editing billable status via the edit modal works', async ({ page }) => { await goToTimeOverview(page); await createEmptyTimeEntry(page); @@ -1399,3 +1427,106 @@ test('test that time entries page loads multiple entries created via API', async const count = await timeEntryRows.count(); expect(count).toBeGreaterThanOrEqual(5); }); + +// ============================================= +// Employee Permission Tests +// ============================================= + +test.describe('Employee Time Entry Isolation', () => { + test('employee can only see their own time entries on the time page', async ({ + ctx, + employee, + }) => { + // Owner creates a time entry + const ownerDescription = 'OwnerWork ' + Math.floor(Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, ownerDescription, '1h'); + + // Create a time entry for the employee using the owner's context + const employeeDescription = 'EmpWork ' + Math.floor(Math.random() * 10000); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description: employeeDescription, duration: '30min' } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect( + employee.page.getByTestId('dashboard_timer').getByTestId('timer_button') + ).toBeVisible({ timeout: 10000 }); + + // Employee's time entry IS visible + const employeeRow = employee.page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: employeeDescription }); + await expect(employeeRow).toBeVisible({ timeout: 10000 }); + + // Owner's time entry is NOT visible + const ownerRow = employee.page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: ownerDescription }); + await expect(ownerRow).not.toBeVisible(); + }); + + test('employee can edit their own time entry', async ({ ctx, employee }) => { + const description = 'EmpEditEntry ' + Math.floor(Math.random() * 10000); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description, duration: '1h' } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time'); + const timeEntryRow = employee.page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: description }); + await expect(timeEntryRow).toBeVisible({ timeout: 10000 }); + + // Update description + const updatedDescription = 'Updated ' + description; + const descriptionInput = timeEntryRow.getByTestId('time_entry_description').first(); + await descriptionInput.fill(updatedDescription); + await Promise.all([ + employee.page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + descriptionInput.press('Tab'), + ]); + + // Verify updated description + await expect(timeEntryRow.getByTestId('time_entry_description').first()).toHaveValue( + updatedDescription + ); + }); + + test('employee can delete their own time entry', async ({ ctx, employee }) => { + const description = 'EmpDeleteEntry ' + Math.floor(Math.random() * 10000); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { description, duration: '1h' } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time'); + const timeEntryRow = employee.page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: description }); + await expect(timeEntryRow).toBeVisible({ timeout: 10000 }); + + // Delete via actions menu + await timeEntryRow + .getByRole('button', { name: 'Actions for the time entry' }) + .first() + .click(); + await Promise.all([ + employee.page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'DELETE' + ), + employee.page.getByTestId('time_entry_delete').click(), + ]); + + // Verify entry is gone + await expect(timeEntryRow).not.toBeVisible(); + }); +}); diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index e40178a1..9c63eb16 100644 --- a/e2e/timetracker.spec.ts +++ b/e2e/timetracker.spec.ts @@ -9,6 +9,7 @@ import { } from './utils/currentTimeEntry'; import type { Page } from '@playwright/test'; import { newTagResponse } from './utils/tags'; +import { updateOrganizationCurrencyViaWeb } from './utils/api'; // Date picker button name patterns for different date formats const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; @@ -28,6 +29,24 @@ test('test that starting and stopping a timer without description and project wo await assertThatTimerIsStopped(page); }); +test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => { + await updateOrganizationCurrencyViaWeb(ctx, 'USD'); + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + const billableButton = page.getByRole('button', { name: 'Non Billable' }).first(); + await expect(billableButton).toBeVisible(); + await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14'); +}); + +test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => { + await updateOrganizationCurrencyViaWeb(ctx, 'EUR'); + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + const billableButton = page.getByRole('button', { name: 'Non Billable' }).first(); + await expect(billableButton).toBeVisible(); + await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12'); +}); + test('test that starting and stopping a timer with a description works', async ({ page }) => { await goToDashboard(page); // Wait for the description input to be editable before filling diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index f8d9e02c..72974fd0 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -155,6 +155,21 @@ function randomColor(): string { // Entity creation // ────────────────────────────────────────────────── +export async function createPublicProjectViaApi( + ctx: TestContext, + data: { + name: string; + is_billable?: boolean; + billable_rate?: number | null; + client_id?: string | null; + } +) { + return createProjectViaApi(ctx, { + ...data, + is_public: true, + }); +} + export async function createProjectViaApi( ctx: TestContext, data: { @@ -164,6 +179,7 @@ export async function createProjectViaApi( billable_rate?: number | null; client_id?: string | null; estimated_time?: number | null; + is_public?: boolean; } ) { const response = await ctx.request.post( @@ -176,6 +192,7 @@ export async function createProjectViaApi( billable_rate: data.billable_rate ?? null, client_id: data.client_id ?? null, estimated_time: data.estimated_time ?? null, + is_public: data.is_public ?? false, }, } ); diff --git a/e2e/utils/members.ts b/e2e/utils/members.ts index 7effa8ef..4cb2e16f 100644 --- a/e2e/utils/members.ts +++ b/e2e/utils/members.ts @@ -2,6 +2,7 @@ import { expect } from '@playwright/test'; import type { Browser, Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../../playwright/config'; import { getInvitationAcceptUrl } from './mailpit'; +import type { TestContext } from './api'; /** * Register a new user in a fresh browser context and return the page + context. @@ -66,3 +67,98 @@ export async function inviteAndAcceptMember( // 4. Clean up await secondUser.close(); } + +/** + * Set up an employee member in the owner's organization. + * Returns the employee's page, their member ID, and a cleanup function. + * + * The owner page (from the fixture) is used to invite the employee. + * Test data should be created via the owner's ctx. + * + * IMPORTANT: Projects must be created with is_public: true for the employee to see them, + * or the employee must be added as a project member via createProjectMemberViaApi. + * Clients are only visible to employees if they have at least one visible project. + * Tags are visible to all org members with tags:view permission. + */ +export async function setupEmployeeUser( + ownerPage: Page, + ownerCtx: TestContext, + browser: Browser +): Promise<{ + employeePage: Page; + employeeMemberId: string; + closeEmployee: () => Promise; +}> { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `employee+${memberId}@emp-perms.test`; + const memberName = 'Emp ' + memberId; + + // Register the employee user first + const employee = await registerUser(browser, memberName, memberEmail); + + // Send invitation from the owner + await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members'); + await ownerPage.getByRole('button', { name: 'Invite Member' }).click(); + await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible(); + await ownerPage.getByPlaceholder('Member Email').fill(memberEmail); + await ownerPage.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + ownerPage.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Accept the invitation + const acceptUrl = await getInvitationAcceptUrl(employee.page.request, memberEmail); + await employee.page.goto(acceptUrl); + await employee.page.waitForURL(/dashboard/); + + // Navigate to dashboard explicitly and wait for it to load to ensure the correct org context. + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 }); + + // Verify we're on the correct organization (John's Organization). + const orgSwitcherText = await employee.page + .getByTestId('organization_switcher') + .first() + .textContent(); + if (!orgSwitcherText?.includes("John's Organization")) { + // Switch to the owner's org using the PUT /current-team endpoint + const cookies = await employee.page.context().cookies(); + const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN'); + const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : ''; + + await employee.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, { + headers: { + 'X-XSRF-TOKEN': xsrfToken, + Accept: 'text/html', + }, + data: { team_id: ownerCtx.orgId }, + }); + + // Reload to pick up the new org + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 }); + } + + // Find the employee's member ID in the owner's organization + const membersResponse = await ownerCtx.request.get( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members` + ); + expect(membersResponse.status()).toBe(200); + const membersBody = await membersResponse.json(); + const employeeMember = membersBody.data.find( + (m: { role: string; name: string }) => m.role === 'employee' && m.name === memberName + ); + expect(employeeMember).toBeTruthy(); + + return { + employeePage: employee.page, + employeeMemberId: employeeMember.id, + closeEmployee: employee.close, + }; +} diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts index 1c4fe152..8773ada6 100644 --- a/playwright/fixtures.ts +++ b/playwright/fixtures.ts @@ -1,17 +1,27 @@ import { test as baseTest } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config'; import { type TestContext, setupTestContext } from '../e2e/utils/api'; +import { setupEmployeeUser } from '../e2e/utils/members'; export * from '@playwright/test'; export type { TestContext }; +export interface EmployeeFixture { + page: Page; + memberId: string; +} + /** * API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions. * This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s). * * Uses page.context().request() to ensure cookies are shared between the API request and page. */ -export const test = baseTest.extend<{ ctx: TestContext }, { workerStorageState: string }>({ +export const test = baseTest.extend< + { ctx: TestContext; employee: EmployeeFixture }, + { workerStorageState: string } +>({ page: async ({ page }, use) => { // Generate unique email for this test const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`; @@ -80,4 +90,14 @@ export const test = baseTest.extend<{ ctx: TestContext }, { workerStorageState: const ctx = await setupTestContext(page); await use(ctx); }, + + employee: async ({ page, ctx, browser }, use) => { + const { employeePage, employeeMemberId, closeEmployee } = await setupEmployeeUser( + page, + ctx, + browser + ); + await use({ page: employeePage, memberId: employeeMemberId }); + await closeEmployee(); + }, }); diff --git a/resources/js/Components/Common/Reporting/ReportingOverview.vue b/resources/js/Components/Common/Reporting/ReportingOverview.vue index 51316e87..13853f00 100644 --- a/resources/js/Components/Common/Reporting/ReportingOverview.vue +++ b/resources/js/Components/Common/Reporting/ReportingOverview.vue @@ -67,6 +67,12 @@ const { groupByOptions, getNameForReportingRowEntry, emptyPlaceholder } = report const organization = inject>('organization'); +const showBillableRate = computed(() => { + return !!( + getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates + ); +}); + // Ensure sub-group falls back when it collides with group watch( group, @@ -280,12 +286,14 @@ const tableData = computed(() => { groupByOptions.filter((el) => el.value !== group) "> -
+
Name
Duration
-
Cost
+
Cost
+ class="chart flex flex-col items-center justify-center py-12" + :class="showBillableRate ? 'col-span-3' : 'col-span-2'">

No time entries found

Try to change the filters and time range

diff --git a/resources/js/Components/Common/Reporting/ReportingRow.vue b/resources/js/Components/Common/Reporting/ReportingRow.vue index 450daf8d..194f034f 100644 --- a/resources/js/Components/Common/Reporting/ReportingRow.vue +++ b/resources/js/Components/Common/Reporting/ReportingRow.vue @@ -20,6 +20,7 @@ const props = defineProps<{ entry: AggregatedGroupedData; indent?: boolean; currency: string; + showCost?: boolean; }>(); const expanded = ref(false); @@ -50,7 +51,7 @@ const organization = inject>('organization'); ) }}
-
+
{{ entry.cost ? formatCents( @@ -66,12 +67,14 @@ const organization = inject>('organization');
+ :class="showCost ? 'col-span-3' : 'col-span-2'" + class="grid bg-tertiary" + :style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
diff --git a/resources/js/Components/Common/Reporting/ReportingTabNavbar.vue b/resources/js/Components/Common/Reporting/ReportingTabNavbar.vue index 5045bc0b..46a0ee66 100644 --- a/resources/js/Components/Common/Reporting/ReportingTabNavbar.vue +++ b/resources/js/Components/Common/Reporting/ReportingTabNavbar.vue @@ -1,31 +1,58 @@ - - diff --git a/resources/js/Pages/Projects.vue b/resources/js/Pages/Projects.vue index b91a00b9..00e885fd 100644 --- a/resources/js/Pages/Projects.vue +++ b/resources/js/Pages/Projects.vue @@ -14,13 +14,12 @@ import { useProjectsStore } from '@/utils/useProjects'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import PageTitle from '@/Components/Common/PageTitle.vue'; import { canCreateProjects } from '@/utils/permissions'; -import { storeToRefs } from 'pinia'; import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsStore } from '@/utils/useClients'; import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src'; import { getOrganizationCurrencyString } from '@/utils/money'; -import { getCurrentRole } from '@/utils/useUser'; -import { useOrganizationStore } from '@/utils/useOrganization'; +import { getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser'; +import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { useStorage } from '@vueuse/core'; import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue'; @@ -31,7 +30,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants'; // Fetch data using TanStack Query const { projects } = useProjectsQuery(); const { clients } = useClientsQuery(); -const { organization } = storeToRefs(useOrganizationStore()); +const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); // Table state persisted in localStorage interface ProjectTableState { diff --git a/resources/js/Pages/Teams/Show.vue b/resources/js/Pages/Teams/Show.vue index 2bb47bac..8361d97d 100644 --- a/resources/js/Pages/Teams/Show.vue +++ b/resources/js/Pages/Teams/Show.vue @@ -5,7 +5,6 @@ import SectionBorder from '@/Components/SectionBorder.vue'; import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue'; import type { Organization } from '@/types/models'; import type { Permissions, Role } from '@/types/jetstream'; -import { canUpdateOrganization } from '@/utils/permissions'; import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue'; import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue'; import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue'; @@ -46,13 +45,13 @@ onMounted(async () => { - + - + - +