From df041254da17622a743a8644069f0bbd554f3bc9 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 18:59:37 -0500 Subject: [PATCH] confirm actions --- AGENTS.md | 7 +- CHANGELOG.md | 7 + INSTRUCTIONS.md | 7 +- README.md | 31 ++- ROADMAP.md | 14 +- STRUCTURE.md | 2 +- UNRAID.md | 4 +- client/src/auth/AuthProvider.tsx | 14 +- client/src/components/AppShell.tsx | 4 +- client/src/components/ConfirmActionDialog.tsx | 107 +++++++++ .../src/components/FileAttachmentsPanel.tsx | 30 ++- client/src/lib/api.ts | 11 + .../modules/inventory/InventoryDetailPage.tsx | 122 +++++++++- .../manufacturing/WorkOrderDetailPage.tsx | 122 +++++++++- .../modules/settings/UserManagementPage.tsx | 227 +++++++++++++++++- .../migration.sql | 25 ++ server/prisma/schema.prisma | 22 ++ server/src/app.ts | 18 +- server/src/lib/auth-sessions.ts | 71 ++++++ server/src/lib/auth.ts | 5 +- server/src/lib/current-user.ts | 5 +- server/src/modules/admin/router.ts | 20 ++ server/src/modules/admin/service.ts | 125 ++++++++++ server/src/modules/auth/router.ts | 15 +- server/src/modules/auth/service.ts | 23 +- server/src/types/express.d.ts | 2 +- shared/src/admin/types.ts | 18 ++ shared/src/auth/types.ts | 4 + 28 files changed, 999 insertions(+), 63 deletions(-) create mode 100644 client/src/components/ConfirmActionDialog.tsx create mode 100644 server/prisma/migrations/20260315170000_auth_sessions/migration.sql create mode 100644 server/src/lib/auth-sessions.ts diff --git a/AGENTS.md b/AGENTS.md index 007b25d..4883098 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,8 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a - pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults - shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing - admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility -- admin user management with account creation, activation, role assignment, and role-permission editing +- admin user management with account creation, activation, role assignment, role-permission editing, and session visibility/revocation +- safer destructive-action confirmations and recovery messaging across admin, inventory, manufacturing, and attachment workflows - CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow - backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow - backup verification checklist and restore-drill runbook in the admin diagnostics workflow @@ -129,8 +130,8 @@ If implementation changes invalidate those docs, update them in the same change Near-term priorities are: -1. Better user and session visibility for operational admins -2. Safer destructive-action confirmations and recovery messaging +1. Deeper session history, filtering, and admin-side access review polish +2. Extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. diff --git a/CHANGELOG.md b/CHANGELOG.md index fad15d6..867261b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w ### Added +- Shared destructive-action confirmation dialog with impact and recovery guidance for high-risk operational actions +- Typed confirmation for sensitive admin actions such as account deactivation, current-session revocation, and terminal manufacturing/inventory postings +- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins +- Admin-side session revocation controls plus server-side logout that invalidates the current JWT-backed session - Shared shortage and readiness rollups across dashboard, planning, project detail, purchasing detail, and manufacturing detail - Prefilled work-order draft launch for build recommendations and prefilled purchase-order draft launch for buy recommendations from sales-order demand planning - Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children @@ -46,6 +50,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w ### Changed +- Admin, inventory, manufacturing, and attachment workflows now use explicit destructive-action confirmation and recovery messaging instead of immediate irreversible clicks +- Admin operations now combine user management with live session visibility so operators can inspect and revoke sign-ins without changing user records +- JWT authentication now validates against persisted session records and inactive users lose access immediately instead of waiting for token expiry - The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping - The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects - The dashboard now treats Planning as a live first-class module with direct gantt access from the landing page diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 4525f7c..e794525 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -32,7 +32,8 @@ This repository implements the platform foundation milestone: - pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults - shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing - admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity -- admin user management with account creation, activation, role assignment, and role-permission editing +- admin user management with account creation, activation, role assignment, role-permission editing, and session visibility/revocation +- safer destructive-action confirmations and recovery messaging across admin, inventory, manufacturing, and attachment workflows - CRM/shipping audit coverage and startup validation surfaced through diagnostics - backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics - backup verification checklist and restore-drill runbook in diagnostics @@ -72,5 +73,5 @@ This repository implements the platform foundation milestone: ## Next roadmap candidates -- better user and session visibility for operational admins -- safer destructive-action confirmations and recovery messaging +- deeper session history, filtering, and admin-side access review polish +- extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows diff --git a/README.md b/README.md index 0471509..e7888c6 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ Current foundation scope includes: - pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items - shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing - admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility -- admin user management with account creation, activation, role assignment, and role-permission editing +- admin user management with account creation, activation, role assignment, role-permission editing, and session visibility/revocation +- safer destructive-action confirmations and recovery messaging across admin, inventory, manufacturing, and attachment workflows - CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page - backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow - backup verification checklist and restore-drill runbook surfaced in admin diagnostics @@ -57,13 +58,13 @@ Current completed foundation areas: Near-term priorities: -1. Better user and session visibility for operational admins -2. Safer destructive-action confirmations and recovery messaging +1. Deeper session history, filtering, and admin-side access review polish +2. Extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows Revisit / deferred items: - local Windows Prisma migration reliability -- better user and session visibility for operational admins +- deeper session history, filtering, and admin-side access review polish - safer destructive-action confirmations and recovery messaging Dashboard direction: @@ -175,7 +176,11 @@ Command-line build notes: docker build --build-arg NODE_VERSION=22 -t mrp-codex . ``` -The container startup script runs `npx prisma migrate deploy` automatically before launching the server. +The container startup script runs the server workspace Prisma binary directly: + +```bash +/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma +``` This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM and inventory migrations on Windows. @@ -325,7 +330,7 @@ Logo uploads are stored through the authenticated file pipeline and are rendered - Apply committed migrations in production: `npm run prisma:deploy` - If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth. -As of March 14, 2026, the latest committed domain migrations include: +As of March 15, 2026, the latest committed domain migrations include: - CRM status and list filters - CRM contact-history timeline @@ -345,7 +350,11 @@ As of March 14, 2026, the latest committed domain migrations include: - shipping foundation - projects foundation - manufacturing foundation -- planning foundation +- manufacturing stations and operation templates +- inventory transfers and reservations +- audit trail and diagnostics foundation +- auth-session visibility and revocation +- supply pegging and preferred-vendor sourcing Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment. @@ -358,7 +367,7 @@ The current admin operations slice supports: - a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations - prefilled work-order and purchase-order draft launch paths from sales-order demand-planning recommendations - shared shortage/readiness rollups across planning, project, purchasing, dashboard, and manufacturing views -- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration +- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, role-permission administration, and session visibility/revocation - CRM customer/vendor changes and shipping mutations now flow into the shared audit trail - startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot - backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff @@ -368,13 +377,13 @@ The current admin operations slice supports: Current follow-up direction: -- better user and session visibility for operational admins -- safer destructive-action confirmations and recovery messaging +- deeper session history, filtering, and admin-side access review polish +- extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows ## UI Notes - Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation. -- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, projects, manufacturing, settings, and planning modules from the same app shell. +- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, shipping, projects, manufacturing, settings, and planning modules from the same app shell. - The active module screens now follow a tighter density baseline for forms, tables, and detail cards. - The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data. - The client now ships with route-level lazy loading and vendor chunking, so future frontend work should preserve that split instead of re-centralizing module imports in `main.tsx`. diff --git a/ROADMAP.md b/ROADMAP.md index bcc2aa0..c17fdfd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - React + Vite + Tailwind frontend shell - Express + TypeScript backend shell - Prisma + SQLite schema foundation with committed initial migration -- Local authentication with JWT-based session flow +- Local authentication with JWT-based session flow plus persisted session visibility and revocation - RBAC permission model and protected routes - Central Company Settings with runtime branding controls - Light and dark mode theme system @@ -73,6 +73,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added - The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins - The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins +- Auth sessions are now persisted and revocable, but the admin surface still needs richer filtering, history retention, and unusual-access review tooling ## Dashboard Plan @@ -288,6 +289,9 @@ Foundation slice shipped: - Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing - Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity - Expanded role-management UI with account creation, activation, role assignment, and permission administration +- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins +- Server-side logout and admin session revocation for JWT-backed access +- Shared destructive-action confirmation and recovery messaging for admin, inventory, manufacturing, and attachment workflows - CRM customer/vendor changes and shipping mutations covered by the shared audit trail - Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults - Backup/restore guidance, support-bundle exports, and support-log viewing surfaced through the admin diagnostics workflow @@ -301,8 +305,8 @@ Foundation slice shipped: QOL subfeatures: - Admin diagnostics screen for permissions, migrations, storage, and PDF health -- Safer destructive-action confirmations and recovery messaging -- Better user/session visibility for operational admins +- Better session filtering, review history, and unusual-access cues for operational admins +- Extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows - More explicit environment validation on startup - Support-log filtering, retention controls, and broader support-package polish - Backup verification checklist and restore drill guidance @@ -325,5 +329,5 @@ QOL subfeatures: ## Near-term priority order -1. Better user and session visibility for operational admins -2. Safer destructive-action confirmations and recovery messaging +1. Better session filtering, review history, and unusual-access cues for operational admins +2. Extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows diff --git a/STRUCTURE.md b/STRUCTURE.md index 49f0b6a..1b14f57 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -36,7 +36,7 @@ - Organize domain modules under `src/modules/`. - Keep HTTP routers thin; place business logic in services. -- Centralize Prisma access, auth middleware, file storage utilities, startup validation, and support logging in `src/lib`. +- Centralize Prisma access, auth middleware, persisted session helpers, file storage utilities, startup validation, and support logging in `src/lib`. - Store persistence-related constants under `src/config`. - Serve the built frontend from the API layer in production. diff --git a/UNRAID.md b/UNRAID.md index 279e784..6a111ae 100644 --- a/UNRAID.md +++ b/UNRAID.md @@ -106,7 +106,7 @@ If you do not set them, the defaults from the app bootstrapping logic are used. On first container start, the entrypoint will: 1. Ensure `/app/data/prisma` and `/app/data/uploads` exist -2. Run `npx prisma migrate deploy` +2. Run `/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma` 3. Start the Node.js server The frontend is served by the same container as the API, so there is only one exposed web port. @@ -130,7 +130,7 @@ When you publish a new image: Because MRP Codex runs `prisma migrate deploy` during startup, committed migrations are applied automatically before the app launches. -This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, and manufacturing work orders. Let the container complete startup migrations before testing new screens. +This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, manufacturing work orders, audit tooling, and persisted auth sessions. Let the container complete startup migrations before testing new screens. ## Backup guidance diff --git a/client/src/auth/AuthProvider.tsx b/client/src/auth/AuthProvider.tsx index 8b85c40..92a12ac 100644 --- a/client/src/auth/AuthProvider.tsx +++ b/client/src/auth/AuthProvider.tsx @@ -8,7 +8,7 @@ interface AuthContextValue { user: AuthUser | null; isReady: boolean; login: (email: string, password: string) => Promise; - logout: () => void; + logout: () => Promise; } const AuthContext = createContext(null); @@ -48,13 +48,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(result.user); window.localStorage.setItem(tokenKey, result.token); }, - logout() { + async logout() { + if (token) { + try { + await api.logout(token); + } catch { + // Clearing local auth state still signs the user out on the client. + } + } window.localStorage.removeItem(tokenKey); setToken(null); setUser(null); }, }), - [isReady, token, user] + [token, user, isReady] ); return {children}; @@ -67,4 +74,3 @@ export function useAuth() { } return context; } - diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 9a69045..c024fff 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -216,7 +216,9 @@ export function AppShell() {

