user management

This commit is contained in:
2026-03-15 14:47:58 -05:00
parent 857d34397e
commit 3197e68749
14 changed files with 1175 additions and 95 deletions

View File

@@ -24,6 +24,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- 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
- Puppeteer PDF foundation - Puppeteer PDF foundation
- single-container Docker deployment - single-container Docker deployment
@@ -120,8 +121,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. Code-splitting and bundle-size reduction 1. CRM/shipping audit coverage and richer startup validation
2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics 2. Backup/restore workflow documentation and support-oriented admin tooling
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.

View File

@@ -6,6 +6,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### Added
- Admin user-management screen with account creation, activation control, role assignment, and role-permission editing
- Route-level lazy loading and vendor chunking across the client so major operational modules no longer ship in the initial bundle
- Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions - Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
- Admin diagnostics page with runtime footprint, storage-path visibility, key record counts, and recent audit activity - Admin diagnostics page with runtime footprint, storage-path visibility, key record counts, and recent audit activity
- Inventory transfers with paired physical stock movement posting between warehouses and locations - Inventory transfers with paired physical stock movement posting between warehouses and locations
@@ -39,7 +41,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records - Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself - Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders - Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- Roadmap and project docs now treat code-splitting and bundle-size reduction as the next active priority after the audit/diagnostics slice - The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx`
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
- Roadmap and project docs now treat CRM/shipping audit coverage and richer startup validation as the next active priority after the user-management slice
## 2026-03-15 ## 2026-03-15

View File

@@ -28,6 +28,7 @@ This repository implements the platform foundation milestone:
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- 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
- Dockerized single-container deployment - Dockerized single-container deployment
- Puppeteer PDF pipeline foundation - Puppeteer PDF pipeline foundation
@@ -63,5 +64,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- code-splitting and bundle-size reduction - CRM/shipping audit coverage and richer startup validation
- expanded role-management and support diagnostics - backup/restore workflow depth and support-oriented admin tooling

View File

@@ -27,6 +27,8 @@ Current foundation scope includes:
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
- planning gantt timelines with live project and manufacturing schedule data - planning gantt timelines with live project and manufacturing schedule data
- 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
- route-level code-splitting and vendor chunking for lighter initial client loads
- file storage and PDF rendering - file storage and PDF rendering
## Product Map ## Product Map
@@ -42,19 +44,19 @@ Current completed foundation areas:
- manufacturing foundation - manufacturing foundation
- planning foundation - planning foundation
- audit and diagnostics foundation - audit and diagnostics foundation
- user and role administration foundation
- branding, attachments, auth/RBAC, and PDF infrastructure - branding, attachments, auth/RBAC, and PDF infrastructure
Near-term priorities: Near-term priorities:
1. Code-splitting and bundle-size reduction 1. CRM/shipping audit coverage and richer startup validation
2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics 2. Backup/restore workflow documentation and support-oriented admin tooling
Revisit / deferred items: Revisit / deferred items:
- local Windows Prisma migration reliability - local Windows Prisma migration reliability
- frontend code-splitting and bundle-size reduction
- expanded role-management and permission administration
- deeper support diagnostics and startup validation - deeper support diagnostics and startup validation
- backup/restore workflow depth and support tooling
Dashboard direction: Dashboard direction:
@@ -345,13 +347,14 @@ The current admin operations slice supports:
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions - persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity - an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration
- operator-facing review of recent high-impact changes without direct database access - operator-facing review of recent high-impact changes without direct database access
Current follow-up direction: Current follow-up direction:
- deeper audit coverage across CRM and shipping mutations - deeper audit coverage across CRM and shipping mutations
- richer environment validation and startup diagnostics - richer environment validation and startup diagnostics
- expanded role and permission administration beyond the bootstrap defaults - backup/restore workflow guidance and support-oriented admin tooling
## UI Notes ## UI Notes
@@ -359,7 +362,7 @@ Current follow-up direction:
- 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, 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 build still emits a Vite chunk-size warning because the app has not been code-split yet. - 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`.
## PDF Generation ## PDF Generation

View File

