# Multi-Admin RBAC — Full Implementation Plan ## Background Currently, the admin panel has exactly **one** hardcoded admin (from `.env`). Authentication is a 2-step flow: password check → OTP → HTTP Basic Auth on all subsequent API calls. Credentials are stored in `localStorage`. OTP always goes to `settings.admin_email` (the global one). This plan adds a **multi-admin system** with a `superadmin` role and restricted sub-admin accounts (e.g. "HR"), each with selective permissions, their own email, their own password, and the ability to change their own password. --- ## Open Questions (Please Answer Before Execution) > [!IMPORTANT] > **Password Hashing Library** > To securely store passwords, we need hashing. Two options: > - **Option A (No new deps):** Use Python's built-in `hashlib.pbkdf2_hmac` with a random salt (SHA-256, 260,000 iterations). This is NIST-approved and requires zero new dependencies. > - **Option B (Add `bcrypt`):** Add `bcrypt` (or `passlib[bcrypt]`) to `requirements.txt`. More commonly recognized in industry. > > **Recommendation: Option A** to keep things self-contained. Please confirm. > [!IMPORTANT] > **Session Authentication: Replace HTTP Basic Auth?** > Currently, the frontend stores raw username+password in `localStorage` and sends them as an HTTP Basic Auth header on every single API call. In a multi-admin world this is still functional, but it means the raw password is always in memory and in every request header. > > A more modern approach: after OTP verification, issue a **short-lived token** (a signed HMAC token stored in the `admins.json`). The frontend stores this token, not the password. On logout or after 8 hours it expires automatically. > > **Recommendation: Keep HTTP Basic Auth** for backward-compatibility and simplicity (it already works), but add a `token` field to the store for logout invalidation. Please confirm. > [!IMPORTANT] > **Permissions Scope** > The plan proposes 5 granular permissions. Please confirm or adjust: > - `manage_sessions` — View sessions, reply, block/unblock, takeover, end chat, archive/unarchive, delete > - `manage_whatsapp` — View and modify WhatsApp settings, test connection, fetch QR > - `view_analytics` — View dashboard metrics, SLA dashboard, charts, export data > - `manage_kb` — Future-proofing for knowledge base / document management > - `manage_admins` — Create, edit, delete other admin accounts (**Superadmin only by default**) --- ## Permission Architecture — Two-Level Hierarchy The permission system has **two levels**. Every piece of UI the superadmin can grant access to maps to one of these levels. ### Level 1 — Top-Level Permissions (Sidebar Items) Each Level 1 permission controls **whether a sidebar nav item is visible at all**. | Sidebar Nav Item | `id` | Level 1 Permission Required | |---|---|---| | Dashboard | `nav-dashboard` | `view_analytics` | | SLA Dashboard | `nav-sla` | `view_analytics` | | Live Sessions | `nav-sessions` | `manage_sessions` | | Queued Users | `nav-queued` | `manage_sessions` | | Daily Users | `nav-daily` | `manage_sessions` | | Blocked Users | `nav-blocked` | `manage_sessions` | | Archived | `nav-archived` | `manage_sessions` | | Settings | `nav-settings` | At least one settings sub-permission | | Admin Users *(new)* | `nav-admin-users` | `manage_admins` | | Chat Widget Demo *(quick link)* | — | Any permission (always shown) | > [!NOTE] > Settings is a **composite** nav item. It is visible only if the sub-admin has at least one Settings sub-permission (e.g. `manage_whatsapp`). It is **hidden entirely** if they have zero settings sub-permissions. --- ### Level 2 — Sub-Permissions (Sections Within a Sidebar Item) Each Level 2 sub-permission controls **a specific section or action inside a top-level view**. #### Under `manage_sessions` (Live Sessions view) | Sub-Permission | What it controls | |---|---| | `sessions.view` | Can see the session list and read transcripts | | `sessions.reply` | Can type and send replies in the reply area | | `sessions.takeover` | Can click "Take Over" and start a human takeover | | `sessions.block` | Can block/unblock users | | `sessions.end_chat` | Can end a chat completely | | `sessions.archive` | Can archive/unarchive sessions and chat threads | | `sessions.delete` | Can delete sessions and individual chat threads | > [!NOTE] > `sessions.view` is always required for any other `sessions.*` sub-permission to make sense. If only `sessions.view` is granted, the sub-admin sees read-only transcripts with no action buttons. #### Under `view_analytics` (Dashboard & SLA views) | Sub-Permission | What it controls | |---|---| | `analytics.dashboard` | Sees the main Dashboard view (metrics + chart) | | `analytics.sla` | Sees the SLA Dashboard view | | `analytics.export` | Sees the "Export Data" button and can download reports | #### Under `manage_whatsapp` (Settings → WhatsApp section) | Sub-Permission | What it controls | |---|---| | `whatsapp.view` | Can view current WhatsApp config (numbers, provider) | | `whatsapp.edit` | Can modify and save WhatsApp settings | | `whatsapp.test` | Can click "Send Test Message" | | `whatsapp.qr` | Can fetch and view the Baileys QR code | #### Under Settings (general sub-sections) | Sub-Permission | What it controls | |---|---| | `settings.notifications` | Can view and toggle the Notifications settings sub-section | | `settings.config` | Can view and toggle the Config settings sub-section (auto-refresh) | | `settings.security` | Can view the Security sub-section (OTP status) | | `settings.version` | Can view the Version Info sub-section | > [!NOTE] > `settings.whatsapp` is controlled by the `whatsapp.*` sub-permissions above — not as a standalone settings sub-permission. This avoids duplication. #### Under `manage_admins` (Admin Users view) | Sub-Permission | What it controls | |---|---| | `admins.view` | Can view the list of all admin accounts | | `admins.create` | Can create new admin accounts | | `admins.edit` | Can edit existing admin accounts (permissions, email, role) | | `admins.delete` | Can delete admin accounts | | `admins.reset_password` | Can reset another admin's password | > [!CAUTION] > `manage_admins` as a whole is **restricted to Superadmins by default**. Even if granted to a sub-admin (e.g. a "Senior HR Manager"), the sub-admin cannot grant permissions or roles they don't already have themselves — this is enforced on the backend. --- ### Sub-Permission Rules 26. Sub-permissions are **nested under their parent**. A sub-admin cannot have `sessions.reply` without also having `sessions.view` — the backend rejects such combinations. 27. When a parent-level permission toggle is turned **OFF** in the admin modal, all its child sub-permissions are **also cleared** simultaneously. The sub-permission toggles become hidden. 28. When a parent-level permission toggle is turned **ON**, the sub-permission group **expands** beneath it with individual toggles, all defaulting to **OFF** (except `sessions.view` which auto-toggles ON when `manage_sessions` is enabled, since you can't have sessions without viewing them). 29. Each sub-permission group has a **"Select All" / "Clear All"** shortcut link next to the group header for quick bulk-toggling. 30. The backend enforces sub-permissions independently — a missing sub-permission returns `403` even if the parent permission is present. Frontend hiding is a UX convenience, **not** the security layer. 31. Superadmins have **all sub-permissions** implicitly. Their `permissions` field in `admins.json` stores only the parent-level keys; sub-permissions for superadmins are implied. 32. Sub-permissions are stored as **flat strings** in the `permissions` array, using dot notation: `["sessions.view", "sessions.reply", "analytics.dashboard"]`. This keeps the JSON schema simple with no nested objects. --- ### Superadmin Rules 1. There must **always be at least one active superadmin**. Deleting or demoting the last superadmin is forbidden. 2. A superadmin **cannot delete or demote themselves** if they are the last superadmin. 3. A superadmin **cannot delete their own account** while logged in (to prevent accidental lockout). 4. Superadmins have **all permissions** implicitly and cannot have permissions individually revoked. 5. The initial superadmin is seeded from `.env` (`ADMIN_USER` / `ADMIN_PASS`) at first startup. After seeding, `.env` credentials are **still checked as a fallback** (so you're never locked out). ### Sub-Admin Rules 6. Sub-admins can only access API routes they have permission for. Attempting to access a route without the required permission returns **403 Forbidden** (not 401). 7. A sub-admin **cannot grant themselves permissions** they do not have. 8. A sub-admin **cannot create, edit, or delete other admins** unless they have `manage_admins`. 9. A sub-admin **cannot change another user's password** — only their own. 10. A sub-admin's email must be **unique** in the store. Duplicate emails are rejected. ### Permission Toggle Rules (During Creation) 11. When creating a sub-admin, **all permission toggles default to OFF** — no permissions are granted unless explicitly turned on by the superadmin. 12. The **role selector** (`Superadmin` / `Sub-admin`) controls toggle availability: - If `Superadmin` is selected: all toggles are **hidden** (replaced by "All permissions granted" label). - If `Sub-admin` is selected: all permission toggles are shown and individually controllable. 13. A new sub-admin must have **at least one permission enabled** before the form can be submitted. Attempting to save with zero permissions shows a validation error inline: "Please enable at least one permission." 14. The superadmin performing the creation **cannot grant a permission they don't have themselves** (enforced on backend too — rule 7 above). ### Permission Toggle Rules (After Creation — Live Editing) 15. A superadmin can open any existing sub-admin's profile at any time and toggle permissions **on or off individually** using the same toggle UI. 16. Toggling a permission in the edit modal does **not auto-save** — the superadmin must click "Save Changes" to commit. 17. If a superadmin turns **all toggles off** for an existing sub-admin and tries to save, the backend rejects it with a `400` error: "A sub-admin must have at least one permission." 18. Saving permission changes takes effect **immediately on the backend** — the updated `admins.json` is written atomically. The affected sub-admin sees the change the next time their session re-validates (`GET /api/admin/me`). 19. A permission that is **currently being used** (e.g., the sub-admin is actively viewing a sessions list) is not interrupted mid-session. The restriction applies to the **next API call** after the change is saved. 20. The edit modal shows a **read-only "last modified"** timestamp so the superadmin knows when permissions were last changed. ### Permission Toggle — UI Behaviour Rules 21. Each toggle has a **label** (the feature name) and a **description** (one line explaining what access it grants). This removes ambiguity. 22. Toggling a permission in the UI gives an **instant visual response** — the toggle animates to the new state immediately, before saving. 23. The "Save Changes" button is **disabled and grayed out** until at least one toggle state has been changed from the original saved state (dirty-state detection). 24. If the superadmin closes the edit modal without saving, a **discard confirmation** is shown: "You have unsaved changes. Discard them?" — prevents accidental permission loss. 25. Toggling to `Superadmin` role while editing shows a **warning banner** inside the modal: "Upgrading to Superadmin grants full unrestricted access. This cannot be scoped." The Save button requires a second click to confirm after this warning appears. ### Password Rules 11. Passwords must be **at least 8 characters**. 12. When changing password, the **current password must be verified** before the new password is set. 13. Passwords are stored as `pbkdf2_hmac` hash+salt — never in plaintext. 14. The superadmin seeded from `.env` uses the **raw `.env` password for its first login**, then the store takes over. On seeding, the `.env` password is hashed and stored. ### OTP Rules 15. OTP is per-user, sent to **their own email** (not the global `settings.admin_email`). 16. If an admin has **no email configured**, the OTP is only logged to the server console (existing fallback behavior). 17. OTP codes expire in **5 minutes** (unchanged). 18. After 5 failed OTP attempts, the OTP is **invalidated** and a new one must be requested. 19. OTP is **single-use** — verified once and immediately deleted from the in-memory store. ### Brute-Force / Security Rules 20. After **5 consecutive failed login attempts** (wrong password at the `/request-otp` stage), the account is **temporarily locked for 15 minutes**. Lock state is in-memory (resets on server restart). 21. All credential comparisons use `secrets.compare_digest()` to prevent timing attacks. 22. Admin usernames are **case-insensitive** for lookup but stored in their original casing. 23. Usernames must be **alphanumeric + underscores only** (`^[a-zA-Z0-9_]{3,32}$`). This prevents path-traversal issues in future file-based or URL-based lookups. ### Data Storage Rules 24. Admin data is stored in a **`data/admins.json`** file (same persistent volume as sessions). 25. File writes use an **atomic write** pattern (write to `.tmp`, then `os.replace()`). This prevents data corruption if the server crashes mid-write. 26. All reads/writes to `admins.json` are wrapped in an **`asyncio.Lock`** to prevent race conditions. 27. The file is **not** exposed via any API endpoint. The `/api/admin/users` endpoint returns only safe fields (`username`, `role`, `email`, `permissions`, `created_at`, `last_login`). The password hash/salt are never returned. --- ## Proposed Changes --- ### 1. `app/services/admin_store.py` — [NEW] This is the core persistence layer. It is the **single source of truth** for all admin accounts. **Data structure in `admins.json` (updated to reflect sub-permissions + ease features):** ```json { "admins": [ { "username": "martech_admin", "role": "superadmin", "email": "admin@example.com", "display_name": "Martech Admin", "notes": "", "password_hash": "...", "password_salt": "...", "password_changed_at": "2026-06-12T00:00:00Z", "permissions": [ "manage_sessions", "manage_whatsapp", "view_analytics", "manage_admins", "manage_kb" ], "is_active": true, "created_at": "2026-06-12T00:00:00Z", "created_by": "system", "last_login": null, "last_active": null, "failed_attempts": 0, "locked_until": null, "force_logout_at": null }, { "username": "hr_user", "role": "sub_admin", "email": "hr@company.com", "display_name": "HR Manager", "notes": "Handles all live chat sessions for the HR department.", "password_hash": "...", "password_salt": "...", "password_changed_at": "2026-06-12T00:00:00Z", "permissions": [ "manage_sessions", "sessions.view", "sessions.reply", "sessions.takeover", "analytics.dashboard" ], "is_active": true, "created_at": "2026-06-12T00:00:00Z", "created_by": "martech_admin", "last_login": null, "last_active": null, "failed_attempts": 0, "locked_until": null, "force_logout_at": null } ], "audit_log": [ { "timestamp": "2026-06-12T10:00:00Z", "actor": "martech_admin", "action": "create_admin", "target": "hr_user", "detail": "Created sub-admin with permissions: manage_sessions, sessions.view, sessions.reply" } ] } ``` > [!NOTE] > Superadmins store only the 5 top-level parent keys. Sub-admins store both the parent key (e.g. `"manage_sessions"`) **and** the specific sub-permission keys (e.g. `"sessions.view"`). The parent key's presence is used for sidebar visibility; the sub-key's presence is used for in-view action enforcement. **Functions to implement:** | Function | Description | |---|---| | `_load_admins()` | Read `admins.json`, return dict. Thread-safe. | | `_save_admins(data)` | Atomic write to `admins.json`. Uses temp file + `os.replace()`. | | `get_admin(username)` | Return a single admin record (case-insensitive lookup). Returns `None` if not found. | | `get_all_admins()` | Return list of all admins (safe fields only, no hash/salt). | | `create_admin(...)` | Validate inputs, hash password, insert record. Raise on duplicate username/email. | | `update_admin(username, ...)` | Update role/email/permissions. Enforce superadmin count rules. | | `delete_admin(username, requesting_user)` | Delete admin. Enforce self-deletion and last-superadmin rules. | | `verify_password(username, password)` | Verify password against stored hash. Also handles `.env` fallback for the initial superadmin. Increments `failed_attempts` on failure, locks after 5. | | `change_password(username, old_pass, new_pass)` | Verify old password first, then update hash. | | `record_login(username)` | Update `last_login` timestamp. | | `check_locked(username)` | Return `True` if account is locked. | | `hash_password(password)` → `(hash, salt)` | `os.urandom(32)` salt + `pbkdf2_hmac('sha256', ...)` | | `seed_initial_admin()` | **Called once at startup.** If `admins.json` does not exist or is empty, create it with the superadmin from `.env`. | --- ### 2. `app/admin/router.py` — [MODIFY] **Auth dependency changes:** - **`verify_admin(credentials)`** → now calls `admin_store.verify_password()`. Still returns `username` on success. Returns 423 (Locked) if account is locked, 401 otherwise. - **NEW: `require_permission(permission_name)` factory** → Returns a FastAPI dependency that checks `admin_store.get_admin(username).permissions` and raises **403** if the permission is missing. Superadmins always pass. - All existing routes that need protection will use **both** `verify_admin` AND `require_permission(...)` where appropriate. **OTP endpoint changes:** - `request_otp`: Look up user from `admin_store`. If found, send OTP to **their own email**. If user not found → return generic `401` (don't reveal user existence). If account locked → return `423`. - `verify_otp`: After success, call `admin_store.record_login(username)` and reset `failed_attempts` to 0. - Add **OTP attempt counter** per username in `_otp_store`. After 5 wrong OTP guesses, invalidate the OTP entry and force a new login. **Permission enforcement — extended to sub-permission level:** | Route | Top-Level Permission | Sub-Permission Required | |---|---|---| | `GET /api/admin/sessions` | `manage_sessions` | `sessions.view` | | `GET /api/admin/sessions/{id}` | `manage_sessions` | `sessions.view` | | `DELETE /api/admin/sessions/{id}` | `manage_sessions` | `sessions.delete` | | `POST /api/admin/sessions/{id}/block` | `manage_sessions` | `sessions.block` | | `POST /api/admin/sessions/{id}/unblock` | `manage_sessions` | `sessions.block` | | `POST /api/admin/sessions/{id}/takeover` | `manage_sessions` | `sessions.takeover` | | `POST /api/admin/sessions/{id}/end_takeover` | `manage_sessions` | `sessions.takeover` | | `POST /api/admin/sessions/{id}/reply` | `manage_sessions` | `sessions.reply` | | `POST /api/admin/sessions/{id}/end_chat` | `manage_sessions` | `sessions.end_chat` | | `POST /api/admin/sessions/{id}/archive` | `manage_sessions` | `sessions.archive` | | `POST /api/admin/sessions/{id}/unarchive` | `manage_sessions` | `sessions.archive` | | `DELETE /api/admin/sessions/{id}/chats/{chat_id}` | `manage_sessions` | `sessions.delete` | | `GET /api/admin/metrics` | `view_analytics` | `analytics.dashboard` | | `GET /api/admin/analytics` | `view_analytics` | `analytics.dashboard` | | `GET /api/admin/sla-metrics` | `view_analytics` | `analytics.sla` | | `GET /api/admin/export/*` | `view_analytics` | `analytics.export` | | `GET /api/admin/settings/whatsapp` | `manage_whatsapp` | `whatsapp.view` | | `POST /api/admin/settings/whatsapp` | `manage_whatsapp` | `whatsapp.edit` | | `POST /api/admin/settings/whatsapp/test` | `manage_whatsapp` | `whatsapp.test` | | `GET /api/admin/settings/whatsapp/baileys/qr` | `manage_whatsapp` | `whatsapp.qr` | | `GET /api/admin/users` | `manage_admins` | `admins.view` | | `POST /api/admin/users` | `manage_admins` | `admins.create` | | `PUT /api/admin/users/{username}` | `manage_admins` | `admins.edit` | | `DELETE /api/admin/users/{username}` | `manage_admins` | `admins.delete` | | `POST /api/admin/change-password` | *(any logged-in user)* | *(no sub-perm required)* | | `GET /api/admin/me` | *(any logged-in user)* | *(no sub-perm required)* | **New endpoints to add:** ``` GET /api/admin/me → Returns current user profile + permissions (no auth dependency beyond verify_admin, used for frontend RBAC) GET /api/admin/users → List all admins [requires manage_admins] POST /api/admin/users → Create admin [requires manage_admins] PUT /api/admin/users/{username} → Update admin role/email/permissions [requires manage_admins] DELETE /api/admin/users/{username} → Delete admin [requires manage_admins] POST /api/admin/change-password → Change own password [requires verify_admin only] ``` **Edge cases for new endpoints:** - `DELETE /api/admin/users/{username}`: Return `400` with message "Cannot delete the last superadmin." if rule is violated. Return `403` if a non-superadmin tries this. Return `400` if trying to delete self. - `POST /api/admin/users`: Validate username regex `^[a-zA-Z0-9_]{3,32}$`. Return `409` on duplicate username/email. - `PUT /api/admin/users/{username}`: If changing `role` from `superadmin` to `sub_admin`, check remaining superadmins first. A sub-admin cannot set permissions higher than their own. - `POST /api/admin/change-password`: Validate new password is >= 8 chars. Must verify old password first. --- ### 3. `app/main.py` — [MODIFY] **Startup hook:** - Call `admin_store.seed_initial_admin()` at application startup (in the `lifespan` context or `@app.on_event("startup")`). - This is safe to call on every restart — it only creates the file if it doesn't already exist. --- ### 4. `app/admin/templates/admin.html` — [MODIFY] This is the largest change on the frontend. All changes are purely additive or guarded so existing admins see no difference. #### A. Post-Login Permission Fetching - Immediately after `initApp()` is called (after OTP verified), call `GET /api/admin/me`. - Store the result in a global `window._adminProfile = { username, role, permissions, email }`. - Call a new `applyPermissions()` function. #### B. `applyPermissions()` Function — Two-Level Rendering This function runs once after `GET /api/admin/me` returns. It performs two passes: **Pass 1 — Sidebar item visibility (Level 1):** | Condition | Action | |---|---| | Does NOT have `view_analytics` | Hide `nav-dashboard`, `nav-sla` | | Does NOT have `manage_sessions` | Hide `nav-sessions`, `nav-queued`, `nav-daily`, `nav-blocked`, `nav-archived` | | Does NOT have any settings sub-permission | Hide `nav-settings` entirely | | Does NOT have `manage_admins` | Hide `nav-admin-users` | **Pass 2 — In-view element visibility (Level 2, sub-permissions):** This pass runs every time the user navigates to a view, or on initial load. *Within the Sessions view (`manage_sessions` granted):* | Sub-Permission Absent | Elements Hidden / Disabled | |---|---| | `sessions.reply` | `#chat-reply-area` (reply input + Send button) hidden | | `sessions.takeover` | `#btn-takeover`, `#btn-end-takeover` hidden | | `sessions.block` | `#btn-block-user` hidden; "Block User" in context menu hidden | | `sessions.end_chat` | `#btn-end-chat` hidden | | `sessions.archive` | "Archive" and "Unarchive" context menu items hidden | | `sessions.delete` | `#btn-delete-current-chat`, `#btn-delete-session` hidden; "Delete" context menu item hidden | *Within the Analytics views (`view_analytics` granted):* | Sub-Permission Absent | Elements Hidden | |---|---| | `analytics.dashboard` | `nav-dashboard` hidden; if they navigate directly, redirect to first allowed view | | `analytics.sla` | `nav-sla` hidden | | `analytics.export` | `#btn-open-export` (topbar Export button) hidden; `#btn-export-single` (inside chat header) hidden | *Within the Settings view (`nav-settings` visible):* | Sub-Permission Absent | Elements Hidden | |---|---| | `settings.notifications` | `data-settings-sec="notifications"` nav item hidden; section hidden | | `settings.config` | `data-settings-sec="config"` nav item hidden; section hidden | | `settings.security` | `data-settings-sec="security"` nav item hidden; section hidden | | `settings.version` | `data-settings-sec="version"` nav item hidden; section hidden | | `whatsapp.view` | `data-settings-sec="whatsapp"` nav item hidden; section hidden | | `whatsapp.edit` | Save Settings button disabled/hidden; inputs become read-only | | `whatsapp.test` | `#btn-test-whatsapp` hidden | | `whatsapp.qr` | QR Code fetch button hidden | *Within the Admin Users view (`manage_admins` granted):* | Sub-Permission Absent | Elements Hidden | |---|---| | `admins.create` | "New Admin" button hidden | | `admins.edit` | Edit (pencil) icon hidden in every table row | | `admins.delete` | Delete (trash) icon hidden in every table row | | `admins.reset_password` | "Reset Password" button hidden inside the edit modal | **Pass 2 default view redirect:** - After hiding elements, if the currently active view has become empty or inaccessible, the app automatically switches to the **first visible nav item**. - Order of fallback: Sessions → Dashboard → SLA → Settings → Admin Users. - If **no** views are accessible at all (zero permissions), show a full-screen empty state: "You don't have access to any sections. Contact your superadmin." #### C. Welcome Toast Update - Change hardcoded "Welcome back, Admin!" → "Welcome back, {username}!" using `window._adminProfile.username`. #### D. Admin Users Management View (new `id="view-admin-users"`) A full CRUD table, only visible to `manage_admins` users: - **Table columns:** Username, Role badge (Superadmin / Sub-admin), Email, Active Permissions (pill badges), Last Login, Actions (Edit, Delete). - **"New Admin" button:** Opens the Create Admin modal. - **Edit action (pencil icon):** Opens the Edit Admin modal, pre-filled with current values. - **Delete action (trash icon):** Shows confirmation dialog. Button is **grayed out and disabled** if target is the last superadmin. - **Quick-toggle column (optional UX enhancement):** Each sub-admin row shows tiny lock/unlock icons for each permission directly in the table, allowing rapid one-click toggling without opening the full edit modal. Superadmin rows show a "🔑 Full Access" badge instead. --- #### D1. Create Admin Modal Triggered by the "New Admin" button. Fields: | Field | Type | Notes | |---|---|---| | Username | Text input | Required. Validated: `^[a-zA-Z0-9_]{3,32}$`. Read-only once created. | | Email | Email input | Required. Must be unique across all admins. | | Password | Password input | Required on create only. Min 8 chars. | | Role | Radio buttons | `Superadmin` or `Sub-admin`. Defaults to `Sub-admin`. | | Permissions | Toggle group | Only shown when `Sub-admin` is selected (see below). | **Permission Toggle Group (create mode — Two Levels):** The create modal shows **collapsible permission groups**. Each group is a parent toggle at the top, with child sub-permission toggles that expand beneath it. ``` [ ▶ 💬 Live Sessions ] ← parent toggle, OFF by default ↳ (expands when parent is ON) [ ] View transcripts (sessions.view — auto-ON when parent enabled) [ ] Send replies (sessions.reply) [ ] Take over chat (sessions.takeover) [ ] Block / Unblock users (sessions.block) [ ] End chat completely (sessions.end_chat) [ ] Archive sessions (sessions.archive) [ ] Delete sessions (sessions.delete) [ ▶ 📊 Analytics & Export ] ← parent toggle, OFF by default ↳ (expands when parent is ON) [ ] Main Dashboard (analytics.dashboard — auto-ON when parent enabled) [ ] SLA Dashboard (analytics.sla) [ ] Export data (analytics.export) [ ▶ 📱 WhatsApp Settings ] ← parent toggle, OFF by default ↳ (expands when parent is ON) [ ] View config (whatsapp.view — auto-ON when parent enabled) [ ] Edit & save config (whatsapp.edit) [ ] Send test message (whatsapp.test) [ ] Fetch QR code (whatsapp.qr) [ ▶ ⚙️ Settings Sections ] ← parent toggle, OFF by default ↳ (expands when parent is ON) [ ] Notifications (settings.notifications) [ ] Config (auto-refresh) (settings.config) [ ] Security info (settings.security) [ ] Version info (settings.version) [ ▶ 📁 Knowledge Base ] ← parent toggle, OFF by default (future) ``` **Sub-permission toggle rules:** - **Auto-ON children:** `sessions.view`, `analytics.dashboard`, and `whatsapp.view` auto-enable when their parent is toggled ON, since they are the minimum required to use that section at all. - **Parent controls children:** Turning a parent toggle OFF **collapses** the sub-group and **clears** all child selections instantly. - **"Select All" / "Clear All"** shortcut appears at the top of each expanded sub-group. - A count badge shows how many sub-permissions are enabled per group: e.g. **"Live Sessions (3/7)"**. --- #### D2. Edit Admin Modal Same modal reused in edit mode. Differences from create mode: | Field | Behaviour in Edit Mode | |---|---| | Username | **Read-only** — displayed but not editable | | Email | Editable | | Password | **Hidden** — replaced by a "Reset Password" button that opens a separate sub-modal | | Role | Editable. Changing to Superadmin shows warning banner (see Rule 25). | | Permissions | Pre-filled with the admin's current permissions. Fully editable. | **Dirty-state detection:** - The "Save Changes" button starts **grayed out** on modal open. - It activates only when at least one field or toggle has been changed from its original value. - If the modal is closed without saving and there are unsaved changes, a browser-style confirmation appears: **"Discard unsaved changes?"** with `[Discard]` and `[Keep Editing]` buttons. **Superadmin upgrade warning:** - When a superadmin changes a sub-admin's role to `Superadmin`, a yellow warning banner appears inside the modal: > ⚠️ You are about to grant **full unrestricted access**. This sub-admin will be able to manage all other admins and all system settings. Click Save again to confirm. - The Save button text changes to **"Confirm & Save"** and requires one more click. **Reset Password sub-modal (inside edit):** - Triggered by "Reset Password" button inside the edit modal. - Fields: New Password, Confirm New Password. - Only available to superadmins editing another user. A sub-admin editing themselves uses the dedicated "Change Password" modal (Section E). - On success: shows toast "Password reset for `{username}`." - The affected sub-admin's next auto-login re-validation will fail and force them to log in again with the new password. --- #### D3. Permission Badges in Table The admin users table shows permissions as **colored pill badges** per user: | Permission | Badge Color | Label | |---|---|---| | `manage_sessions` | Blue | Sessions | | `view_analytics` | Purple | Analytics | | `manage_whatsapp` | Green | WhatsApp | | `manage_kb` | Orange | KB | - Superadmin rows show a single gold **"Full Access"** badge instead of individual permission pills. - If a sub-admin has **no permissions** (should not happen, but defensive), their row shows a red **"No Access"** badge. > [!NOTE] > Permission toggles (during both create and edit) are **disabled** for Superadmin role — all permissions are implied and cannot be individually scoped. #### E. Change Password Modal - A "Change Password" button added to the **sidebar footer** (next to "Sign Out"), visible to all logged-in users. - Modal fields: Current Password, New Password, Confirm New Password. - Client-side validation: new and confirm must match; length >= 8. - On success: show toast "Password changed. Please log in again." → force logout after 2 seconds (credentials are now invalid). - On failure (wrong current password): show error inline. #### F. Sidebar Profile Chip - Add a small profile chip in the **sidebar footer area** above "Sign Out" showing: - Avatar initial (first letter of username) - Username - Role badge (small, colored) - This replaces the raw button look with a more polished identity indicator. #### G. Auto-login Re-validation - Current code: reads `localStorage` and auto-logs in if `otp_verified: true`. In multi-admin world, this still works for HTTP Basic Auth. - **Edge case:** After auto-login, we must still call `GET /api/admin/me` with the stored credentials. If it returns `401` (password changed, user deleted, account locked), **force logout** and redirect to login screen with message "Your session has expired." - This prevents ghost sessions where a deleted or demoted admin stays logged in indefinitely. --- ## Admin Ease Features These are quality-of-life improvements that make daily admin management faster and less error-prone. All are purely additive — they do not affect existing auth or permission logic. --- ### Ease Feature 1 — Suspend / Activate Toggle **What it does:** A superadmin can temporarily disable a sub-admin account **without deleting it**. The account stays in `admins.json` with all its permissions intact, but the user cannot log in. **Rules:** - A suspended admin's `is_active` field is set to `false`. - Attempting to log in as a suspended admin returns `403` (not `401`) with the message: "Your account has been suspended. Contact your superadmin." - Suspending does **not** affect the last superadmin (superadmins cannot be suspended while they are the only one). - A superadmin **cannot suspend their own account**. - Suspension is reflected immediately — no restart required. **UI:** - In the admin users table, each row has a **Status toggle switch** (green = Active, gray = Suspended) next to the action buttons. - Toggling it shows a confirmation: "Suspend `{username}`? They will be locked out immediately." or "Reactivate `{username}`?" - Suspended rows are styled with a **dimmed / strikethrough** appearance in the table. - A red **"Suspended"** badge replaces the role badge in the table row. **Backend:** - `PATCH /api/admin/users/{username}/status` → `{ "is_active": true/false }` — requires `admins.edit`. - `verify_password()` checks `is_active` before proceeding. --- ### Ease Feature 2 — Permission Templates **What it does:** Pre-defined permission sets that a superadmin can apply to a new or existing sub-admin with one click, instead of manually toggling every permission. **Built-in templates:** | Template Name | Permissions Included | |---|---| | 🎧 Support Agent | `manage_sessions`, `sessions.view`, `sessions.reply`, `sessions.takeover` | | 👁️ Read-Only Viewer | `manage_sessions`, `sessions.view`, `analytics.dashboard`, `analytics.sla` | | 📊 Analytics Manager | `view_analytics`, `analytics.dashboard`, `analytics.sla`, `analytics.export` | | 📱 WhatsApp Manager | `manage_whatsapp`, `whatsapp.view`, `whatsapp.edit`, `whatsapp.test`, `whatsapp.qr` | | 🛡️ Full Sub-Admin | All non-admin permissions (everything except `manage_admins`) | **Rules:** - Templates are **suggestions only** — after applying a template, all toggles remain individually editable. - Applying a template **overwrites** any currently selected permissions in the modal (with a confirmation: "Apply template? This will replace your current selection."). - Custom templates can be saved by the superadmin (stored in `admins.json` under a `"templates"` key). - A superadmin cannot save or apply a template that contains permissions they don't have themselves. **UI:** - A **"Use Template" dropdown button** appears above the permission toggle group in both Create and Edit modals. - After selecting a template, the toggles animate to their new states. - A **"Save as Template"** button appears when the current permission set doesn't match any existing template. --- ### Ease Feature 3 — Clone Permissions from Existing Admin **What it does:** When creating a new sub-admin, a superadmin can copy the exact permission set of an existing admin as the starting point. **Rules:** - Cloning is only available during **create mode** (not edit — use templates for edit mode). - A superadmin can only clone from admins whose permissions are a **subset of their own**. - Cloning from a superadmin account produces a sub-admin with all non-admin permissions (cannot clone superadmin status). - After cloning, all toggles are editable individually. **UI:** - A **"Copy from existing admin"** link appears below the "Use Template" button. - Opens a small dropdown/search of existing admin usernames. - On selection, the permission toggles animate to match the cloned admin's set. - A note appears: "Permissions copied from `{username}`. You can adjust below." --- ### Ease Feature 4 — Audit Log **What it does:** A chronological log of all admin management actions taken by any superadmin. This gives a paper trail for accountability. **Logged actions:** - Admin created / deleted / suspended / reactivated - Permissions changed (stores before + after) - Password reset by superadmin - Role changed (promoted / demoted) - Force logout triggered **Rules:** - Audit log entries are **append-only** — they can never be deleted via the UI. - Each entry contains: `timestamp`, `actor` (who did it), `action` (what), `target` (who it was done to), `detail` (human-readable summary). - The log is stored in `admins.json` under an `"audit_log"` array. - The log is capped at **500 entries** — oldest entries are pruned automatically when the cap is exceeded. - The log is accessible only to users with `manage_admins` permission. **UI:** - An **"Audit Log"** tab/button inside the Admin Users view (beside the "New Admin" button). - Renders as a timeline list: avatar icon of the actor, action description, target, and relative timestamp ("2 hours ago"). - Filterable by: actor, action type, date range. - The 10 most recent entries are shown inline; older entries load on scroll (pagination). **Backend:** - `GET /api/admin/audit-log?limit=50&offset=0` — requires `admins.view`. --- ### Ease Feature 5 — Admin Notes / Description **What it does:** An optional free-text notes field on each admin account. Useful for recording context like "HR department, Pakistan office" or "Temp account, expires July 2026". **Rules:** - Notes are plain text, max **500 characters**. - Notes are visible to all users with `admins.view`. - Notes can be edited by users with `admins.edit`. - Notes are shown in the admin table as a tooltip on hover (truncated to 60 chars inline). **UI:** - A **Notes** textarea field in both Create and Edit modals, below the email field. - In the admin users table, rows with non-empty notes show a small **📝 icon** that reveals the full note on hover. --- ### Ease Feature 6 — Force Logout **What it does:** A superadmin can immediately invalidate a specific sub-admin's active session, forcing them to log in again on their next API call. **How it works technically:** - A `force_logout_at` timestamp is stored per admin in `admins.json`. - Every authenticated API call checks if `force_logout_at` is newer than when the user last logged in (`last_login`). - If `force_logout_at > last_login`, the request returns `401` with message "Your session has been terminated by an administrator." - The sub-admin's `localStorage` credentials are then cleared on the frontend and they are redirected to the login screen. **Rules:** - A superadmin **cannot force-logout themselves**. - Force logout does **not** delete the account or change the password. - After the sub-admin logs in again, `force_logout_at` has no further effect. **UI:** - A **"Force Logout"** button in the edit modal (appears next to "Reset Password"). - Confirmation prompt: "Force `{username}` to log out immediately?" - After confirmation: shows toast "Session terminated for `{username}`." **Backend:** - `POST /api/admin/users/{username}/force-logout` — requires `admins.edit`. - The `GET /api/admin/me` endpoint checks `force_logout_at` and returns `401` if triggered. --- ### Ease Feature 7 — Welcome Email on Account Creation **What it does:** When a new sub-admin account is created, they automatically receive a welcome email with their username and a prompt to log in and change their temporary password. **Rules:** - The welcome email is sent using the same email service already used for OTPs. - The email contains: username, a note that their password was set by the superadmin, and a link to the admin panel login page. - The email does **not** contain the password (for security). Only the username. - If the sub-admin has no email configured, no email is sent (silently skipped — same as OTP fallback). - The superadmin creating the account sees a toast: "Account created. Welcome email sent to `{email}`." **UI:** - No extra UI required — this happens automatically in the background on `POST /api/admin/users`. - An optional **"Resend Welcome Email"** button in the edit modal (only shown if the sub-admin has never logged in — `last_login` is `null`). **Backend:** - Triggered automatically inside the `create_admin()` flow in `admin_store.py`. --- ### Ease Feature 8 — Search & Filter in Admin Table **What it does:** Makes it easy to find a specific admin in the table when there are many accounts. **Filter options:** - **Search box** — real-time filter by username or display name - **Role filter** — "All", "Superadmin", "Sub-admin" - **Status filter** — "All", "Active", "Suspended" - **Permission filter** — dropdown to show only admins who have a specific permission (e.g., show only users with `manage_sessions`) **Rules:** - Filtering is **client-side only** (no extra backend calls) — the full admin list is already loaded. - Filters are combined with AND logic (e.g., Active + has Sessions permission). - The count of shown results is displayed: "Showing 3 of 7 admins". - Clearing all filters restores the full list. **UI:** - A filter bar above the admin table with the search box on the left and filter dropdowns on the right. - An **"Active filters"** chip row showing applied filters with `×` to remove individual ones. --- ### Ease Feature 9 — Credential Card (Copy Credentials for New Admin) **What it does:** After creating a new admin, show a one-time credential summary card that the superadmin can copy and send to the new user manually (e.g., via WhatsApp or email). **Content of the card:** ``` Admin Panel Login Details ───────────────────────── URL: https://yoursite.com/admin Username: hr_user Password: [the temporary password set during creation] ───────────────────────── Please log in and change your password immediately. ``` **Rules:** - The credential card is shown **exactly once** — immediately after the create modal is submitted successfully. - It is never stored anywhere. Once dismissed, it cannot be retrieved. - A **"Copy to Clipboard"** button copies the full card text. - A clear warning is shown: "⚠️ This is the only time you will see this password. Save it now." - The password is shown as plaintext in the card (it is the temp password the superadmin just typed in). **UI:** - Appears as a modal overlay immediately after the create success toast. - Styled as a receipt/card with a monospace font and a prominent copy button. - Dismissing the card requires a deliberate click: **"I have saved the credentials"** button. --- ### Ease Feature 10 — Password Age Warning **What it does:** Warns a sub-admin (and the superadmin viewing their profile) if their password has not been changed in over 90 days. **Rules:** - The `password_changed_at` field tracks when the password was last changed (updated on `change_password` and `reset_password`). - If `password_changed_at` is older than 90 days, the sub-admin sees a **banner** on login: "Your password is 95 days old. Consider changing it." (dismissable per session). - Superadmins viewing a sub-admin's row in the table see a **🔔 yellow badge** on that row indicating stale password. - This is a **warning only** — logins are not blocked. No forced password expiry. **UI:** - Banner shown in the topbar area after login (dismissable, does not reappear until next login session). - In the admin table, a `password_changed_at` column with a relative date ("43 days ago") and a yellow clock icon if > 90 days. --- ### New Backend Endpoints for Ease Features ``` PATCH /api/admin/users/{username}/status → Suspend/activate [admins.edit] POST /api/admin/users/{username}/force-logout → Force logout [admins.edit] GET /api/admin/audit-log → Get audit log [admins.view] POST /api/admin/users/{username}/resend-welcome → Resend welcome email [admins.edit] ``` --- ### New Rules for Ease Features 33. A suspended admin (`is_active: false`) cannot log in. Login attempt returns `403 Suspended`. 34. The last active superadmin cannot be suspended. 35. A superadmin cannot suspend their own account. 36. Force logout is checked on every authenticated API call by comparing `force_logout_at` against `last_login`. 37. The credential card is shown once immediately after admin creation and is never retrievable again. 38. Audit log entries are append-only and capped at 500; oldest entries are pruned automatically. 39. Permission templates cannot contain permissions the applying superadmin doesn't have themselves. 40. "Clone permissions" only clones permissions that are a subset of the superadmin's own permissions. 41. The password age warning fires at 90 days and is advisory only — it does not block logins. 42. Welcome emails are sent automatically on creation and can be resent only if `last_login` is `null`. --- ## Data Flow Diagrams ### Login Flow (Updated) ``` [User enters username + password] ↓ POST /api/admin/request-otp ↓ [Backend: check locked → verify password via admin_store → generate OTP → send to user's own email] ↓ (on success) [Frontend: show OTP input] ↓ POST /api/admin/verify-otp ↓ [Backend: verify password again + OTP code → record_login → clear OTP] ↓ (on success) [Frontend: store creds in localStorage → call GET /api/admin/me → applyPermissions() → show UI] ``` ### Permission Enforcement Flow (Backend) ``` [API Request arrives with HTTP Basic Auth] ↓ verify_admin() → admin_store.verify_password() ↓ (401 if wrong creds, 423 if locked) require_permission("manage_sessions")() → admin_store.get_admin(username).permissions ↓ (403 if permission missing) [Route handler executes] ``` --- ## Migration Strategy At first startup after deployment: 1. `seed_initial_admin()` runs. 2. It checks if `data/admins.json` exists and has at least one entry. 3. If not: creates the file with a single superadmin seeded from `ADMIN_USER` / `ADMIN_PASS` / `ADMIN_EMAIL` in `.env`. The password is hashed using `pbkdf2_hmac` at this point. 4. The `.env` `admin_user` / `admin_pass` settings remain in place as an **emergency fallback** in `verify_admin`. If the `admins.json` file is ever corrupted or deleted, the `.env` credentials will still grant access (and `seed_initial_admin` will recreate the file on next restart). --- ## Verification Plan ### Manual Test Checklist **Authentication & Security** | # | Scenario | Expected Result | |---|---|---| | 1 | Fresh deploy (no `admins.json`) — log in with `.env` creds | ✅ Seeds file, login works | | 2 | Enter wrong password 5 times | ✅ Account locked 15 min, returns 423 | | 3 | Enter wrong OTP 5 times | ✅ OTP invalidated, must request new one | | 4 | HR receives OTP to their own email (not global admin email) | ✅ Email matches HR's configured email | | 5 | After HR changes password, auto-login re-validates | ✅ Old session invalidated, shown login screen | | 6 | Superadmin resets HR's password — HR auto-session expires | ✅ HR forced to log in with new password | **Backend Sub-Permission Enforcement** | # | Scenario | Expected Result | |---|---|---| | 7 | HR (sessions.view only) calls `POST /sessions/{id}/reply` | ✅ Returns 403 | | 8 | HR (sessions.view only) calls `POST /sessions/{id}/takeover` | ✅ Returns 403 | | 9 | HR (sessions.view + sessions.reply) calls `POST /sessions/{id}/reply` | ✅ Returns 200 | | 10 | HR (analytics.dashboard only) calls `GET /api/admin/sla-metrics` | ✅ Returns 403 | | 11 | HR (analytics.dashboard + analytics.sla) calls `GET /api/admin/sla-metrics` | ✅ Returns 200 | | 12 | HR (whatsapp.view only) calls `POST /api/admin/settings/whatsapp` | ✅ Returns 403 | | 13 | HR (whatsapp.view only) calls `GET /api/admin/settings/whatsapp` | ✅ Returns 200 | | 14 | HR calls `GET /api/admin/export/all` without `analytics.export` | ✅ Returns 403 | | 15 | Try to delete the only superadmin | ✅ Returns 400 | | 16 | Try to create admin with duplicate username | ✅ Returns 409 | | 17 | Try to create sub-admin with zero permissions via API | ✅ Returns 400 | | 18 | Try to create sub-admin with `sessions.reply` but no `sessions.view` | ✅ Returns 400 (invalid combo) | **Sidebar Visibility (Level 1)** | # | Scenario | Expected Result | |---|---|---| | 19 | HR (sessions only) logs in — check sidebar | ✅ Only Sessions nav items visible; Dashboard, SLA, Settings, Admin Users all hidden | | 20 | HR (analytics only) logs in — check sidebar | ✅ Only Dashboard and SLA items visible | | 21 | HR (whatsapp.view only) logs in — check sidebar | ✅ Only Settings visible; no other nav items | | 22 | HR with no permissions logs in | ✅ Sidebar empty; full-screen "no access" state shown | | 23 | Superadmin logs in — check sidebar | ✅ All 10 nav items visible | **In-View Sub-Element Visibility (Level 2)** | # | Scenario | Expected Result | |---|---|---| | 24 | HR has `sessions.view` but NOT `sessions.reply` | ✅ Reply input area is hidden; transcript is read-only | | 25 | HR has `sessions.view` but NOT `sessions.block` | ✅ "Block" button in chat header and context menu are hidden | | 26 | HR has `sessions.view` but NOT `sessions.delete` | ✅ "Delete" in context menu and delete buttons in header are hidden | | 27 | HR has `sessions.view` but NOT `sessions.takeover` | ✅ "Take Over" button hidden | | 28 | HR has `analytics.dashboard` but NOT `analytics.export` | ✅ "Export Data" topbar button hidden; export button in chat header hidden | | 29 | HR has `analytics.dashboard` but NOT `analytics.sla` | ✅ SLA nav item hidden; SLA view inaccessible | | 30 | HR has `whatsapp.view` but NOT `whatsapp.edit` | ✅ Save Settings button hidden; all inputs are read-only | | 31 | HR has `whatsapp.view` but NOT `whatsapp.test` | ✅ "Send Test Message" button hidden | | 32 | HR has `whatsapp.view` but NOT `whatsapp.qr` | ✅ QR code fetch button hidden | | 33 | HR has settings access but only `settings.notifications` | ✅ Only Notifications sub-section visible in Settings; WhatsApp, Config, Security, Version all hidden | | 34 | HR has `admins.view` but NOT `admins.create` | ✅ "New Admin" button hidden; table is read-only | | 35 | HR has `admins.edit` but NOT `admins.reset_password` | ✅ Edit modal opens but "Reset Password" button is absent | **Permission Toggle UI — Create Modal (with sub-permissions)** | # | Scenario | Expected Result | |---|---|---| | 36 | Open "New Admin" modal — all parent toggles | ✅ All 4 parent toggles OFF, sub-groups collapsed | | 37 | Enable "Live Sessions" parent toggle | ✅ Sub-group expands; `sessions.view` auto-checks ON; others OFF | | 38 | Disable "Live Sessions" parent toggle | ✅ Sub-group collapses; all sub-permissions cleared | | 39 | Enable parent, click "Select All" in sub-group | ✅ All 7 sessions sub-permissions toggle ON | | 40 | Count badge on parent toggle | ✅ Shows "Live Sessions (1/7)" with only view enabled | | 41 | Switch to Superadmin role | ✅ All parent toggles and sub-groups hidden; "Full Access" badge shown | | 42 | Create HR with `sessions.view` + `sessions.reply` only | ✅ `admins.json` contains `["manage_sessions","sessions.view","sessions.reply"]` | **Permission Toggle UI — Edit Modal** | # | Scenario | Expected Result | |---|---|---| | 43 | Open edit for HR with `sessions.view` + `sessions.reply` | ✅ Sessions parent ON; sub-group expanded; view+reply checked; others unchecked | | 44 | Uncheck `sessions.reply`, close without saving | ✅ Discard confirmation shown | | 45 | Turn all sub-permissions OFF but leave parent ON | ✅ Validation error: parent must have at least one sub-permission | | 46 | Superadmin edits HR — HR currently active | ✅ Change takes effect on HR's next API call | **Suspend / Activate (Ease Feature 1)** | # | Scenario | Expected Result | |---|---|---| | 47 | Suspend HR account via table toggle | ✅ `is_active: false` saved; HR's table row dims with "Suspended" badge | | 48 | HR tries to log in while suspended | ✅ Returns 403 "Account suspended" | | 49 | Reactivate HR from table | ✅ HR can log in again | | 50 | Try to suspend the only superadmin | ✅ Disabled; confirmation blocked with error | | 51 | Superadmin tries to suspend themselves | ✅ Blocked; error shown | **Permission Templates (Ease Feature 2)** | # | Scenario | Expected Result | |---|---|---| | 52 | Apply "Support Agent" template in create modal | ✅ Toggles animate to `sessions.view + sessions.reply + sessions.takeover` ON | | 53 | Apply template with existing selections | ✅ Confirmation prompt appears before overwriting | | 54 | Save current toggle state as custom template | ✅ Template saved; appears in "Use Template" dropdown | **Audit Log (Ease Feature 4)** | # | Scenario | Expected Result | |---|---|---| | 55 | Create a new admin — check audit log | ✅ New entry: actor, "create_admin", target, permission detail | | 56 | Change HR's permissions — check audit log | ✅ Before + after permissions recorded | | 57 | Sub-admin (non-manage_admins) calls `GET /api/admin/audit-log` | ✅ Returns 403 | **Force Logout (Ease Feature 6)** | # | Scenario | Expected Result | |---|---|---| | 58 | Force logout HR from edit modal | ✅ `force_logout_at` updated; next API call by HR returns 401 | | 59 | HR receives 401 after force logout | ✅ Frontend clears session, shows login screen with "terminated" message | | 60 | HR logs back in after force logout | ✅ Session restored; `force_logout_at` no longer blocks | **Credential Card (Ease Feature 9)** | # | Scenario | Expected Result | |---|---|---| | 61 | Create new admin — credential card behavior | ✅ Card modal appears immediately after creation with username + password | | 62 | Click "Copy to Clipboard" on card | ✅ Full card text copied; toast "Copied!" shown | | 63 | Close card, reopen admin edit modal | ✅ Password is NOT visible anywhere in edit modal (not recoverable) |