confirm actions
This commit is contained in:
@@ -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
|
- 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
|
- 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 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
|
- 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/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
|
- 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:
|
Near-term priorities are:
|
||||||
|
|
||||||
1. Better user and session visibility for operational admins
|
1. Deeper session history, filtering, and admin-side access review polish
|
||||||
2. Safer destructive-action confirmations and recovery messaging
|
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.
|
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
|||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
- 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
|
- 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
|
### 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 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 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
|
- The dashboard now treats Planning as a live first-class module with direct gantt access from the landing page
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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 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
|
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
||||||
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
|
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
|
||||||
- backup verification checklist and restore-drill runbook 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
|
## Next roadmap candidates
|
||||||
|
|
||||||
- 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
|
- extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows
|
||||||
|
|||||||
31
README.md
31
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
|
- 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
|
- 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 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
|
- 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/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
|
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics
|
||||||
@@ -57,13 +58,13 @@ Current completed foundation areas:
|
|||||||
|
|
||||||
Near-term priorities:
|
Near-term priorities:
|
||||||
|
|
||||||
1. Better user and session visibility for operational admins
|
1. Deeper session history, filtering, and admin-side access review polish
|
||||||
2. Safer destructive-action confirmations and recovery messaging
|
2. Extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows
|
||||||
|
|
||||||
Revisit / deferred items:
|
Revisit / deferred items:
|
||||||
|
|
||||||
- local Windows Prisma migration reliability
|
- 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
|
- safer destructive-action confirmations and recovery messaging
|
||||||
|
|
||||||
Dashboard direction:
|
Dashboard direction:
|
||||||
@@ -175,7 +176,11 @@ Command-line build notes:
|
|||||||
docker build --build-arg NODE_VERSION=22 -t mrp-codex .
|
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.
|
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`
|
- 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.
|
- 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 status and list filters
|
||||||
- CRM contact-history timeline
|
- CRM contact-history timeline
|
||||||
@@ -345,7 +350,11 @@ As of March 14, 2026, the latest committed domain migrations include:
|
|||||||
- shipping foundation
|
- shipping foundation
|
||||||
- projects foundation
|
- projects foundation
|
||||||
- manufacturing 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.
|
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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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:
|
Current follow-up direction:
|
||||||
|
|
||||||
- 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
|
- extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows
|
||||||
|
|
||||||
## UI Notes
|
## UI Notes
|
||||||
|
|
||||||
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
|
- 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 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 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`.
|
- 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`.
|
||||||
|
|||||||
14
ROADMAP.md
14
ROADMAP.md
@@ -17,7 +17,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
|||||||
- React + Vite + Tailwind frontend shell
|
- React + Vite + Tailwind frontend shell
|
||||||
- Express + TypeScript backend shell
|
- Express + TypeScript backend shell
|
||||||
- Prisma + SQLite schema foundation with committed initial migration
|
- 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
|
- RBAC permission model and protected routes
|
||||||
- Central Company Settings with runtime branding controls
|
- Central Company Settings with runtime branding controls
|
||||||
- Light and dark mode theme system
|
- 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 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 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
|
- 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
|
## Dashboard Plan
|
||||||
|
|
||||||
@@ -288,6 +289,9 @@ Foundation slice shipped:
|
|||||||
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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:
|
QOL subfeatures:
|
||||||
|
|
||||||
- Admin diagnostics screen for permissions, migrations, storage, and PDF health
|
- Admin diagnostics screen for permissions, migrations, storage, and PDF health
|
||||||
- Safer destructive-action confirmations and recovery messaging
|
- Better session filtering, review history, and unusual-access cues for operational admins
|
||||||
- Better user/session visibility for operational admins
|
- Extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows
|
||||||
- More explicit environment validation on startup
|
- More explicit environment validation on startup
|
||||||
- Support-log filtering, retention controls, and broader support-package polish
|
- Support-log filtering, retention controls, and broader support-package polish
|
||||||
- Backup verification checklist and restore drill guidance
|
- Backup verification checklist and restore drill guidance
|
||||||
@@ -325,5 +329,5 @@ QOL subfeatures:
|
|||||||
|
|
||||||
## Near-term priority order
|
## Near-term priority order
|
||||||
|
|
||||||
1. Better user and session visibility for operational admins
|
1. Better session filtering, review history, and unusual-access cues for operational admins
|
||||||
2. Safer destructive-action confirmations and recovery messaging
|
2. Extend destructive-action safety coverage across sales, purchasing, shipping, and project workflows
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
- Organize domain modules under `src/modules/<domain>`.
|
- Organize domain modules under `src/modules/<domain>`.
|
||||||
- Keep HTTP routers thin; place business logic in services.
|
- 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`.
|
- Store persistence-related constants under `src/config`.
|
||||||
- Serve the built frontend from the API layer in production.
|
- Serve the built frontend from the API layer in production.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
On first container start, the entrypoint will:
|
||||||
|
|
||||||
1. Ensure `/app/data/prisma` and `/app/data/uploads` exist
|
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
|
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.
|
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.
|
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
|
## Backup guidance
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface AuthContextValue {
|
|||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@@ -48,13 +48,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
window.localStorage.setItem(tokenKey, result.token);
|
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);
|
window.localStorage.removeItem(tokenKey);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[isReady, token, user]
|
[token, user, isReady]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
@@ -67,4 +74,3 @@ export function useAuth() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,9 @@ export function AppShell() {
|
|||||||
<p className="text-xs text-muted">{user?.email}</p>
|
<p className="text-xs text-muted">{user?.email}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={() => {
|
||||||
|
void logout();
|
||||||
|
}}
|
||||||
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
|
|||||||
107
client/src/components/ConfirmActionDialog.tsx
Normal file
107
client/src/components/ConfirmActionDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface ConfirmActionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact?: string;
|
||||||
|
recovery?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
intent?: "danger" | "primary";
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
isConfirming?: boolean;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmActionDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
impact,
|
||||||
|
recovery,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
cancelLabel = "Cancel",
|
||||||
|
intent = "danger",
|
||||||
|
confirmationLabel,
|
||||||
|
confirmationValue,
|
||||||
|
isConfirming = false,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: ConfirmActionDialogProps) {
|
||||||
|
const [typedValue, setTypedValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTypedValue("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
||||||
|
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
|
||||||
|
const confirmButtonClass =
|
||||||
|
intent === "danger"
|
||||||
|
? "bg-red-600 text-white hover:bg-red-700"
|
||||||
|
: "bg-brand text-white hover:brightness-110";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
|
||||||
|
<div className="w-full max-w-xl rounded-[28px] border border-line/70 bg-surface p-5 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
|
||||||
|
{impact ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
|
||||||
|
<span className="mt-1 block">{impact}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{recovery ? (
|
||||||
|
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
|
||||||
|
<span className="mt-1 block">{recovery}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{requiresTypedConfirmation ? (
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">
|
||||||
|
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={typedValue}
|
||||||
|
onChange={(event) => setTypedValue(event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isConfirming}
|
||||||
|
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onConfirm();
|
||||||
|
}}
|
||||||
|
disabled={isConfirmDisabled}
|
||||||
|
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
|
||||||
|
>
|
||||||
|
{isConfirming ? "Working..." : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { useAuth } from "../auth/AuthProvider";
|
import { useAuth } from "../auth/AuthProvider";
|
||||||
import { api, ApiError } from "../lib/api";
|
import { api, ApiError } from "../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "./ConfirmActionDialog";
|
||||||
|
|
||||||
interface FileAttachmentsPanelProps {
|
interface FileAttachmentsPanelProps {
|
||||||
ownerType: string;
|
ownerType: string;
|
||||||
@@ -41,6 +42,7 @@ export function FileAttachmentsPanel({
|
|||||||
const [status, setStatus] = useState("Loading attachments...");
|
const [status, setStatus] = useState("Loading attachments...");
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
|
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
|
||||||
|
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
|
||||||
|
|
||||||
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||||
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
|
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
|
||||||
@@ -120,12 +122,13 @@ export function FileAttachmentsPanel({
|
|||||||
onAttachmentCountChange?.(nextAttachments.length);
|
onAttachmentCountChange?.(nextAttachments.length);
|
||||||
return nextAttachments;
|
return nextAttachments;
|
||||||
});
|
});
|
||||||
setStatus("Attachment deleted.");
|
setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
|
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
|
||||||
setStatus(message);
|
setStatus(message);
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingAttachmentId(null);
|
setDeletingAttachmentId(null);
|
||||||
|
setAttachmentPendingDelete(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +180,7 @@ export function FileAttachmentsPanel({
|
|||||||
{canWriteFiles ? (
|
{canWriteFiles ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(attachment)}
|
onClick={() => setAttachmentPendingDelete(attachment)}
|
||||||
disabled={deletingAttachmentId === attachment.id}
|
disabled={deletingAttachmentId === attachment.id}
|
||||||
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
||||||
>
|
>
|
||||||
@@ -189,6 +192,29 @@ export function FileAttachmentsPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={attachmentPendingDelete != null}
|
||||||
|
title="Delete attachment"
|
||||||
|
description={
|
||||||
|
attachmentPendingDelete
|
||||||
|
? `Delete ${attachmentPendingDelete.originalName} from this record.`
|
||||||
|
: "Delete this attachment."
|
||||||
|
}
|
||||||
|
impact="The file link will be removed from this record immediately."
|
||||||
|
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
|
||||||
|
confirmLabel="Delete file"
|
||||||
|
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
|
||||||
|
onClose={() => {
|
||||||
|
if (!deletingAttachmentId) {
|
||||||
|
setAttachmentPendingDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (attachmentPendingDelete) {
|
||||||
|
await handleDelete(attachmentPendingDelete);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminDiagnosticsDto,
|
AdminDiagnosticsDto,
|
||||||
|
AdminAuthSessionDto,
|
||||||
BackupGuidanceDto,
|
BackupGuidanceDto,
|
||||||
AdminPermissionOptionDto,
|
AdminPermissionOptionDto,
|
||||||
AdminRoleDto,
|
AdminRoleDto,
|
||||||
@@ -15,6 +16,7 @@ import type {
|
|||||||
PlanningTimelineDto,
|
PlanningTimelineDto,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
LogoutResponse,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
import type {
|
import type {
|
||||||
CrmContactDto,
|
CrmContactDto,
|
||||||
@@ -138,6 +140,9 @@ export const api = {
|
|||||||
me(token: string) {
|
me(token: string) {
|
||||||
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
||||||
},
|
},
|
||||||
|
logout(token: string) {
|
||||||
|
return request<LogoutResponse>("/api/v1/auth/logout", { method: "POST" }, token);
|
||||||
|
},
|
||||||
getAdminDiagnostics(token: string) {
|
getAdminDiagnostics(token: string) {
|
||||||
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
|
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
|
||||||
},
|
},
|
||||||
@@ -165,6 +170,12 @@ export const api = {
|
|||||||
getAdminUsers(token: string) {
|
getAdminUsers(token: string) {
|
||||||
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
|
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
|
||||||
},
|
},
|
||||||
|
getAdminSessions(token: string) {
|
||||||
|
return request<AdminAuthSessionDto[]>("/api/v1/admin/sessions", undefined, token);
|
||||||
|
},
|
||||||
|
revokeAdminSession(token: string, sessionId: string) {
|
||||||
|
return request<LogoutResponse>(`/api/v1/admin/sessions/${sessionId}/revoke`, { method: "POST" }, token);
|
||||||
|
},
|
||||||
createAdminUser(token: string, payload: AdminUserInput) {
|
createAdminUser(token: string, payload: AdminUserInput) {
|
||||||
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
|
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Link, useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
|
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
|
||||||
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
|
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
|
||||||
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
||||||
@@ -48,6 +49,19 @@ export function InventoryDetailPage() {
|
|||||||
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
|
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
|
||||||
const [isSavingReservation, setIsSavingReservation] = useState(false);
|
const [isSavingReservation, setIsSavingReservation] = useState(false);
|
||||||
const [status, setStatus] = useState("Loading inventory item...");
|
const [status, setStatus] = useState("Loading inventory item...");
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "transaction" | "transfer" | "reservation";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||||
|
|
||||||
@@ -100,8 +114,7 @@ export function InventoryDetailPage() {
|
|||||||
setTransferForm((current) => ({ ...current, [key]: value }));
|
setTransferForm((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function submitTransaction() {
|
||||||
event.preventDefault();
|
|
||||||
if (!token || !itemId) {
|
if (!token || !itemId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,7 +125,7 @@ export function InventoryDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
|
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
|
||||||
setItem(nextItem);
|
setItem(nextItem);
|
||||||
setTransactionStatus("Stock transaction recorded.");
|
setTransactionStatus("Stock transaction recorded. If this was posted in error, create an offsetting stock entry and verify the result in Recent Movements.");
|
||||||
setTransactionForm((current) => ({
|
setTransactionForm((current) => ({
|
||||||
...emptyInventoryTransactionInput,
|
...emptyInventoryTransactionInput,
|
||||||
transactionType: current.transactionType,
|
transactionType: current.transactionType,
|
||||||
@@ -127,8 +140,7 @@ export function InventoryDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function submitTransfer() {
|
||||||
event.preventDefault();
|
|
||||||
if (!token || !itemId) {
|
if (!token || !itemId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,7 +151,7 @@ export function InventoryDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
|
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
|
||||||
setItem(nextItem);
|
setItem(nextItem);
|
||||||
setTransferStatus("Transfer recorded.");
|
setTransferStatus("Transfer recorded. Review stock balances on both locations and post a return transfer if this movement was entered incorrectly.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
|
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
|
||||||
setTransferStatus(message);
|
setTransferStatus(message);
|
||||||
@@ -148,8 +160,7 @@ export function InventoryDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function submitReservation() {
|
||||||
event.preventDefault();
|
|
||||||
if (!token || !itemId) {
|
if (!token || !itemId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -160,7 +171,7 @@ export function InventoryDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
|
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
|
||||||
setItem(nextItem);
|
setItem(nextItem);
|
||||||
setReservationStatus("Reservation recorded.");
|
setReservationStatus("Reservation recorded. Verify available stock and add a compensating reservation change if this demand hold was entered incorrectly.");
|
||||||
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
|
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
|
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
|
||||||
@@ -170,6 +181,64 @@ export function InventoryDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionLabel = inventoryTransactionOptions.find((option) => option.value === transactionForm.transactionType)?.label ?? "transaction";
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "transaction",
|
||||||
|
title: `Post ${transactionLabel.toLowerCase()}`,
|
||||||
|
description: `Post a ${transactionLabel.toLowerCase()} of ${transactionForm.quantity} units for ${item.sku} at the selected stock location.`,
|
||||||
|
impact:
|
||||||
|
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||||
|
? "This reduces available inventory immediately and affects downstream shortage and readiness calculations."
|
||||||
|
: "This updates the stock ledger immediately and becomes part of the item transaction history.",
|
||||||
|
recovery: "If this is incorrect, post an explicit offsetting transaction instead of editing history.",
|
||||||
|
confirmLabel: `Post ${transactionLabel.toLowerCase()}`,
|
||||||
|
confirmationLabel:
|
||||||
|
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||||
|
? "Type item SKU to confirm:"
|
||||||
|
: undefined,
|
||||||
|
confirmationValue:
|
||||||
|
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||||
|
? item.sku
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "transfer",
|
||||||
|
title: "Post inventory transfer",
|
||||||
|
description: `Move ${transferForm.quantity} units of ${item.sku} between the selected source and destination locations.`,
|
||||||
|
impact: "This creates paired stock movement entries and changes both source and destination availability immediately.",
|
||||||
|
recovery: "If the move was entered incorrectly, post a reversing transfer back to the original location.",
|
||||||
|
confirmLabel: "Post transfer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "reservation",
|
||||||
|
title: "Create manual reservation",
|
||||||
|
description: `Reserve ${reservationForm.quantity} units of ${item.sku}${reservationForm.locationId ? " at the selected location" : ""}.`,
|
||||||
|
impact: "This reduces available quantity used by planning, purchasing, manufacturing, and readiness views.",
|
||||||
|
recovery: "Add the correcting reservation entry if this hold should be reduced or removed.",
|
||||||
|
confirmLabel: "Create reservation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
}
|
}
|
||||||
@@ -530,6 +599,41 @@ export function InventoryDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<InventoryAttachmentsPanel itemId={item.id} />
|
<InventoryAttachmentsPanel itemId={item.id} />
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm inventory action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={
|
||||||
|
(pendingConfirmation?.kind === "transaction" && isSavingTransaction) ||
|
||||||
|
(pendingConfirmation?.kind === "transfer" && isSavingTransfer) ||
|
||||||
|
(pendingConfirmation?.kind === "reservation" && isSavingReservation)
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isSavingTransaction && !isSavingTransfer && !isSavingReservation) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "transaction") {
|
||||||
|
await submitTransaction();
|
||||||
|
} else if (pendingConfirmation.kind === "transfer") {
|
||||||
|
await submitTransfer();
|
||||||
|
} else {
|
||||||
|
await submitReservation();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Link, useParams } from "react-router-dom";
|
|||||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
|
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
|
||||||
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
|
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
|
||||||
|
|
||||||
@@ -21,6 +22,20 @@ export function WorkOrderDetailPage() {
|
|||||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||||
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "status" | "issue" | "completion";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
nextStatus?: WorkOrderStatus;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||||
|
|
||||||
@@ -56,7 +71,7 @@ export function WorkOrderDetailPage() {
|
|||||||
[issueForm.warehouseId, locationOptions]
|
[issueForm.warehouseId, locationOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleStatusChange(nextStatus: WorkOrderStatus) {
|
async function applyStatusChange(nextStatus: WorkOrderStatus) {
|
||||||
if (!token || !workOrder) {
|
if (!token || !workOrder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,7 +81,7 @@ export function WorkOrderDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
|
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
|
||||||
setWorkOrder(nextWorkOrder);
|
setWorkOrder(nextWorkOrder);
|
||||||
setStatus("Work-order status updated.");
|
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
||||||
setStatus(message);
|
setStatus(message);
|
||||||
@@ -75,8 +90,7 @@ export function WorkOrderDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function submitIssue() {
|
||||||
event.preventDefault();
|
|
||||||
if (!token || !workOrder) {
|
if (!token || !workOrder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,7 +105,7 @@ export function WorkOrderDetailPage() {
|
|||||||
warehouseId: nextWorkOrder.warehouseId,
|
warehouseId: nextWorkOrder.warehouseId,
|
||||||
locationId: nextWorkOrder.locationId,
|
locationId: nextWorkOrder.locationId,
|
||||||
});
|
});
|
||||||
setStatus("Material issue posted.");
|
setStatus("Material issue posted. This consumed inventory immediately; post a correcting stock movement if the issue quantity was wrong.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
|
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
|
||||||
setStatus(message);
|
setStatus(message);
|
||||||
@@ -100,8 +114,7 @@ export function WorkOrderDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function submitCompletion() {
|
||||||
event.preventDefault();
|
|
||||||
if (!token || !workOrder) {
|
if (!token || !workOrder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -115,7 +128,7 @@ export function WorkOrderDetailPage() {
|
|||||||
...emptyCompletionInput,
|
...emptyCompletionInput,
|
||||||
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||||
});
|
});
|
||||||
setStatus("Completion posted.");
|
setStatus("Completion posted. Finished-goods stock has been received; verify the remaining quantity and post a correcting transaction if this completion was overstated.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
|
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
|
||||||
setStatus(message);
|
setStatus(message);
|
||||||
@@ -124,6 +137,64 @@ export function WorkOrderDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||||
|
if (!workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = workOrderStatusOptions.find((entry) => entry.value === nextStatus);
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "status",
|
||||||
|
title: `Change status to ${option?.label ?? nextStatus}`,
|
||||||
|
description: `Update work order ${workOrder.workOrderNumber} from ${workOrder.status} to ${nextStatus}.`,
|
||||||
|
impact:
|
||||||
|
nextStatus === "CANCELLED"
|
||||||
|
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
||||||
|
: nextStatus === "COMPLETE"
|
||||||
|
? "Completing the work order signals execution closure and can change readiness views across the system."
|
||||||
|
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
||||||
|
recovery: "If this status was selected in error, set the work order back to the correct state immediately after review.",
|
||||||
|
confirmLabel: `Set ${option?.label ?? nextStatus}`,
|
||||||
|
confirmationLabel: nextStatus === "CANCELLED" ? "Type work-order number to confirm:" : undefined,
|
||||||
|
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
||||||
|
nextStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const component = workOrder.materialRequirements.find((requirement) => requirement.componentItemId === issueForm.componentItemId);
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "issue",
|
||||||
|
title: "Post material issue",
|
||||||
|
description: `Issue ${issueForm.quantity} units of ${component?.componentSku ?? "the selected component"} to work order ${workOrder.workOrderNumber}.`,
|
||||||
|
impact: "This consumes component inventory immediately and updates work-order material history.",
|
||||||
|
recovery: "If the wrong quantity was issued, post a correcting stock transaction and note the reason on the work order.",
|
||||||
|
confirmLabel: "Post issue",
|
||||||
|
confirmationLabel: "Type work-order number to confirm:",
|
||||||
|
confirmationValue: workOrder.workOrderNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "completion",
|
||||||
|
title: "Post production completion",
|
||||||
|
description: `Receive ${completionForm.quantity} finished units into ${workOrder.warehouseCode} / ${workOrder.locationCode}.`,
|
||||||
|
impact: "This increases finished-goods inventory immediately and advances the execution history for this work order.",
|
||||||
|
recovery: "If the completion quantity is wrong, post the correcting inventory movement and verify the work-order remaining quantity.",
|
||||||
|
confirmLabel: "Post completion",
|
||||||
|
confirmationLabel: completionForm.quantity >= workOrder.dueQuantity ? "Type work-order number to confirm:" : undefined,
|
||||||
|
confirmationValue: completionForm.quantity >= workOrder.dueQuantity ? workOrder.workOrderNumber : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
}
|
}
|
||||||
@@ -375,6 +446,41 @@ export function WorkOrderDetailPage() {
|
|||||||
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
|
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
|
||||||
/>
|
/>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm manufacturing action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={
|
||||||
|
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||||
|
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
||||||
|
(pendingConfirmation?.kind === "completion" && isPostingCompletion)
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isUpdatingStatus && !isPostingIssue && !isPostingCompletion) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||||
|
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||||
|
} else if (pendingConfirmation.kind === "issue") {
|
||||||
|
await submitIssue();
|
||||||
|
} else if (pendingConfirmation.kind === "completion") {
|
||||||
|
await submitCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { AdminPermissionOptionDto, AdminRoleDto, AdminRoleInput, AdminUserDto, AdminUserInput } from "@mrp/shared";
|
import type {
|
||||||
|
AdminAuthSessionDto,
|
||||||
|
AdminPermissionOptionDto,
|
||||||
|
AdminRoleDto,
|
||||||
|
AdminRoleInput,
|
||||||
|
AdminUserDto,
|
||||||
|
AdminUserInput,
|
||||||
|
} from "@mrp/shared";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
|
||||||
const emptyUserForm: AdminUserInput = {
|
const emptyUserForm: AdminUserInput = {
|
||||||
email: "",
|
email: "",
|
||||||
@@ -21,15 +29,33 @@ const emptyRoleForm: AdminRoleInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function UserManagementPage() {
|
export function UserManagementPage() {
|
||||||
const { token } = useAuth();
|
const { token, user: authUser, logout } = useAuth();
|
||||||
const [users, setUsers] = useState<AdminUserDto[]>([]);
|
const [users, setUsers] = useState<AdminUserDto[]>([]);
|
||||||
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
|
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
|
||||||
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
|
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<AdminAuthSessionDto[]>([]);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>("new");
|
const [selectedUserId, setSelectedUserId] = useState<string>("new");
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
|
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
|
||||||
|
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
|
||||||
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
|
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
|
||||||
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
|
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
|
||||||
const [status, setStatus] = useState("Loading admin access controls...");
|
const [status, setStatus] = useState("Loading admin access controls...");
|
||||||
|
const [isConfirmingAction, setIsConfirmingAction] = useState(false);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "deactivate-user" | "revoke-session";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
userId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -38,14 +64,15 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token)])
|
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token), api.getAdminSessions(token)])
|
||||||
.then(([nextUsers, nextRoles, nextPermissions]) => {
|
.then(([nextUsers, nextRoles, nextPermissions, nextSessions]) => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUsers(nextUsers);
|
setUsers(nextUsers);
|
||||||
setRoles(nextRoles);
|
setRoles(nextRoles);
|
||||||
setPermissions(nextPermissions);
|
setPermissions(nextPermissions);
|
||||||
|
setSessions(nextSessions);
|
||||||
setStatus("User management loaded.");
|
setStatus("User management loaded.");
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
@@ -108,19 +135,41 @@ export function UserManagementPage() {
|
|||||||
const authToken = token;
|
const authToken = token;
|
||||||
|
|
||||||
async function refreshData(nextStatus: string) {
|
async function refreshData(nextStatus: string) {
|
||||||
const [nextUsers, nextRoles, nextPermissions] = await Promise.all([
|
const [nextUsers, nextRoles, nextPermissions, nextSessions] = await Promise.all([
|
||||||
api.getAdminUsers(authToken),
|
api.getAdminUsers(authToken),
|
||||||
api.getAdminRoles(authToken),
|
api.getAdminRoles(authToken),
|
||||||
api.getAdminPermissions(authToken),
|
api.getAdminPermissions(authToken),
|
||||||
|
api.getAdminSessions(authToken),
|
||||||
]);
|
]);
|
||||||
setUsers(nextUsers);
|
setUsers(nextUsers);
|
||||||
setRoles(nextRoles);
|
setRoles(nextRoles);
|
||||||
setPermissions(nextPermissions);
|
setPermissions(nextPermissions);
|
||||||
|
setSessions(nextSessions);
|
||||||
setStatus(nextStatus);
|
setStatus(nextStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
|
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const selectedUser = users.find((entry) => entry.id === selectedUserId);
|
||||||
|
if (selectedUser && selectedUser.isActive && !userForm.isActive) {
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "deactivate-user",
|
||||||
|
title: `Deactivate ${selectedUser.firstName} ${selectedUser.lastName}`,
|
||||||
|
description: `Disable sign-in for ${selectedUser.email}. Existing active sessions will remain revoked only if you separately revoke them below.`,
|
||||||
|
impact: "The user will be blocked from new sign-ins as soon as this save completes.",
|
||||||
|
recovery: "Re-enable the account later if the change was made in error, and revoke live sessions separately if immediate cut-off is required.",
|
||||||
|
confirmLabel: "Deactivate user",
|
||||||
|
confirmationLabel: "Type user email to confirm:",
|
||||||
|
confirmationValue: selectedUser.email,
|
||||||
|
userId: selectedUser.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
if (selectedUserId === "new") {
|
if (selectedUserId === "new") {
|
||||||
const createdUser = await api.createAdminUser(authToken, userForm);
|
const createdUser = await api.createAdminUser(authToken, userForm);
|
||||||
await refreshData(`Created user ${createdUser.email}.`);
|
await refreshData(`Created user ${createdUser.email}.`);
|
||||||
@@ -165,6 +214,21 @@ export function UserManagementPage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSessionRevoke(sessionId: string, isCurrentSession: boolean) {
|
||||||
|
await api.revokeAdminSession(authToken, sessionId);
|
||||||
|
if (isCurrentSession) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshData("Revoked session. The user must sign in again to restore access unless their account is inactive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSessions = sessions.filter((session) => sessionUserFilter === "all" || session.userId === sessionUserFilter);
|
||||||
|
const activeSessionCount = sessions.filter((session) => session.status === "ACTIVE").length;
|
||||||
|
const revokedSessionCount = sessions.filter((session) => session.status === "REVOKED").length;
|
||||||
|
const expiredSessionCount = sessions.filter((session) => session.status === "EXPIRED").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
@@ -358,6 +422,159 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sessions</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Active sign-ins and revocation control</h3>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||||
|
Review recent authenticated sessions, see their current state, and revoke stale or risky access without changing the user record.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Filter by user</span>
|
||||||
|
<select
|
||||||
|
value={sessionUserFilter}
|
||||||
|
onChange={(event) => setSessionUserFilter(event.target.value)}
|
||||||
|
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All users</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Active</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-text">{activeSessionCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Revoked</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-text">{revokedSessionCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Expired</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-text">{expiredSessionCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{filteredSessions.map((session) => (
|
||||||
|
<div key={session.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-text">{session.userName}</p>
|
||||||
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
{session.status}
|
||||||
|
</span>
|
||||||
|
{session.isCurrent ? (
|
||||||
|
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted">{session.userEmail}</p>
|
||||||
|
<div className="mt-3 grid gap-2 text-xs text-muted md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<p>Started: {new Date(session.createdAt).toLocaleString()}</p>
|
||||||
|
<p>Last seen: {new Date(session.lastSeenAt).toLocaleString()}</p>
|
||||||
|
<p>Expires: {new Date(session.expiresAt).toLocaleString()}</p>
|
||||||
|
<p>IP: {session.ipAddress || "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
|
||||||
|
{session.revokedAt ? (
|
||||||
|
<p className="mt-2 text-xs text-muted">
|
||||||
|
Revoked {new Date(session.revokedAt).toLocaleString()}
|
||||||
|
{session.revokedByName ? ` by ${session.revokedByName}` : ""}.
|
||||||
|
{session.revokedReason ? ` ${session.revokedReason}` : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{session.status === "ACTIVE" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "revoke-session",
|
||||||
|
title: session.isCurrent ? "Revoke current session" : `Revoke session for ${session.userName}`,
|
||||||
|
description: session.isCurrent
|
||||||
|
? "Revoke the session you are using right now. Your current browser session will lose access immediately."
|
||||||
|
: `Revoke the selected active session for ${session.userEmail}.`,
|
||||||
|
impact: "The selected token becomes unusable immediately.",
|
||||||
|
recovery: "The user can sign in again unless the account itself is inactive. Review the remaining session list after revocation.",
|
||||||
|
confirmLabel: "Revoke session",
|
||||||
|
confirmationLabel: session.isCurrent ? "Type REVOKE to confirm:" : undefined,
|
||||||
|
confirmationValue: session.isCurrent ? "REVOKE" : undefined,
|
||||||
|
sessionId: session.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-2xl border border-red-300 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
|
||||||
|
>
|
||||||
|
Revoke session
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredSessions.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-line/70 bg-page/40 px-3 py-6 text-sm text-muted">
|
||||||
|
No sessions match the current filter.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm admin action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={isConfirmingAction}
|
||||||
|
onClose={() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -18,6 +18,8 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
|
authSessions AuthSession[] @relation("AuthSessionUser")
|
||||||
|
revokedAuthSessions AuthSession[] @relation("AuthSessionRevokedBy")
|
||||||
contactEntries CrmContactEntry[]
|
contactEntries CrmContactEntry[]
|
||||||
inventoryTransactions InventoryTransaction[]
|
inventoryTransactions InventoryTransaction[]
|
||||||
purchaseReceipts PurchaseReceipt[]
|
purchaseReceipts PurchaseReceipt[]
|
||||||
@@ -72,6 +74,26 @@ model RolePermission {
|
|||||||
@@id([roleId, permissionId])
|
@@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 {
|
model CompanyProfile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
companyName String
|
companyName String
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pinoHttp from "pino-http";
|
|||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { paths } from "./config/paths.js";
|
import { paths } from "./config/paths.js";
|
||||||
import { verifyToken } from "./lib/auth.js";
|
import { verifyToken } from "./lib/auth.js";
|
||||||
|
import { getActiveAuthSession, touchAuthSession } from "./lib/auth-sessions.js";
|
||||||
import { getCurrentUserById } from "./lib/current-user.js";
|
import { getCurrentUserById } from "./lib/current-user.js";
|
||||||
import { fail, ok } from "./lib/http.js";
|
import { fail, ok } from "./lib/http.js";
|
||||||
import { recordSupportLog } from "./lib/support-log.js";
|
import { recordSupportLog } from "./lib/support-log.js";
|
||||||
@@ -44,10 +45,25 @@ export function createApp() {
|
|||||||
try {
|
try {
|
||||||
const token = authHeader.slice("Bearer ".length);
|
const token = authHeader.slice("Bearer ".length);
|
||||||
const payload = verifyToken(token);
|
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);
|
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 {
|
} catch {
|
||||||
request.authUser = undefined;
|
request.authUser = undefined;
|
||||||
|
request.authSessionId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
71
server/src/lib/auth-sessions.ts
Normal file
71
server/src/lib/auth-sessions.ts
Normal file
@@ -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<AuthSessionContext | null> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,14 +5,16 @@ import { env } from "../config/env.js";
|
|||||||
|
|
||||||
interface AuthTokenPayload {
|
interface AuthTokenPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
|
sid: string;
|
||||||
email: string;
|
email: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signToken(user: AuthUser) {
|
export function signToken(user: AuthUser, sessionId: string) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
sid: sessionId,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
permissions: user.permissions,
|
permissions: user.permissions,
|
||||||
} satisfies AuthTokenPayload,
|
} satisfies AuthTokenPayload,
|
||||||
@@ -24,4 +26,3 @@ export function signToken(user: AuthUser) {
|
|||||||
export function verifyToken(token: string) {
|
export function verifyToken(token: string) {
|
||||||
return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload;
|
return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const permissionKeys = new Set<PermissionKey>();
|
const permissionKeys = new Set<PermissionKey>();
|
||||||
const roleNames = user.userRoles.map(({ role }) => {
|
const roleNames = user.userRoles.map(({ role }) => {
|
||||||
for (const rolePermission of role.rolePermissions) {
|
for (const rolePermission of role.rolePermissions) {
|
||||||
@@ -44,4 +48,3 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
|
|||||||
permissions: [...permissionKeys],
|
permissions: [...permissionKeys],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fail, ok } from "../../lib/http.js";
|
|||||||
import { requirePermissions } from "../../lib/rbac.js";
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
import {
|
import {
|
||||||
createAdminRole,
|
createAdminRole,
|
||||||
|
listAdminAuthSessions,
|
||||||
createAdminUser,
|
createAdminUser,
|
||||||
getBackupGuidance,
|
getBackupGuidance,
|
||||||
getAdminDiagnostics,
|
getAdminDiagnostics,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
listAdminPermissions,
|
listAdminPermissions,
|
||||||
listAdminRoles,
|
listAdminRoles,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
|
revokeAdminAuthSession,
|
||||||
updateAdminRole,
|
updateAdminRole,
|
||||||
updateAdminUser,
|
updateAdminUser,
|
||||||
} from "./service.js";
|
} from "./service.js";
|
||||||
@@ -100,6 +102,24 @@ adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (
|
|||||||
return ok(response, await listAdminUsers());
|
return ok(response, await listAdminUsers());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminRouter.get("/sessions", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||||
|
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) => {
|
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||||
const parsed = userSchema.safeParse(request.body);
|
const parsed = userSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminDiagnosticsDto,
|
AdminDiagnosticsDto,
|
||||||
|
AdminAuthSessionDto,
|
||||||
BackupGuidanceDto,
|
BackupGuidanceDto,
|
||||||
AdminPermissionOptionDto,
|
AdminPermissionOptionDto,
|
||||||
AdminRoleDto,
|
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[]) {
|
async function validatePermissionKeys(permissionKeys: string[]) {
|
||||||
const uniquePermissionKeys = [...new Set(permissionKeys)];
|
const uniquePermissionKeys = [...new Set(permissionKeys)];
|
||||||
const permissions = await prisma.permission.findMany({
|
const permissions = await prisma.permission.findMany({
|
||||||
@@ -338,6 +383,76 @@ export async function listAdminUsers(): Promise<AdminUserDto[]> {
|
|||||||
return users.map(mapUser);
|
return users.map(mapUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAdminAuthSessions(currentSessionId?: string | null): Promise<AdminAuthSessionDto[]> {
|
||||||
|
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) {
|
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
|
||||||
if (!payload.password || payload.password.trim().length < 8) {
|
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." };
|
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<AdminDiagnosticsDto> {
|
|||||||
companyProfile,
|
companyProfile,
|
||||||
userCount,
|
userCount,
|
||||||
activeUserCount,
|
activeUserCount,
|
||||||
|
activeSessionCount,
|
||||||
roleCount,
|
roleCount,
|
||||||
permissionCount,
|
permissionCount,
|
||||||
customerCount,
|
customerCount,
|
||||||
@@ -504,6 +620,14 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
|||||||
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
|
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
|
||||||
prisma.user.count(),
|
prisma.user.count(),
|
||||||
prisma.user.count({ where: { isActive: true } }),
|
prisma.user.count({ where: { isActive: true } }),
|
||||||
|
prisma.authSession.count({
|
||||||
|
where: {
|
||||||
|
revokedAt: null,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
prisma.role.count(),
|
prisma.role.count(),
|
||||||
prisma.permission.count(),
|
prisma.permission.count(),
|
||||||
prisma.customer.count(),
|
prisma.customer.count(),
|
||||||
@@ -542,6 +666,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
|||||||
companyProfilePresent: Boolean(companyProfile),
|
companyProfilePresent: Boolean(companyProfile),
|
||||||
userCount,
|
userCount,
|
||||||
activeUserCount,
|
activeUserCount,
|
||||||
|
activeSessionCount,
|
||||||
roleCount,
|
roleCount,
|
||||||
permissionCount,
|
permissionCount,
|
||||||
customerCount,
|
customerCount,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { fail, ok } from "../../lib/http.js";
|
import { fail, ok } from "../../lib/http.js";
|
||||||
import { requireAuth } from "../../lib/rbac.js";
|
import { requireAuth } from "../../lib/rbac.js";
|
||||||
import { login } from "./service.js";
|
import { login, logout } from "./service.js";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
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.");
|
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) {
|
if (!result) {
|
||||||
return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect.");
|
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.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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import type { LoginRequest, LoginResponse } from "@mrp/shared";
|
import type { LoginRequest, LoginResponse } from "@mrp/shared";
|
||||||
|
|
||||||
import { signToken } from "../../lib/auth.js";
|
import { signToken } from "../../lib/auth.js";
|
||||||
|
import { createAuthSession, revokeAuthSession } from "../../lib/auth-sessions.js";
|
||||||
import { getCurrentUserById } from "../../lib/current-user.js";
|
import { getCurrentUserById } from "../../lib/current-user.js";
|
||||||
import { verifyPassword } from "../../lib/password.js";
|
import { verifyPassword } from "../../lib/password.js";
|
||||||
import { prisma } from "../../lib/prisma.js";
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
export async function login(payload: LoginRequest): Promise<LoginResponse | null> {
|
export async function login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
context?: {
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<LoginResponse | null> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email: payload.email.toLowerCase() },
|
where: { email: payload.email.toLowerCase() },
|
||||||
});
|
});
|
||||||
@@ -23,9 +30,21 @@ export async function login(payload: LoginRequest): Promise<LoginResponse | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await createAuthSession({
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: context?.ipAddress ?? null,
|
||||||
|
userAgent: context?.userAgent ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: signToken(authUser),
|
token: signToken(authUser, session.id),
|
||||||
user: authUser,
|
user: authUser,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logout(sessionId: string, actorId?: string | null) {
|
||||||
|
await revokeAuthSession(sessionId, {
|
||||||
|
revokedById: actorId ?? null,
|
||||||
|
reason: actorId ? "User signed out." : "Session signed out.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
2
server/src/types/express.d.ts
vendored
2
server/src/types/express.d.ts
vendored
@@ -4,9 +4,9 @@ declare global {
|
|||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
authUser?: AuthUser;
|
authUser?: AuthUser;
|
||||||
|
authSessionId?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ export interface AdminUserInput {
|
|||||||
password: string | null;
|
password: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminAuthSessionDto {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
status: "ACTIVE" | "EXPIRED" | "REVOKED";
|
||||||
|
isCurrent: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
revokedReason: string | null;
|
||||||
|
revokedByName: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StartupValidationCheckDto {
|
export interface StartupValidationCheckDto {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -124,6 +141,7 @@ export interface AdminDiagnosticsDto {
|
|||||||
companyProfilePresent: boolean;
|
companyProfilePresent: boolean;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
activeUserCount: number;
|
activeUserCount: number;
|
||||||
|
activeSessionCount: number;
|
||||||
roleCount: number;
|
roleCount: number;
|
||||||
permissionCount: number;
|
permissionCount: number;
|
||||||
customerCount: number;
|
customerCount: number;
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ export interface LoginResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogoutResponse {
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user