{user?.email}

+ + + + + ); +} diff --git a/client/src/components/FileAttachmentsPanel.tsx b/client/src/components/FileAttachmentsPanel.tsx index f6c5836..216419e 100644 --- a/client/src/components/FileAttachmentsPanel.tsx +++ b/client/src/components/FileAttachmentsPanel.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useAuth } from "../auth/AuthProvider"; import { api, ApiError } from "../lib/api"; +import { ConfirmActionDialog } from "./ConfirmActionDialog"; interface FileAttachmentsPanelProps { ownerType: string; @@ -41,6 +42,7 @@ export function FileAttachmentsPanel({ const [status, setStatus] = useState("Loading attachments..."); const [isUploading, setIsUploading] = useState(false); const [deletingAttachmentId, setDeletingAttachmentId] = useState(null); + const [attachmentPendingDelete, setAttachmentPendingDelete] = useState(null); const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false; @@ -120,12 +122,13 @@ export function FileAttachmentsPanel({ onAttachmentCountChange?.(nextAttachments.length); return nextAttachments; }); - setStatus("Attachment deleted."); + setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record."); } catch (error: unknown) { const message = error instanceof ApiError ? error.message : "Unable to delete attachment."; setStatus(message); } finally { setDeletingAttachmentId(null); + setAttachmentPendingDelete(null); } } @@ -177,7 +180,7 @@ export function FileAttachmentsPanel({ {canWriteFiles ? ( + ) : null} + + + ))} + {filteredSessions.length === 0 ? ( +
+ No sessions match the current filter. +
+ ) : null} + + + { + if (!isConfirmingAction) { + setPendingConfirmation(null); + } + }} + onConfirm={async () => { + if (!pendingConfirmation) { + return; + } + + setIsConfirmingAction(true); + + try { + if (pendingConfirmation.kind === "deactivate-user" && pendingConfirmation.userId) { + await saveUser(); + } + + if (pendingConfirmation.kind === "revoke-session" && pendingConfirmation.sessionId) { + const isCurrentSession = sessions.find((session) => session.id === pendingConfirmation.sessionId)?.isCurrent ?? false; + await handleSessionRevoke(pendingConfirmation.sessionId, isCurrentSession); + } + + if ( + pendingConfirmation.kind === "deactivate-user" && + pendingConfirmation.userId && + pendingConfirmation.userId === authUser?.id + ) { + setStatus("Your own account was deactivated. Sign-in will fail after this session ends unless another admin re-enables the account."); + } + + setPendingConfirmation(null); + } finally { + setIsConfirmingAction(false); + } + }} + /> ); } diff --git a/server/prisma/migrations/20260315170000_auth_sessions/migration.sql b/server/prisma/migrations/20260315170000_auth_sessions/migration.sql new file mode 100644 index 0000000..405a198 --- /dev/null +++ b/server/prisma/migrations/20260315170000_auth_sessions/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "AuthSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ipAddress" TEXT, + "userAgent" TEXT, + "revokedAt" DATETIME, + "revokedById" TEXT, + "revokedReason" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "AuthSession_revokedById_fkey" FOREIGN KEY ("revokedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "AuthSession_userId_createdAt_idx" ON "AuthSession"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "AuthSession_expiresAt_idx" ON "AuthSession"("expiresAt"); + +-- CreateIndex +CREATE INDEX "AuthSession_revokedAt_idx" ON "AuthSession"("revokedAt"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index de47e8b..82b8322 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -18,6 +18,8 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userRoles UserRole[] + authSessions AuthSession[] @relation("AuthSessionUser") + revokedAuthSessions AuthSession[] @relation("AuthSessionRevokedBy") contactEntries CrmContactEntry[] inventoryTransactions InventoryTransaction[] purchaseReceipts PurchaseReceipt[] @@ -72,6 +74,26 @@ model RolePermission { @@id([roleId, permissionId]) } +model AuthSession { + id String @id @default(cuid()) + userId String + expiresAt DateTime + lastSeenAt DateTime @default(now()) + ipAddress String? + userAgent String? + revokedAt DateTime? + revokedById String? + revokedReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation("AuthSessionUser", fields: [userId], references: [id], onDelete: Cascade) + revokedBy User? @relation("AuthSessionRevokedBy", fields: [revokedById], references: [id], onDelete: SetNull) + + @@index([userId, createdAt]) + @@index([expiresAt]) + @@index([revokedAt]) +} + model CompanyProfile { id String @id @default(cuid()) companyName String diff --git a/server/src/app.ts b/server/src/app.ts index b38ef25..1ac4892 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -9,6 +9,7 @@ import pinoHttp from "pino-http"; import { env } from "./config/env.js"; import { paths } from "./config/paths.js"; import { verifyToken } from "./lib/auth.js"; +import { getActiveAuthSession, touchAuthSession } from "./lib/auth-sessions.js"; import { getCurrentUserById } from "./lib/current-user.js"; import { fail, ok } from "./lib/http.js"; import { recordSupportLog } from "./lib/support-log.js"; @@ -44,10 +45,25 @@ export function createApp() { try { const token = authHeader.slice("Bearer ".length); const payload = verifyToken(token); + const session = await getActiveAuthSession(payload.sid, payload.sub); + if (!session) { + request.authUser = undefined; + request.authSessionId = undefined; + return next(); + } const authUser = await getCurrentUserById(payload.sub); - request.authUser = authUser ?? undefined; + if (!authUser) { + request.authUser = undefined; + request.authSessionId = undefined; + return next(); + } + + request.authUser = authUser; + request.authSessionId = session.id; + void touchAuthSession(session.id).catch(() => undefined); } catch { request.authUser = undefined; + request.authSessionId = undefined; } next(); diff --git a/server/src/lib/auth-sessions.ts b/server/src/lib/auth-sessions.ts new file mode 100644 index 0000000..3bce66b --- /dev/null +++ b/server/src/lib/auth-sessions.ts @@ -0,0 +1,71 @@ +import { prisma } from "./prisma.js"; + +const SESSION_DURATION_MS = 12 * 60 * 60 * 1000; + +export interface AuthSessionContext { + id: string; + userId: string; + expiresAt: Date; +} + +export function getSessionExpiryDate(now = new Date()) { + return new Date(now.getTime() + SESSION_DURATION_MS); +} + +export async function createAuthSession(input: { userId: string; ipAddress?: string | null; userAgent?: string | null }) { + return prisma.authSession.create({ + data: { + userId: input.userId, + expiresAt: getSessionExpiryDate(), + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + }, + }); +} + +export async function getActiveAuthSession(sessionId: string, userId: string): Promise { + const session = await prisma.authSession.findFirst({ + where: { + id: sessionId, + userId, + revokedAt: null, + expiresAt: { + gt: new Date(), + }, + }, + select: { + id: true, + userId: true, + expiresAt: true, + }, + }); + + if (!session) { + return null; + } + + return session; +} + +export async function touchAuthSession(sessionId: string) { + await prisma.authSession.update({ + where: { id: sessionId }, + data: { + lastSeenAt: new Date(), + }, + }); +} + +export async function revokeAuthSession(sessionId: string, input: { revokedById?: string | null; reason: string }) { + return prisma.authSession.updateMany({ + where: { + id: sessionId, + revokedAt: null, + }, + data: { + revokedAt: new Date(), + revokedById: input.revokedById ?? null, + revokedReason: input.reason, + }, + }); +} diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts index 349de73..1ef8f66 100644 --- a/server/src/lib/auth.ts +++ b/server/src/lib/auth.ts @@ -5,14 +5,16 @@ import { env } from "../config/env.js"; interface AuthTokenPayload { sub: string; + sid: string; email: string; permissions: string[]; } -export function signToken(user: AuthUser) { +export function signToken(user: AuthUser, sessionId: string) { return jwt.sign( { sub: user.id, + sid: sessionId, email: user.email, permissions: user.permissions, } satisfies AuthTokenPayload, @@ -24,4 +26,3 @@ export function signToken(user: AuthUser) { export function verifyToken(token: string) { return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload; } - diff --git a/server/src/lib/current-user.ts b/server/src/lib/current-user.ts index 19fb432..f067962 100644 --- a/server/src/lib/current-user.ts +++ b/server/src/lib/current-user.ts @@ -26,6 +26,10 @@ export async function getCurrentUserById(userId: string): Promise(); const roleNames = user.userRoles.map(({ role }) => { for (const rolePermission of role.rolePermissions) { @@ -44,4 +48,3 @@ export async function getCurrentUserById(userId: string): Promise { + return ok(response, await listAdminAuthSessions(request.authSessionId)); +}); + +adminRouter.post("/sessions/:sessionId/revoke", requirePermissions([permissions.adminManage]), async (request, response) => { + const sessionId = getRouteParam(request.params.sessionId); + if (!sessionId) { + return fail(response, 400, "INVALID_INPUT", "Session id is invalid."); + } + + const result = await revokeAdminAuthSession(sessionId, request.authUser?.id); + if (!result.ok) { + return fail(response, 400, "INVALID_INPUT", result.reason); + } + + return ok(response, { success: true as const }); +}); + adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => { const parsed = userSchema.safeParse(request.body); if (!parsed.success) { diff --git a/server/src/modules/admin/service.ts b/server/src/modules/admin/service.ts index 01f8c1b..ae8eaf1 100644 --- a/server/src/modules/admin/service.ts +++ b/server/src/modules/admin/service.ts @@ -1,5 +1,6 @@ import type { AdminDiagnosticsDto, + AdminAuthSessionDto, BackupGuidanceDto, AdminPermissionOptionDto, AdminRoleDto, @@ -124,6 +125,50 @@ function mapUser(record: { }; } +function mapAuthSession( + record: { + id: string; + userId: string; + expiresAt: Date; + lastSeenAt: Date; + revokedAt: Date | null; + revokedReason: string | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: Date; + user: { + email: string; + firstName: string; + lastName: string; + }; + revokedBy: { + firstName: string; + lastName: string; + } | null; + }, + currentSessionId?: string +): AdminAuthSessionDto { + const now = Date.now(); + const status = record.revokedAt ? "REVOKED" : record.expiresAt.getTime() <= now ? "EXPIRED" : "ACTIVE"; + + return { + id: record.id, + userId: record.userId, + userEmail: record.user.email, + userName: `${record.user.firstName} ${record.user.lastName}`.trim(), + status, + isCurrent: record.id === currentSessionId, + createdAt: record.createdAt.toISOString(), + lastSeenAt: record.lastSeenAt.toISOString(), + expiresAt: record.expiresAt.toISOString(), + revokedAt: record.revokedAt?.toISOString() ?? null, + revokedReason: record.revokedReason, + revokedByName: record.revokedBy ? `${record.revokedBy.firstName} ${record.revokedBy.lastName}`.trim() : null, + ipAddress: record.ipAddress, + userAgent: record.userAgent, + }; +} + async function validatePermissionKeys(permissionKeys: string[]) { const uniquePermissionKeys = [...new Set(permissionKeys)]; const permissions = await prisma.permission.findMany({ @@ -338,6 +383,76 @@ export async function listAdminUsers(): Promise { return users.map(mapUser); } +export async function listAdminAuthSessions(currentSessionId?: string | null): Promise { + const sessions = await prisma.authSession.findMany({ + include: { + user: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + revokedBy: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + orderBy: [{ revokedAt: "asc" }, { lastSeenAt: "desc" }, { createdAt: "desc" }], + take: 200, + }); + + return sessions.map((session) => mapAuthSession(session, currentSessionId ?? undefined)); +} + +export async function revokeAdminAuthSession(sessionId: string, actorId?: string | null) { + const existingSession = await prisma.authSession.findUnique({ + where: { id: sessionId }, + include: { + user: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + + if (!existingSession) { + return { ok: false as const, reason: "Session was not found." }; + } + + if (existingSession.revokedAt) { + return { ok: false as const, reason: "Session is already revoked." }; + } + + await prisma.authSession.update({ + where: { id: sessionId }, + data: { + revokedAt: new Date(), + revokedById: actorId ?? null, + revokedReason: "Revoked by administrator.", + }, + }); + + await logAuditEvent({ + actorId, + entityType: "auth-session", + entityId: existingSession.id, + action: "revoked", + summary: `Revoked session for ${existingSession.user.email}.`, + metadata: { + userId: existingSession.userId, + userEmail: existingSession.user.email, + }, + }); + + return { ok: true as const }; +} + export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) { if (!payload.password || payload.password.trim().length < 8) { return { ok: false as const, reason: "A password with at least 8 characters is required for new users." }; @@ -485,6 +600,7 @@ export async function getAdminDiagnostics(): Promise { companyProfile, userCount, activeUserCount, + activeSessionCount, roleCount, permissionCount, customerCount, @@ -504,6 +620,14 @@ export async function getAdminDiagnostics(): Promise { prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }), prisma.user.count(), prisma.user.count({ where: { isActive: true } }), + prisma.authSession.count({ + where: { + revokedAt: null, + expiresAt: { + gt: new Date(), + }, + }, + }), prisma.role.count(), prisma.permission.count(), prisma.customer.count(), @@ -542,6 +666,7 @@ export async function getAdminDiagnostics(): Promise { companyProfilePresent: Boolean(companyProfile), userCount, activeUserCount, + activeSessionCount, roleCount, permissionCount, customerCount, diff --git a/server/src/modules/auth/router.ts b/server/src/modules/auth/router.ts index 65cdae6..c896f12 100644 --- a/server/src/modules/auth/router.ts +++ b/server/src/modules/auth/router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { fail, ok } from "../../lib/http.js"; import { requireAuth } from "../../lib/rbac.js"; -import { login } from "./service.js"; +import { login, logout } from "./service.js"; const loginSchema = z.object({ email: z.string().email(), @@ -18,7 +18,10 @@ authRouter.post("/login", async (request, response) => { return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password."); } - const result = await login(parsed.data); + const result = await login(parsed.data, { + ipAddress: request.ip, + userAgent: request.header("user-agent"), + }); if (!result) { return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect."); } @@ -28,3 +31,11 @@ authRouter.post("/login", async (request, response) => { authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser)); +authRouter.post("/logout", requireAuth, async (request, response) => { + if (!request.authSessionId || !request.authUser) { + return fail(response, 401, "UNAUTHORIZED", "Authentication is required."); + } + + await logout(request.authSessionId, request.authUser.id); + return ok(response, { success: true as const }); +}); diff --git a/server/src/modules/auth/service.ts b/server/src/modules/auth/service.ts index d962ed7..0f21b50 100644 --- a/server/src/modules/auth/service.ts +++ b/server/src/modules/auth/service.ts @@ -1,11 +1,18 @@ import type { LoginRequest, LoginResponse } from "@mrp/shared"; import { signToken } from "../../lib/auth.js"; +import { createAuthSession, revokeAuthSession } from "../../lib/auth-sessions.js"; import { getCurrentUserById } from "../../lib/current-user.js"; import { verifyPassword } from "../../lib/password.js"; import { prisma } from "../../lib/prisma.js"; -export async function login(payload: LoginRequest): Promise { +export async function login( + payload: LoginRequest, + context?: { + ipAddress?: string | null; + userAgent?: string | null; + } +): Promise { const user = await prisma.user.findUnique({ where: { email: payload.email.toLowerCase() }, }); @@ -23,9 +30,21 @@ export async function login(payload: LoginRequest): Promise