feat: implement permissions and actions management
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- 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:
jayjennings committed 2026-10-06 11:20:44 -07:00
1 parent 708da6b9f7
commit 3f6dff5f24
94 files changed
+7213 -643

No files matched your search

+47
View File
@@ -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([])
})
+26
View File
@@ -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'),
}
+20
View File
@@ -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()
}
+79
View File
@@ -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()
})
+44
View File
@@ -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()
})
-33
View File
@@ -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([])
})