Browse Source

feat(admin): add persistent roles and inventory demo

softword 2 months ago
parent
commit
e006d284cc

+ 29 - 2
omlink.sql

@@ -48,6 +48,33 @@ CREATE TABLE `queuesystem` (
 ) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4;
 ) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4;
 
 
 
 
+DROP TABLE IF EXISTS `roles`;
+CREATE TABLE `roles` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) NOT NULL,
+  `description` varchar(1000) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uq_roles_name` (`name`),
+  CONSTRAINT `chk_roles_name_non_empty` CHECK (char_length(trim(`name`)) > 0)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO `roles` (`id`, `name`, `description`) VALUES
+(1, 'Administrator', 'Full administrative access'),
+(2, 'Inventory operator', 'Inventory management access');
+
+DROP TABLE IF EXISTS `inventory_items`;
+CREATE TABLE `inventory_items` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `sku` varchar(255) NOT NULL,
+  `name` varchar(255) NOT NULL,
+  `quantity` int(11) UNSIGNED NOT NULL DEFAULT 0,
+  `reorder_level` int(11) UNSIGNED NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uq_inventory_items_sku` (`sku`),
+  CONSTRAINT `chk_inventory_items_sku_non_empty` CHECK (char_length(trim(`sku`)) > 0),
+  CONSTRAINT `chk_inventory_items_name_non_empty` CHECK (char_length(trim(`name`)) > 0)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
 DROP TABLE IF EXISTS `users`;
 DROP TABLE IF EXISTS `users`;
 CREATE TABLE `users` (
 CREATE TABLE `users` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `id` int(11) NOT NULL AUTO_INCREMENT,
@@ -60,7 +87,7 @@ CREATE TABLE `users` (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 
 INSERT INTO `users` (`id`, `username`, `saltedpassword`, `roles`, `avatar`) VALUES
 INSERT INTO `users` (`id`, `username`, `saltedpassword`, `roles`, `avatar`) VALUES
-(1,	'admin',	'$2y$10$cJiNAGj.XSELKuVuQ9gSwOlExVSDGj6IsuIsSsK.fV0KEJX56ncWK',	'a:2:{i:0;s:5:\"admin\";i:1;s:4:\"demo\";}',	''),
-(2,	'operatore1',	'$2y$10$9uRKekk0i9Z08fXQFSjW6.PK6l/fk956bNvMjmBMmaJNqo9KJ5Txu',	'a:1:{i:0;s:4:\"demo\";}',	'');
+(1,	'admin',	'$2y$10$cJiNAGj.XSELKuVuQ9gSwOlExVSDGj6IsuIsSsK.fV0KEJX56ncWK',	'a:2:{i:0;i:1;i:1;i:2;}',	''),
+(2,	'operatore1',	'$2y$10$9uRKekk0i9Z08fXQFSjW6.PK6l/fk956bNvMjmBMmaJNqo9KJ5Txu',	'a:1:{i:0;i:2;}',	'');
 
 
 -- 2019-04-22 22:27:01
 -- 2019-04-22 22:27:01

+ 19 - 8
specs/api-routes.md

@@ -6,16 +6,24 @@ All routes are registered in `src/OpenMind/Api/Api.php`. Responses are JSON with
 
 
 | Method | Path | Handler | Description | Authorization |
 | Method | Path | Handler | Description | Authorization |
 |---|---|---|---|---|
 |---|---|---|---|---|
-| GET | `/api/v1/auth/session` | `AuthController::session` | Returns authenticated state, safe user DTO, roles, and CSRF token. | Public |
+| GET | `/api/v1/auth/session` | `AuthController::session` | Returns authenticated state, safe user DTO with numeric role IDs, and CSRF token. | Public |
 | POST | `/api/v1/auth/login` | `AuthController::login` | Validates JSON credentials, regenerates the session ID, stores the user, and returns session state. | Public |
 | POST | `/api/v1/auth/login` | `AuthController::login` | Validates JSON credentials, regenerates the session ID, stores the user, and returns session state. | Public |
 | POST | `/api/v1/auth/logout` | `AuthController::logout` | Clears session and returns anonymous state. | CSRF |
 | POST | `/api/v1/auth/logout` | `AuthController::logout` | Clears session and returns anonymous state. | CSRF |
 | GET | `/api/v1/navigation` | `NavigationController::index` | Returns permission-filtered, ordered menu entries. | Authenticated |
 | GET | `/api/v1/navigation` | `NavigationController::index` | Returns permission-filtered, ordered menu entries. | Authenticated |
-| GET | `/api/v1/admin/config` | `AdminController::load` | Returns decrypted configuration text. | `admin` |
-| PUT | `/api/v1/admin/config` | `AdminController::save` | Encrypts and replaces configuration text. | `admin` + CSRF |
-| GET | `/api/v1/admin/users` | `UserController::index` | Returns safe user DTOs and configured role choices. | `admin` |
-| POST | `/api/v1/admin/users` | `UserController::create` | Creates a user from username, password, and configured roles. | `admin` + CSRF |
-| PUT | `/api/v1/admin/users/@id` | `UserController::update` | Updates selected user fields. | `admin` + CSRF |
-| DELETE | `/api/v1/admin/users/@id` | `UserController::delete` | Deletes an eligible user. | `admin` + CSRF |
+| GET | `/api/v1/admin/config` | `AdminController::load` | Returns decrypted configuration text. | Role ID `1` |
+| PUT | `/api/v1/admin/config` | `AdminController::save` | Encrypts and replaces configuration text. | Role ID `1` + CSRF |
+| GET | `/api/v1/admin/users` | `UserController::index` | Returns safe user DTOs with numeric role IDs and persistent role choices. | Role ID `1` |
+| POST | `/api/v1/admin/users` | `UserController::create` | Creates a user from username, password, and existing role IDs. | Role ID `1` + CSRF |
+| PUT | `/api/v1/admin/users/@id` | `UserController::update` | Updates selected user fields and existing role IDs. | Role ID `1` + CSRF |
+| DELETE | `/api/v1/admin/users/@id` | `UserController::delete` | Deletes an eligible user. | Role ID `1` + CSRF |
+| GET | `/api/v1/admin/roles` | `RoleController::list` | Lists the persistent role catalog as safe `{id, name, description}` DTOs. | Role ID `1` |
+| POST | `/api/v1/admin/roles` | `RoleController::create` | Creates a role catalog record. | Role ID `1` + CSRF |
+| PUT | `/api/v1/admin/roles/@id` | `RoleController::update` | Updates role metadata without changing its ID. | Role ID `1` + CSRF |
+| DELETE | `/api/v1/admin/roles/@id` | `RoleController::delete` | Deletes a role only when it is not ID `1` and is not assigned to any user. | Role ID `1` + CSRF |
+| GET | `/api/v1/inventory/items` | `InventoryController::index` | Lists Inventory Monitor demo item DTOs. | Role ID `2` |
+| POST | `/api/v1/inventory/items` | `InventoryController::create` | Creates an Inventory Monitor demo item. | Role ID `2` + CSRF |
+| PUT | `/api/v1/inventory/items/@id` | `InventoryController::update` | Updates an Inventory Monitor demo item. | Role ID `2` + CSRF |
+| DELETE | `/api/v1/inventory/items/@id` | `InventoryController::delete` | Deletes an Inventory Monitor demo item. | Role ID `2` + CSRF |
 | GET | `/api/v1/notifications` | `NotificationController::index` | Returns notifications grouped by caption. | Authenticated |
 | GET | `/api/v1/notifications` | `NotificationController::index` | Returns notifications grouped by caption. | Authenticated |
 | DELETE | `/api/v1/notifications` | `NotificationController::delete` | Clears every notification. | Authenticated + CSRF |
 | DELETE | `/api/v1/notifications` | `NotificationController::delete` | Clears every notification. | Authenticated + CSRF |
 | DELETE | `/api/v1/notifications/@code` | `NotificationController::delete` | Deletes notifications matching a non-negative integer code. | Authenticated + CSRF |
 | DELETE | `/api/v1/notifications/@code` | `NotificationController::delete` | Deletes notifications matching a non-negative integer code. | Authenticated + CSRF |
@@ -23,7 +31,10 @@ All routes are registered in `src/OpenMind/Api/Api.php`. Responses are JSON with
 ## Contract Notes
 ## Contract Notes
 - `Controller::decodeJsonRequest()` accepts only a JSON object; malformed or empty bodies are rejected.
 - `Controller::decodeJsonRequest()` accepts only a JSON object; malformed or empty bodies are rejected.
 - The authentication/session response is the source of the current CSRF token. The client caches it only in memory.
 - The authentication/session response is the source of the current CSRF token. The client caches it only in memory.
-- Unauthorized routes return 401; insufficient roles return 403; user-management business conflicts return 409 with a stable `code`.
+- Unauthorized routes return 401; insufficient roles return 403; role, user, and inventory business conflicts return 409 with a stable `code`.
+- Role authorization uses persistent numeric IDs: ID `1` is Administrator and ID `2` is Inventory operator. Renaming a role does not change access.
+- Role deletion rejects the protected Administrator role (ID `1`) and any role assigned to a user; these business conflicts return 409.
+- Inventory routes require role ID `2`; ID `1` does not implicitly grant inventory access. State-changing role, user, and inventory requests require the session CSRF token.
 - Route registration is separate from legacy system methods that are not registered in `System::setSystemRoutes()`.
 - Route registration is separate from legacy system methods that are not registered in `System::setSystemRoutes()`.
 
 
 See [identity/spec.md](identity/spec.md), [configuration/spec.md](configuration/spec.md), and [rules/add-api-endpoint.md](rules/add-api-endpoint.md).
 See [identity/spec.md](identity/spec.md), [configuration/spec.md](configuration/spec.md), and [rules/add-api-endpoint.md](rules/add-api-endpoint.md).

+ 5 - 4
specs/conventions-backend.md

@@ -12,13 +12,14 @@ Read this when changing PHP, Fat-Free Framework integration, plugins, or API con
 - API route declarations belong in `OpenMind\Api\Api`; controllers extend `Controller`.
 - API route declarations belong in `OpenMind\Api\Api`; controllers extend `Controller`.
 - Use `sendJson()` / `sendError()` rather than emitting ad hoc response formats. They set JSON, status, and `Cache-Control: no-store`.
 - Use `sendJson()` / `sendError()` rather than emitting ad hoc response formats. They set JSON, status, and `Cache-Control: no-store`.
 - Decode request objects through `decodeJsonRequest()` and return 400 for invalid JSON or required-field failures.
 - Decode request objects through `decodeJsonRequest()` and return 400 for invalid JSON or required-field failures.
-- Call `requireAuthentication()` for protected reads; call `requireRole()` plus `validateCsrfToken()` before protected mutations.
-- Return DTOs, never a mapper or credential fields directly. See [api-routes.md](api-routes.md) and [identity/spec.md](identity/spec.md).
+- Call `requireAuthentication()` for protected reads; call numeric `requireRole()` plus `validateCsrfToken()` before protected mutations. Role ID `1` is Administrator and role ID `2` is Inventory operator.
+- Return explicit DTOs, never a mapper or credential fields directly. Role DTOs contain `id`, `name`, and `description`; user DTOs contain role IDs; Inventory Monitor demo DTOs contain only safe item fields. See [api-routes.md](api-routes.md) and [role-and-inventory.md](role-and-inventory.md).
 
 
 ## Persistence and Errors
 ## Persistence and Errors
 - F3 globals (`$f3`, `$frameworkUtils`) are the established dependency access pattern; retain it where surrounding code does.
 - F3 globals (`$f3`, `$frameworkUtils`) are the established dependency access pattern; retain it where surrounding code does.
-- `User` and `Notifications` are `DB\SQL\Mapper` classes. Use parameterized mapper predicates for request values.
-- Wrap multi-step user mutation checks and writes in a transaction; use the existing row-locking pattern where the invariant requires serialization.
+- `User`, `Role`, `InventoryItem`, and `Notifications` are `DB\SQL\Mapper` classes. Use parameterized mapper predicates for request values.
+- Wrap multi-step user and role mutation checks and writes in a transaction; use row locks where final-admin or assignment-safe invariants require serialization.
+- Validate trimmed role names, SKUs, and item names at the controller boundary; enforce uniqueness and non-negative inventory values in the database as the final safeguard.
 - Unexpected controller failures are logged server-side and return a safe generic message.
 - Unexpected controller failures are logged server-side and return a safe generic message.
 - API errors are JSON; `ErrorHandlers` identifies `/api/v1` requests before setting the status.
 - API errors are JSON; `ErrorHandlers` identifies `/api/v1` requests before setting the status.
 
 

+ 8 - 4
specs/conventions-database.md

@@ -6,22 +6,26 @@ Read this when changing MySQL schema, F3 mappers, or data-integrity rules.
 `OpenMind\System\System` creates the F3 `DB\SQL` connection from `DSN`, `dbuser`, and `dbpass` F3 globals. It uses PDO exceptions and enables persistent connections on Linux and all CLI requests. Keep credentials in configuration, not source code.
 `OpenMind\System\System` creates the F3 `DB\SQL` connection from `DSN`, `dbuser`, and `dbpass` F3 globals. It uses PDO exceptions and enables persistent connections on Linux and all CLI requests. Keep credentials in configuration, not source code.
 
 
 ## Schema Source
 ## Schema Source
-`omlink.sql` is the tracked schema source. It defines four tables:
+`omlink.sql` is the tracked schema source. It defines six tables:
 
 
 | Table | Engine | Role |
 | Table | Engine | Role |
 |---|---|---|
 |---|---|---|
-| `users` | InnoDB | Accounts, username-prefixed password hashes, serialized roles, optional avatar. |
+| `users` | InnoDB | Accounts, username-prefixed password hashes, serialized numeric role IDs, optional avatar. |
+| `roles` | InnoDB | Persistent role catalog with stable IDs and editable labels. |
+| `inventory_items` | InnoDB | Inventory Monitor demo SKU, name, quantity, and reorder-level records. |
 | `notifications` | InnoDB | System notification records. |
 | `notifications` | InnoDB | System notification records. |
 | `label_bindings` | InnoDB | Legacy label/barcode data; no current PHP mapper uses it. |
 | `label_bindings` | InnoDB | Legacy label/barcode data; no current PHP mapper uses it. |
 | `queuesystem` | MEMORY | Legacy queued command records; no current PHP mapper uses it. |
 | `queuesystem` | MEMORY | Legacy queued command records; no current PHP mapper uses it. |
 
 
 ## Active Mapper Rules
 ## Active Mapper Rules
-- `User` maps `users`; its `roles` column is a PHP-serialized array and is loaded with `allowed_classes => false`.
+- `User` maps `users`; its `roles` column is a PHP-serialized array of unique integer role IDs and is loaded with `allowed_classes => false`.
+- `Role` maps `roles`; role IDs are stable authorization keys while names and descriptions are editable metadata.
+- `InventoryItem` maps the persistent Inventory Monitor demo table; SKU is unique and quantity/reorder level are non-negative integers.
 - `Notifications` maps `notifications`; `emptyAll()` truncates the entire table.
 - `Notifications` maps `notifications`; `emptyAll()` truncates the entire table.
 - `users.username` is unique (`uq_users_username`). Keep it as the final concurrency safeguard even when application prechecks exist.
 - `users.username` is unique (`uq_users_username`). Keep it as the final concurrency safeguard even when application prechecks exist.
 - `UserController` locks user rows with `SELECT id FROM users FOR UPDATE` inside transactions before checks that could remove the final administrator.
 - `UserController` locks user rows with `SELECT id FROM users FOR UPDATE` inside transactions before checks that could remove the final administrator.
 
 
 ## Change Constraints
 ## Change Constraints
-There is no migration framework or seed command. Any schema change must account for the dump, existing mapper field names, and the database-level uniqueness constraint. Do not document a migration workflow that does not exist.
+There is no general migration framework. The explicit role-catalog migration creates missing role/inventory tables and seed records, then converts legacy serialized role names to numeric IDs; it must be run manually and is not part of application boot. Any schema change must account for the dump, existing mapper field names, and database-level uniqueness constraints.
 
 
 See [identity/spec.md](identity/spec.md) and [integrations/mysql.md](integrations/mysql.md).
 See [identity/spec.md](identity/spec.md) and [integrations/mysql.md](integrations/mysql.md).

+ 3 - 1
specs/conventions-frontend.md

@@ -10,10 +10,12 @@ Read this when changing the React application, MUI components, routing, or front
 ## Authentication and Routing
 ## Authentication and Routing
 - `getSession()` initializes in-memory CSRF state before protected mutations.
 - `getSession()` initializes in-memory CSRF state before protected mutations.
 - The client always uses same-origin requests with `credentials: 'include'`; non-GET requests attach `X-CSRF-Token` only when a token is available.
 - The client always uses same-origin requests with `credentials: 'include'`; non-GET requests attach `X-CSRF-Token` only when a token is available.
-- Frontend redirects are usability controls only. The API remains the authorization authority.
+- Frontend redirects are usability controls only. The API remains the authorization authority; administration uses role ID `1` and the Inventory Monitor demo uses role ID `2`.
+- Role-management controls use `{id, name, description}` records while user assignments use numeric role IDs.
 - Use `APPLICATION_BASE_PATH` for internal application and static-asset paths so the `/phengine` deployment prefix is retained.
 - Use `APPLICATION_BASE_PATH` for internal application and static-asset paths so the `/phengine` deployment prefix is retained.
 
 
 ## UI
 ## UI
+- Role-catalog and Inventory Monitor demo pages use the shared API client and preserve the server-side authorization and CSRF contracts.
 - Prefer MUI components for structure, controls, feedback, and responsive behavior.
 - Prefer MUI components for structure, controls, feedback, and responsive behavior.
 - Tailwind utilities are used through `className` for layout and small visual adjustments.
 - Tailwind utilities are used through `className` for layout and small visual adjustments.
 - Theme defaults are in `theme.js`, including the custom `navigation` palette, typography families, and shared border radius; global CSS only imports Tailwind layers and sets root sizing/margins.
 - Theme defaults are in `theme.js`, including the custom `navigation` palette, typography families, and shared border radius; global CSS only imports Tailwind layers and sets root sizing/margins.

+ 4 - 5
specs/identity/spec.md

@@ -12,19 +12,18 @@ This subsystem authenticates database-backed users into PHP sessions and lets ad
 | `users` | `id` | auto-increment integer | Account identifier. |
 | `users` | `id` | auto-increment integer | Account identifier. |
 | `users` | `username` | `varchar(255)`, unique | Login identity and password-hash input. |
 | `users` | `username` | `varchar(255)`, unique | Login identity and password-hash input. |
 | `users` | `saltedpassword` | `varchar(255)` | Bcrypt hash of `username|password`. |
 | `users` | `saltedpassword` | `varchar(255)` | Bcrypt hash of `username|password`. |
-| `users` | `roles` | `varchar(4096)` | PHP-serialized role array; unserialized with classes disabled. |
+| `users` | `roles` | `varchar(4096)` | PHP-serialized array of persistent numeric permission IDs; unserialized with classes disabled. |
 | PHP session | `SESSION.user` | integer | Current authenticated user ID. |
 | PHP session | `SESSION.user` | integer | Current authenticated user ID. |
 | PHP session | `SESSION.csrf_token` | 64-character hex string | Random 32-byte token used for state-changing API requests. |
 | PHP session | `SESSION.csrf_token` | 64-character hex string | Random 32-byte token used for state-changing API requests. |
-| F3 global | `USERROLES` | array of strings | Runtime role registry populated by plugins. |
 
 
 ## Business Rules
 ## Business Rules
 - Authentication loads by username and verifies `username|password` against the stored bcrypt hash.
 - Authentication loads by username and verifies `username|password` against the stored bcrypt hash.
 - Login starts a session when required, regenerates its ID, then stores only the user ID. Session responses include a CSRF token for anonymous and authenticated callers.
 - Login starts a session when required, regenerates its ID, then stores only the user ID. Session responses include a CSRF token for anonymous and authenticated callers.
 - Server-side state-changing protected operations require a CSRF token using `hash_equals`; client route guards never replace this check.
 - Server-side state-changing protected operations require a CSRF token using `hash_equals`; client route guards never replace this check.
-- Roles assigned through user administration must be strings in the current runtime role registry and are deduplicated.
+- Permissions assigned through user administration must be existing persistent numeric permission IDs and are deduplicated.
 - Usernames are trimmed and must be non-empty. The unique database constraint is authoritative under concurrency.
 - Usernames are trimmed and must be non-empty. The unique database constraint is authoritative under concurrency.
 - If a username changes, a non-empty replacement password is required because the username is part of the password-hash input.
 - If a username changes, a non-empty replacement password is required because the username is part of the password-hash input.
-- An administrator cannot delete their own account. The final account with the `admin` role cannot lose that role or be deleted.
+- An administrator cannot delete their own account. The final account with permission ID `1` cannot lose that permission or be deleted.
 - Update/delete serialize final-admin checks with a transaction and `SELECT id FROM users FOR UPDATE`; failures roll back the transaction.
 - Update/delete serialize final-admin checks with a transaction and `SELECT id FROM users FOR UPDATE`; failures roll back the transaction.
 - Public DTOs return only `id`, `username`, and, for administrator management, roles. Password hashes and serialized persistence fields never leave the API.
 - Public DTOs return only `id`, `username`, and, for administrator management, roles. Password hashes and serialized persistence fields never leave the API.
 
 
@@ -35,7 +34,7 @@ This subsystem authenticates database-backed users into PHP sessions and lets ad
 | `User::PASSWORD_SEPARATOR` | `|` | `src/OpenMind/System/User.php` |
 | `User::PASSWORD_SEPARATOR` | `|` | `src/OpenMind/System/User.php` |
 | `Controller::CSRF_SESSION_KEY` | `SESSION.csrf_token` | `src/OpenMind/Api/Controller.php` |
 | `Controller::CSRF_SESSION_KEY` | `SESSION.csrf_token` | `src/OpenMind/Api/Controller.php` |
 | `Controller::CSRF_HEADER` | `HTTP_X_CSRF_TOKEN` | `src/OpenMind/Api/Controller.php` |
 | `Controller::CSRF_HEADER` | `HTTP_X_CSRF_TOKEN` | `src/OpenMind/Api/Controller.php` |
-| `UserController::ADMIN_ROLE` | `admin` | `src/OpenMind/Api/UserController.php` |
+| Administrator authorization permission ID | `1` | Authorization contract used by administrator-only operations. |
 | `UserController::USERNAME_CONSTRAINT` | `uq_users_username` | `src/OpenMind/Api/UserController.php` |
 | `UserController::USERNAME_CONSTRAINT` | `uq_users_username` | `src/OpenMind/Api/UserController.php` |
 
 
 ## Cross-links
 ## Cross-links

+ 11 - 8
specs/module-index.md

@@ -9,18 +9,21 @@ Tier 1 requires full context loading. Tier 2 is customized business logic. Tier
 | Bootstrap | `index.php` | 1 | Starts F3, loads encrypted runtime configuration, and boots plugins. | [file structure](file-structure.md) |
 | Bootstrap | `index.php` | 1 | Starts F3, loads encrypted runtime configuration, and boots plugins. | [file structure](file-structure.md) |
 | Plugin composition | `src/OpenMind/System/ApplicationComposition.php`, `PluginStrategy.php` | 1 | Discovers initializer classes by directory convention and maintains menu/route registration primitives. | [add plugin rule](rules/add-plugin.md) |
 | Plugin composition | `src/OpenMind/System/ApplicationComposition.php`, `PluginStrategy.php` | 1 | Discovers initializer classes by directory convention and maintains menu/route registration primitives. | [add plugin rule](rules/add-plugin.md) |
 | System services | `src/OpenMind/System/System.php`, `OMUtils.php`, `ErrorHandlers.php` | 1 | Database/cache initialization, config codec, framework error responses, and legacy utilities. | [integrations](integrations/index.md) |
 | System services | `src/OpenMind/System/System.php`, `OMUtils.php`, `ErrorHandlers.php` | 1 | Database/cache initialization, config codec, framework error responses, and legacy utilities. | [integrations](integrations/index.md) |
-| Identity and user management | `src/OpenMind/System/User.php`, `UserRoles.php`, `src/OpenMind/Api/AuthController.php`, `UserController.php`, `Controller.php` | 1 | Session login, CSRF, roles, account persistence, and user-mutation invariants. | [identity spec](identity/spec.md) |
-| API registry | `src/OpenMind/Api/Api.php` | 2 | Maps all versioned JSON routes to controllers. | [API routes](api-routes.md) |
-| Configuration administration | `src/OpenMind/Api/AdminController.php`, `cfg/`, `scripts/generate-config-bin.sh` | 1 | Reads, encodes, backs up, and replaces runtime configuration. | [configuration spec](configuration/spec.md) |
-| Navigation | `src/OpenMind/Api/NavigationController.php`, `src/OpenMind/Admin/Admin.php` | 2 | Builds the role-filtered and ordered application menu. | [API routes](api-routes.md) |
+| Identity and user management | `src/OpenMind/System/User.php`, `Role.php`, `src/OpenMind/Api/AuthController.php`, `UserController.php`, `Controller.php` | 1 | Session login, CSRF, numeric role assignments, account persistence, and user-mutation invariants. | [identity spec](identity/spec.md) |
+| Role catalog | `src/OpenMind/System/Role.php`, `src/OpenMind/Api/RoleController.php` | 1 | Persistent role metadata and assignment-safe administrator CRUD; IDs are authorization keys. | [role and inventory spec](role-and-inventory.md) |
+| Inventory demo persistence and API | `src/OpenMind/System/InventoryItem.php`, `src/OpenMind/Api/InventoryController.php` | 1 | Role-ID-2 persistent inventory demo CRUD with validation, safe DTOs, CSRF, and duplicate-SKU handling. | [role and inventory spec](role-and-inventory.md) |
+| API registry | `src/OpenMind/Api/Api.php` | 2 | Maps all versioned JSON routes, including role catalog and inventory endpoints, to controllers. | [API routes](api-routes.md) |
+| Configuration administration | `src/OpenMind/Api/AdminController.php`, `cfg/` | 1 | Reads, encodes, backs up, and replaces runtime configuration; local plaintext config is copied from the tracked template. | [configuration spec](configuration/spec.md) |
+| Navigation | `src/OpenMind/Api/NavigationController.php`, `src/OpenMind/Admin/Admin.php`, `src/Modules/Blank/Blank.php` | 2 | Builds the role-ID-filtered and ordered application menu, including Inventory Monitor. | [API routes](api-routes.md) |
 | Notifications | `src/OpenMind/System/Notifications.php`, `src/OpenMind/Api/NotificationController.php` | 2 | Persists, groups, polls, and deletes notification records. | [API routes](api-routes.md) |
 | Notifications | `src/OpenMind/System/Notifications.php`, `src/OpenMind/Api/NotificationController.php` | 2 | Persists, groups, polls, and deletes notification records. | [API routes](api-routes.md) |
-| Blank plugin | `src/Modules/Blank/Blank.php` | 4 | Example/demo plugin and permission registration. | [add plugin rule](rules/add-plugin.md) |
-| Database schema | `omlink.sql` | 1 | MySQL schema, constraints, and sample data source. | [database conventions](conventions-database.md) |
+| Inventory Monitor demo plugin | `src/Modules/Blank/Blank.php` | 4 | Retained demo plugin initializer that publishes the ID-2 Inventory Monitor navigation entry without volatile role registration. | [role and inventory spec](role-and-inventory.md) |
+| Database schema and migration | `omlink.sql`, `scripts/migrate-role-catalog.php` | 1 | MySQL role/inventory schema, constraints, seed records, and explicit legacy-role migration. | [database conventions](conventions-database.md) |
 | React boot and routes | `ui/app/src/main.jsx`, `App.jsx`, `routes.jsx` | 2 | React/MUI setup, session bootstrap, guarded routes, and page selection. | [frontend conventions](conventions-frontend.md) |
 | React boot and routes | `ui/app/src/main.jsx`, `App.jsx`, `routes.jsx` | 2 | React/MUI setup, session bootstrap, guarded routes, and page selection. | [frontend conventions](conventions-frontend.md) |
 | Frontend API client | `ui/app/src/api/client.js` | 1 | Same-origin URL resolution, cookies, JSON errors, and in-memory CSRF handling. | [browser API integration](integrations/browser-api.md) |
 | Frontend API client | `ui/app/src/api/client.js` | 1 | Same-origin URL resolution, cookies, JSON errors, and in-memory CSRF handling. | [browser API integration](integrations/browser-api.md) |
-| Admin React features | `ui/app/src/features/admin/` | 2 | Configuration editor and user-management UI. | [API routes](api-routes.md) |
+| Admin React features | `ui/app/src/features/admin/` | 2 | Configuration, user-management, and role-catalog UI. | [API routes](api-routes.md) |
+| Inventory demo React features | `ui/app/src/features/demo/` | 2 | Persistent Inventory Monitor demo UI for role ID `2`. | [role and inventory spec](role-and-inventory.md) |
 | Shell and notification UI | `ui/app/src/features/shell/`, `features/notifications/` | 2 | Responsive dark drawer, route-aware selected navigation, contextual header, and 30-second notification polling. | [frontend conventions](conventions-frontend.md) |
 | Shell and notification UI | `ui/app/src/features/shell/`, `features/notifications/` | 2 | Responsive dark drawer, route-aware selected navigation, contextual header, and 30-second notification polling. | [frontend conventions](conventions-frontend.md) |
-| Home, auth, and demo pages | `ui/app/src/features/auth/`, `features/home/`, `features/demo/` | 3 | Standard page-level views and login form. | [frontend conventions](conventions-frontend.md) |
+| Home and auth pages | `ui/app/src/features/auth/`, `features/home/` | 3 | Standard page-level views and login form. | [frontend conventions](conventions-frontend.md) |
 | Visual configuration | `ui/app/src/theme.js`, `styles.css` | 2 | Customized MUI palette, navigation tokens, typography, shape, and Tailwind/global style entry points. | [frontend conventions](conventions-frontend.md) |
 | Visual configuration | `ui/app/src/theme.js`, `styles.css` | 2 | Customized MUI palette, navigation tokens, typography, shape, and Tailwind/global style entry points. | [frontend conventions](conventions-frontend.md) |
 | Frontend build output | `vite.config.js`, `assets/app/` | 4 | Vite configuration (including filtered `"use client"` warnings) and generated application assets. | [commands](commands.md) |
 | Frontend build output | `vite.config.js`, `assets/app/` | 4 | Vite configuration (including filtered `"use client"` warnings) and generated application assets. | [commands](commands.md) |
 | Static assets | `assets/images/` | 5 | Images referenced by the UI and legacy menus. | [file structure](file-structure.md) |
 | Static assets | `assets/images/` | 5 | Images referenced by the UI and legacy menus. | [file structure](file-structure.md) |

+ 53 - 0
specs/role-and-inventory.md

@@ -0,0 +1,53 @@
+Read this when changing persistent roles, user role assignments, inventory items, or their API and navigation contracts.
+
+# Role and Inventory
+
+## Purpose
+
+OM-Link uses a persistent role catalog for authorization and provides a persistent Inventory Monitor demo. Role IDs are stable authorization keys; display labels are editable metadata and must not be used as permission checks.
+
+## Role Catalog
+
+The `roles` table contains a numeric `id`, a unique non-empty `name`, and an editable `description`. The initial catalog reserves:
+
+| ID | Initial name | Purpose |
+|---:|---|---|
+| `1` | Administrator | User, configuration, and role administration. |
+| `2` | Inventory operator | Inventory Monitor access and item CRUD. |
+
+Users store a deduplicated PHP-serialized array of integer role IDs. Session and user-management DTOs expose those IDs; role-management responses expose `{id, name, description}`. Password hashes and serialized persistence fields never leave the API.
+
+Only role ID `1` may manage the role catalog or users. Role deletion is rejected for role ID `1` and for any role assigned to a user; assignment checks and user mutations use the existing transaction and row-locking safeguards.
+
+## Inventory Demo Items
+
+The `inventory_items` table stores:
+
+| Field | Contract |
+|---|---|
+| `id` | Immutable numeric identifier. |
+| `sku` | Trimmed, non-empty, unique stock-keeping unit. |
+| `name` | Trimmed, non-empty item name. |
+| `quantity` | Non-negative integer quantity. |
+| `reorder_level` | Non-negative integer threshold. |
+
+Inventory endpoints return safe item DTOs only. Role ID `2` is required for reads and mutations; role ID `1` does not implicitly grant inventory access. Mutations require the session CSRF token. Duplicate SKUs and invalid numeric values are rejected with safe JSON errors.
+
+## API and Navigation
+
+The route registry owns these endpoints:
+
+- `/api/v1/admin/roles` and `/api/v1/admin/roles/@id` for role catalog CRUD.
+- `/api/v1/inventory/items` and `/api/v1/inventory/items/@id` for inventory CRUD.
+
+The administration menu uses role ID `1`. The retained Blank plugin publishes Inventory Monitor at `/inventory` using role ID `2`; it does not register volatile role names. Frontend guards improve navigation only and never replace server-side authorization.
+
+## Migration
+
+`scripts/migrate-role-catalog.php` is an explicit CLI migration. It creates missing role and inventory tables, inserts missing seed records without overwriting edited labels, and transactionally converts legacy serialized role names to numeric IDs. It maps `admin` to ID `1`, `demo` to ID `2`, preserves existing integer IDs, and creates catalog records for other legacy names before replacing assignments.
+
+The migration is idempotent, is not run during application boot, and must be reviewed before being run against a database. Malformed serialized role data must fail clearly rather than being discarded.
+
+## Scope Boundaries
+
+This demo does not represent a production inventory subsystem and does not provide role hierarchies, permission matrices, stock-movement history, audit trails, or multi-warehouse inventory. Role IDs are immutable even when their names and descriptions change.

+ 1 - 1
specs/rules/add-api-endpoint.md

@@ -9,7 +9,7 @@ Read this when adding or changing a versioned JSON API endpoint and its React ca
    ```
    ```
 3. **Implement the controller** as a `Controller` subclass. Use `requireAuthentication()` / `requireRole()` first. For mutations, reject invalid CSRF before persistence.
 3. **Implement the controller** as a `Controller` subclass. Use `requireAuthentication()` / `requireRole()` first. For mutations, reject invalid CSRF before persistence.
    ```php
    ```php
-   if (!$this->requireRole('admin')) return;
+   if (!$this->requireRole(1)) return;
    if (!$this->validateCsrfToken()) {
    if (!$this->validateCsrfToken()) {
        $this->sendError('Invalid CSRF token', 403);
        $this->sendError('Invalid CSRF token', 403);
        return;
        return;

+ 4 - 7
specs/rules/add-plugin.md

@@ -16,16 +16,13 @@ Read this when adding or changing a discoverable PHP plugin, permission, or menu
    }
    }
    ```
    ```
 2. **Keep plugin construction safe at boot.** `ApplicationComposition` instantiates every valid plugin after `System`; configuration, database, and framework globals are then available.
 2. **Keep plugin construction safe at boot.** `ApplicationComposition` instantiates every valid plugin after `System`; configuration, database, and framework globals are then available.
-3. **Register permissions before relying on them.** Add role names to `UserRoles` in the plugin constructor. User administration accepts only roles in this registry.
+3. **Use persistent numeric permission IDs.** Use the existing catalog ID defined by the subsystem, such as permission ID `2` for Inventory Monitor. Permission ID `1` is reserved for administrator authorization.
+4. **Register menu entries through `AddMenuItem()`.** Use a numeric `permissionId` for protected entries. The method publishes entries only when the restored user has the required permission.
    ```php
    ```php
-   (new UserRoles())->add('example');
-   ```
-4. **Register menu entries through `AddMenuItem()`.** Use `permissionName` for protected entries. The method publishes entries only when the restored user has the required role.
-   ```php
-   $this->AddMenuItem('Example', ['url' => 'example', 'permissionName' => 'example']);
+   $this->AddMenuItem('Inventory Monitor', ['url' => 'inventory', 'permissionId' => 2]);
    ```
    ```
 5. **For API behavior, add routes in `OpenMind\Api\Api`, not in the new plugin.** Follow [add-api-endpoint.md](add-api-endpoint.md).
 5. **For API behavior, add routes in `OpenMind\Api\Api`, not in the new plugin.** Follow [add-api-endpoint.md](add-api-endpoint.md).
-6. **For an interactive page, add a matching React route and ensure the navigation URL resolves under the application base path.** Frontend route guards are not a substitute for API authorization.
+6. **For an interactive page, add a matching React route and ensure the navigation URL resolves under the application base path.** The implemented Inventory Monitor page uses `/inventory`; frontend route guards are not a substitute for API authorization.
 7. **Update documentation.** Add the subsystem to [module index](../module-index.md); create a domain spec only if it introduces a non-obvious data model and business contracts.
 7. **Update documentation.** Add the subsystem to [module index](../module-index.md); create a domain spec only if it introduces a non-obvious data model and business contracts.
 
 
 See [backend conventions](../conventions-backend.md) and [file structure](../file-structure.md).
 See [backend conventions](../conventions-backend.md) and [file structure](../file-structure.md).

+ 7 - 10
src/Modules/Blank/Blank.php

@@ -9,25 +9,22 @@
 namespace Modules\Blank;
 namespace Modules\Blank;
 
 
 use OpenMind\System\PluginStrategy;
 use OpenMind\System\PluginStrategy;
-use OpenMind\System\UserRoles;
 
 
 class Blank extends PluginStrategy
 class Blank extends PluginStrategy
 {
 {
+	private const INVENTORY_ROLE_ID = 2;
+
 	/**
 	/**
-	 * Registers the demo menu entry and permission role.
+	 * Registers the Inventory Monitor menu entry protected by its role ID.
 	 *
 	 *
 	 * @return void
 	 * @return void
+	 * @side-effects Adds the role-filtered entry to the global application menu.
 	 */
 	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
-		$this->AddMenuItem('Demo', [
-			'url' => 'demo',
-			'imgClass' => 'shadow p-3 rounded bg-white',
-			'image' => 'assets/images/demo.png',
-			'permissionName' => 'demo'
+		$this->AddMenuItem('Inventory Monitor', [
+			'url' => '/inventory',
+			'permissionId' => self::INVENTORY_ROLE_ID,
 		]);
 		]);
-
-		$roles = new UserRoles();
-		$roles->add('demo');
 	}
 	}
 }
 }

