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

@@ -0,0 +1,13 @@
CREATE TYPE "public"."auth_token_purpose" AS ENUM('magic_link', 'password_reset');--> statement-breakpoint
CREATE TABLE "auth_tokens" (
"id" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"purpose" "auth_token_purpose" NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD COLUMN "reminder_sent_on" date;--> statement-breakpoint
ALTER TABLE "auth_tokens" ADD CONSTRAINT "auth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "auth_tokens_user_idx" ON "auth_tokens" USING btree ("user_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1791306770507,
"tag": "0000_initial",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1791309275942,
"tag": "0001_workflow_and_email",
"breakpoints": true
}
]
}
+20
View File
@@ -12,6 +12,7 @@ export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
const id = () => uuid().primaryKey().defaultRandom()
const timestamptz = () => timestamp({ withTimezone: true })
@@ -62,6 +63,23 @@ export const sessions = pgTable(
(t) => [index('sessions_user_idx').on(t.userId)],
)
/** Single-use links sent by email (magic sign-in, password reset/invite). */
export const authTokens = pgTable(
'auth_tokens',
{
/** SHA-256 of the token sent in the link. */
id: text().primaryKey(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
purpose: authTokenPurposeEnum().notNull(),
expiresAt: timestamptz().notNull(),
usedAt: timestamptz(),
createdAt: createdAt(),
},
(t) => [index('auth_tokens_user_idx').on(t.userId)],
)
export const sites = pgTable(
'sites',
{
@@ -230,6 +248,8 @@ export const correctiveActions = pgTable(
completedAt: timestamptz(),
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
verifiedAt: timestamptz(),
/** Date the last due/overdue reminder was emailed, so it goes out at most once a day. */
reminderSentOn: date({ mode: 'string' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
+18 -3
View File
@@ -2,7 +2,7 @@
import { hashPassword } from '../auth/password.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from './client.ts'
import { areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
const DEMO_PASSWORD = 'safetylog-demo'
@@ -49,8 +49,10 @@ try {
{ title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 },
{ title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 },
] as const
await tx.insert(incidents).values(
demo.map((item, index) => ({
const created = await tx
.insert(incidents)
.values(
demo.map((item, index) => ({
organizationId: org.id,
number: index + 1,
title: item.title,
@@ -58,10 +60,23 @@ try {
severity: item.severity,
status: item.status,
occurredAt: daysAgo(item.days),
createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000),
...areaIds[item.building],
locationDetail: item.locationDetail,
reporterId: person(item.reporter),
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
})),
)
.returning()
await tx.insert(auditLog).values(
created.map((incident) => ({
organizationId: org.id,
actorId: incident.reporterId,
entityType: 'incident',
entityId: incident.id,
action: 'create',
changes: { number: incident.number, title: incident.title },
createdAt: incident.createdAt,
})),
)
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })