feat: implement permissions and actions management
- Add permissions logic for status options in corrective actions. - Create tests for permissions functionality. - Implement queries for user authentication and actions management. - Develop Actions page to display and manage corrective actions. - Create authentication pages for login, setup, and password reset. - Add IncidentDetail page to view incident details and associated actions. - Implement Settings page for user management and password changes.
This commit is contained in:
1 parent
708da6b9f7
commit
3f6dff5f24
94 files changed
+7213
-643
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { PASSWORD, signIn, users } from './fixtures'
|
||||
|
||||
test('sends visitors to sign in and back after', async ({ page }) => {
|
||||
await page.goto('/incidents')
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
await page.getByLabel('Email').fill(users.reporter.email)
|
||||
await page.getByLabel('Password').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/incidents$/)
|
||||
})
|
||||
|
||||
test('shows an error for a wrong password', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(users.reporter.email)
|
||||
await page.getByLabel('Password').fill('not the password')
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toHaveText('Incorrect email or password')
|
||||
})
|
||||
|
||||
test('offers an emailed sign-in link without revealing accounts', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByRole('button', { name: 'Email me a sign-in link instead' }).click()
|
||||
await page.getByLabel('Email').fill('nobody@e2e.test')
|
||||
await page.getByRole('button', { name: 'Email me a sign-in link' }).click()
|
||||
await expect(page.getByRole('status')).toContainText('If that email belongs to an account')
|
||||
})
|
||||
|
||||
test('signs out', async ({ page, isMobile }) => {
|
||||
await signIn(page, 'reporter')
|
||||
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('button', { name: 'Sign out' }).click()
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
})
|
||||
|
||||
test('loads with no third-party requests', async ({ page }) => {
|
||||
const external: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (!request.url().startsWith('http://localhost')) external.push(request.url())
|
||||
})
|
||||
await signIn(page, 'reporter')
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
expect(await page.evaluate(() => document.fonts.check('16px "DM Sans Variable"'))).toBe(true)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, type Page } from '@playwright/test'
|
||||
|
||||
export const PASSWORD = 'e2e test password'
|
||||
export const users = {
|
||||
admin: { name: 'Avery Admin', email: 'admin@e2e.test' },
|
||||
supervisor: { name: 'Sam Supervisor', email: 'supervisor@e2e.test' },
|
||||
reporter: { name: 'Riley Reporter', email: 'reporter@e2e.test' },
|
||||
} as const
|
||||
|
||||
export async function signIn(page: Page, who: keyof typeof users) {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(users[who].email)
|
||||
await page.getByLabel('Password').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(users[who].name.split(' ')[0])
|
||||
}
|
||||
|
||||
/** A unique title so parallel tests (and the desktop/mobile projects) never collide. */
|
||||
export const unique = (title: string) => `${title} ${Math.random().toString(36).slice(2, 7)}`
|
||||
|
||||
/** 1×1 PNG. */
|
||||
export const photo = {
|
||||
name: 'scene.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { request } from '@playwright/test'
|
||||
import { baseURL } from '../playwright.config'
|
||||
import { PASSWORD, users } from './fixtures'
|
||||
|
||||
/** Creates the workspace, a site and one user per role in the fresh server. */
|
||||
export default async function globalSetup() {
|
||||
const api = await request.newContext({ baseURL })
|
||||
// oxlint-disable-next-line typescript/no-explicit-any -- loosely typed setup calls
|
||||
const ok = async (res: Awaited<ReturnType<typeof api.post>>): Promise<any> => {
|
||||
if (!res.ok()) throw new Error(`${res.url()} → ${res.status()} ${await res.text()}`)
|
||||
return res.json()
|
||||
}
|
||||
await ok(await api.post('/api/setup', { data: { organizationName: 'E2E Plant', ...users.admin, password: PASSWORD } }))
|
||||
const { site } = await ok(await api.post('/api/sites', { data: { name: 'Main Plant' } }))
|
||||
await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
|
||||
for (const role of ['supervisor', 'reporter'] as const) {
|
||||
await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } }))
|
||||
}
|
||||
await api.dispose()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { photo, signIn, unique, users } from './fixtures'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
/** The incident page section with this heading. */
|
||||
const section = (page: Page, heading: string) => page.locator('section').filter({ has: page.getByRole('heading', { name: heading, exact: true }) })
|
||||
|
||||
async function move(page: Page, button: string) {
|
||||
await page.getByRole('button', { name: button }).click()
|
||||
await expect(page.getByRole('button', { name: button })).toHaveCount(0)
|
||||
}
|
||||
|
||||
test('takes an incident from report to close across three roles', async ({ browser, isMobile }) => {
|
||||
test.skip(isMobile, 'Long multi-role flow runs on desktop only')
|
||||
test.setTimeout(90_000)
|
||||
const title = unique('Guard missing on press 4')
|
||||
|
||||
// Reporter files the report with a photo
|
||||
const reporter = await (await browser.newContext()).newPage()
|
||||
await signIn(reporter, 'reporter')
|
||||
await reporter.getByRole('link', { name: 'Report incident' }).click()
|
||||
await reporter.getByLabel('Incident title').fill(title)
|
||||
await reporter.getByLabel('Incident type').selectOption('hazard')
|
||||
await reporter.getByLabel('Severity').selectOption('high')
|
||||
await reporter.getByLabel('Building or area').selectOption({ label: 'Press Shop' })
|
||||
await reporter.getByLabel('What happened?').fill('Safety guard removed and left off after maintenance.')
|
||||
await reporter.locator('input[type=file]').setInputFiles(photo)
|
||||
await reporter.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(reporter.getByText(/has been reported/)).toBeVisible()
|
||||
await expect(reporter.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
await expect(reporter.getByRole('img', { name: 'scene.png' })).toBeVisible()
|
||||
const url = reporter.url()
|
||||
|
||||
// Supervisor triages, investigates and assigns an action back to the reporter
|
||||
const supervisor = await (await browser.newContext()).newPage()
|
||||
await signIn(supervisor, 'supervisor')
|
||||
await supervisor.goto(url)
|
||||
await move(supervisor, 'Start triage')
|
||||
await move(supervisor, 'Start investigation')
|
||||
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
|
||||
await supervisor.getByLabel('Category').selectOption('process')
|
||||
await supervisor.getByLabel('Root cause').fill('No step to refit guards after maintenance')
|
||||
await supervisor.getByLabel('Why 1').fill('Guard was not refitted')
|
||||
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
|
||||
await expect(section(supervisor, 'Investigation').getByText('No step to refit guards after maintenance')).toBeVisible()
|
||||
await move(supervisor, 'Move to corrective actions')
|
||||
await supervisor.getByRole('button', { name: 'Add action' }).click()
|
||||
await supervisor.getByLabel('Action', { exact: true }).fill('Add guard check to maintenance sign-off')
|
||||
await supervisor.getByLabel('Assign to').selectOption({ label: users.reporter.name })
|
||||
await supervisor.getByRole('button', { name: 'Add action' }).click()
|
||||
await expect(section(supervisor, 'Corrective actions').getByText(`Assigned to ${users.reporter.name}`)).toBeVisible()
|
||||
await expect(supervisor.getByRole('button', { name: 'Send for verification' })).toBeDisabled()
|
||||
|
||||
// Reporter completes the action from My actions
|
||||
await reporter.goto('/actions')
|
||||
const row = reporter.getByRole('listitem').filter({ hasText: 'Add guard check to maintenance sign-off' })
|
||||
await row.getByRole('combobox').selectOption('done')
|
||||
await expect(row).toHaveCount(0) // leaves the Open list
|
||||
await reporter.getByRole('tab', { name: 'Done' }).click()
|
||||
await expect(row.getByRole('combobox')).toHaveValue('done')
|
||||
|
||||
// Supervisor sends it for verification; admin verifies and closes
|
||||
await supervisor.reload()
|
||||
await move(supervisor, 'Send for verification')
|
||||
const admin = await (await browser.newContext()).newPage()
|
||||
await signIn(admin, 'admin')
|
||||
await admin.goto(url)
|
||||
await expect(admin.getByRole('button', { name: 'Verify and close' })).toBeDisabled()
|
||||
await admin.getByRole('combobox', { name: /Status of Add guard check/ }).selectOption('verified')
|
||||
await admin.getByLabel('Note').fill('Checked on the floor: guard refitted and checklist updated.')
|
||||
await move(admin, 'Verify and close')
|
||||
|
||||
// The record shows the whole story
|
||||
await expect(admin.getByText('Closed', { exact: true }).first()).toBeVisible()
|
||||
const activity = section(admin, 'Activity')
|
||||
await expect(activity.getByText('moved it from Verification to Closed')).toBeVisible()
|
||||
await expect(activity.getByText('Checked on the floor')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signIn, unique } from './fixtures'
|
||||
|
||||
test('reports from a phone and finds the incident again', async ({ page, isMobile }) => {
|
||||
const title = unique(isMobile ? 'Phone report: oil on floor' : 'Desktop report: oil on floor')
|
||||
await signIn(page, 'reporter')
|
||||
if (isMobile) {
|
||||
await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('link', { name: 'Report an incident' }).click()
|
||||
} else {
|
||||
await page.getByRole('link', { name: 'Report incident' }).click()
|
||||
}
|
||||
await page.getByLabel('Incident title').fill(title)
|
||||
await page.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
const reference = (await page.locator('text=/INC-\\d{4}/').first().textContent())!.match(/INC-\d{4}/)![0]
|
||||
|
||||
await page.goto('/incidents')
|
||||
await page.getByLabel('Search incidents').last().fill(reference)
|
||||
await expect(page.getByRole('link', { name: title })).toBeVisible()
|
||||
await expect(page.getByRole('row')).toHaveCount(2) // header + match
|
||||
})
|
||||
|
||||
test('filters the register', async ({ page }) => {
|
||||
await signIn(page, 'supervisor')
|
||||
await page.goto('/incidents?status=closed&type=environmental')
|
||||
await expect(page.getByText('No incidents match these filters.')).toBeVisible()
|
||||
await expect(page.getByLabel('Filter by status')).toHaveValue('closed')
|
||||
})
|
||||
|
||||
test("reporters can't see other people's reports", async ({ page, browser }) => {
|
||||
const title = unique('Supervisor-only report')
|
||||
const supervisor = await (await browser.newContext()).newPage()
|
||||
await signIn(supervisor, 'supervisor')
|
||||
await supervisor.goto('/report')
|
||||
await supervisor.getByLabel('Incident title').fill(title)
|
||||
await supervisor.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
const url = new URL(supervisor.url()).pathname
|
||||
|
||||
await signIn(page, 'reporter')
|
||||
await page.goto(url)
|
||||
await expect(page.getByText('doesn’t exist or you don’t have access')).toBeVisible()
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('navigates between the main pages', async ({ page, isMobile }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Jordan')
|
||||
|
||||
for (const name of ['Incidents', 'Locations', 'Analytics']) {
|
||||
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('navigation').getByRole('link', { name }).click()
|
||||
await expect(page.getByRole('heading', { level: 1, name })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('reports an incident', async ({ page }) => {
|
||||
await page.goto('/report')
|
||||
await page.getByLabel('Incident title').fill('Blocked fire exit in warehouse')
|
||||
await page.getByLabel('Specific location').fill('North exit')
|
||||
await page.getByRole('button', { name: /submit report/i }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/incidents$/)
|
||||
await expect(page.getByText('Blocked fire exit in warehouse')).toBeVisible()
|
||||
})
|
||||
|
||||
test('loads bundled fonts without third-party requests', async ({ page }) => {
|
||||
const external: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (!request.url().startsWith('http://localhost')) external.push(request.url())
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
expect(await page.evaluate(() => document.fonts.check('16px "DM Sans Variable"'))).toBe(true)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
Reference in new issue
Block a user