From 3197e687493d718b898cb554ccf305e53d3b45bc Mon Sep 17 00:00:00 2001
From: jason
Date: Sun, 15 Mar 2026 14:47:58 -0500
Subject: [PATCH] user management
---
AGENTS.md | 5 +-
CHANGELOG.md | 6 +-
INSTRUCTIONS.md | 5 +-
README.md | 15 +-
ROADMAP.md | 9 +-
client/src/lib/api.ts | 26 ++
client/src/main.tsx | 217 ++++++---
.../modules/settings/AdminDiagnosticsPage.tsx | 3 +
.../modules/settings/CompanySettingsPage.tsx | 15 +-
.../modules/settings/UserManagementPage.tsx | 363 +++++++++++++++
client/vite.config.ts | 19 +-
server/src/modules/admin/router.ts | 111 ++++-
server/src/modules/admin/service.ts | 433 +++++++++++++++++-
shared/src/admin/types.ts | 43 ++
14 files changed, 1175 insertions(+), 95 deletions(-)
create mode 100644 client/src/modules/settings/UserManagementPage.tsx
diff --git a/AGENTS.md b/AGENTS.md
index d6197d9..b6243b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
- planning gantt timelines backed by live project and manufacturing schedule data
- 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
- single-container Docker deployment
@@ -120,8 +121,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are:
-1. Code-splitting and bundle-size reduction
-2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics
+1. CRM/shipping audit coverage and richer startup validation
+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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afe1e69..47d7c07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### 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
- 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
@@ -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
- 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
-- 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
diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md
index 19104f2..509bde5 100644
--- a/INSTRUCTIONS.md
+++ b/INSTRUCTIONS.md
@@ -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
- 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 user management with account creation, activation, role assignment, and role-permission editing
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
@@ -63,5 +64,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
-- code-splitting and bundle-size reduction
-- expanded role-management and support diagnostics
+- CRM/shipping audit coverage and richer startup validation
+- backup/restore workflow depth and support-oriented admin tooling
diff --git a/README.md b/README.md
index ce1c24c..2e96c36 100644
--- a/README.md
+++ b/README.md
@@ -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
- planning gantt timelines with live project and manufacturing schedule data
- 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
## Product Map
@@ -42,19 +44,19 @@ Current completed foundation areas:
- manufacturing foundation
- planning foundation
- audit and diagnostics foundation
+- user and role administration foundation
- branding, attachments, auth/RBAC, and PDF infrastructure
Near-term priorities:
-1. Code-splitting and bundle-size reduction
-2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics
+1. CRM/shipping audit coverage and richer startup validation
+2. Backup/restore workflow documentation and support-oriented admin tooling
Revisit / deferred items:
- local Windows Prisma migration reliability
-- frontend code-splitting and bundle-size reduction
-- expanded role-management and permission administration
- deeper support diagnostics and startup validation
+- backup/restore workflow depth and support tooling
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
- 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
Current follow-up direction:
- deeper audit coverage across CRM and shipping mutations
- 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
@@ -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 active module screens now follow a tighter density baseline for forms, tables, and detail cards.
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data.
-- The client 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
diff --git a/ROADMAP.md b/ROADMAP.md
index efa347d..cff33c0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
- 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
+- 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
- Theme persistence fixes and denser responsive workspace layouts
- 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
- 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
- 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
@@ -253,6 +254,7 @@ Foundation slice shipped:
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
+- Expanded role-management UI with account creation, activation, role assignment, and permission administration
- Expanded role management UI
- Permission assignment administration
@@ -272,7 +274,6 @@ QOL subfeatures:
## Revisit / Deferred Items
- 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
- 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
@@ -288,5 +289,5 @@ QOL subfeatures:
## Near-term priority order
-1. Code-splitting and bundle-size reduction
-2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics
+1. CRM/shipping audit coverage plus richer startup validation
+2. Backup/restore workflow documentation and support-oriented admin tooling
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index ab709ea..5d4023c 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -1,5 +1,10 @@
import type {
AdminDiagnosticsDto,
+ AdminPermissionOptionDto,
+ AdminRoleDto,
+ AdminRoleInput,
+ AdminUserDto,
+ AdminUserInput,
ApiResponse,
CompanyProfileDto,
CompanyProfileInput,
@@ -131,6 +136,27 @@ export const api = {
getAdminDiagnostics(token: string) {
return request("/api/v1/admin/diagnostics", undefined, token);
},
+ getAdminPermissions(token: string) {
+ return request("/api/v1/admin/permissions", undefined, token);
+ },
+ getAdminRoles(token: string) {
+ return request("/api/v1/admin/roles", undefined, token);
+ },
+ createAdminRole(token: string, payload: AdminRoleInput) {
+ return request("/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }, token);
+ },
+ updateAdminRole(token: string, roleId: string, payload: AdminRoleInput) {
+ return request(`/api/v1/admin/roles/${roleId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
+ },
+ getAdminUsers(token: string) {
+ return request("/api/v1/admin/users", undefined, token);
+ },
+ createAdminUser(token: string, payload: AdminUserInput) {
+ return request("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
+ },
+ updateAdminUser(token: string, userId: string, payload: AdminUserInput) {
+ return request(`/api/v1/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
+ },
getCompanyProfile(token: string) {
return request("/api/v1/company-profile", undefined, token);
},
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 84e16ee..3c6218f 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -9,39 +9,111 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
import { AuthProvider } from "./auth/AuthProvider";
import { DashboardPage } from "./modules/dashboard/DashboardPage";
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 "./index.css";
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 (
+
+ Loading module...
+
+ );
+}
+
+function lazyElement(element: React.ReactNode) {
+ return }>{element};
+}
+
const router = createBrowserRouter([
{ path: "/login", element: },
{
@@ -53,125 +125,128 @@ const router = createBrowserRouter([
{ path: "/", element: },
{
element: ,
- children: [{ path: "/settings/company", element: }],
+ children: [{ path: "/settings/company", element: lazyElement() }],
},
{
element: ,
- children: [{ path: "/settings/admin-diagnostics", element: }],
+ children: [
+ { path: "/settings/admin-diagnostics", element: lazyElement() },
+ { path: "/settings/users", element: lazyElement() },
+ ],
},
{
element: ,
children: [
- { path: "/crm/customers", element: },
- { path: "/crm/customers/:customerId", element: },
- { path: "/crm/vendors", element: },
- { path: "/crm/vendors/:vendorId", element: },
+ { path: "/crm/customers", element: lazyElement() },
+ { path: "/crm/customers/:customerId", element: lazyElement() },
+ { path: "/crm/vendors", element: lazyElement() },
+ { path: "/crm/vendors/:vendorId", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/inventory/items", element: },
- { path: "/inventory/items/:itemId", element: },
- { path: "/inventory/warehouses", element: },
- { path: "/inventory/warehouses/:warehouseId", element: },
+ { path: "/inventory/items", element: lazyElement() },
+ { path: "/inventory/items/:itemId", element: lazyElement() },
+ { path: "/inventory/warehouses", element: lazyElement() },
+ { path: "/inventory/warehouses/:warehouseId", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/projects", element: },
- { path: "/projects/:projectId", element: },
+ { path: "/projects", element: lazyElement() },
+ { path: "/projects/:projectId", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/manufacturing/work-orders", element: },
- { path: "/manufacturing/work-orders/:workOrderId", element: },
+ { path: "/manufacturing/work-orders", element: lazyElement() },
+ { path: "/manufacturing/work-orders/:workOrderId", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/purchasing/orders", element: },
- { path: "/purchasing/orders/:orderId", element: },
+ { path: "/purchasing/orders", element: lazyElement() },
+ { path: "/purchasing/orders/:orderId", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/sales/quotes", element: },
- { path: "/sales/quotes/:quoteId", element: },
- { path: "/sales/orders", element: },
- { path: "/sales/orders/:orderId", element: },
+ { path: "/sales/quotes", element: lazyElement() },
+ { path: "/sales/quotes/:quoteId", element: lazyElement() },
+ { path: "/sales/orders", element: lazyElement() },
+ { path: "/sales/orders/:orderId", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/shipping/shipments", element: },
- { path: "/shipping/shipments/:shipmentId", element: },
+ { path: "/shipping/shipments", element: lazyElement() },
+ { path: "/shipping/shipments/:shipmentId", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/crm/customers/new", element: },
- { path: "/crm/customers/:customerId/edit", element: },
- { path: "/crm/vendors/new", element: },
- { path: "/crm/vendors/:vendorId/edit", element: },
+ { path: "/crm/customers/new", element: lazyElement() },
+ { path: "/crm/customers/:customerId/edit", element: lazyElement() },
+ { path: "/crm/vendors/new", element: lazyElement() },
+ { path: "/crm/vendors/:vendorId/edit", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/projects/new", element: },
- { path: "/projects/:projectId/edit", element: },
+ { path: "/projects/new", element: lazyElement() },
+ { path: "/projects/:projectId/edit", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/manufacturing/work-orders/new", element: },
- { path: "/manufacturing/work-orders/:workOrderId/edit", element: },
+ { path: "/manufacturing/work-orders/new", element: lazyElement() },
+ { path: "/manufacturing/work-orders/:workOrderId/edit", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/purchasing/orders/new", element: },
- { path: "/purchasing/orders/:orderId/edit", element: },
+ { path: "/purchasing/orders/new", element: lazyElement() },
+ { path: "/purchasing/orders/:orderId/edit", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/sales/quotes/new", element: },
- { path: "/sales/quotes/:quoteId/edit", element: },
- { path: "/sales/orders/new", element: },
- { path: "/sales/orders/:orderId/edit", element: },
+ { path: "/sales/quotes/new", element: lazyElement() },
+ { path: "/sales/quotes/:quoteId/edit", element: lazyElement() },
+ { path: "/sales/orders/new", element: lazyElement() },
+ { path: "/sales/orders/:orderId/edit", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/shipping/shipments/new", element: },
- { path: "/shipping/shipments/:shipmentId/edit", element: },
+ { path: "/shipping/shipments/new", element: lazyElement() },
+ { path: "/shipping/shipments/:shipmentId/edit", element: lazyElement() },
],
},
{
element: ,
children: [
- { path: "/inventory/items/new", element: },
- { path: "/inventory/items/:itemId/edit", element: },
- { path: "/inventory/warehouses/new", element: },
- { path: "/inventory/warehouses/:warehouseId/edit", element: },
+ { path: "/inventory/items/new", element: lazyElement() },
+ { path: "/inventory/items/:itemId/edit", element: lazyElement() },
+ { path: "/inventory/warehouses/new", element: lazyElement() },
+ { path: "/inventory/warehouses/:warehouseId/edit", element: lazyElement() },
],
},
{
element: ,
- children: [{ path: "/planning/gantt", element: }],
+ children: [{ path: "/planning/gantt", element: lazyElement() }],
},
],
},
diff --git a/client/src/modules/settings/AdminDiagnosticsPage.tsx b/client/src/modules/settings/AdminDiagnosticsPage.tsx
index d09b42d..d78f232 100644
--- a/client/src/modules/settings/AdminDiagnosticsPage.tsx
+++ b/client/src/modules/settings/AdminDiagnosticsPage.tsx
@@ -90,6 +90,9 @@ export function AdminDiagnosticsPage() {
+
+ User management
+
Company settings
diff --git a/client/src/modules/settings/CompanySettingsPage.tsx b/client/src/modules/settings/CompanySettingsPage.tsx
index 3b028c8..ab4b32f 100644
--- a/client/src/modules/settings/CompanySettingsPage.tsx
+++ b/client/src/modules/settings/CompanySettingsPage.tsx
@@ -151,12 +151,17 @@ export function CompanySettingsPage() {
Admin
-
Diagnostics and audit trail
-
Review runtime footprint and recent change activity from the admin diagnostics surface.
+
Admin access and diagnostics
+
Manage users, roles, and system diagnostics from the linked admin surfaces.
+
+
+
+ User management
+
+
+ Open diagnostics
+
-
- Open diagnostics
-
) : null}
diff --git a/client/src/modules/settings/UserManagementPage.tsx b/client/src/modules/settings/UserManagementPage.tsx
new file mode 100644
index 0000000..dbd5bd1
--- /dev/null
+++ b/client/src/modules/settings/UserManagementPage.tsx
@@ -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
([]);
+ const [roles, setRoles] = useState([]);
+ const [permissions, setPermissions] = useState([]);
+ const [selectedUserId, setSelectedUserId] = useState("new");
+ const [selectedRoleId, setSelectedRoleId] = useState("new");
+ const [userForm, setUserForm] = useState(emptyUserForm);
+ const [roleForm, setRoleForm] = useState(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) {
+ 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) {
+ 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 (
+
+
+
+
+
User Management
+
Accounts, roles, and permission assignment
+
+ Manage user accounts and the role-permission model from one admin surface so onboarding and access control stay tied together.
+
+
+
+
+ Company settings
+
+
+ Diagnostics
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 44c0e47..5f32af9 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -4,6 +4,24 @@ import { defineConfig } from "vite";
export default defineConfig({
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: {
alias: {
"@": path.resolve(__dirname, "src"),
@@ -16,4 +34,3 @@ export default defineConfig({
},
},
});
-
diff --git a/server/src/modules/admin/router.ts b/server/src/modules/admin/router.ts
index 5b5834e..bed0cf2 100644
--- a/server/src/modules/admin/router.ts
+++ b/server/src/modules/admin/router.ts
@@ -1,12 +1,119 @@
import { permissions } from "@mrp/shared";
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 { getAdminDiagnostics } from "./service.js";
+import {
+ createAdminRole,
+ createAdminUser,
+ getAdminDiagnostics,
+ listAdminPermissions,
+ listAdminRoles,
+ listAdminUsers,
+ updateAdminRole,
+ updateAdminUser,
+} from "./service.js";
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) => {
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);
+});
diff --git a/server/src/modules/admin/service.ts b/server/src/modules/admin/service.ts
index f8a8f81..14a063a 100644
--- a/server/src/modules/admin/service.ts
+++ b/server/src/modules/admin/service.ts
@@ -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 { env } from "../../config/env.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";
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();
+ 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 {
+ const permissions = await prisma.permission.findMany({
+ orderBy: [{ key: "asc" }],
+ });
+
+ return permissions.map((permission) => ({
+ key: permission.key,
+ description: permission.description,
+ }));
+}
+
+export async function listAdminRoles(): Promise {
+ 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 {
+ 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 {
const [
companyProfile,
diff --git a/shared/src/admin/types.ts b/shared/src/admin/types.ts
index 38674d4..28a525b 100644
--- a/shared/src/admin/types.ts
+++ b/shared/src/admin/types.ts
@@ -10,6 +10,49 @@ export interface AuditEventDto {
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 {
serverTime: string;
nodeVersion: string;