+ 9 - 9
src/OpenMind/Admin/Admin.php

@@ -8,26 +8,26 @@
 namespace OpenMind\Admin;
 namespace OpenMind\Admin;
 
 
 use OpenMind\System\PluginStrategy;
 use OpenMind\System\PluginStrategy;
-use OpenMind\System\UserRoles;
 
 
 class Admin extends PluginStrategy
 class Admin extends PluginStrategy
 {
 {
+	private const ADMIN_ROLE_ID = 1;
+
 	/**
 	/**
-	 * Registers the administration menu entries and permission role.
+	 * Registers administration menu entries protected by the administrator role ID.
 	 *
 	 *
 	 * @return void
 	 * @return void
+	 * @side-effects Adds role-filtered entries to the global application menu.
 	 */
 	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		// Admin Load/Save
 		// Admin Load/Save
-		$this->AddMenuItem('Admin', ['url' => 'admin', 'permissionName' => 'admin']);
+		$this->AddMenuItem('Admin', ['url' => 'admin', 'permissionId' => self::ADMIN_ROLE_ID]);
 
 
 		// System Settings
 		// System Settings
-		$this->AddMenuItem('System Settings', ['mainMenu' => 'adminMainMenu', 'mdiClass' => 'settings', 'permissionName' => 'admin']);
-		$this->AddMenuItem('OM-Link Settings', ['mainMenu' => 'System Settings', 'url' => 'admin/omlsettings', 'permissionName' => 'admin']);
-		$this->AddMenuItem('Users', ['mainMenu' => 'System Settings', 'url' => 'admin/users', 'permissionName' => 'admin']);
-
-		$roles = new UserRoles();
-		$roles->add('admin');
+		$this->AddMenuItem('System Settings', ['mainMenu' => 'adminMainMenu', 'mdiClass' => 'settings', 'permissionId' => self::ADMIN_ROLE_ID]);
+		$this->AddMenuItem('OM-Link Settings', ['mainMenu' => 'System Settings', 'url' => 'admin/omlsettings', 'permissionId' => self::ADMIN_ROLE_ID]);
+		$this->AddMenuItem('Users', ['mainMenu' => 'System Settings', 'url' => 'admin/users', 'permissionId' => self::ADMIN_ROLE_ID]);
+		$this->AddMenuItem('Roles', ['mainMenu' => 'System Settings', 'url' => 'admin/roles', 'permissionId' => self::ADMIN_ROLE_ID]);
 	}
 	}
 }
 }