@@ -49,6 +49,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Vendor-detail purchasing visibility with recent purchase-order activity - Vendor-detail purchasing visibility with recent purchase-order activity
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows - Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity - Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets - SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts - Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
@@ -60,7 +62,6 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
### Current known gaps in the foundation ### Current known gaps in the foundation
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution - Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
- The frontend bundle is functional but should be code-split later, especially around the gantt module
- CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later - CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
- The current sales/purchasing/shipping foundation now includes sales approvals and revision history, but still needs vendor exception handling, deeper carrier integration, and richer document comparison tooling - The current sales/purchasing/shipping foundation now includes sales approvals and revision history, but still needs vendor exception handling, deeper carrier integration, and richer document comparison tooling
- 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
@@ -253,6 +254,7 @@ 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 - Expanded role management UI
- Permission assignment administration - Permission assignment administration
@@ -272,7 +274,6 @@ QOL subfeatures:
## Revisit / Deferred Items ## Revisit / Deferred Items
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper - Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
- Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist - CRM document rollups and broader account-role depth were deferred until more downstream modules exist
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use - Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
- Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred - Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred
@@ -288,5 +289,5 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. Code-splitting and bundle-size reduction 1. CRM/shipping audit coverage plus richer startup validation
2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics 2. Backup/restore workflow documentation and support-oriented admin tooling

View File

