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

+70 -16
View File
@@ -1,11 +1,13 @@
# API reference
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in and password change.
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in, emailed links and password changes.
Request bodies are validated against the schemas in [`shared/schemas.ts`](../shared/schemas.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
Request bodies are validated against [`shared/schemas.ts`](../shared/schemas.ts); response shapes are in [`shared/api-types.ts`](../shared/api-types.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can.
Browsers must send an `Origin` header matching the site on form/multipart and body-less requests (all browsers do); JSON requests are protected by CORS.
## Setup and authentication
| Method | Path | Who | Notes |
@@ -14,19 +16,24 @@ Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manage
| GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists |
| POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards |
| POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes |
| POST | `/api/auth/magic-link` | anyone | `{ email }`. Emails a single-use sign-in link (15 minutes). Always 204 |
| POST | `/api/auth/magic-link/verify` | anyone | `{ token }`. Signs in |
| POST | `/api/auth/password-reset` | anyone | `{ email }`. Emails a reset link (1 hour). Always 204 |
| POST | `/api/auth/password-reset/confirm` | anyone | `{ token, newPassword }`. Sets the password, signs out other sessions, signs in. Also completes invitations |
| POST | `/api/auth/logout` | anyone | Ends the session. 204 |
| GET | `/api/auth/me` | signed in | `{ user, organization }` |
| POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 |
Passwords need at least 10 characters.
Passwords need at least 10 characters. Email requests are limited to 5 per address per 15 minutes. Emailed links land on web pages (`/auth/magic`, `/auth/reset`) that POST the token, so link scanners in email systems can't use them up.
## Users
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/users` | safety_manager | All users in the organization |
| POST | `/api/users` | admin | `{ name, email, role, password }`. 409 if the email exists |
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active? }`. Deactivating signs the user out. The last active admin can't be demoted or deactivated |
| GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions |
| POST | `/api/users` | admin | `{ name, email, role, password? }`. Without `password` an invitation email is sent (link valid 7 days). Returns `{ user, invited }` |
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active?, password? }`. Deactivating or setting a password signs the user out. The last active admin can't be demoted or deactivated |
## Sites and areas
@@ -35,21 +42,31 @@ Passwords need at least 10 characters.
| GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived |
| POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` |
| PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` |
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other`. Parent must be in the same site |
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other` |
| PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` |
## Incidents
Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
**Visibility:** supervisors and above see every incident. Reporters see incidents they reported plus any where they're assigned a corrective action. Anything else answers 404.
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/incidents` | signed in | Paginated `{ items, page, pageSize, total }`, newest first. Reporters see only their own reports |
| POST | `/api/incidents` | signed in | Create a report (below). 201 |
| GET | `/api/incidents/:number` | signed in | 404 if it doesn't exist or isn't visible to you |
| PATCH | `/api/incidents/:number` | supervisor | Any create field plus `status`. Closing sets `closedAt`; reopening clears it. Moving to another site clears the area unless one is given |
| GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first |
| GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) |
| POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins |
| GET | `/api/incidents/:number` | signed in | Full detail: investigation, root causes, actions, attachments, the `transitions` you may perform (with `blockedReason` when not yet possible) and `can` flags |
| PATCH | `/api/incidents/:number` | supervisor | Any create field except `anonymous`. Status changes use transitions |
| POST | `/api/incidents/:number/transitions` | see below | `{ to, note? }`. The note is also posted as a comment |
| GET/POST | `/api/incidents/:number/comments` | can see it | POST `{ body }` |
| GET | `/api/incidents/:number/timeline` | can see it | Audit entries `{ id, at, actor, action, changes }` |
| PUT | `/api/incidents/:number/investigation` | supervisor | `{ leadId?, method?, summary?, completed? }`. Creates the investigation if needed |
| POST | `/api/incidents/:number/root-causes` | supervisor | `{ category, description, whys? }` |
| POST | `/api/incidents/:number/actions` | supervisor | `{ title, description?, kind?, assigneeId?, dueDate?, rootCauseId? }`. Not on closed incidents. Emails the assignee |
| POST | `/api/incidents/:number/attachments` | can see it | Multipart, field `file`. Photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs only, detected from content; `MAX_UPLOAD_MB` each (default 10), 20 per incident |
**List query parameters:** `page` (default 1), `pageSize` (1–100, default 25), `q` (searches title, description and location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
**List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
**Create body:**
@@ -70,8 +87,45 @@ Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Num
- `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental`
- `severity`: `low`, `medium`, `high`, `critical`
- `status`: `reported`, `triage`, `investigation`, `actions`, `verification`, `closed`
- `occurredAt` must include a time-zone offset and can't be in the future.
- Anonymous reports don't record the reporter at all, so they won't appear in the reporter's own list.
- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all.
Every create and update is recorded in the audit log (`audit_log` table) with who made it and what changed.
### Workflow
`reported` → `triage` → `investigation` → `actions` → `verification` → `closed`. The rules live in [`shared/workflow.ts`](../shared/workflow.ts) and are enforced by the API.
| From | To | Who | Condition |
| --- | --- | --- | --- |
| reported | triage | supervisor | |
| triage | investigation | supervisor | |
| triage | actions | supervisor | |
| triage | closed | supervisor | Low or medium severity only |
| investigation | actions | supervisor | At least one root cause |
| actions | investigation | supervisor | |
| actions | verification | supervisor | At least one action, none open or in progress |
| verification | actions | safety_manager | |
| verification | closed | safety_manager | Every (non-cancelled) action verified |
| closed | investigation | safety_manager | Reopen |
## Root causes, actions, attachments
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| PATCH | `/api/root-causes/:id` | supervisor | `{ category?, description?, whys? }` |
| DELETE | `/api/root-causes/:id` | supervisor | |
| GET | `/api/actions` | signed in | `?scope=mine\|all` (`all` needs supervisor) and `?filter=open\|overdue\|done\|all`. Sorted by due date |
| PATCH | `/api/actions/:id` | see notes | Supervisors: any field and status except `verified`. Safety managers: also `verified`. Assignees: only `status` among `open`, `in_progress`, `done` |
| GET | `/api/attachments/:id` | can see the incident | The file, with a sandboxing Content-Security-Policy |
| DELETE | `/api/attachments/:id` | uploader or supervisor | |
Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue.
## Stats
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year |
| GET | `/api/stats/analytics` | signed in | `?days=` (7–730, default 90): counts by type and severity, top root-cause category, on-time action rate, monthly completions |
Stats only count what the signed-in user can see.
Every create and update on an incident, its investigation, actions and files is recorded in the audit log with who made it and what changed.