+ 3 - 2
src/OpenMind/Api/AdminController.php

@@ -10,6 +10,7 @@ use Throwable;
 
 
 class AdminController extends Controller
 class AdminController extends Controller
 {
 {
+	private const ADMINISTRATOR_ID = 1;
 	private const CONFIGURATION_BACKUP_FILE = 'cfg/config.bin.old';
 	private const CONFIGURATION_BACKUP_FILE = 'cfg/config.bin.old';
 	private const CONFIGURATION_FILE = 'cfg/config.bin';
 	private const CONFIGURATION_FILE = 'cfg/config.bin';
 
 
@@ -22,7 +23,7 @@ class AdminController extends Controller
 	 */
 	 */
 	public function load(Base $f3): void
 	public function load(Base $f3): void
 	{
 	{
-		if (!$this->requireRole('admin')) {
+		if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
 			return;
 			return;
 		}
 		}
 
 
@@ -45,7 +46,7 @@ class AdminController extends Controller
 	 */
 	 */
 	public function save(Base $f3): void
 	public function save(Base $f3): void
 	{
 	{
-		if (!$this->requireRole('admin')) {
+		if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
 			return;
 			return;
 		}
 		}
 
 

+ 8 - 0
src/OpenMind/Api/Api.php

@@ -26,6 +26,14 @@ class Api extends PluginStrategy
 		$this->AddRoute('POST /api/v1/admin/users', __NAMESPACE__ . '\\UserController->create');
 		$this->AddRoute('POST /api/v1/admin/users', __NAMESPACE__ . '\\UserController->create');
 		$this->AddRoute('PUT /api/v1/admin/users/@id', __NAMESPACE__ . '\\UserController->update');
 		$this->AddRoute('PUT /api/v1/admin/users/@id', __NAMESPACE__ . '\\UserController->update');
 		$this->AddRoute('DELETE /api/v1/admin/users/@id', __NAMESPACE__ . '\\UserController->delete');
 		$this->AddRoute('DELETE /api/v1/admin/users/@id', __NAMESPACE__ . '\\UserController->delete');
+		$this->AddRoute('GET /api/v1/admin/roles', __NAMESPACE__ . '\\RoleController->list');
+		$this->AddRoute('POST /api/v1/admin/roles', __NAMESPACE__ . '\\RoleController->create');
+		$this->AddRoute('PUT /api/v1/admin/roles/@id', __NAMESPACE__ . '\\RoleController->update');
+		$this->AddRoute('DELETE /api/v1/admin/roles/@id', __NAMESPACE__ . '\\RoleController->delete');
+		$this->AddRoute('GET /api/v1/inventory/items', __NAMESPACE__ . '\\InventoryController->index');
+		$this->AddRoute('POST /api/v1/inventory/items', __NAMESPACE__ . '\\InventoryController->create');
+		$this->AddRoute('PUT /api/v1/inventory/items/@id', __NAMESPACE__ . '\\InventoryController->update');
+		$this->AddRoute('DELETE /api/v1/inventory/items/@id', __NAMESPACE__ . '\\InventoryController->delete');
 		$this->AddRoute('GET /api/v1/notifications', __NAMESPACE__ . '\\NotificationController->index');
 		$this->AddRoute('GET /api/v1/notifications', __NAMESPACE__ . '\\NotificationController->index');
 		$this->AddRoute('DELETE /api/v1/notifications', __NAMESPACE__ . '\\NotificationController->delete');
 		$this->AddRoute('DELETE /api/v1/notifications', __NAMESPACE__ . '\\NotificationController->delete');
 		$this->AddRoute('DELETE /api/v1/notifications/@code', __NAMESPACE__ . '\\NotificationController->delete');
 		$this->AddRoute('DELETE /api/v1/notifications/@code', __NAMESPACE__ . '\\NotificationController->delete');

+ 1 - 1
src/OpenMind/Api/AuthController.php

@@ -100,7 +100,7 @@ class AuthController extends Controller
 	 * Builds the common authenticated and anonymous session response.
 	 * Builds the common authenticated and anonymous session response.
 	 *
 	 *
 	 * @param User|null $user Current authenticated user.
 	 * @param User|null $user Current authenticated user.
-	 * @return array<string, mixed> Session state, safe user DTO, roles, and CSRF token.
+	 * @return array{authenticated: bool, user: array<string, int|string>|null, roles: array<int, int>, csrfToken: string} Session state with safe DTOs and numeric role IDs.
 	 * @side-effects Creates a CSRF token when the session has none.
 	 * @side-effects Creates a CSRF token when the session has none.
 	 */
 	 */
 	private function sessionState(?User $user): array
 	private function sessionState(?User $user): array

+ 7 - 7
src/OpenMind/Api/Controller.php

@@ -76,7 +76,7 @@ class Controller
 			return null;
 			return null;
 		}
 		}
 
 
-		return is_array($request) ? $request : null;
+		return is_array($request) && str_starts_with(ltrim($body), '{') ? $request : null;
 	}
 	}
 
 
 	/**
 	/**
@@ -159,14 +159,14 @@ class Controller
 	/**
 	/**
 	 * Checks whether the current user has a role.
 	 * Checks whether the current user has a role.
 	 *
 	 *
-	 * @param string $role Role name to check.
+	 * @param int $roleId Role identifier to check.
 	 * @return bool True when the authenticated user has the role.
 	 * @return bool True when the authenticated user has the role.
 	 * @side-effects Reads the current user from the PHP session.
 	 * @side-effects Reads the current user from the PHP session.
 	 */
 	 */
-	protected function hasRole(string $role): bool
+	protected function hasRole(int $roleId): bool
 	{
 	{
 		$user = $this->currentUser();
 		$user = $this->currentUser();
-		return $user !== null && $user->hasPermission($role);
+		return $user !== null && $user->hasPermission($roleId);
 	}
 	}
 
 
 	/**
 	/**
@@ -188,17 +188,17 @@ class Controller
 	/**
 	/**
 	 * Requires an authenticated user with a specific role.
 	 * Requires an authenticated user with a specific role.
 	 *
 	 *
-	 * @param string $role Required role name.
+	 * @param int $roleId Required role identifier.
 	 * @return bool True when the user has the role.
 	 * @return bool True when the user has the role.
 	 * @side-effects May write a JSON 401 or 403 response.
 	 * @side-effects May write a JSON 401 or 403 response.
 	 */
 	 */