@@ -1,5 +1,10 @@
import type { import type {
AdminDiagnosticsDto, AdminDiagnosticsDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
ApiResponse, ApiResponse,
CompanyProfileDto, CompanyProfileDto,
CompanyProfileInput, CompanyProfileInput,
@@ -131,6 +136,27 @@ export const api = {
getAdminDiagnostics(token: string) { getAdminDiagnostics(token: string) {
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token); return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
}, },
getAdminPermissions(token: string) {
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
},
getAdminRoles(token: string) {
return request<AdminRoleDto[]>("/api/v1/admin/roles", undefined, token);
},
createAdminRole(token: string, payload: AdminRoleInput) {
return request<AdminRoleDto>("/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateAdminRole(token: string, roleId: string, payload: AdminRoleInput) {
return request<AdminRoleDto>(`/api/v1/admin/roles/${roleId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getAdminUsers(token: string) {
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
},
createAdminUser(token: string, payload: AdminUserInput) {
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateAdminUser(token: string, userId: string, payload: AdminUserInput) {
return request<AdminUserDto>(`/api/v1/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getCompanyProfile(token: string) { getCompanyProfile(token: string) {
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token); return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
}, },

View File

@@ -9,39 +9,111 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
import { AuthProvider } from "./auth/AuthProvider"; import { AuthProvider } from "./auth/AuthProvider";
import { DashboardPage } from "./modules/dashboard/DashboardPage"; import { DashboardPage } from "./modules/dashboard/DashboardPage";
import { LoginPage } from "./modules/login/LoginPage"; import { LoginPage } from "./modules/login/LoginPage";
import { AdminDiagnosticsPage } from "./modules/settings/AdminDiagnosticsPage";
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage";
import { CrmDetailPage } from "./modules/crm/CrmDetailPage";
import { CrmFormPage } from "./modules/crm/CrmFormPage";
import { CustomersPage } from "./modules/crm/CustomersPage";
import { VendorsPage } from "./modules/crm/VendorsPage";
import { GanttPage } from "./modules/gantt/GanttPage";
import { InventoryDetailPage } from "./modules/inventory/InventoryDetailPage";
import { InventoryFormPage } from "./modules/inventory/InventoryFormPage";
import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage";
import { ManufacturingPage } from "./modules/manufacturing/ManufacturingPage";
import { WorkOrderDetailPage } from "./modules/manufacturing/WorkOrderDetailPage";
import { WorkOrderFormPage } from "./modules/manufacturing/WorkOrderFormPage";
import { PurchaseDetailPage } from "./modules/purchasing/PurchaseDetailPage";
import { PurchaseFormPage } from "./modules/purchasing/PurchaseFormPage";
import { PurchaseListPage } from "./modules/purchasing/PurchaseListPage";
import { ProjectDetailPage } from "./modules/projects/ProjectDetailPage";
import { ProjectFormPage } from "./modules/projects/ProjectFormPage";
import { ProjectsPage } from "./modules/projects/ProjectsPage";
import { WarehouseDetailPage } from "./modules/inventory/WarehouseDetailPage";
import { WarehouseFormPage } from "./modules/inventory/WarehouseFormPage";
import { WarehousesPage } from "./modules/inventory/WarehousesPage";
import { SalesDetailPage } from "./modules/sales/SalesDetailPage";
import { SalesFormPage } from "./modules/sales/SalesFormPage";
import { SalesListPage } from "./modules/sales/SalesListPage";
import { ShipmentDetailPage } from "./modules/shipping/ShipmentDetailPage";
import { ShipmentFormPage } from "./modules/shipping/ShipmentFormPage";
import { ShipmentListPage } from "./modules/shipping/ShipmentListPage";
import { ThemeProvider } from "./theme/ThemeProvider"; import { ThemeProvider } from "./theme/ThemeProvider";
import "./index.css"; import "./index.css";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const CompanySettingsPage = React.lazy(() =>
import("./modules/settings/CompanySettingsPage").then((module) => ({ default: module.CompanySettingsPage }))
);
const AdminDiagnosticsPage = React.lazy(() =>
import("./modules/settings/AdminDiagnosticsPage").then((module) => ({ default: module.AdminDiagnosticsPage }))
);
const UserManagementPage = React.lazy(() =>
import("./modules/settings/UserManagementPage").then((module) => ({ default: module.UserManagementPage }))
);
const CustomersPage = React.lazy(() =>
import("./modules/crm/CustomersPage").then((module) => ({ default: module.CustomersPage }))
);
const VendorsPage = React.lazy(() =>
import("./modules/crm/VendorsPage").then((module) => ({ default: module.VendorsPage }))
);
const CrmDetailPage = React.lazy(() =>
import("./modules/crm/CrmDetailPage").then((module) => ({ default: module.CrmDetailPage }))
);
const CrmFormPage = React.lazy(() =>
import("./modules/crm/CrmFormPage").then((module) => ({ default: module.CrmFormPage }))
);
const InventoryItemsPage = React.lazy(() =>
import("./modules/inventory/InventoryItemsPage").then((module) => ({ default: module.InventoryItemsPage }))
);
const InventoryDetailPage = React.lazy(() =>
import("./modules/inventory/InventoryDetailPage").then((module) => ({ default: module.InventoryDetailPage }))
);
const InventoryFormPage = React.lazy(() =>
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
);
const WarehousesPage = React.lazy(() =>
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
);
const WarehouseDetailPage = React.lazy(() =>
import("./modules/inventory/WarehouseDetailPage").then((module) => ({ default: module.WarehouseDetailPage }))
);
const WarehouseFormPage = React.lazy(() =>
import("./modules/inventory/WarehouseFormPage").then((module) => ({ default: module.WarehouseFormPage }))
);
const ProjectsPage = React.lazy(() =>
import("./modules/projects/ProjectsPage").then((module) => ({ default: module.ProjectsPage }))
);
const ProjectDetailPage = React.lazy(() =>
import("./modules/projects/ProjectDetailPage").then((module) => ({ default: module.ProjectDetailPage }))
);
const ProjectFormPage = React.lazy(() =>
import("./modules/projects/ProjectFormPage").then((module) => ({ default: module.ProjectFormPage }))
);
const ManufacturingPage = React.lazy(() =>
import("./modules/manufacturing/ManufacturingPage").then((module) => ({ default: module.ManufacturingPage }))
);
const WorkOrderDetailPage = React.lazy(() =>
import("./modules/manufacturing/WorkOrderDetailPage").then((module) => ({ default: module.WorkOrderDetailPage }))
);
const WorkOrderFormPage = React.lazy(() =>
import("./modules/manufacturing/WorkOrderFormPage").then((module) => ({ default: module.WorkOrderFormPage }))
);
const PurchaseListPage = React.lazy(() =>
import("./modules/purchasing/PurchaseListPage").then((module) => ({ default: module.PurchaseListPage }))
);
const PurchaseDetailPage = React.lazy(() =>
import("./modules/purchasing/PurchaseDetailPage").then((module) => ({ default: module.PurchaseDetailPage }))
);
const PurchaseFormPage = React.lazy(() =>
import("./modules/purchasing/PurchaseFormPage").then((module) => ({ default: module.PurchaseFormPage }))
);
const SalesListPage = React.lazy(() =>
import("./modules/sales/SalesListPage").then((module) => ({ default: module.SalesListPage }))
);
const SalesDetailPage = React.lazy(() =>
import("./modules/sales/SalesDetailPage").then((module) => ({ default: module.SalesDetailPage }))
);
const SalesFormPage = React.lazy(() =>
import("./modules/sales/SalesFormPage").then((module) => ({ default: module.SalesFormPage }))
);
const ShipmentListPage = React.lazy(() =>
import("./modules/shipping/ShipmentListPage").then((module) => ({ default: module.ShipmentListPage }))
);
const ShipmentDetailPage = React.lazy(() =>
import("./modules/shipping/ShipmentDetailPage").then((module) => ({ default: module.ShipmentDetailPage }))
);
const ShipmentFormPage = React.lazy(() =>
import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage }))
);
const GanttPage = React.lazy(() =>
import("./modules/gantt/GanttPage").then((module) => ({ default: module.GanttPage }))
);
function RouteFallback() {
return (
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
Loading module...
</div>
);
}
function lazyElement(element: React.ReactNode) {
return <React.Suspense fallback={<RouteFallback />}>{element}</React.Suspense>;
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> }, { path: "/login", element: <LoginPage /> },
{ {
@@ -53,125 +125,128 @@ const router = createBrowserRouter([
{ path: "/", element: <DashboardPage /> }, { path: "/", element: <DashboardPage /> },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
children: [{ path: "/settings/company", element: <CompanySettingsPage /> }], children: [{ path: "/settings/company", element: lazyElement(<CompanySettingsPage />) }],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />, element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />,
children: [{ path: "/settings/admin-diagnostics", element: <AdminDiagnosticsPage /> }], children: [
{ path: "/settings/admin-diagnostics", element: lazyElement(<AdminDiagnosticsPage />) },
{ path: "/settings/users", element: lazyElement(<UserManagementPage />) },
],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
children: [ children: [
{ path: "/crm/customers", element: <CustomersPage /> }, { path: "/crm/customers", element: lazyElement(<CustomersPage />) },
{ path: "/crm/customers/:customerId", element: <CrmDetailPage entity="customer" /> }, { path: "/crm/customers/:customerId", element: lazyElement(<CrmDetailPage entity="customer" />) },
{ path: "/crm/vendors", element: <VendorsPage /> }, { path: "/crm/vendors", element: lazyElement(<VendorsPage />) },
{ path: "/crm/vendors/:vendorId", element: <CrmDetailPage entity="vendor" /> }, { path: "/crm/vendors/:vendorId", element: lazyElement(<CrmDetailPage entity="vendor" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.inventoryRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.inventoryRead]} />,
children: [ children: [
{ path: "/inventory/items", element: <InventoryItemsPage /> }, { path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
{ path: "/inventory/items/:itemId", element: <InventoryDetailPage /> }, { path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
{ path: "/inventory/warehouses", element: <WarehousesPage /> }, { path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
{ path: "/inventory/warehouses/:warehouseId", element: <WarehouseDetailPage /> }, { path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />,
children: [ children: [
{ path: "/projects", element: <ProjectsPage /> }, { path: "/projects", element: lazyElement(<ProjectsPage />) },
{ path: "/projects/:projectId", element: <ProjectDetailPage /> }, { path: "/projects/:projectId", element: lazyElement(<ProjectDetailPage />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />,
children: [ children: [
{ path: "/manufacturing/work-orders", element: <ManufacturingPage /> }, { path: "/manufacturing/work-orders", element: lazyElement(<ManufacturingPage />) },
{ path: "/manufacturing/work-orders/:workOrderId", element: <WorkOrderDetailPage /> }, { path: "/manufacturing/work-orders/:workOrderId", element: lazyElement(<WorkOrderDetailPage />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />, element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
children: [ children: [
{ path: "/purchasing/orders", element: <PurchaseListPage /> }, { path: "/purchasing/orders", element: lazyElement(<PurchaseListPage />) },
{ path: "/purchasing/orders/:orderId", element: <PurchaseDetailPage /> }, { path: "/purchasing/orders/:orderId", element: lazyElement(<PurchaseDetailPage />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
children: [ children: [
{ path: "/sales/quotes", element: <SalesListPage entity="quote" /> }, { path: "/sales/quotes", element: lazyElement(<SalesListPage entity="quote" />) },
{ path: "/sales/quotes/:quoteId", element: <SalesDetailPage entity="quote" /> }, { path: "/sales/quotes/:quoteId", element: lazyElement(<SalesDetailPage entity="quote" />) },
{ path: "/sales/orders", element: <SalesListPage entity="order" /> }, { path: "/sales/orders", element: lazyElement(<SalesListPage entity="order" />) },
{ path: "/sales/orders/:orderId", element: <SalesDetailPage entity="order" /> }, { path: "/sales/orders/:orderId", element: lazyElement(<SalesDetailPage entity="order" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.shippingRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.shippingRead]} />,
children: [ children: [
{ path: "/shipping/shipments", element: <ShipmentListPage /> }, { path: "/shipping/shipments", element: lazyElement(<ShipmentListPage />) },
{ path: "/shipping/shipments/:shipmentId", element: <ShipmentDetailPage /> }, { path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />, element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [ children: [
{ path: "/crm/customers/new", element: <CrmFormPage entity="customer" mode="create" /> }, { path: "/crm/customers/new", element: lazyElement(<CrmFormPage entity="customer" mode="create" />) },
{ path: "/crm/customers/:customerId/edit", element: <CrmFormPage entity="customer" mode="edit" /> }, { path: "/crm/customers/:customerId/edit", element: lazyElement(<CrmFormPage entity="customer" mode="edit" />) },
{ path: "/crm/vendors/new", element: <CrmFormPage entity="vendor" mode="create" /> }, { path: "/crm/vendors/new", element: lazyElement(<CrmFormPage entity="vendor" mode="create" />) },
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> }, { path: "/crm/vendors/:vendorId/edit", element: lazyElement(<CrmFormPage entity="vendor" mode="edit" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />, element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />,
children: [ children: [
{ path: "/projects/new", element: <ProjectFormPage mode="create" /> }, { path: "/projects/new", element: lazyElement(<ProjectFormPage mode="create" />) },
{ path: "/projects/:projectId/edit", element: <ProjectFormPage mode="edit" /> }, { path: "/projects/:projectId/edit", element: lazyElement(<ProjectFormPage mode="edit" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingWrite]} />, element: <ProtectedRoute requiredPermissions={[permissions.manufacturingWrite]} />,
children: [ children: [
{ path: "/manufacturing/work-orders/new", element: <WorkOrderFormPage mode="create" /> }, { path: "/manufacturing/work-orders/new", element: lazyElement(<WorkOrderFormPage mode="create" />) },
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: <WorkOrderFormPage mode="edit" /> }, { path: "/manufacturing/work-orders/:workOrderId/edit", element: lazyElement(<WorkOrderFormPage mode="edit" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />, element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
children: [ children: [
{ path: "/purchasing/orders/new", element: <PurchaseFormPage mode="create" /> }, { path: "/purchasing/orders/new", element: lazyElement(<PurchaseFormPage mode="create" />) },
{ path: "/purchasing/orders/:orderId/edit", element: <PurchaseFormPage mode="edit" /> }, { path: "/purchasing/orders/:orderId/edit", element: lazyElement(<PurchaseFormPage mode="edit" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />, element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />,
children: [ children: [
{ path: "/sales/quotes/new", element: <SalesFormPage entity="quote" mode="create" /> }, { path: "/sales/quotes/new", element: lazyElement(<SalesFormPage entity="quote" mode="create" />) },
{ path: "/sales/quotes/:quoteId/edit", element: <SalesFormPage entity="quote" mode="edit" /> }, { path: "/sales/quotes/:quoteId/edit", element: lazyElement(<SalesFormPage entity="quote" mode="edit" />) },
{ path: "/sales/orders/new", element: <SalesFormPage entity="order" mode="create" /> }, { path: "/sales/orders/new", element: lazyElement(<SalesFormPage entity="order" mode="create" />) },
{ path: "/sales/orders/:orderId/edit", element: <SalesFormPage entity="order" mode="edit" /> }, { path: "/sales/orders/:orderId/edit", element: lazyElement(<SalesFormPage entity="order" mode="edit" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.shippingWrite]} />, element: <ProtectedRoute requiredPermissions={[permissions.shippingWrite]} />,
children: [ children: [
{ path: "/shipping/shipments/new", element: <ShipmentFormPage mode="create" /> }, { path: "/shipping/shipments/new", element: lazyElement(<ShipmentFormPage mode="create" />) },
{ path: "/shipping/shipments/:shipmentId/edit", element: <ShipmentFormPage mode="edit" /> }, { path: "/shipping/shipments/:shipmentId/edit", element: lazyElement(<ShipmentFormPage mode="edit" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />, element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
children: [ children: [
{ path: "/inventory/items/new", element: <InventoryFormPage mode="create" /> }, { path: "/inventory/items/new", element: lazyElement(<InventoryFormPage mode="create" />) },
{ path: "/inventory/items/:itemId/edit", element: <InventoryFormPage mode="edit" /> }, { path: "/inventory/items/:itemId/edit", element: lazyElement(<InventoryFormPage mode="edit" />) },
{ path: "/inventory/warehouses/new", element: <WarehouseFormPage mode="create" /> }, { path: "/inventory/warehouses/new", element: lazyElement(<WarehouseFormPage mode="create" />) },
{ path: "/inventory/warehouses/:warehouseId/edit", element: <WarehouseFormPage mode="edit" /> }, { path: "/inventory/warehouses/:warehouseId/edit", element: lazyElement(<WarehouseFormPage mode="edit" />) },
], ],
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
children: [{ path: "/planning/gantt", element: <GanttPage /> }], children: [{ path: "/planning/gantt", element: lazyElement(<GanttPage />) }],
}, },
], ],
}, },

View File

@@ -90,6 +90,9 @@ export function AdminDiagnosticsPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"> <Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings Company settings
</Link> </Link>

View File

@@ -151,12 +151,17 @@ export function CompanySettingsPage() {
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
<h3 className="mt-2 text-lg font-bold text-text">Diagnostics and audit trail</h3> <h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3>
<p className="mt-2 text-sm text-muted">Review runtime footprint and recent change activity from the admin diagnostics surface.</p> <p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Open diagnostics
</Link>
</div> </div>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Open diagnostics
</Link>
</div> </div>
</section> </section>
) : null} ) : null}

View File

@@ -0,0 +1,363 @@
import type { AdminPermissionOptionDto, AdminRoleDto, AdminRoleInput, AdminUserDto, AdminUserInput } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
const emptyUserForm: AdminUserInput = {
email: "",
firstName: "",
lastName: "",
isActive: true,
roleIds: [],
password: "",
};
const emptyRoleForm: AdminRoleInput = {
name: "",
description: "",
permissionKeys: [],
};
export function UserManagementPage() {
const { token } = useAuth();
const [users, setUsers] = useState<AdminUserDto[]>([]);
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>("new");
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
const [status, setStatus] = useState("Loading admin access controls...");
useEffect(() => {
if (!token) {
return;
}
let active = true;
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token)])
.then(([nextUsers, nextRoles, nextPermissions]) => {
if (!active) {
return;
}
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setStatus("User management loaded.");
})
.catch((error: Error) => {
if (!active) {
return;
}
setStatus(error.message || "Unable to load admin access controls.");
});
return () => {
active = false;
};
}, [token]);
useEffect(() => {
if (selectedUserId === "new") {
setUserForm(emptyUserForm);
return;
}
const selectedUser = users.find((user) => user.id === selectedUserId);
if (!selectedUser) {
setUserForm(emptyUserForm);
return;
}
setUserForm({
email: selectedUser.email,
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
isActive: selectedUser.isActive,
roleIds: selectedUser.roleIds,
password: "",
});
}, [selectedUserId, users]);
useEffect(() => {
if (selectedRoleId === "new") {
setRoleForm(emptyRoleForm);
return;
}
const selectedRole = roles.find((role) => role.id === selectedRoleId);
if (!selectedRole) {
setRoleForm(emptyRoleForm);
return;
}
setRoleForm({
name: selectedRole.name,
description: selectedRole.description,
permissionKeys: selectedRole.permissionKeys,
});
}, [roles, selectedRoleId]);
if (!token) {
return null;
}
const authToken = token;
async function refreshData(nextStatus: string) {
const [nextUsers, nextRoles, nextPermissions] = await Promise.all([
api.getAdminUsers(authToken),
api.getAdminRoles(authToken),
api.getAdminPermissions(authToken),
]);
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setStatus(nextStatus);
}
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (selectedUserId === "new") {
const createdUser = await api.createAdminUser(authToken, userForm);
await refreshData(`Created user ${createdUser.email}.`);
setSelectedUserId(createdUser.id);
return;
}
const updatedUser = await api.updateAdminUser(authToken, selectedUserId, userForm);
await refreshData(`Updated user ${updatedUser.email}.`);
setSelectedUserId(updatedUser.id);
}
async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (selectedRoleId === "new") {
const createdRole = await api.createAdminRole(authToken, roleForm);
await refreshData(`Created role ${createdRole.name}.`);
setSelectedRoleId(createdRole.id);
return;
}
const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm);
await refreshData(`Updated role ${updatedRole.name}.`);
setSelectedRoleId(updatedRole.id);
}
function toggleUserRole(roleId: string) {
setUserForm((current) => ({
...current,
roleIds: current.roleIds.includes(roleId)
? current.roleIds.filter((currentRoleId) => currentRoleId !== roleId)
: [...current.roleIds, roleId],
}));
}
function toggleRolePermission(permissionKey: string) {
setRoleForm((current) => ({
...current,
permissionKeys: current.permissionKeys.includes(permissionKey)
? current.permissionKeys.filter((currentPermissionKey) => currentPermissionKey !== permissionKey)
: [...current.permissionKeys, permissionKey],
}));
}
return (
<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">
<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">User Management</p>
<h3 className="mt-2 text-lg font-bold text-text">Accounts, roles, and permission assignment</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Manage user accounts and the role-permission model from one admin surface so onboarding and access control stay tied together.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Diagnostics
</Link>
</div>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleUserSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Users</p>
<h3 className="mt-2 text-lg font-bold text-text">Account generation and role assignment</h3>
</div>
<select
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New user</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.firstName} {user.lastName} ({user.email})
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
<input
value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
<input
type="password"
value={userForm.password ?? ""}
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
placeholder={selectedUserId === "new" ? "Required for new user" : "Leave blank to keep current password"}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">First name</span>
<input
value={userForm.firstName}
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Last name</span>
<input
value={userForm.lastName}
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.isActive}
onChange={(event) => setUserForm((current) => ({ ...current, isActive: event.target.checked }))}
/>
User can sign in
</label>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Assigned roles</p>
<div className="mt-3 grid gap-3">
{roles.map((role) => (
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.roleIds.includes(role.id)}
onChange={() => toggleUserRole(role.id)}
/>
<span>
<span className="block font-semibold">{role.name}</span>
<span className="block text-xs text-muted">{role.description || "No description"}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedUserId === "new" ? "Create user" : "Save user"}
</button>
</div>
</form>
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleRoleSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roles</p>
<h3 className="mt-2 text-lg font-bold text-text">Permission assignment administration</h3>
</div>
<select
value={selectedRoleId}
onChange={(event) => setSelectedRoleId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New role</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role name</span>
<input
value={roleForm.name}
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea
value={roleForm.description}
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
rows={3}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Role permissions</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{permissions.map((permission) => (
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={roleForm.permissionKeys.includes(permission.key)}
onChange={() => toggleRolePermission(permission.key)}
/>
<span>
<span className="block font-semibold">{permission.key}</span>
<span className="block text-xs text-muted">{permission.description}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{roles.map((role) => (
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">{role.name}</p>
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
</div>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedRoleId === "new" ? "Create role" : "Save role"}
</button>
</div>
</form>
</section>
</div>
);
}

View File

@@ -4,6 +4,24 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("@svar-ui/react-gantt")) {
return "gantt-vendor";
}
if (id.includes("@tanstack/react-query")) {
return "query-vendor";
}
if (id.includes("react-router-dom") || id.includes("react-dom") || id.includes(`${path.sep}react${path.sep}`)) {
return "react-vendor";
}
return undefined;
},
},
},
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "src"), "@": path.resolve(__dirname, "src"),
@@ -16,4 +34,3 @@ export default defineConfig({
}, },
}, },
}); });

View File

@@ -1,12 +1,119 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import { Router } from "express"; import { Router } from "express";
import { z } from "zod";
import { ok } from "../../lib/http.js"; import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js"; import { requirePermissions } from "../../lib/rbac.js";
import { getAdminDiagnostics } from "./service.js"; import {
createAdminRole,
createAdminUser,
getAdminDiagnostics,
listAdminPermissions,
listAdminRoles,
listAdminUsers,
updateAdminRole,
updateAdminUser,
} from "./service.js";
export const adminRouter = Router(); export const adminRouter = Router();
const roleSchema = z.object({
name: z.string().trim().min(1).max(120),
description: z.string(),
permissionKeys: z.array(z.string().trim().min(1)),
});
const userSchema = z.object({
email: z.string().email(),
firstName: z.string().trim().min(1).max(120),
lastName: z.string().trim().min(1).max(120),
isActive: z.boolean(),
roleIds: z.array(z.string().trim().min(1)),
password: z.string().min(8).nullable(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => { adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await getAdminDiagnostics()); return ok(response, await getAdminDiagnostics());
}); });
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminPermissions());
});
adminRouter.get("/roles", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminRoles());
});
adminRouter.post("/roles", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = roleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
}
const result = await createAdminRole(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.role, 201);
});
adminRouter.put("/roles/:roleId", requirePermissions([permissions.adminManage]), async (request, response) => {
const roleId = getRouteParam(request.params.roleId);
if (!roleId) {
return fail(response, 400, "INVALID_INPUT", "Role id is invalid.");
}
const parsed = roleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
}
const result = await updateAdminRole(roleId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.role);
});
adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminUsers());
});
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
}
const result = await createAdminUser(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.user, 201);
});
adminRouter.put("/users/:userId", requirePermissions([permissions.adminManage]), async (request, response) => {
const userId = getRouteParam(request.params.userId);
if (!userId) {
return fail(response, 400, "INVALID_INPUT", "User id is invalid.");
}
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
}
const result = await updateAdminUser(userId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.user);
});

View File

@@ -1,8 +1,18 @@
import type { AdminDiagnosticsDto, AuditEventDto } from "@mrp/shared"; import type {
AdminDiagnosticsDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
AuditEventDto,
} from "@mrp/shared";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
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 { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
function mapAuditEvent(record: { function mapAuditEvent(record: {
@@ -32,6 +42,427 @@ function mapAuditEvent(record: {
}; };
} }
function mapRole(record: {
id: string;
name: string;
description: string;
createdAt: Date;
updatedAt: Date;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
_count: {
userRoles: number;
};
}): AdminRoleDto {
return {
id: record.id,
name: record.name,
description: record.description,
permissionKeys: record.rolePermissions.map((rolePermission) => rolePermission.permission.key).sort(),
userCount: record._count.userRoles,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function mapUser(record: {
id: string;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
userRoles: Array<{
role: {
id: string;
name: string;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
};
}>;
}): AdminUserDto {
const permissionKeys = new Set<string>();
for (const userRole of record.userRoles) {
for (const rolePermission of userRole.role.rolePermissions) {
permissionKeys.add(rolePermission.permission.key);
}
}
return {
id: record.id,
email: record.email,
firstName: record.firstName,
lastName: record.lastName,
isActive: record.isActive,
roleIds: record.userRoles.map((userRole) => userRole.role.id),
roleNames: record.userRoles.map((userRole) => userRole.role.name),
permissionKeys: [...permissionKeys].sort(),
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function validatePermissionKeys(permissionKeys: string[]) {
const uniquePermissionKeys = [...new Set(permissionKeys)];
const permissions = await prisma.permission.findMany({
where: {
key: {
in: uniquePermissionKeys,
},
},
select: {
id: true,
key: true,
},
});
if (permissions.length !== uniquePermissionKeys.length) {
return { ok: false as const, reason: "One or more selected permissions are invalid." };
}
return { ok: true as const, permissions };
}
async function validateRoleIds(roleIds: string[]) {
const uniqueRoleIds = [...new Set(roleIds)];
const roles = await prisma.role.findMany({
where: {
id: {
in: uniqueRoleIds,
},
},
select: {
id: true,
name: true,
},
});
if (roles.length !== uniqueRoleIds.length) {
return { ok: false as const, reason: "One or more selected roles are invalid." };
}
return { ok: true as const, roles };
}
export async function listAdminPermissions(): Promise<AdminPermissionOptionDto[]> {
const permissions = await prisma.permission.findMany({
orderBy: [{ key: "asc" }],
});
return permissions.map((permission) => ({
key: permission.key,
description: permission.description,
}));
}
export async function listAdminRoles(): Promise<AdminRoleDto[]> {
const roles = await prisma.role.findMany({
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
orderBy: [{ name: "asc" }],
});
return roles.map(mapRole);
}
export async function createAdminRole(payload: AdminRoleInput, actorId?: string | null) {
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.create({
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "created",
summary: `Created role ${role.name}.`,
metadata: {
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function updateAdminRole(roleId: string, payload: AdminRoleInput, actorId?: string | null) {
const existingRole = await prisma.role.findUnique({
where: { id: roleId },
select: { id: true, name: true },
});
if (!existingRole) {
return { ok: false as const, reason: "Role was not found." };
}
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.update({
where: { id: roleId },
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
deleteMany: {},
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "updated",
summary: `Updated role ${role.name}.`,
metadata: {
previousName: existingRole.name,
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function listAdminUsers(): Promise<AdminUserDto[]> {
const users = await prisma.user.findMany({
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
});
return users.map(mapUser);
}
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
if (!payload.password || payload.password.trim().length < 8) {
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const user = await prisma.user.create({
data: {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
passwordHash: await hashPassword(payload.password.trim()),
userRoles: {
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
},
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "created",
summary: `Created user account for ${user.email}.`,
metadata: {
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function updateAdminUser(userId: string, payload: AdminUserInput, actorId?: string | null) {
const existingUser = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
},
});
if (!existingUser) {
return { ok: false as const, reason: "User was not found." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const data = {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
...(payload.password?.trim()
? {
passwordHash: await hashPassword(payload.password.trim()),
}
: {}),
userRoles: {
deleteMany: {},
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
};
const user = await prisma.user.update({
where: { id: userId },
data,
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "updated",
summary: `Updated user account for ${user.email}.`,
metadata: {
previousEmail: existingUser.email,
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
passwordReset: Boolean(payload.password?.trim()),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> { export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const [ const [
companyProfile, companyProfile,

View File

@@ -10,6 +10,49 @@ export interface AuditEventDto {
createdAt: string; createdAt: string;
} }
export interface AdminPermissionOptionDto {
key: string;
description: string;
}
export interface AdminRoleDto {
id: string;
name: string;
description: string;
permissionKeys: string[];
userCount: number;
createdAt: string;
updatedAt: string;
}
export interface AdminRoleInput {
name: string;
description: string;
permissionKeys: string[];
}
export interface AdminUserDto {
id: string;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
roleIds: string[];
roleNames: string[];
permissionKeys: string[];
createdAt: string;
updatedAt: string;
}
export interface AdminUserInput {
email: string;
firstName: string;
lastName: string;
isActive: boolean;
roleIds: string[];
password: string | null;
}
export interface AdminDiagnosticsDto { export interface AdminDiagnosticsDto {
serverTime: string; serverTime: string;
nodeVersion: string; nodeVersion: string;