-	protected function requireRole(string $role): bool
+	protected function requireRole(int $roleId): bool
 	{
 	{
 		if (!$this->requireAuthentication()) {
 		if (!$this->requireAuthentication()) {
 			return false;
 			return false;
 		}
 		}
 
 
-		if (!$this->hasRole($role)) {
+		if (!$this->hasRole($roleId)) {
 			$this->sendError('Forbidden', 403);
 			$this->sendError('Forbidden', 403);
 			return false;
 			return false;
 		}
 		}

+ 359 - 0
src/OpenMind/Api/InventoryController.php

@@ -0,0 +1,359 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenMind\Api;
+
+use OpenMind\System\InventoryItem;
+use PDOException;
+use Throwable;
+
+/**
+ * Provides role-protected CRUD operations for inventory items.
+ */
+final class InventoryController extends Controller
+{
+    private const HTTP_OK = 200;
+    private const HTTP_CREATED = 201;
+    private const HTTP_BAD_REQUEST = 400;
+    private const HTTP_FORBIDDEN = 403;
+    private const HTTP_INTERNAL_SERVER_ERROR = 500;
+    private const HTTP_NOT_FOUND = 404;
+    private const HTTP_CONFLICT = 409;
+    private const HTTP_UNPROCESSABLE_ENTITY = 422;
+
+    /**
+     * List all inventory items as safe DTOs.
+     */
+    public function index(): void
+    {
+        if (!$this->requireRole(2)) {
+            return;
+        }
+
+        try {
+            $items = array_map(
+                fn (InventoryItem $item): array => $this->toDto($item),
+                (new InventoryItem())->find()
+            );
+        } catch (Throwable $exception) {
+            $this->persistenceError($exception, 'list');
+            return;
+        }
+
+        $this->respond(['items' => $items], self::HTTP_OK);
+    }
+
+    /**
+     * Create an inventory item from the request JSON payload.
+     */
+    public function create(): void
+    {
+        if (!$this->requireRole(2)) {
+            return;
+        }
+
+        if (!$this->validateCsrfToken()) {
+            $this->sendError('Invalid CSRF token', self::HTTP_FORBIDDEN);
+            return;
+        }
+
+        $payload = $this->payload();
+        if ($payload === null) {
+            return;
+        }
+
+        $values = $this->validatedValues($payload, false);
+        if ($values === null) {
+            return;
+        }
+
+        $item = new InventoryItem();
+        $this->assign($item, $values);
+
+        if (!$this->save($item)) {
+            return;
+        }
+
+        $this->respond($this->toDto($item), self::HTTP_CREATED);
+    }
+
+    /**
+     * Update an inventory item identified by the route parameter.
+     */
+    public function update(): void
+    {
+        if (!$this->requireRole(2)) {
+            return;
+        }
+
+        if (!$this->validateCsrfToken()) {
+            $this->sendError('Invalid CSRF token', self::HTTP_FORBIDDEN);
+            return;
+        }
+
+        $id = $this->routeId();
+        if ($id === null) {
+            return;
+        }
+
+        $payload = $this->payload();
+        if ($payload === null) {
+            return;
+        }
+
+        $values = $this->validatedValues($payload, true);
+        if ($values === null) {
+            return;
+        }
+
+        $item = new InventoryItem();
+        try {
+            $item->load($id);
+        } catch (Throwable $exception) {
+            $this->persistenceError($exception, 'load');
+            return;
+        }
+
+        if ($item->dry()) {
+            $this->respond(['error' => 'Inventory item not found.'], self::HTTP_NOT_FOUND);
+            return;
+        }
+
+        $this->assign($item, $values);
+        if (!$this->save($item)) {
+            return;
+        }
+
+        $this->respond($this->toDto($item), self::HTTP_OK);
+    }
+
+    /**
+     * Delete an inventory item identified by the route parameter.
+     */
+    public function delete(): void
+    {
+        if (!$this->requireRole(2)) {
+            return;
+        }
+
+        if (!$this->validateCsrfToken()) {
+            $this->sendError('Invalid CSRF token', self::HTTP_FORBIDDEN);
+            return;
+        }
+
+        $id = $this->routeId();
+        if ($id === null) {
+            return;
+        }
+
+        $item = new InventoryItem();
+        try {
+            $item->load($id);
+        } catch (Throwable $exception) {
+            $this->persistenceError($exception, 'load');
+            return;
+        }
+
+        if ($item->dry()) {
+            $this->respond(['error' => 'Inventory item not found.'], self::HTTP_NOT_FOUND);
+            return;
+        }
+
+        try {
+            $item->erase();
+        } catch (Throwable $exception) {
+            $this->persistenceError($exception, 'delete');
+            return;
+        }
+
+        $this->sendJson(['deleted' => true], self::HTTP_OK);
+    }
+
+    /**
+     * Sends an inventory API response with the standard JSON and cache headers.
+     *
+     * @param array<string, mixed> $payload Response payload.
+     * @param int $status HTTP status code.
+     * @return void
+     * @side-effects Writes the HTTP status and JSON response.
+     */
+    private function respond(array $payload, int $status): void
+    {
+        $this->sendJson($payload, $status);
+    }
+
+    /**
+     * Read and decode the request JSON object.
+     *
+     * @return array<string, mixed>|null
+     */
+    private function payload(): ?array
+    {
+        $body = \Base::instance()->get('BODY');
+        if (!is_string($body) || trim($body) === '') {
+            $this->respond(['error' => 'A JSON object is required.'], self::HTTP_BAD_REQUEST);
+            return null;
+        }
+
+        try {
+            $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+        } catch (\JsonException) {
+            $this->respond(['error' => 'Malformed JSON payload.'], self::HTTP_BAD_REQUEST);
+            return null;
+        }
+
+        if (!is_array($payload) || array_is_list($payload)) {
+            $this->respond(['error' => 'A JSON object is required.'], self::HTTP_BAD_REQUEST);
+            return null;
+        }
+
+        return $payload;
+    }
+
+    /**
+     * Validate and normalize the fields accepted by the inventory API.
+     *
+     * @param array<string, mixed> $payload
+     * @return array<string, mixed>|null
+     */
+    private function validatedValues(array $payload, bool $partial): ?array
+    {
+        $allowed = ['sku', 'name', 'quantity', 'reorder_level'];
+        if (array_diff(array_keys($payload), $allowed) !== []) {
+            $this->respond(['error' => 'Payload contains unsupported fields.'], self::HTTP_UNPROCESSABLE_ENTITY);
+            return null;
+        }
+
+        if (!$partial && array_diff($allowed, array_keys($payload)) !== []) {
+            $this->respond(['error' => 'SKU, name, quantity, and reorder level are required.'], self::HTTP_UNPROCESSABLE_ENTITY);
+            return null;
+        }
+
+        if ($partial && $payload === []) {
+            $this->respond(['error' => 'At least one item field is required.'], self::HTTP_UNPROCESSABLE_ENTITY);
+            return null;
+        }
+
+        $values = [];
+        foreach (['sku', 'name'] as $field) {
+            if (array_key_exists($field, $payload)) {
+                if (!is_string($payload[$field]) || trim($payload[$field]) === '') {
+                    $this->respond(['error' => sprintf('%s must be a non-empty string.', $field)], self::HTTP_UNPROCESSABLE_ENTITY);
+                    return null;
+                }
+                $values[$field] = trim($payload[$field]);
+            }
+        }
+
+        foreach (['quantity', 'reorder_level'] as $field) {
+            if (array_key_exists($field, $payload)) {
+                if (!is_int($payload[$field]) || $payload[$field] < 0) {
+                    $this->respond(['error' => sprintf('%s must be a non-negative integer.', $field)], self::HTTP_UNPROCESSABLE_ENTITY);
+                    return null;
+                }
+                $values[$field] = $payload[$field];
+            }
+        }
+
+        return $values;
+    }
+
+    /**
+     * Assign only validated API fields to a mapper.
+     *
+     * @param array<string, mixed> $values
+     */
+    private function assign(InventoryItem $item, array $values): void
+    {
+        foreach ($values as $field => $value) {
+            $item->{$field} = $value;
+        }
+    }
+
+    /**
+     * Save an item and turn duplicate-SKU database errors into a conflict response.
+     */
+    private function save(InventoryItem $item): bool
+    {
+        try {
+            $item->save();
+        } catch (PDOException $exception) {
+            if ($this->isDuplicateSku($exception)) {
+                $this->respond([
+                    'error' => 'SKU already exists.',
+                    'code' => 'duplicate_sku',
+                ], self::HTTP_CONFLICT);
+                return false;
+            }
+
+            $this->persistenceError($exception, 'save');
+            return false;
+        } catch (Throwable $exception) {
+            $this->persistenceError($exception, 'save');
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Contain an unexpected persistence failure without exposing implementation details.
+     *
+     * @param Throwable $exception Persistence failure to record.
+     * @param string $operation Inventory persistence operation that failed.
+     * @return void
+     * @side-effects Logs the failure and writes a generic JSON error response.
+     */
+    private function persistenceError(Throwable $exception, string $operation): void
+    {
+        error_log(sprintf('Inventory %s persistence failed: %s', $operation, $exception->getMessage()));
+        $this->sendError('Unable to complete the inventory operation.', self::HTTP_INTERNAL_SERVER_ERROR);
+    }
+
+    /**
+     * Determine whether a database exception represents a duplicate SKU.
+     */
+    private function isDuplicateSku(PDOException $exception): bool
+    {
+        return (string) $exception->getCode() === '23000'
+            || str_contains(strtolower($exception->getMessage()), 'duplicate');
+    }
+
+    /**
+     * Read and validate the numeric inventory-item route parameter.
+     */
+    private function routeId(): ?int
+    {
+        $id = \Base::instance()->get('PARAMS.id');
+        if (!is_string($id) && !is_int($id)) {
+            $this->respond(['error' => 'A valid item ID is required.'], self::HTTP_BAD_REQUEST);
+            return null;
+        }
+
+        $id = (string) $id;
+        if (!ctype_digit($id) || (int) $id < 1) {
+            $this->respond(['error' => 'A valid item ID is required.'], self::HTTP_BAD_REQUEST);
+            return null;
+        }
+
+        return (int) $id;
+    }
+
+    /**
+     * Convert a mapper into the public inventory DTO.
+     *
+     * @return array{id: int, sku: string, name: string, quantity: int, reorder_level: int}
+     */
+    private function toDto(InventoryItem $item): array
+    {
+        return [
+            'id' => (int) $item->id,
+            'sku' => (string) $item->sku,
+            'name' => (string) $item->name,
+            'quantity' => (int) $item->quantity,
+            'reorder_level' => (int) $item->reorder_level,
+        ];
+    }
+
+}

+ 478 - 0
src/OpenMind/Api/RoleController.php

@@ -0,0 +1,478 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenMind\Api;
+
+use Base;
+use DB\SQL;
+use PDOException;
+
+final class RoleController extends Controller
+{
+    private const ADMINISTRATOR_ID = 1;
+    private const ROLES_TABLE = 'roles';
+    private const USERS_TABLE = 'users';
+    private const STATUS_OK = 200;
+    private const STATUS_CREATED = 201;
+    private const STATUS_BAD_REQUEST = 400;
+    private const STATUS_INTERNAL_SERVER_ERROR = 500;
+    private const STATUS_NOT_FOUND = 404;
+    private const STATUS_CONFLICT = 409;
+
+    /**
+     * Lists role metadata for the administrator.
+     *
+     * @return void Writes a JSON response containing safe role DTOs.
+     * @side-effects Reads the roles table and the current session.
+     */
+    public function list(): void
+    {
+        if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
+            return;
+        }
+
+        $this->respond(function (): array {
+            $database = $this->database();
+            $roles = $database->exec(
+                'SELECT id, name, description FROM ' . self::ROLES_TABLE . ' ORDER BY name, id'
+            );
+
+            return ['roles' => array_map($this->roleDto(...), $roles ?: [])];
+        });
+    }
+
+    /**
+     * Creates a role from the JSON request body.
+     *
+     * @return void Writes a JSON response containing only the created role DTO.
+     * @side-effects Validates CSRF, inserts a role, and reads the inserted row.
+     */
+    public function create(): void
+    {
+        if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
+            return;
+        }
+
+        if (!$this->validateCsrfToken()) {
+            $this->sendError('Invalid CSRF token', 403);
+            return;
+        }
+
+        $this->respond(function (): array {
+            $input = $this->decodeJsonRequest();
+            if ($input === null) {
+                throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_JSON', 'The request body must be valid JSON.');
+            }
+
+            [$name, $description] = $this->roleAttributes($input);
+            $database = $this->database();
+
+            try {
+                $this->ensureUniqueName($database, $name);
+                $database->exec(
+                    'INSERT INTO ' . self::ROLES_TABLE . ' (name, description) VALUES (?, ?)',
+                    [$name, $description]
+                );
+            } catch (PDOException $exception) {
+                $this->throwIfDuplicateName($exception);
+                throw $exception;
+            }
+
+            $roleId = (int) $database->lastInsertId();
+            $role = $this->findRole($database, $roleId);
+            if ($role === null) {
+                throw new RoleApiException(self::STATUS_INTERNAL_SERVER_ERROR, 'ROLE_CREATE_FAILED', 'The role could not be created.');
+            }
+
+            return ['role' => $this->roleDto($role)];
+        }, self::STATUS_CREATED);
+    }
+
+    /**
+     * Updates a role from the JSON request body.
+     *
+     * @param Base $f3 F3 application instance.
+     * @param array<string, mixed> $args Route arguments containing the role ID.
+     * @return void Writes a JSON response containing only the updated role DTO.
+     * @side-effects Validates CSRF, updates a role, and reads the updated row.
+     */
+    public function update(Base $f3, array $args = []): void
+    {
+        if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
+            return;
+        }
+
+        if (!$this->validateCsrfToken()) {
+            $this->sendError('Invalid CSRF token', 403);
+            return;
+        }
+
+        $this->respond(function () use ($args): array {
+            $roleId = $this->roleId($args);
+            $input = $this->decodeJsonRequest();
+            if ($roleId === null || $input === null) {
+                throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_REQUEST', 'A valid role ID and JSON request are required.');
+            }
+
+            [$name, $description] = $this->roleAttributes($input);
+            $database = $this->database();
+            $database->begin();
+            try {
+                $this->lockRolesForMutation($database);
+                $this->lockUsersForMutation($database);
+
+                if ($this->findRole($database, $roleId) === null) {
+                    throw new RoleApiException(self::STATUS_NOT_FOUND, 'ROLE_NOT_FOUND', 'Role not found.');
+                }
+
+                try {
+                    $this->ensureUniqueName($database, $name, $roleId);
+                    $database->exec(
+                        'UPDATE ' . self::ROLES_TABLE . ' SET name = ?, description = ? WHERE id = ?',
+                        [$name, $description, $roleId]
+                    );
+                } catch (PDOException $exception) {
+                    $this->throwIfDuplicateName($exception);
+                    throw $exception;
+                }
+
+                $role = $this->findRole($database, $roleId);
+                if ($role === null) {
+                    throw new RoleApiException(self::STATUS_INTERNAL_SERVER_ERROR, 'ROLE_UPDATE_FAILED', 'The role could not be updated.');
+                }
+
+                $database->commit();
+            } catch (RoleApiException $exception) {
+                $database->rollback();
+                throw $exception;
+            } catch (\Throwable $exception) {
+                $database->rollback();
+                throw $exception;
+            }
+
+            return ['role' => $this->roleDto($role)];
+        });
+    }
+
+    /**
+     * Deletes an unassigned role.
+     *
+     * @param Base $f3 F3 application instance.
+     * @param array<string, mixed> $args Route arguments containing the role ID.
+     * @return void Writes a JSON response containing the deleted role ID.
+     * @side-effects Validates CSRF, locks the role and users, and deletes the role.
+     */
+    public function delete(Base $f3, array $args = []): void
+    {
+        if (!$this->requireRole(self::ADMINISTRATOR_ID)) {
+            return;
+        }
+
+        if (!$this->validateCsrfToken()) {
+            $this->sendError('Invalid CSRF token', 403);
+            return;
+        }
+
+        $this->respond(function () use ($args): array {
+            $roleId = $this->roleId($args);
+            if ($roleId === null) {
+                throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_ROLE_ID', 'A valid role ID is required.');
+            }
+
+            if ($roleId === self::ADMINISTRATOR_ID) {
+                throw $this->deletionConflict();
+            }
+
+            $database = $this->database();
+            $database->begin();
+            try {
+                $this->lockRolesForMutation($database);
+                $this->lockUsersForMutation($database);
+
+                $role = $database->exec(
+                    'SELECT id FROM ' . self::ROLES_TABLE . ' WHERE id = ?',
+                    [$roleId]
+                );
+                if (!$role) {
+                    throw new RoleApiException(self::STATUS_NOT_FOUND, 'ROLE_NOT_FOUND', 'Role not found.');
+                }
+
+                $assignedUsers = $database->exec(
+                    'SELECT id, roles FROM ' . self::USERS_TABLE . ' WHERE roles IS NOT NULL'
+                );
+                foreach ($assignedUsers ?: [] as $user) {
+                    if ($this->hasRoleAssignment($user['roles'] ?? null, $roleId)) {
+                        throw $this->deletionConflict();
+                    }
+                }
+
+                $database->exec('DELETE FROM ' . self::ROLES_TABLE . ' WHERE id = ?', [$roleId]);
+                $database->commit();
+            } catch (RoleApiException $exception) {
+                $database->rollback();
+                throw $exception;
+            } catch (\Throwable $exception) {
+                $database->rollback();
+                throw $exception;
+            }
+
+            return ['id' => $roleId];
+        });
+    }
+
+    /**
+     * Executes a controller operation and serializes its result or API error.
+     *
+     * @param callable(): array $operation Controller operation to execute.
+     * @param int $successStatus HTTP status for a successful operation.
+     * @return void Writes a JSON response.
+     * @side-effects Sets the HTTP status and response body.
+     */
+    private function respond(callable $operation, int $successStatus = self::STATUS_OK): void
+    {
+        try {
+            $this->sendJson($operation(), $successStatus);
+        } catch (RoleApiException $exception) {
+            $this->sendJson([
+                'error' => $exception->getMessage(),
+                'code' => $exception->errorCode(),
+            ], $exception->status());
+        } catch (\Throwable $exception) {
+            error_log('Role API operation failed: ' . $exception->getMessage());
+            $this->sendError('Internal server error', self::STATUS_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * Checks whether a serialized user role list contains the requested role ID.
+     *
+     * @param mixed $serializedRoles Serialized roles column value.
+     * @param int $roleId Role ID to find.
+     * @return bool True when the user is assigned to the role.
+     * @side-effects None.
+     */
+    private function hasRoleAssignment(mixed $serializedRoles, int $roleId): bool
+    {
+        if (!is_string($serializedRoles) || $serializedRoles === '') {
+            return false;
+        }
+
+        $roles = unserialize($serializedRoles, ['allowed_classes' => false]);
+        if (!is_array($roles)) {
+            return false;
+        }
+
+        foreach ($roles as $assignedRoleId) {
+            if ((is_int($assignedRoleId) || (is_string($assignedRoleId) && ctype_digit($assignedRoleId)))
+                && (int) $assignedRoleId === $roleId) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns the configured database connection.
+     *
+     * @return SQL Database connection from the F3 hive.
+     * @side-effects Reads the F3 database hive.
+     */
+    private function database(): SQL
+    {
+        $database = \Base::instance()->get('DB');
+        if (!$database instanceof SQL) {
+            throw new RoleApiException(self::STATUS_INTERNAL_SERVER_ERROR, 'DATABASE_UNAVAILABLE', 'The database is unavailable.');
+        }
+
+        return $database;
+    }
+
+    /**
+     * Validates and normalizes role metadata.
+     *
+     * @param array<string, mixed> $input Decoded request data.
+     * @return array{0: string, 1: string} Normalized name and description.
+     * @side-effects None.
+     */
+    private function roleAttributes(array $input): array
+    {
+        $name = $input['name'] ?? null;
+        if (!is_string($name) || trim($name) === '') {
+            throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_NAME', 'Role name must be a non-empty string.');
+        }
+
+        $description = $input['description'] ?? null;
+        if ($description !== null && !is_string($description)) {
+            throw new RoleApiException(self::STATUS_BAD_REQUEST, 'INVALID_DESCRIPTION', 'Role description must be a string or null.');
+        }
+
+        return [trim($name), $description === null ? '' : trim($description)];
+    }
+
+    /**
+     * Finds a role by ID and selects only public role metadata.
+     *
+     * @param SQL $database Database connection.
+     * @param int $id Role ID.
+     * @return array<string, mixed>|null Role row or null when absent.
+     * @side-effects Reads one row from the roles table.
+     */
+    private function findRole(SQL $database, int $id): ?array
+    {
+        $roles = $database->exec(
+            'SELECT id, name, description FROM ' . self::ROLES_TABLE . ' WHERE id = ? LIMIT 1',
+            [$id]
+        );
+
+        return $roles[0] ?? null;
+    }
+
+    /**
+     * Rejects a duplicate role name before attempting a write.
+     *
+     * @param SQL $database Database connection.
+     * @param string $name Normalized role name.
+     * @param int|null $excludedId Role ID excluded during updates.
+     * @return void Throws on a duplicate name.
+     * @side-effects Reads the roles table.
+     */
+    private function ensureUniqueName(SQL $database, string $name, ?int $excludedId = null): void
+    {
+        $sql = 'SELECT id FROM ' . self::ROLES_TABLE . ' WHERE name = ?';
+        $parameters = [$name];
+        if ($excludedId !== null) {
+            $sql .= ' AND id <> ?';
+            $parameters[] = $excludedId;
+        }
+        $sql .= ' LIMIT 1';
+
+        if ($database->exec($sql, $parameters)) {
+            throw new RoleApiException(self::STATUS_CONFLICT, 'ROLE_NAME_CONFLICT', 'Role name already exists.');
+        }
+    }
+
+    /**
+     * Converts a database row into the public role DTO.
+     *
+     * @param array<string, mixed> $role Database role row.
+     * @return array{id: int, name: string, description: string} Safe role DTO.
+     * @side-effects None.
+     */
+    private function roleDto(array $role): array
+    {
+        return [
+            'id' => (int) $role['id'],
+            'name' => (string) $role['name'],
+            'description' => (string) ($role['description'] ?? ''),
+        ];
+    }
+
+    /**
+     * Parses a positive role ID from route arguments.
+     *
+     * @param array<string, mixed> $args Route arguments.
+     * @return int|null Positive role ID, or null for invalid input.
+     * @side-effects None.
+     */
+    private function roleId(array $args): ?int
+    {
+        $id = filter_var($args['id'] ?? null, FILTER_VALIDATE_INT);
+        return $id === false || $id < 1 ? null : $id;
+    }
+
+    /**
+     * Locks user rows after role rows for a role mutation.
+     *
+     * @param SQL $database Active database connection.
+     * @return void
+     * @side-effects Acquires InnoDB row locks for the current transaction.
+     */
+    private function lockUsersForMutation(SQL $database): void
+    {
+        $database->exec('SELECT id FROM ' . self::USERS_TABLE . ' FOR UPDATE');
+    }
+
+    /**
+     * Locks role rows before user rows for a role mutation.
+     *
+     * @param SQL $database Active database connection.
+     * @return void
+     * @side-effects Acquires InnoDB row locks for the current transaction.
+     */
+    private function lockRolesForMutation(SQL $database): void
+    {
+        $database->exec('SELECT id FROM ' . self::ROLES_TABLE . ' FOR UPDATE');
+    }
+
+    /**
+     * Raises a stable conflict for protected or assigned roles.
+     *
+     * @return RoleApiException Conflict exception for deletion failures.
+     * @side-effects None.
+     */
+    private function deletionConflict(): RoleApiException
+    {
+        return new RoleApiException(self::STATUS_CONFLICT, 'ROLE_DELETE_CONFLICT', 'The role cannot be deleted.');
+    }
+
+    /**
+     * Converts a database uniqueness violation into the role-name conflict response.
+     *
+     * @param PDOException $exception Database exception raised by a write.
+     * @return void Throws a stable conflict for duplicate names.
+     * @side-effects None.
+     */
+    private function throwIfDuplicateName(PDOException $exception): void
+    {
+        if ($exception->getCode() === '23000' || str_contains(strtolower($exception->getMessage()), 'duplicate')) {
+            throw new RoleApiException(self::STATUS_CONFLICT, 'ROLE_NAME_CONFLICT', 'Role name already exists.', $exception);
+        }
+    }
+
+}
+
+final class RoleApiException extends \RuntimeException
+{
+    /**
+     * Creates a structured role API exception.
+     *
+     * @param int $status HTTP status code.
+     * @param string $errorCode Stable application error code.
+     * @param string $message Safe client-facing message.
+     * @param \Throwable|null $previous Original exception, when applicable.
+     * @return void Initializes the exception.
+     * @side-effects None.
+     */
+    public function __construct(
+        private readonly int $status,
+        private readonly string $errorCode,
+        string $message,
+        ?\Throwable $previous = null
+    ) {
+        parent::__construct($message, 0, $previous);
+    }
+
+    /**
+     * Returns the HTTP status associated with the exception.
+     *
+     * @return int HTTP status code.
+     * @side-effects None.
+     */
+    public function status(): int
+    {
+        return $this->status;
+    }
+
+    /**
+     * Returns the stable application error code.
+     *
+     * @return string Error code.
+     * @side-effects None.
+     */
+    public function errorCode(): string
+    {
+        return $this->errorCode;
+    }
+}

+ 76 - 37
src/OpenMind/Api/UserController.php

@@ -6,13 +6,13 @@ namespace OpenMind\Api;
 
 
 use Base;
 use Base;
 use DB\SQL;
 use DB\SQL;
+use OpenMind\System\Role;
 use OpenMind\System\User;
 use OpenMind\System\User;
-use OpenMind\System\UserRoles;
 use Throwable;
 use Throwable;
 
 
 class UserController extends Controller
 class UserController extends Controller
 {
 {
-	private const ADMIN_ROLE = 'admin';
+	private const ADMIN_ROLE_ID = 1;
 	private const BAD_REQUEST_STATUS = 400;
 	private const BAD_REQUEST_STATUS = 400;
 	private const CREATED_STATUS = 201;
 	private const CREATED_STATUS = 201;
 	private const FORBIDDEN_STATUS = 403;
 	private const FORBIDDEN_STATUS = 403;
@@ -32,7 +32,7 @@ class UserController extends Controller
 	 */
 	 */
 	public function index(Base $f3): void
 	public function index(Base $f3): void
 	{
 	{
-		if (!$this->requireRole(self::ADMIN_ROLE)) {
+		if (!$this->requireRole(self::ADMIN_ROLE_ID)) {
 			return;
 			return;
 		}
 		}
 
 
@@ -56,7 +56,7 @@ class UserController extends Controller
 	 */
 	 */
 	public function create(Base $f3): void
 	public function create(Base $f3): void
 	{
 	{
-		if (!$this->requireRole(self::ADMIN_ROLE)) {
+		if (!$this->requireRole(self::ADMIN_ROLE_ID)) {
 			return;
 			return;
 		}
 		}
 
 
@@ -73,31 +73,43 @@ class UserController extends Controller
 
 
 		$username = $this->usernameFromRequest($request);
 		$username = $this->usernameFromRequest($request);
 		$password = $request['password'] ?? null;
 		$password = $request['password'] ?? null;
-		$roles = $this->normalizeRoles($request['roles'] ?? []);
 		if ($username === null || !is_string($password) || $password === '') {
 		if ($username === null || !is_string($password) || $password === '') {
 			$this->sendError('Username and password are required', self::BAD_REQUEST_STATUS);
 			$this->sendError('Username and password are required', self::BAD_REQUEST_STATUS);
 			return;
 			return;
 		}
 		}
 
 
-		if ($roles === null) {
-			$this->sendError('Roles must contain only configured roles', self::BAD_REQUEST_STATUS);
-			return;
-		}
+		$db = $this->f3->get('DB');
+		$transactionStarted = false;
+		try {
+			$db->begin();
+			$transactionStarted = true;
+			$this->lockRolesForMutation($db);
+			$this->lockUsersForMutation($db);
 
 
-		if ($this->usernameTaken($username)) {
-			$this->sendConflict('username_exists', 'A user with this username already exists');
-			return;
-		}
+			$roles = $this->normalizeRoles($request['roles'] ?? []);
+			if ($roles === null) {
+				$this->rollbackTransaction($db, $transactionStarted);
+				$this->sendError('Roles must contain only existing unique integer IDs', self::BAD_REQUEST_STATUS);
+				return;
+			}
+
+			if ($this->usernameTaken($username)) {
+				$this->rollbackTransaction($db, $transactionStarted);
+				$this->sendConflict('username_exists', 'A user with this username already exists');
+				return;
+			}
 
 
-		try {
 			$user = new User(null);
 			$user = new User(null);
 			$user->username = $username;
 			$user->username = $username;
 			$user->saltedpassword = $user->generatePassword($password);
 			$user->saltedpassword = $user->generatePassword($password);
 			$this->setRoles($user, $roles);
 			$this->setRoles($user, $roles);
 			$user->save();
 			$user->save();
 
 
+			$db->commit();
+			$transactionStarted = false;
 			$this->sendJson(['user' => $this->administratorUserDto($user)], self::CREATED_STATUS);
 			$this->sendJson(['user' => $this->administratorUserDto($user)], self::CREATED_STATUS);
 		} catch (Throwable $exception) {
 		} catch (Throwable $exception) {
+			$this->rollbackTransaction($db, $transactionStarted);
 			if ($this->isDuplicateUsernameException($exception)) {
 			if ($this->isDuplicateUsernameException($exception)) {
 				$this->sendConflict('username_exists', 'A user with this username already exists');
 				$this->sendConflict('username_exists', 'A user with this username already exists');
 				return;
 				return;
@@ -118,7 +130,7 @@ class UserController extends Controller
 	 */
 	 */
 	public function update(Base $f3, array $args = []): void
 	public function update(Base $f3, array $args = []): void
 	{
 	{
-		if (!$this->requireRole(self::ADMIN_ROLE)) {
+		if (!$this->requireRole(self::ADMIN_ROLE_ID)) {
 			return;
 			return;
 		}
 		}
 
 
@@ -139,6 +151,7 @@ class UserController extends Controller
 		try {
 		try {
 			$db->begin();
 			$db->begin();
 			$transactionStarted = true;
 			$transactionStarted = true;
+			$this->lockRolesForMutation($db);
 			$this->lockUsersForMutation($db);
 			$this->lockUsersForMutation($db);
 
 
 			$user = new User(['id=?', $id]);
 			$user = new User(['id=?', $id]);
@@ -181,8 +194,8 @@ class UserController extends Controller
 				return;
 				return;
 			}
 			}
 
 
-			if ($user->hasPermission(self::ADMIN_ROLE)
-				&& !in_array(self::ADMIN_ROLE, $roles, true)
+			if ($user->hasPermission(self::ADMIN_ROLE_ID)
+				&& !in_array(self::ADMIN_ROLE_ID, $roles, true)
 				&& $this->adminCount() <= 1) {
 				&& $this->adminCount() <= 1) {
 				$this->rollbackTransaction($db, $transactionStarted);
 				$this->rollbackTransaction($db, $transactionStarted);
 				$this->sendConflict('final_admin', 'The final administrator cannot lose the admin role');
 				$this->sendConflict('final_admin', 'The final administrator cannot lose the admin role');
@@ -221,7 +234,7 @@ class UserController extends Controller
 	 */
 	 */
 	public function delete(Base $f3, array $args = []): void
 	public function delete(Base $f3, array $args = []): void
 	{
 	{
-		if (!$this->requireRole(self::ADMIN_ROLE)) {
+		if (!$this->requireRole(self::ADMIN_ROLE_ID)) {
 			return;
 			return;
 		}
 		}
 
 
@@ -247,6 +260,7 @@ class UserController extends Controller
 		try {
 		try {
 			$db->begin();
 			$db->begin();
 			$transactionStarted = true;
 			$transactionStarted = true;
+			$this->lockRolesForMutation($db);
 			$this->lockUsersForMutation($db);
 			$this->lockUsersForMutation($db);
 
 
 			$user = new User(['id=?', $id]);
 			$user = new User(['id=?', $id]);
@@ -256,7 +270,7 @@ class UserController extends Controller
 				return;
 				return;
 			}
 			}
 
 
-			if ($user->hasPermission(self::ADMIN_ROLE) && $this->adminCount() <= 1) {
+			if ($user->hasPermission(self::ADMIN_ROLE_ID) && $this->adminCount() <= 1) {
 				$this->rollbackTransaction($db, $transactionStarted);
 				$this->rollbackTransaction($db, $transactionStarted);
 				$this->sendConflict('final_admin', 'The final administrator cannot be deleted');
 				$this->sendConflict('final_admin', 'The final administrator cannot be deleted');
 				return;
 				return;
@@ -289,7 +303,7 @@ class UserController extends Controller
 	 * Converts a user mapper into the public administrator DTO.
 	 * Converts a user mapper into the public administrator DTO.
 	 *
 	 *
 	 * @param User $user User to serialize.
 	 * @param User $user User to serialize.
-	 * @return array{id: int, username: string, roles: array<int, string>} Safe user DTO.
+	 * @return array{id: int, username: string, roles: array<int, int>} Safe user DTO.
 	 * @side-effects None.
 	 * @side-effects None.
 	 */
 	 */
 	private function administratorUserDto(User $user): array
 	private function administratorUserDto(User $user): array
@@ -302,24 +316,39 @@ class UserController extends Controller
 	}
 	}
 
 
 	/**
 	/**
-	 * Returns configured role names for the role selector.
+	 * Returns persistent role records for the role selector.
 	 *
 	 *
-	 * @return array<int, string> Unique configured role names.
-	 * @side-effects Reads the global role registry.
+	 * @return array<int, array{id: int, name: string, description: string}> Safe role DTOs.
+	 * @side-effects Reads the persistent role catalog.
 	 */
 	 */
 	private function availableRoles(): array
 	private function availableRoles(): array
 	{
 	{
-		new UserRoles();
-		$roles = array_filter(UserRoles::getAllRoles(), 'is_string');
-		return array_values(array_unique($roles));
+		$roles = new Role();
+		return array_map([$this, 'roleDto'], $roles->find());
 	}
 	}
 
 
 	/**
 	/**
-	 * Validates and normalizes a submitted role list.
+	 * Converts a role mapper into the public role DTO.
+	 *
+	 * @param Role $role Role to serialize.
+	 * @return array{id: int, name: string, description: string} Safe role DTO.
+	 * @side-effects None.
+	 */
+	private function roleDto(Role $role): array
+	{
+		return [
+			'id' => (int) $role->id,
+			'name' => (string) $role->name,
+			'description' => (string) $role->description,
+		];
+	}
+
+	/**
+	 * Validates a submitted role list against the persistent role catalog.
 	 *
 	 *
 	 * @param mixed $value Submitted role list.
 	 * @param mixed $value Submitted role list.
-	 * @return array<int, string>|null Normalized roles, or null for invalid input.
-	 * @side-effects Reads the global role registry.
+	 * @return array<int, int>|null Role IDs, or null for invalid input.
+	 * @side-effects Reads the persistent role catalog.
 	 */
 	 */
 	private function normalizeRoles(mixed $value): ?array
 	private function normalizeRoles(mixed $value): ?array
 	{
 	{
@@ -327,16 +356,14 @@ class UserController extends Controller
 			return null;
 			return null;
 		}
 		}
 
 
-		$availableRoles = $this->availableRoles();
+		$availableRoleIds = array_column($this->availableRoles(), 'id');
 		$roles = [];
 		$roles = [];
-		foreach ($value as $role) {
-			if (!is_string($role) || !in_array($role, $availableRoles, true)) {
+		foreach ($value as $roleId) {
+			if (!is_int($roleId) || !in_array($roleId, $availableRoleIds, true) || in_array($roleId, $roles, true)) {
 				return null;
 				return null;
 			}
 			}
 
 
-			if (!in_array($role, $roles, true)) {
-				$roles[] = $role;
-			}
+			$roles[] = $roleId;
 		}
 		}
 
 
 		return $roles;
 		return $roles;
@@ -346,7 +373,7 @@ class UserController extends Controller
 	 * Applies a validated role list through the existing User role storage API.
 	 * Applies a validated role list through the existing User role storage API.
 	 *
 	 *
 	 * @param User $user User to update.
 	 * @param User $user User to update.
-	 * @param array<int, string> $roles Desired roles.
+	 * @param array<int, int> $roles Desired role IDs.
 	 * @return void
 	 * @return void
 	 * @side-effects Updates the user's in-memory role list.
 	 * @side-effects Updates the user's in-memory role list.
 	 */
 	 */
@@ -373,7 +400,7 @@ class UserController extends Controller
 	{
 	{
 		$count = 0;
 		$count = 0;
 		foreach ($this->allUsers() as $user) {
 		foreach ($this->allUsers() as $user) {
-			if ($user->hasPermission(self::ADMIN_ROLE)) {
+			if ($user->hasPermission(self::ADMIN_ROLE_ID)) {
 				$count++;
 				$count++;
 			}
 			}
 		}
 		}
@@ -393,6 +420,18 @@ class UserController extends Controller
 		$db->exec('SELECT id FROM users FOR UPDATE');
 		$db->exec('SELECT id FROM users FOR UPDATE');
 	}
 	}
 
 
+	/**
+	 * Locks all role rows so assignments cannot race role catalog mutations.
+	 *
+	 * @param SQL $db Active database connection.
+	 * @return void
+	 * @side-effects Acquires InnoDB row locks for the current transaction.
+	 */
+	private function lockRolesForMutation(SQL $db): void
+	{
+		$db->exec('SELECT id FROM roles FOR UPDATE');
+	}
+
 	/**
 	/**
 	 * Rolls back a user mutation transaction when one is active.
 	 * Rolls back a user mutation transaction when one is active.
 	 *
 	 *

+ 25 - 0
src/OpenMind/System/InventoryItem.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenMind\System;
+
+use DB\SQL\Mapper;
+
+/**
+ * Maps persisted inventory items to the application database.
+ */
+final class InventoryItem extends Mapper
+{
+    private const TABLE_NAME = 'inventory_items';
+
+    /**
+     * Create an inventory-item mapper using the application's database connection.
+     *
+     * @param \DB\SQL|null $database Optional connection for isolated callers.
+     */
+    public function __construct(?\DB\SQL $database = null)
+    {
+        parent::__construct($database ?? \Base::instance()->get('DB'), self::TABLE_NAME);
+    }
+}

+ 7 - 4
src/OpenMind/System/PluginStrategy.php

@@ -141,7 +141,8 @@ class PluginStrategy implements IPluginStrategy
 	 * [<br/>
 	 * [<br/>
 	 * &nbsp;&nbsp;'menu_name' => 'Bind',<br/>
 	 * &nbsp;&nbsp;'menu_name' => 'Bind',<br/>
 	 * &nbsp;&nbsp;'url' => '/bind',<br/>
 	 * &nbsp;&nbsp;'url' => '/bind',<br/>
-	 * &nbsp;&nbsp;'isDisabled' => false<br/>
+	 * &nbsp;&nbsp;'isDisabled' => false,<br/>
+	 * &nbsp;&nbsp;'permissionId' => 1<br/>
 	 * ]
 	 * ]
 	 *
 	 *
 	 * @return void
 	 * @return void
@@ -157,7 +158,7 @@ class PluginStrategy implements IPluginStrategy
 			'image' => '',
 			'image' => '',
 			'imgClass' => '',
 			'imgClass' => '',
 			'mainMenu' => null,
 			'mainMenu' => null,
-			'permissionName' => null,
+			'permissionId' => null,
 			'order' => self::DEFAULT_MENU_ORDER,
 			'order' => self::DEFAULT_MENU_ORDER,
 		];
 		];
 
 
@@ -169,9 +170,11 @@ class PluginStrategy implements IPluginStrategy
 		// Check permissions for logged-in users before publishing the menu.
 		// Check permissions for logged-in users before publishing the menu.
 		$user = User::Restore();
 		$user = User::Restore();
 		if (!$user->dry()) {
 		if (!$user->dry()) {
-			$hasMenuPermissionRequirements = $newMenu['permissionName'] !== null;
+			$permissionId = $newMenu['permissionId'];
+			$hasMenuPermissionRequirements = $permissionId !== null;
+			$hasMenuPermission = is_int($permissionId) && $user->hasPermission($permissionId);
 
 
-			if (!$hasMenuPermissionRequirements || $user->hasPermission($newMenu['permissionName'])) {
+			if (!$hasMenuPermissionRequirements || $hasMenuPermission) {
 				$f3->set('GLOBALMENU', $globalMenuUpdated);
 				$f3->set('GLOBALMENU', $globalMenuUpdated);
 			}
 			}
 		}
 		}

+ 36 - 0
src/OpenMind/System/Role.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenMind\System;
+
+use DB\SQL\Mapper;
+
+/**
+ * Persistent role catalog mapper.
+ *
+ * @property int $id
+ * @property string $name
+ * @property string $description
+ */
+class Role extends Mapper
+{
+	private const TABLE_NAME = 'roles';
+
+	/**
+	 * Initialize the role mapper and optionally load a matching record.
+	 *
+	 * @param string|array|null $where Optional mapper load condition.
+	 * @side-effects Connects the mapper to the configured database and may load a role.
+	 */
+	public function __construct($where = null)
+	{
+		global $f3;
+
+		parent::__construct($f3->get('DB'), self::TABLE_NAME);
+
+		if ($where !== null) {
+			$this->load($where);
+		}
+	}
+}

+ 33 - 16
src/OpenMind/System/User.php

@@ -23,7 +23,8 @@ class User extends Mapper
 {
 {
 	public const PASSWORD_SEPARATOR = '|';
 	public const PASSWORD_SEPARATOR = '|';
 
 
-	private $userRoles;
+	/** @var array<int, int> */
+	private array $userRoles = [];
 
 
 	/**
 	/**
 	 * User constructor.
 	 * User constructor.
@@ -35,7 +36,7 @@ class User extends Mapper
 
 
 		$this->onload(function ($obj) {
 		$this->onload(function ($obj) {
 			$roles = unserialize($obj->roles, ['allowed_classes' => false]);
 			$roles = unserialize($obj->roles, ['allowed_classes' => false]);
-			$obj->userRoles = is_array($roles) ? $roles : [];
+			$obj->userRoles = is_array($roles) ? $obj->normalizeRoleIds($roles) : [];
 		});
 		});
 
 
 		parent::__construct($f3->get('DB'), 'users', NULL, 3600);
 		parent::__construct($f3->get('DB'), 'users', NULL, 3600);
@@ -80,42 +81,44 @@ class User extends Mapper
 	}
 	}
 
 
 	/**
 	/**
-	 * Check if user can do <$permissionName>
+	 * Check whether the user has a role ID.
 	 *
 	 *
-	 * @param string $permissionName
-	 * @return bool
+	 * @param int $roleId Role identifier to check.
+	 * @return bool True when the role is assigned.
+	 * @sideEffects Reads the in-memory role list.
 	 */
 	 */
-	public function hasPermission(string $permissionName): bool
+	public function hasPermission(int $roleId): bool
 	{
 	{
-		return in_array($permissionName, $this->userRoles, true);
+		return in_array($roleId, $this->userRoles, true);
 	}
 	}
 
 
 	/**
 	/**
-	 * Grants a role to the current user if it is not already assigned.
+	 * Grant a role ID to the current user if it is not already assigned.
 	 *
 	 *
-	 * @param string $permissionName Role to grant.
+	 * @param int $roleId Role identifier to grant.
 	 * @return void
 	 * @return void
 	 * @sideEffects Updates the in-memory role list.
 	 * @sideEffects Updates the in-memory role list.
 	 */
 	 */
-	public function grantPermission(string $permissionName): void
+	public function grantPermission(int $roleId): void
 	{
 	{
-		if (!in_array($permissionName, $this->userRoles, true)) {
-			$this->userRoles[] = $permissionName;
+		if (!in_array($roleId, $this->userRoles, true)) {
+			$this->userRoles[] = $roleId;
 		}
 		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Removes a role from the current user.
+	 * Remove a role ID from the current user.
 	 *
 	 *
-	 * @param string $permissionName Role to remove.
+	 * @param int $roleId Role identifier to remove.
 	 * @return void
 	 * @return void
 	 * @sideEffects Updates the in-memory role list.
 	 * @sideEffects Updates the in-memory role list.
 	 */
 	 */
-	public function removePermission(string $permissionName): void
+	public function removePermission(int $roleId): void
 	{
 	{
-		$roleIndex = array_search($permissionName, $this->userRoles, true);
+		$roleIndex = array_search($roleId, $this->userRoles, true);
 		if ($roleIndex !== false) {
 		if ($roleIndex !== false) {
 			unset($this->userRoles[$roleIndex]);
 			unset($this->userRoles[$roleIndex]);
+			$this->userRoles = array_values($this->userRoles);
 		}
 		}
 	}
 	}
 
 
@@ -127,6 +130,7 @@ class User extends Mapper
 	 */
 	 */
 	public function save()
 	public function save()
 	{
 	{
+		$this->userRoles = $this->normalizeRoleIds($this->userRoles);
 		$this->roles = serialize($this->userRoles);
 		$this->roles = serialize($this->userRoles);
 		return parent::save();
 		return parent::save();
 	}
 	}
@@ -153,4 +157,17 @@ class User extends Mapper
 	{
 	{
 		return $this->userRoles;
 		return $this->userRoles;
 	}
 	}
+
+	/**
+	 * Normalize role assignments to a zero-based unique integer collection.
+	 *
+	 * @param array<mixed> $roles Role values read from persistence or memory.
+	 * @return array<int, int> Unique integer role IDs.
+	 * @sideEffects None.
+	 */
+	private function normalizeRoleIds(array $roles): array
+	{
+		$integerRoles = array_filter($roles, static fn (mixed $role): bool => is_int($role));
+		return array_values(array_unique($integerRoles, SORT_NUMERIC));
+	}
 }
 }

+ 62 - 0
ui/app/src/api/client.js

@@ -103,6 +103,68 @@ export async function getNavigation() {
 	return Array.isArray(response.items) ? response.items : [];
 	return Array.isArray(response.items) ? response.items : [];
 }
 }
 
 
+/**
+ * Retrieves roles available to the current user.
+ *
+ * @returns {Promise<Array<Record<string, unknown>>>} Role entries.
+ * @sideEffects Performs a same-origin network request.
+ */
+export async function getRoles() {
+	const response = await apiRequest('/admin/roles');
+	return Array.isArray(response.roles) ? response.roles : [];
+}
+
+/**
+ * Retrieves inventory items visible to the current user.
+ *
+ * @returns {Promise<Array<Record<string, unknown>>>} Inventory item entries.
+ * @sideEffects Performs a same-origin network request.
+ */
+export async function getInventoryItems() {
+	const response = await apiRequest('/inventory/items');
+	return Array.isArray(response.items) ? response.items : [];
+}
+
+/**
+ * Creates an inventory item through the CSRF-protected API.
+ *
+ * @param {Record<string, unknown>} item Inventory item fields.
+ * @returns {Promise<Record<string, unknown>>} Created inventory item response.
+ * @sideEffects Performs a CSRF-protected same-origin request.
+ */
+export function createInventoryItem(item) {
+	return apiRequest('/inventory/items', {
+		method: 'POST',
+		body: JSON.stringify(item),
+	});
+}
+
+/**
+ * Updates an inventory item through the CSRF-protected API.
+ *
+ * @param {number|string} id Inventory item identifier.
+ * @param {Record<string, unknown>} item Updated inventory item fields.
+ * @returns {Promise<Record<string, unknown>>} Updated inventory item response.
+ * @sideEffects Performs a CSRF-protected same-origin request.
+ */
+export function updateInventoryItem(id, item) {
+	return apiRequest(`/inventory/items/${id}`, {
+		method: 'PUT',
+		body: JSON.stringify(item),
+	});
+}
+
+/**
+ * Deletes an inventory item through the CSRF-protected API.
+ *
+ * @param {number|string} id Inventory item identifier.
+ * @returns {Promise<Record<string, unknown>>} Deletion response.
+ * @sideEffects Performs a CSRF-protected same-origin request.
+ */
+export function deleteInventoryItem(id) {
+	return apiRequest(`/inventory/items/${id}`, { method: 'DELETE' });
+}
+
 /**
 /**
  * Retrieves grouped notifications for the current user.
  * Retrieves grouped notifications for the current user.
  *
  *

+ 119 - 0
ui/app/src/features/admin/RoleDialog.jsx

@@ -0,0 +1,119 @@
+import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material';
+import { useEffect, useState } from 'react';
+
+/**
+ * Renders the create and edit form for one administrator-managed role.
+ *
+ * @param {{open: boolean, role: {id: number, name: string, description: string}|null, isSaving: boolean, errorMessage?: string|null, onClose: () => void, onSubmit: (values: {name: string, description: string}) => void}} props Dialog properties.
+ * @returns {JSX.Element} Role form dialog.
+ * @sideEffects Updates local form state and submits validated role values to the parent page.
+ */
+function RoleDialog({ open, role, isSaving, errorMessage, onClose, onSubmit }) {
+	const [name, setName] = useState('');
+	const [description, setDescription] = useState('');
+	const [errors, setErrors] = useState({ name: '', description: '' });
+	const isEditing = role !== null;
+
+	useEffect(() => {
+		if (!open) {
+			return;
+		}
+
+		setName(role?.name ?? '');
+		setDescription(role?.description ?? '');
+		setErrors({ name: '', description: '' });
+	}, [open, role]);
+
+	/**
+	 * Submits the role form after validating required metadata.
+	 *
+	 * @param {SubmitEvent} event Form submission event.
+	 * @returns {void}
+	 * @sideEffects Updates validation state or passes trimmed values to the parent page.
+	 */
+	function handleSubmit(event) {
+		event.preventDefault();
+		const values = { name: name.trim(), description: description.trim() };
+		const nextErrors = {
+			name: values.name ? '' : 'Role name is required.',
+			description: values.description ? '' : 'Role description is required.',
+		};
+
+		if (nextErrors.name || nextErrors.description) {
+			setErrors(nextErrors);
+			return;
+		}
+
+		onSubmit(values);
+	}
+
+	/**
+	 * Updates the role name and clears its current validation message.
+	 *
+	 * @param {React.ChangeEvent<HTMLInputElement>} event Name input change event.
+	 * @returns {void}
+	 * @sideEffects Updates local form state.
+	 */
+	function handleNameChange(event) {
+		setName(event.target.value);
+		if (errors.name) {
+			setErrors(currentErrors => ({ ...currentErrors, name: '' }));
+		}
+	}
+
+	/**
+	 * Updates the role description and clears its current validation message.
+	 *
+	 * @param {React.ChangeEvent<HTMLInputElement>} event Description input change event.
+	 * @returns {void}
+	 * @sideEffects Updates local form state.
+	 */
+	function handleDescriptionChange(event) {
+		setDescription(event.target.value);
+		if (errors.description) {
+			setErrors(currentErrors => ({ ...currentErrors, description: '' }));
+		}
+	}
+
+	return (
+		<Dialog open={open} onClose={isSaving ? undefined : onClose} fullWidth maxWidth="sm">
+			<DialogTitle>{isEditing ? 'Edit role' : 'Create role'}</DialogTitle>
+			<DialogContent>
+				<Stack component="form" id="role-dialog-form" spacing={3} sx={{ pt: 1 }} onSubmit={handleSubmit}>
+					{errorMessage && <Alert severity="error" role="alert">{errorMessage}</Alert>}
+					<TextField
+						autoFocus
+						label="Name"
+						value={name}
+						onChange={handleNameChange}
+						error={Boolean(errors.name)}
+						helperText={errors.name}
+						required
+						fullWidth
+						disabled={isSaving}
+					/>
+					<TextField
+						label="Description"
+						value={description}
+						onChange={handleDescriptionChange}
+						error={Boolean(errors.description)}
+						helperText={errors.description}
+						required
+						fullWidth
+						multiline
+						minRows={3}
+						disabled={isSaving}
+					/>
+				</Stack>
+			</DialogContent>
+			<DialogActions>
+				<Button onClick={onClose} disabled={isSaving}>Cancel</Button>
+				<Button type="submit" form="role-dialog-form" variant="contained" disabled={isSaving}>
+					{isEditing ? 'Save changes' : 'Create role'}
+				</Button>
+			</DialogActions>
+		</Dialog>
+	);
+}
+
+export default RoleDialog;

+ 284 - 0
ui/app/src/features/admin/RolesPage.jsx

@@ -0,0 +1,284 @@
+import {
+	Alert,
+	Button,
+	CircularProgress,
+	Container,
+	Dialog,
+	DialogActions,
+	DialogContent,
+	DialogContentText,
+	DialogTitle,
+	Paper,
+	Stack,
+	Table,
+	TableBody,
+	TableCell,
+	TableContainer,
+	TableHead,
+	TableRow,
+	Typography,
+} from '@mui/material';
+import { useEffect, useState } from 'react';
+import { apiRequest } from '../../api/client';
+import RoleDialog from './RoleDialog';
+
+const ROLES_ENDPOINT = '/admin/roles';
+const PROTECTED_ROLE_ID = 1;
+
+/**
+ * Converts an API failure into a user-visible message.
+ *
+ * @param {unknown} error Request failure.
+ * @param {string} fallback Fallback message.
+ * @returns {string} Safe role-management error message.
+ * @sideEffects None.
+ */
+function getErrorMessage(error, fallback) {
+	return error instanceof Error && error.message ? error.message : fallback;
+}
+
+/**
+ * Renders the administrator role catalog management page.
+ *
+ * @returns {JSX.Element} Role management page.
+ * @sideEffects Loads roles and sends CSRF-protected create, update, and delete requests.
+ */
+function RolesPage() {
+	const [roles, setRoles] = useState([]);
+	const [isLoading, setIsLoading] = useState(true);
+	const [isSaving, setIsSaving] = useState(false);
+	const [isDeleting, setIsDeleting] = useState(false);
+	const [dialogOpen, setDialogOpen] = useState(false);
+	const [selectedRole, setSelectedRole] = useState(null);
+	const [roleToDelete, setRoleToDelete] = useState(null);
+	const [notification, setNotification] = useState(null);
+
+	useEffect(() => {
+		let isMounted = true;
+
+		async function loadRoles() {
+			try {
+				const response = await apiRequest(ROLES_ENDPOINT);
+				if (!Array.isArray(response.roles)) {
+					throw new Error('The roles response was invalid.');
+				}
+
+				if (isMounted) {
+					setRoles(response.roles);
+				}
+			} catch (error) {
+				if (isMounted) {
+					setNotification({ severity: 'error', message: getErrorMessage(error, 'Unable to load roles.') });
+				}
+			} finally {
+				if (isMounted) {
+					setIsLoading(false);
+				}
+			}
+		}
+
+		void loadRoles();
+		return () => {
+			isMounted = false;
+		};
+	}, []);
+
+	/**
+	 * Opens the role dialog for a new or existing role.
+	 *
+	 * @param {Record<string, unknown>|null} role Role to edit, or null to create.
+	 * @returns {void}
+	 * @sideEffects Updates dialog state and clears stale feedback.
+	 */
+	function openRoleDialog(role = null) {
+		setNotification(null);
+		setSelectedRole(role);
+		setDialogOpen(true);
+	}
+
+	/**
+	 * Closes the role dialog when no mutation is running.
+	 *
+	 * @returns {void}
+	 * @sideEffects Updates dialog state.
+	 */
+	function closeRoleDialog() {
+		if (!isSaving) {
+			setDialogOpen(false);
+		}
+	}
+
+	/**
+	 * Saves role metadata through the administrator API.
+	 *
+	 * @param {{name: string, description: string}} values Role metadata.
+	 * @returns {Promise<void>} Resolves after the mutation completes.
+	 * @sideEffects Sends a CSRF-protected request and updates the role table.
+	 */
+	async function handleRoleSubmit(values) {
+		setNotification(null);
+		setIsSaving(true);
+
+		try {
+			const isEditing = selectedRole !== null;
+			const response = await apiRequest(
+				isEditing ? `${ROLES_ENDPOINT}/${selectedRole.id}` : ROLES_ENDPOINT,
+				{
+					method: isEditing ? 'PUT' : 'POST',
+					body: JSON.stringify(values),
+				},
+			);
+
+			if (!response.role || typeof response.role.id !== 'number') {
+				throw new Error('The saved role response was invalid.');
+			}
+
+			setRoles(currentRoles => {
+				if (!isEditing) {
+					return [...currentRoles, response.role];
+				}
+
+				return currentRoles.map(role => role.id === response.role.id ? response.role : role);
+			});
+			setDialogOpen(false);
+			setNotification({ severity: 'success', message: isEditing ? 'Role updated.' : 'Role created.' });
+		} catch (error) {
+			setNotification({ severity: 'error', message: getErrorMessage(error, 'Unable to save role.') });
+		} finally {
+			setIsSaving(false);
+		}
+	}
+
+	/**
+	 * Deletes the role selected in the confirmation dialog.
+	 *
+	 * @returns {Promise<void>} Resolves after the deletion completes.
+	 * @sideEffects Sends a CSRF-protected deletion request and updates the role table.
+	 */
+	async function handleDelete() {
+		if (!roleToDelete || roleToDelete.id === PROTECTED_ROLE_ID) {
+			return;
+		}
+
+		setNotification(null);
+		setIsDeleting(true);
+
+		try {
+			await apiRequest(`${ROLES_ENDPOINT}/${roleToDelete.id}`, { method: 'DELETE' });
+			setRoles(currentRoles => currentRoles.filter(role => role.id !== roleToDelete.id));
+			setRoleToDelete(null);
+			setNotification({ severity: 'success', message: 'Role deleted.' });
+		} catch (error) {
+			setNotification({ severity: 'error', message: getErrorMessage(error, 'Unable to delete role.') });
+		} finally {
+			setIsDeleting(false);
+		}
+	}
+
+	/**
+	 * Clears the current page notification.
+	 *
+	 * @returns {void}
+	 * @sideEffects Updates notification state.
+	 */
+	function dismissNotification() {
+		setNotification(null);
+	}
+
+	return (
+		<Container maxWidth="lg" disableGutters>
+			<Stack spacing={3}>
+				<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ sm: 'center' }}>
+					<Stack spacing={1}>
+						<Typography variant="overline" color="primary">Administration</Typography>
+						<Typography variant="h3" component="h1">Roles</Typography>
+						<Typography color="text.secondary">Manage the application role catalog.</Typography>
+					</Stack>
+					<Button
+						variant="contained"
+						onClick={() => openRoleDialog()}
+						disabled={isLoading || isSaving || isDeleting}
+						sx={{ alignSelf: { xs: 'stretch', sm: 'center' }, minHeight: 40, px: 2 }}
+					>
+						Create role
+					</Button>
+				</Stack>
+
+				{notification && <Alert severity={notification.severity} onClose={dismissNotification} aria-live="polite">{notification.message}</Alert>}
+
+				<Paper elevation={0} className="overflow-hidden rounded-2xl border border-slate-200">
+					{isLoading ? (
+						<Stack alignItems="center" className="p-8">
+							<CircularProgress aria-label="Loading roles" />
+						</Stack>
+					) : (
+						<TableContainer>
+							<Table sx={{ minWidth: 560 }} aria-label="Roles">
+								<TableHead>
+									<TableRow>
+										<TableCell>ID</TableCell>
+										<TableCell>Name</TableCell>
+										<TableCell>Description</TableCell>
+										<TableCell align="right">Actions</TableCell>
+									</TableRow>
+								</TableHead>
+								<TableBody>
+									{roles.map(role => (
+										<TableRow key={role.id}>
+											<TableCell>{role.id}</TableCell>
+											<TableCell>{role.name}</TableCell>
+											<TableCell>{role.description}</TableCell>
+											<TableCell align="right">
+												<Stack direction="row" spacing={1} justifyContent="flex-end">
+													<Button size="small" onClick={() => openRoleDialog(role)} disabled={isSaving || isDeleting}>Edit</Button>
+													<Button
+														size="small"
+														color="error"
+														onClick={() => setRoleToDelete(role)}
+														disabled={role.id === PROTECTED_ROLE_ID || isSaving || isDeleting}
+													>
+														Delete
+													</Button>
+												</Stack>
+											</TableCell>
+										</TableRow>
+									))}
+									{roles.length === 0 && (
+										<TableRow><TableCell colSpan={4} align="center">No roles found.</TableCell></TableRow>
+									)}
+								</TableBody>
+							</Table>
+						</TableContainer>
+					)}
+				</Paper>
+			</Stack>
+
+			<RoleDialog
+				open={dialogOpen}
+				role={selectedRole}
+				isSaving={isSaving}
+				errorMessage={dialogOpen && notification?.severity === 'error' ? notification.message : null}
+				onClose={closeRoleDialog}
+				onSubmit={handleRoleSubmit}
+			/>
+
+			<Dialog open={roleToDelete !== null} onClose={isDeleting ? undefined : () => setRoleToDelete(null)}>
+				<DialogTitle>Delete role?</DialogTitle>
+				<DialogContent>
+					{notification?.severity === 'error' && <Alert severity="error" role="alert" sx={{ mb: 2 }}>{notification.message}</Alert>}
+					<DialogContentText>
+						This permanently deletes {roleToDelete?.name ?? 'this role'}. Roles assigned to users cannot be deleted.
+					</DialogContentText>
+				</DialogContent>
+				<DialogActions>
+					<Button onClick={() => setRoleToDelete(null)} disabled={isDeleting}>Cancel</Button>
+					<Button color="error" variant="contained" onClick={handleDelete} disabled={isDeleting}>
+						{isDeleting ? <CircularProgress size={20} color="inherit" aria-label="Deleting role" /> : 'Delete'}
+					</Button>
+				</DialogActions>
+			</Dialog>
+		</Container>
+	);
+}
+
+export default RolesPage;

+ 23 - 7
ui/app/src/features/admin/UserDialog.jsx

@@ -17,10 +17,23 @@ import {
 } from '@mui/material';
 } from '@mui/material';
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 
 
+/**
+ * Resolves a role ID to its catalog name without failing for removed roles.
+ *
+ * @param {number} roleId Role ID assigned to a user.
+ * @param {{id: number, name: string}[]} availableRoles Current role catalog.
+ * @returns {string} Role name or a safe unknown-role label.
+ * @sideEffects None.
+ */
+function getRoleLabel(roleId, availableRoles) {
+	const role = availableRoles.find(availableRole => availableRole.id === roleId);
+	return role?.name ?? `Unknown role (${roleId})`;
+}
+
 /**
 /**
  * Renders the create and edit form for one administrator-managed user.
  * Renders the create and edit form for one administrator-managed user.
  *
  *
- * @param {{open: boolean, user: {id: number, username: string, roles: string[]}|null, availableRoles: string[], isSaving: boolean, onClose: () => void, onSubmit: (values: {username: string, password: string, roles: string[]}) => void}} props Dialog properties.
+ * @param {{open: boolean, user: {id: number, username: string, roles: number[]}|null, availableRoles: {id: number, name: string, description?: string}[], isSaving: boolean, onClose: () => void, onSubmit: (values: {username: string, password: string, roles: number[]}) => void}} props Dialog properties.
  * @returns {JSX.Element} User form dialog.
  * @returns {JSX.Element} User form dialog.
  * @sideEffects Updates local form state and submits user values through the parent page.
  * @sideEffects Updates local form state and submits user values through the parent page.
  */
  */
@@ -37,7 +50,7 @@ function UserDialog({ open, user, availableRoles, isSaving, onClose, onSubmit })
 
 
 		setUsername(user?.username ?? '');
 		setUsername(user?.username ?? '');
 		setPassword('');
 		setPassword('');
-		setRoles(Array.isArray(user?.roles) ? user.roles : []);
+		setRoles(Array.isArray(user?.roles) ? user.roles.map(Number).filter(Number.isInteger) : []);
 	}, [open, user]);
 	}, [open, user]);
 
 
 	/**
 	/**
@@ -82,18 +95,21 @@ function UserDialog({ open, user, availableRoles, isSaving, onClose, onSubmit })
 							labelId="user-roles-label"
 							labelId="user-roles-label"
 							multiple
 							multiple
 							value={roles}
 							value={roles}
-							onChange={event => setRoles(typeof event.target.value === 'string' ? event.target.value.split(',') : event.target.value)}
+							onChange={event => {
+								const selectedValues = typeof event.target.value === 'string' ? event.target.value.split(',') : event.target.value;
+								setRoles(selectedValues.map(Number).filter(Number.isInteger));
+							}}
 							input={<OutlinedInput label="Roles" />}
 							input={<OutlinedInput label="Roles" />}
 							renderValue={selected => (
 							renderValue={selected => (
 								<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
 								<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
-									{selected.map(role => <Chip key={role} label={role} size="small" />)}
+									{selected.map(roleId => <Chip key={roleId} label={getRoleLabel(roleId, availableRoles)} size="small" />)}
 								</Stack>
 								</Stack>
 							)}
 							)}
 						>
 						>
 							{availableRoles.map(role => (
 							{availableRoles.map(role => (
-								<MenuItem key={role} value={role}>
-									<Checkbox checked={roles.includes(role)} />
-									<ListItemText primary={role} />
+								<MenuItem key={role.id} value={role.id}>
+									<Checkbox checked={roles.includes(role.id)} />
+									<ListItemText primary={role.name} secondary={role.description} />
 								</MenuItem>
 								</MenuItem>
 							))}
 							))}
 						</Select>
 						</Select>

+ 15 - 2
ui/app/src/features/admin/UsersPage.jsx

@@ -37,6 +37,19 @@ function getErrorMessage(error, fallback) {
 	return error instanceof Error && error.message ? error.message : fallback;
 	return error instanceof Error && error.message ? error.message : fallback;
 }
 }
 
 
+/**
+ * Resolves a role ID to its catalog name without failing for removed roles.
+ *
+ * @param {number} roleId Role ID assigned to a user.
+ * @param {{id: number, name: string}[]} availableRoles Current role catalog.
+ * @returns {string} Role name or a safe unknown-role label.
+ * @sideEffects None.
+ */
+function getRoleLabel(roleId, availableRoles) {
+	const role = availableRoles.find(availableRole => availableRole.id === roleId);
+	return role?.name ?? `Unknown role (${roleId})`;
+}
+
 /**
 /**
  * Renders the administrator user and role management page.
  * Renders the administrator user and role management page.
  *
  *
@@ -113,7 +126,7 @@ function UsersPage() {
 	/**
 	/**
 	 * Saves a new or existing user through the administrator API.
 	 * Saves a new or existing user through the administrator API.
 	 *
 	 *
-	 * @param {{username: string, password: string, roles: string[]}} values User form values.
+	 * @param {{username: string, password: string, roles: number[]}} values User form values.
 	 * @returns {Promise<void>} Resolves after the mutation completes.
 	 * @returns {Promise<void>} Resolves after the mutation completes.
 	 * @sideEffects Sends a CSRF-protected request and updates the table.
 	 * @sideEffects Sends a CSRF-protected request and updates the table.
 	 */
 	 */
@@ -229,7 +242,7 @@ function UsersPage() {
 											<TableCell>{user.username}</TableCell>
 											<TableCell>{user.username}</TableCell>
 											<TableCell>
 											<TableCell>
 												<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
 												<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
-													{(Array.isArray(user.roles) ? user.roles : []).map(role => <Chip key={role} label={role} size="small" />)}
+													{(Array.isArray(user.roles) ? user.roles : []).map(roleId => <Chip key={roleId} label={getRoleLabel(roleId, availableRoles)} size="small" />)}
 												</Stack>
 												</Stack>
 											</TableCell>
 											</TableCell>
 											<TableCell align="right">
 											<TableCell align="right">

+ 449 - 15
ui/app/src/features/demo/DemoPage.jsx

@@ -1,27 +1,461 @@
-import { Container, Paper, Stack, Typography } from '@mui/material';
+import AddOutlinedIcon from '@mui/icons-material/AddOutlined';
+import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
+import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
+import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
+import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
+import {
+	Alert,
+	Box,
+	Button,
+	Card,
+	CardContent,
+	Chip,
+	CircularProgress,
+	Container,
+	Dialog,
+	DialogActions,
+	DialogContent,
+	DialogTitle,
+	FormControl,
+	IconButton,
+	InputAdornment,
+	InputLabel,
+	MenuItem,
+	Paper,
+	Select,
+	Snackbar,
+	Stack,
+	Table,
+	TableBody,
+	TableCell,
+	TableContainer,
+	TableHead,
+	TableRow,
+	TextField,
+	Tooltip,
+	Typography,
+} from '@mui/material';
+import { useEffect, useMemo, useState } from 'react';
+import {
+	createInventoryItem,
+	deleteInventoryItem,
+	getInventoryItems,
+	updateInventoryItem,
+} from '../../api/client';
+import InventoryItemDialog from './InventoryItemDialog';
+
+const FILTER_ALL = 'all';
+const FILTER_LOW_STOCK = 'low-stock';
+
+/**
+ * Converts an API value to a non-negative inventory quantity.
+ *
+ * @param {unknown} value API quantity value.
+ * @returns {number} Normalized quantity.
+ * @sideEffects None.
+ */
+function toQuantity(value) {
+	const quantity = Number(value);
+	return Number.isFinite(quantity) && quantity >= 0 ? quantity : 0;
+}
+
+/**
+ * Normalizes an API item for consistent table and form rendering.
+ *
+ * @param {Record<string, unknown>} item API inventory item.
+ * @returns {Record<string, unknown>} Normalized inventory item.
+ * @sideEffects None.
+ */
+function normalizeItem(item) {
+	return {
+		...item,
+		id: item.id,
+		name: String(item.name ?? ''),
+		sku: String(item.sku ?? item.code ?? ''),
+		quantity: toQuantity(item.quantity ?? item.stock),
+		reorder_level: toQuantity(item.reorder_level),
+		updatedAt: item.updatedAt ?? item.updated_at ?? '',
+	};
+}
+
+/**
+ * Identifies whether an item is at or below its low-stock threshold.
+ *
+ * @param {Record<string, unknown>} item Inventory item.
+ * @returns {boolean} Whether the item needs attention.
+ * @sideEffects None.
+ */
+function isLowStock(item) {
+	return item.quantity <= item.reorder_level;
+}
 
 
 /**
 /**
- * Renders the demo module page.
+ * Formats an optional inventory update timestamp for display.
  *
  *
- * @returns {JSX.Element} Demo page.
+ * @param {unknown} value API timestamp.
+ * @returns {string} Localized timestamp or a dash.
  * @sideEffects None.
  * @sideEffects None.
  */
  */
+function formatUpdatedAt(value) {
+	if (!value) {
+		return '—';
+	}
+
+	const date = new Date(String(value));
+	return Number.isNaN(date.getTime()) ? '—' : date.toLocaleDateString();
+}
+
+/**
+ * Renders a compact inventory KPI card.
+ *
+ * @param {{label: string, value: string|number, detail: string}} props KPI properties.
+ * @returns {JSX.Element} KPI card.
+ * @sideEffects None.
+ */
+function KpiCard({ label, value, detail }) {
+	return (
+		<Card elevation={0} sx={{ flex: '1 1 220px', border: '1px solid', borderColor: 'divider', borderRadius: 3 }}>
+			<CardContent>
+				<Typography variant="overline" color="text.secondary">
+					{label}
+				</Typography>
+				<Typography variant="h4" component="p" sx={{ mt: 0.5, fontWeight: 800 }}>
+					{value}
+				</Typography>
+				<Typography variant="body2" color="text.secondary">
+					{detail}
+				</Typography>
+			</CardContent>
+		</Card>
+	);
+}
+
+/**
+ * Renders the inventory monitor with item management controls.
+ *
+ * @returns {JSX.Element} Inventory monitor page.
+ * @sideEffects Reads and mutates inventory through the same-origin API and updates local UI state.
+ */
 function DemoPage() {
 function DemoPage() {
+	const [items, setItems] = useState([]);
+	const [loading, setLoading] = useState(true);
+	const [errorMessage, setErrorMessage] = useState('');
+	const [search, setSearch] = useState('');
+	const [stockFilter, setStockFilter] = useState(FILTER_ALL);
+	const [dialogOpen, setDialogOpen] = useState(false);
+	const [editingItem, setEditingItem] = useState(null);
+	const [deleteItem, setDeleteItem] = useState(null);
+	const [submitting, setSubmitting] = useState(false);
+	const [feedback, setFeedback] = useState({ open: false, message: '', severity: 'success' });
+
+	useEffect(() => {
+		let isMounted = true;
+
+		getInventoryItems()
+			.then(nextItems => {
+				if (isMounted) {
+					setItems(nextItems.map(normalizeItem));
+				}
+			})
+			.catch(error => {
+				if (isMounted) {
+					setErrorMessage(error.message || 'Unable to load inventory items.');
+				}
+			})
+			.finally(() => {
+				if (isMounted) {
+					setLoading(false);
+				}
+			});
+
+		return () => {
+			isMounted = false;
+		};
+	}, []);
+
+	const filteredItems = useMemo(() => {
+		const normalizedSearch = search.trim().toLowerCase();
+
+		return items.filter(item => {
+			const matchesSearch =
+				!normalizedSearch ||
+				item.name.toLowerCase().includes(normalizedSearch) ||
+				item.sku.toLowerCase().includes(normalizedSearch);
+			const matchesFilter = stockFilter === FILTER_ALL || isLowStock(item);
+
+			return matchesSearch && matchesFilter;
+		});
+	}, [items, search, stockFilter]);
+
+	const lowStockCount = items.filter(isLowStock).length;
+	const totalUnits = items.reduce((total, item) => total + item.quantity, 0);
+
+	/**
+	 * Opens the empty item form.
+	 *
+	 * @returns {void}
+	 * @sideEffects Updates dialog state.
+	 */
+	function handleAddItem() {
+		setEditingItem(null);
+		setDialogOpen(true);
+	}
+
+	/**
+	 * Opens the item form populated with an existing item.
+	 *
+	 * @param {Record<string, unknown>} item Inventory item to edit.
+	 * @returns {void}
+	 * @sideEffects Updates dialog state.
+	 */
+	function handleEditItem(item) {
+		setEditingItem(item);
+		setDialogOpen(true);
+	}
+
+	/**
+	 * Saves a new or edited item and refreshes the list from the API.
+	 *
+	 * @param {Record<string, unknown>} values Inventory item form values.
+	 * @returns {Promise<void>} Resolves after the mutation completes.
+	 * @sideEffects Performs a CSRF-protected API mutation and updates page state.
+	 */
+	async function handleSave(values) {
+		setSubmitting(true);
+		setErrorMessage('');
+
+		try {
+			if (editingItem) {
+				await updateInventoryItem(editingItem.id, values);
+			} else {
+				await createInventoryItem(values);
+			}
+
+			const nextItems = await getInventoryItems();
+			setItems(nextItems.map(normalizeItem));
+			setDialogOpen(false);
+			setFeedback({
+				open: true,
+				message: editingItem ? 'Inventory item updated.' : 'Inventory item added.',
+				severity: 'success',
+			});
+		} catch (error) {
+			setFeedback({ open: true, message: error.message || 'Unable to save the inventory item.', severity: 'error' });
+		} finally {
+			setSubmitting(false);
+		}
+	}
+
+	/**
+	 * Deletes the selected item and removes it from the table.
+	 *
+	 * @returns {Promise<void>} Resolves after the deletion completes.
+	 * @sideEffects Performs a CSRF-protected API mutation and updates page state.
+	 */
+	async function handleDeleteItem() {
+		if (!deleteItem) {
+			return;
+		}
+
+		setSubmitting(true);
+
+		try {
+			await deleteInventoryItem(deleteItem.id);
+			setItems(previous => previous.filter(item => item.id !== deleteItem.id));
+			setDeleteItem(null);
+			setFeedback({ open: true, message: 'Inventory item deleted.', severity: 'success' });
+		} catch (error) {
+			setFeedback({ open: true, message: error.message || 'Unable to delete the inventory item.', severity: 'error' });
+		} finally {
+			setSubmitting(false);
+		}
+	}
+
+	/**
+	 * Renders the loading and initial error states.
+	 *
+	 * @returns {JSX.Element|null} State content when loading or unavailable.
+	 * @sideEffects None.
+	 */
+	function renderLoadState() {
+		if (loading) {
+			return (
+				<Box className="flex min-h-64 items-center justify-center">
+					<CircularProgress aria-label="Loading inventory" />
+				</Box>
+			);
+		}
+
+		if (errorMessage) {
+			return <Alert severity="error">{errorMessage}</Alert>;
+		}
+
+		return null;
+	}
+
+	const loadState = renderLoadState();
+
 	return (
 	return (
-		<Container maxWidth="lg" disableGutters>
-			<Paper elevation={0} className="rounded-2xl border border-slate-200 p-6 sm:p-8">
-				<Stack spacing={2}>
-					<Typography variant="overline" color="primary">
-						Demo
-					</Typography>
-					<Typography variant="h3" component="h1">
-						Hello World
-					</Typography>
+		<Container maxWidth="xl" disableGutters>
+			<Stack spacing={3}>
+				<Box sx={{ display: 'flex', alignItems: { xs: 'flex-start', sm: 'center' }, justifyContent: 'space-between', gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
+					<Box>
+						<Stack direction="row" spacing={1} alignItems="center">
+							<Inventory2OutlinedIcon color="primary" />
+							<Typography variant="overline" color="primary">
+								Operations
+							</Typography>
+						</Stack>
+						<Typography variant="h3" component="h1" sx={{ mt: 0.5, fontWeight: 800 }}>
+							Inventory monitor
+						</Typography>
+						<Typography color="text.secondary">Keep an eye on stock levels and act before supplies run out.</Typography>
+					</Box>
+					<Button variant="contained" startIcon={<AddOutlinedIcon />} onClick={handleAddItem}>
+						Add item
+					</Button>
+				</Box>
+
+				{loadState ?? (
+					<>
+						<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
+							<KpiCard label="Tracked items" value={items.length} detail="Active SKUs in inventory" />
+							<KpiCard label="Low stock" value={lowStockCount} detail="Items at or below threshold" />
+							<KpiCard label="Total units" value={totalUnits} detail="Across all tracked items" />
+						</Stack>
+
+						<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 3, overflow: 'hidden' }}>
+							<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ p: 2 }}>
+								<TextField
+									value={search}
+									onChange={event => setSearch(event.target.value)}
+									placeholder="Search by item or SKU"
+									aria-label="Search inventory"
+									fullWidth
+									InputProps={{
+										startAdornment: (
+											<InputAdornment position="start">
+												<SearchOutlinedIcon fontSize="small" />
+											</InputAdornment>
+										),
+									}}
+								/>
+								<FormControl sx={{ minWidth: { md: 180 } }}>
+									<InputLabel id="stock-filter-label">Stock status</InputLabel>
+									<Select
+										labelId="stock-filter-label"
+										value={stockFilter}
+										label="Stock status"
+										onChange={event => setStockFilter(event.target.value)}
+									>
+										<MenuItem value={FILTER_ALL}>All items</MenuItem>
+										<MenuItem value={FILTER_LOW_STOCK}>Low stock</MenuItem>
+									</Select>
+								</FormControl>
+							</Stack>
+							<TableContainer>
+								<Table sx={{ minWidth: 680 }} aria-label="Inventory items">
+									<TableHead>
+										<TableRow>
+											<TableCell>Item</TableCell>
+											<TableCell>SKU</TableCell>
+											<TableCell align="right">Stock</TableCell>
+											<TableCell>Status</TableCell>
+											<TableCell>Updated</TableCell>
+											<TableCell align="right">Actions</TableCell>
+										</TableRow>
+									</TableHead>
+									<TableBody>
+										{filteredItems.map(item => {
+											const lowStock = isLowStock(item);
+
+											return (
+												<TableRow key={item.id} hover>
+													<TableCell>
+														<Typography fontWeight={700}>{item.name}</Typography>
+														<Typography variant="caption" color="text.secondary">
+															Threshold: {item.reorder_level}
+														</Typography>
+													</TableCell>
+													<TableCell>{item.sku}</TableCell>
+													<TableCell align="right">
+														{item.quantity}
+													</TableCell>
+													<TableCell>
+														<Chip
+															label={lowStock ? 'Low stock' : 'In stock'}
+															color={lowStock ? 'warning' : 'success'}
+															size="small"
+														/>
+													</TableCell>
+													<TableCell>{formatUpdatedAt(item.updatedAt)}</TableCell>
+													<TableCell align="right">
+														<Tooltip title="Edit item">
+															<IconButton aria-label={`Edit ${item.name}`} onClick={() => handleEditItem(item)}>
+																<EditOutlinedIcon fontSize="small" />
+															</IconButton>
+														</Tooltip>
+														<Tooltip title="Delete item">
+															<IconButton aria-label={`Delete ${item.name}`} onClick={() => setDeleteItem(item)} color="error">
+																<DeleteOutlineOutlinedIcon fontSize="small" />
+															</IconButton>
+														</Tooltip>
+													</TableCell>
+												</TableRow>
+											);
+										})}
+										{filteredItems.length === 0 && (
+											<TableRow>
+												<TableCell colSpan={6}>
+													<Stack spacing={0.5} alignItems="center" sx={{ py: 6 }}>
+														<Typography fontWeight={700}>No inventory items found</Typography>
+														<Typography variant="body2" color="text.secondary">
+															Try another search or add a new item.
+														</Typography>
+													</Stack>
+												</TableCell>
+											</TableRow>
+										)}
+									</TableBody>
+								</Table>
+							</TableContainer>
+						</Paper>
+					</>
+				)}
+			</Stack>
+
+			<InventoryItemDialog
+				open={dialogOpen}
+				item={editingItem}
+				submitting={submitting}
+				onClose={() => setDialogOpen(false)}
+				onSubmit={handleSave}
+			/>
+			<Dialog open={Boolean(deleteItem)} onClose={submitting ? undefined : () => setDeleteItem(null)} maxWidth="xs" fullWidth>
+				<DialogTitle>Delete inventory item?</DialogTitle>
+				<DialogContent>
 					<Typography color="text.secondary">
 					<Typography color="text.secondary">
-						This page is rendered by the React application shell.
+						This will permanently remove {deleteItem?.name ?? 'this item'} from the inventory monitor.
 					</Typography>
 					</Typography>
-				</Stack>
-			</Paper>
+				</DialogContent>
+				<DialogActions>
+					<Button onClick={() => setDeleteItem(null)} disabled={submitting}>
+						Cancel
+					</Button>
+					<Button onClick={handleDeleteItem} color="error" variant="contained" disabled={submitting}>
+						Delete item
+					</Button>
+				</DialogActions>
+			</Dialog>
+			<Snackbar
+				open={feedback.open}
+				autoHideDuration={4000}
+				onClose={() => setFeedback(previous => ({ ...previous, open: false }))}
+			>
+				<Alert severity={feedback.severity} variant="filled" onClose={() => setFeedback(previous => ({ ...previous, open: false }))}>
+					{feedback.message}
+				</Alert>
+			</Snackbar>
 		</Container>
 		</Container>
 	);
 	);
 }
 }

+ 147 - 0
ui/app/src/features/demo/InventoryItemDialog.jsx

@@ -0,0 +1,147 @@
+import {
+	Button,
+	Dialog,
+	DialogActions,
+	DialogContent,
+	DialogTitle,
+	Stack,
+	TextField,
+} from '@mui/material';
+import { useEffect, useState } from 'react';
+
+const EMPTY_FORM = {
+	name: '',
+	sku: '',
+	quantity: '0',
+	reorder_level: '0',
+};
+
+/**
+ * Converts an inventory item into editable form values.
+ *
+ * @param {Record<string, unknown>|null} item Inventory item to edit.
+ * @returns {Record<string, string>} Form values.
+ * @sideEffects None.
+ */
+function getFormValues(item) {
+	if (!item) {
+		return EMPTY_FORM;
+	}
+
+	return {
+		name: String(item.name ?? ''),
+		sku: String(item.sku ?? ''),
+		quantity: String(item.quantity ?? 0),
+		reorder_level: String(item.reorder_level ?? 0),
+	};
+}
+
+/**
+ * Renders the create and edit form for an inventory item.
+ *
+ * @param {{open: boolean, item: Record<string, unknown>|null, submitting: boolean, onClose: () => void, onSubmit: (values: Record<string, unknown>) => void}} props Dialog properties.
+ * @returns {JSX.Element} Inventory item dialog.
+ * @sideEffects Updates local form state and invokes the submit callback.
+ */
+function InventoryItemDialog({ open, item, submitting, onClose, onSubmit }) {
+	const [formValues, setFormValues] = useState(EMPTY_FORM);
+	const isEditing = Boolean(item);
+
+	useEffect(() => {
+		if (open) {
+			setFormValues(getFormValues(item));
+		}
+	}, [item, open]);
+
+	/**
+	 * Updates one form field.
+	 *
+	 * @param {React.ChangeEvent<HTMLInputElement>} event Input change event.
+	 * @returns {void}
+	 * @sideEffects Updates local form state.
+	 */
+	function handleChange(event) {
+		const { name, value } = event.target;
+		setFormValues(previous => ({ ...previous, [name]: value }));
+	}
+
+	/**
+	 * Validates and submits the current form values.
+	 *
+	 * @param {React.FormEvent<HTMLFormElement>} event Form submit event.
+	 * @returns {void}
+	 * @sideEffects Invokes the parent submit callback when values are valid.
+	 */
+	function handleSubmit(event) {
+		event.preventDefault();
+
+		if (!formValues.name.trim() || !formValues.sku.trim()) {
+			return;
+		}
+
+		onSubmit({
+			name: formValues.name.trim(),
+			sku: formValues.sku.trim(),
+			quantity: Number(formValues.quantity),
+			reorder_level: Number(formValues.reorder_level),
+		});
+	}
+
+	return (
+		<Dialog open={open} onClose={submitting ? undefined : onClose} fullWidth maxWidth="sm">
+			<DialogTitle>{isEditing ? 'Edit inventory item' : 'Add inventory item'}</DialogTitle>
+			<DialogContent>
+				<Stack component="form" id="inventory-item-form" spacing={2} sx={{ pt: 1 }} onSubmit={handleSubmit}>
+					<TextField
+						autoFocus
+						label="Item name"
+						name="name"
+						value={formValues.name}
+						onChange={handleChange}
+						required
+						fullWidth
+					/>
+					<TextField
+						label="SKU"
+						name="sku"
+						value={formValues.sku}
+						onChange={handleChange}
+						required
+						fullWidth
+					/>
+					<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
+						<TextField
+							label="Current stock"
+							name="quantity"
+							type="number"
+							value={formValues.quantity}
+							onChange={handleChange}
+							inputProps={{ min: 0, step: 1 }}
+							fullWidth
+						/>
+						<TextField
+							label="Low-stock threshold"
+							name="reorder_level"
+							type="number"
+							value={formValues.reorder_level}
+							onChange={handleChange}
+							inputProps={{ min: 0, step: 1 }}
+							fullWidth
+						/>
+					</Stack>
+
+				</Stack>
+			</DialogContent>
+			<DialogActions sx={{ px: 3, pb: 2 }}>
+				<Button onClick={onClose} disabled={submitting}>
+					Cancel
+				</Button>
+				<Button type="submit" form="inventory-item-form" variant="contained" disabled={submitting}>
+					{isEditing ? 'Save changes' : 'Add item'}
+				</Button>
+			</DialogActions>
+		</Dialog>
+	);
+}
+
+export default InventoryItemDialog;

+ 4 - 0
ui/app/src/features/shell/AppShell.jsx

@@ -17,10 +17,12 @@ import {
 } from '@mui/material';
 } from '@mui/material';
 import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
 import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
 import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
 import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
+import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
 import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
 import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
 import MenuIcon from '@mui/icons-material/Menu';
 import MenuIcon from '@mui/icons-material/Menu';
 import LogoutIcon from '@mui/icons-material/Logout';
 import LogoutIcon from '@mui/icons-material/Logout';
 import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
 import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
+import SecurityOutlinedIcon from '@mui/icons-material/SecurityOutlined';
 import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
 import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 import { useLocation } from 'react-router-dom';
 import { useLocation } from 'react-router-dom';
@@ -32,8 +34,10 @@ const DEFAULT_HEADER_LABEL = 'Workspace';
 const NAVIGATION_ICONS = {
 const NAVIGATION_ICONS = {
 	Demo: DashboardOutlinedIcon,
 	Demo: DashboardOutlinedIcon,
 	Admin: AdminPanelSettingsOutlinedIcon,
 	Admin: AdminPanelSettingsOutlinedIcon,
+	Roles: SecurityOutlinedIcon,
 	'OM-Link Settings': TuneOutlinedIcon,
 	'OM-Link Settings': TuneOutlinedIcon,
 	Users: PeopleOutlinedIcon,
 	Users: PeopleOutlinedIcon,
+	'Inventory Monitor': Inventory2OutlinedIcon,
 };
 };
 
 
 /**
 /**

+ 36 - 8
ui/app/src/routes.jsx

@@ -8,17 +8,20 @@ import HomePage from './features/home/HomePage';
 import AppShell from './features/shell/AppShell';
 import AppShell from './features/shell/AppShell';
 import ConfigPage from './features/admin/ConfigPage';
 import ConfigPage from './features/admin/ConfigPage';
 import UsersPage from './features/admin/UsersPage';
 import UsersPage from './features/admin/UsersPage';
+import RolesPage from './features/admin/RolesPage';
 
 
 const HOME_ROUTE = '/home';
 const HOME_ROUTE = '/home';
 const LOGIN_ROUTE = '/';
 const LOGIN_ROUTE = '/';
-const ADMIN_ROLE = 'admin';
+const ADMIN_ROLE_ID = 1;
+const INVENTORY_ROLE_ID = 2;
 
 
 const ROUTES = {
 const ROUTES = {
 	'/home': HomePage,
 	'/home': HomePage,
-	'/demo': DemoPage,
+	'/inventory': DemoPage,
 	'/admin': () => <RoutePlaceholder title="Administration" />,
 	'/admin': () => <RoutePlaceholder title="Administration" />,
 	'/admin/omlsettings': ConfigPage,
 	'/admin/omlsettings': ConfigPage,
 	'/admin/users': UsersPage,
 	'/admin/users': UsersPage,
+	'/admin/roles': RolesPage,
 };
 };
 
 
 /**
 /**
@@ -78,16 +81,36 @@ function AuthenticatedRoute({ session, onLogout }) {
 }
 }
 
 
 /**
 /**
- * Restricts administrator routes to sessions that explicitly include the admin role.
+ * Resolves the numeric identifier from a session role value.
  *
  *
- * @param {{session: Record<string, unknown>}} props Authorization properties.
+ * @param {unknown} role Session role value.
+ * @returns {number|null} Numeric role identifier when available.
+ * @sideEffects None.
+ */
+function getRoleId(role) {
+	if (typeof role === 'number' && Number.isInteger(role)) {
+		return role;
+	}
+
+	if (role && typeof role === 'object') {
+		return getRoleId(role.id ?? role.roleId);
+	}
+
+	return null;
+}
+
+/**
+ * Restricts a route to sessions containing the requested numeric role.
+ *
+ * @param {{session: Record<string, unknown>, roleId: number}} props Authorization properties.
  * @returns {JSX.Element} Authorized outlet or home redirect.
  * @returns {JSX.Element} Authorized outlet or home redirect.
  * @sideEffects Updates browser history through React Router when authorization fails.
  * @sideEffects Updates browser history through React Router when authorization fails.
  */
  */
-function AdminRoute({ session }) {
+function RoleRoute({ session, roleId }) {
 	const roles = Array.isArray(session.roles) ? session.roles : [];
 	const roles = Array.isArray(session.roles) ? session.roles : [];
+	const hasRole = roles.some(role => getRoleId(role) === roleId);
 
 
-	return roles.includes(ADMIN_ROLE) ? <Outlet /> : <Navigate to={HOME_ROUTE} replace />;
+	return hasRole ? <Outlet /> : <Navigate to={HOME_ROUTE} replace />;
 }
 }
 
 
 /**
 /**
@@ -183,14 +206,19 @@ function AppRoutes() {
 				}
 				}
 			>
 			>
 				<Route path={HOME_ROUTE} element={<RouteContent path={HOME_ROUTE} session={session} />} />
 				<Route path={HOME_ROUTE} element={<RouteContent path={HOME_ROUTE} session={session} />} />
-				<Route path="/demo" element={<RouteContent path="/demo" session={session} />} />
-				<Route element={<AdminRoute session={session} />}>
+				<Route
+					element={<RoleRoute session={session} roleId={INVENTORY_ROLE_ID} />}
+				>
+					<Route path="/inventory" element={<RouteContent path="/inventory" session={session} />} />
+				</Route>
+				<Route element={<RoleRoute session={session} roleId={ADMIN_ROLE_ID} />}>
 					<Route path="/admin" element={<RouteContent path="/admin" session={session} />} />
 					<Route path="/admin" element={<RouteContent path="/admin" session={session} />} />
 					<Route
 					<Route
 						path="/admin/omlsettings"
 						path="/admin/omlsettings"
 						element={<RouteContent path="/admin/omlsettings" session={session} />}
 						element={<RouteContent path="/admin/omlsettings" session={session} />}
 					/>
 					/>
 					<Route path="/admin/users" element={<RouteContent path="/admin/users" session={session} />} />
 					<Route path="/admin/users" element={<RouteContent path="/admin/users" session={session} />} />
+					<Route path="/admin/roles" element={<RouteContent path="/admin/roles" session={session} />} />
 				</Route>
 				</Route>
 			</Route>
 			</Route>
 			<Route
 			<Route