Skip to content

People

Written April 2026 — read this first

The role tiers below are wrong. There is no bypass tier: SUPER_ADMIN is the only role with every permission, and it holds them because every permission row is granted to it explicitly. CEO and GM hold a leadership bundle, IT_ADMIN holds system settings only, ADMIN_HR holds no finance, approval, invoice or permission-editing rights, and two roles were added — SUPER_ADMIN and CHARTERED_ACCOUNTANT. Read PERMISSIONS.md and ACC-011, ACC-012.

Internal directory that rolls up customers + partners + staff, plus the staff-only user management surface at /people/employees. This is the module ops uses to see "who's who" across all user types in one place; Admin → Permissions is where roles are assigned once a user exists.

Scope

Two pages:

  • /people — src/pages/people/People.tsx. Cross-party directory with KPIs (customers, partners, staff counts + 30-day new-arrivals) and a recent-activity feed drawn from the audit log.
  • /people/employees — src/pages/people/Employees.tsx. Employees & staff: the staff directory, as a table, cards or an organisation chart. A thin wrapper around <UserManagement>; see Employees & staff below.

Shared component: src/components/admin/UserManagement.tsx — the actual CRUD surface for staff users, reused here and on Admin.

Pages

People.tsx — /people

Route gated by customers.view (src/App.tsx:181).

Route permission is inherited, not semantic

/people reuses customers.view because most viewers of this directory are customer-facing staff. There's no dedicated people.view permission. See docs/PERMISSIONS.md §6.15.

Pulls three parallel fetches on mount (People.tsx:40-75):

  1. fetchCustomers() → customer count + new-last-30d.
  2. fetchAgentsWithStats() → partner count + new-last-30d.
  3. apiFetch('/users') → staff count + new-last-30d. If the call 403s the staff KPI shows null instead of a number.

Then loads recent audit-log activity (People.tsx:77-99) filtered to customer / agent / lead entities, top 20. If the audit call is forbidden, the activity panel shows the "restricted" empty state instead of an error.

AccountDeletions.tsx — /people/account-deletions

See Account deletions below. Linked from the Customers card (with customers.edit) and the Agents card (with partners.edit).

Employees.tsx — /people/employees

A wrapper: the page title Employees & staff and <UserManagement>. Route gated by admin.view. All behaviour lives in <UserManagement>, EmployeeDirectoryViews.tsx (table, cards, the ⋯ menu) and EmployeeOrgChart.tsx (the chart, loaded only when chosen), with the pure parts in src/lib/employeeDirectory.ts.

Employees & staff (/people/employees)

Staff only. A partner's or traveller's login (AGENT / CUSTOMER) is never listed here — partners are on Partners, travellers on Customers. A login with no role yet is listed when it has an employee profile or a company address (@alhudatravels.in, @alhuda.co.in): the office creates staff logins with no role (ACC-082), and they stay visible until the role is applied. There is no other list of every login.

Header. One line of counts — "24 staff · 3 paused · 5 profiles incomplete" (deactivated logins are not counted) — then Add employee (admin.users.create, the create-user form below) and More (admin.users.edit), which holds Move staff to @alhudatravels.in (ACC-075).

Toolbar. Search finds a name, employee code, email, username, phone (three digits or more), designation or department. Filters: department, role (any role the person holds), status (Active and paused by default; Active, Paused, Deactivated, Everyone — deactivated logins are read only when asked for), reporting officer (or "No reporting officer"), and Profile incomplete. The view switch — Table, Cards, Organisation chart — is remembered in this browser.

Table. One row per person: initials, full name, employee code and email; designation; department; Reports to (a link to that person's record); Joined — the employment joining date, DD/MM/YYYY, with when the login was made on hover; roles (two shown, then "+N"); status (Active, Paused in amber, Deactivated); Last sign-in, relative ("2 hours ago", "Never"), the last successful sign-in (the same one the record's Access tab shows). Clicking a row opens the employee record (admin.users.view).

Cards. The same people as a directory grid: name, designation · department, code, reports to, joined, last sign-in, roles and status.

The ⋯ menu is the only control on a row or card. Each item keeps its gate and its confirmation: Open record (admin.users.view), Edit profile (admin.users.edit or hr.profiles.edit, the profile form below — the form says why when it refuses), Change email (admin.users.edit), Change username (admin.users.edit), Request role change (roles.request), Pause account (read-only) / Resume account (admin.users.edit), Reset password (admin.users.reset_password), Reset MFA (admin.mfa.reset), Deactivate / Reactivate (admin.users.delete). An item you do not hold is not shown.

Whose access you can manage. The server refuses every access action — change email, change username, pause / resume, reset password, reset MFA, deactivate / reactivate — on a login that holds a permission you do not hold (can_manage_user, so nobody manages a login above their station). The list reads the same answer for each row in the same one call (canManage), and the menu hides those actions where it is no: in their place it says "You can't manage this login's access". HR, for example, holds admin.users.edit but not the sales, operations or finance rights, so on a salesperson's row HR sees Edit profile (the profile details, ACC-084) and Request role change, but no pause, email, username, password, MFA or deactivation. The employee record applies the same rule to its Pause / Resume button.

Profile completeness. A profile is incomplete when any of these is empty: designation, department, reports to, joining date, father's / guardian's name (EA-013), emergency contact (name and phone). The person gets an amber Incomplete marker; its tooltip lists what is missing. A banner above the list gives the count and Show them, which turns on the Profile incomplete filter. Fill the fields with Edit profile.

Organisation chart. Built from each person's Reports to (ACC-081). Two people who report to each other — the two CEOs — sit together at the top as joint heads. Someone with no reporting officer who has reports is a head of their own. Everyone else sits under their reporting officer; a team with no sub-teams is stacked in a column so the chart stays narrow. Each node shows initials, name, designation, department and code, and opens the record. Each branch collapses (Expand all / Collapse all too). People with no reporting officer and nobody under them — or whose reporting officer is deactivated — are listed at the bottom under No reporting officer yet. The chart shows everyone active or paused; the search and filters apply to Table and Cards only. It scrolls inside its box on a small screen. It is not built for printing.

One read. The page reads GET /users?view=directory once (PRF-010): the logins and roles, plus each profile's designation, department, reporting officer (with the officer's name), joining date, and yes/no for father's / guardian's name and emergency contact — the personal values themselves are not sent to the page — and the last successful sign-in. The profile is read under EmployeeProfile's row security (own row or admin.users.view). The sign-in log (AuthLoginAttempt) stays server-only; the list gets one timestamp from it through the computed field employee_last_sign_in_at (migration 20261003210000), which answers for your own profile or for anyone when you hold admin.users.view, and is empty otherwise. A staff login with no employee profile yet shows "Never".

Role assignment — <UserManagement>

Source: src/components/admin/UserManagement.tsx. Used on /people/employees (sidebar Employees & staff); /admin has no user list. The employee record's back button returns here.

The list is described under Employees & staff above. Joined is the employment joining date from the profile (EmployeeProfile.joinedOn, blank until HR records it); when the login was made shows on hover. Only SUPER_ADMIN holds every permission; every other role, CEO and GM included, holds its bundle (ACC-012). The old "Role Hierarchy" strip of role badges is gone: the organisation chart shows who reports to whom.

Operations

Operation Call
List staff GET /users?view=directory — one call with the profile fields, last sign-in and canManage (may you manage this login's access — can_manage_user; computed field employee_can_manage)
Create user POST /users — the login starts with no role; a role chosen in the form becomes a role request (ACC-082). The role list offers an administrative role (CEO, General Manager, HR Admin, IT Admin, Super Admin today) only to IT Admin or Super Admin; anyone else picking one is refused before the login is made (ACC-083)
Request a role change Request role change in a staff row's ⋯ menu opens RoleRequestDialog → POST /role-requests (roles.request). The role changes only after a CEO approves and an admin applies it — see Admin → Role requests (ACC-077). Only IT Admin or Super Admin see, add or remove an administrative role (ACC-083). The old direct route answers 410.
Reset password POST /users/:id/password (admin.users.reset_password)
Deactivate / reactivate PATCH /users/:id with { action: 'deactivate' \| 'reactivate' } (admin.users.delete)
Delete user DELETE /users/:id (admin.users.delete)
Change a staff email PATCH /users/:id with { action: 'change_email', email, reason } — ACC-075
Move staff to @alhudatravels.in POST /users/workspace-domain — a preview first, then a run with a reason; see Admin → Changing a staff email

Role catalog

The definitive catalogue is PERMISSIONS.md §3 — 20 roles, matching the RoleType enum exactly, a match a test enforces. SUPER_ADMIN and CHARTERED_ACCOUNTANT were added on 2026-09-17 (ACC-011).

director appears in the UI colour map and in some old grant migrations, but DIRECTOR was never added to the RoleType enum, so around 28 permission grants written for it matched nothing and were silently dropped. A test now fails the build when a role in the docs is missing from the enum, or the reverse. Whether Director should exist as a real role separate from CEO is still an open question (ACC-011). EMPLOYEE, MANAGER and ADMIN are likewise not roles — admin is only a frontend route tier.

Wire format

UI uses lowercase (sales_manager), API uses uppercase (SALES_MANAGER). Conversion helpers at UserManagement.tsx:158-166 (mapApiRole / mapAppRoleToApiRole). Unknown values fall back to null so nothing mis-renders.

Staff email domain gate

isAllowedStaffEmail (UserManagement.tsx:123-126) checks the user's email ends with @alhuda.co.in or @alhudatravels.in.

Relationship to permissions

Role membership does not directly grant permissions in code. The chain is:

  1. A role (e.g. SALES_MANAGER) is requested for a user (POST /role-requests), approved by a CEO and applied by an admin, who writes the UserRole row (role_change_admin_decide). Nothing else may write a staff role (ACC-077 … ACC-079).
  2. The DB has a RolePermission table populated by seed migrations (see supabase/migrations/*permissions*.sql) that maps each role to its explicit list of permission rows.
  3. auth_user_has_permission(perm_name) (Postgres function) resolves a user's effective permissions by UNION-ing (a) role grants with (b) per-user overrides from UserPermission.
  4. Code checks specific permissions via <ProtectedRoute>, <PermissionGate>, or requirePermission() — never by role name.

SUPER_ADMIN is the only role that holds every permission. It holds them because every permission row is granted to it explicitly, and a trigger grants it each newly created permission as well — not by a hardcoded role short-circuit, which was removed in 20260416020000_remove_permission_bypass.sql. Revoking a row in Admin → Permissions really does restrict the holder.

CEO and GM do not hold full rights. They hold a leadership bundle (every view and export, the business approvals, high-value approval and supplier-transaction correction). IT_ADMIN holds system settings only, and ADMIN_HR holds no finance, approval, invoice or permission-editing rights (ACC-012, decided 2026-09-17). apply_role_bundles() converges each role onto its bundle and deletes anything else, so a grant made by hand outside the bundle does not survive.

Per-user overrides (UserPermission.allowed=true/false) beat role grants — see PERMISSIONS.md §2 for the precedence order.

Deactivating a user is the reliable revocation

A permission change reaches an active session when its auth context refreshes. Deactivating the user is checked on every request — is_staff_user() requires an active user — so it takes effect immediately. ACC-041 asks for deactivation to also end every active session and close the role grants in the same step; that part is not built. For offboarding, deactivate and end the sessions from the admin tool.

Permissions

Canonical reference: docs/PERMISSIONS.md §6.15.

Action Permission
View /people customers.view
View /people/employees admin.view
View and edit own profile (/me, GET/PATCH /me/profile, GET /me/documents) none — own row; profile_me / profile_update_me and row security decide (ACC-071)
Create staff user admin.users.create
Edit profile admin.users.edit (every field, over a login you can manage) or hr.profiles.edit (profile details of staff who are not an IT Admin or Super Admin, ACC-084)
Change email / username, pause / resume admin.users.edit, and only on a login you can manage (canManage)
Reset staff password admin.users.reset_password, and only on a login you can manage
Reset MFA admin.mfa.reset, and only on a login you can manage
Request a role change roles.request (the role changes after approval, ACC-077)
Deactivate / reactivate / delete staff user admin.users.delete, and only on a login you can manage
View recent activity feed customers.view + (audit log read on server)

"A login you can manage" is one whose every permission you hold (can_manage_user). The server checks it on every one of these actions; the screens read it as canManage and hide what the server would refuse.

The employee code

Every staff login has a permanent employee code (EM-0007), given by the system when the login gets its first staff role (PTY-003). It shows beside the name on Employees, on the employee record and on My profile; the Employees search finds it. The profile form shows it read-only — nobody can change it (PTY-002). Codes typed by hand before 27 Sep 2026 were kept. See Party codes.

The employee record (/admin/users/:id)

Every login has one record (ACC-071), src/pages/admin/EmployeeRecord.tsx, opened by clicking a row (or Open record in its ⋯ menu) on People → Employees & staff (/people/employees), from a node of the organisation chart, or from a tour leader's name on a group page. It is read in one call (GET /users/:id/record → employee_record, admin.users.view) and has four tabs:

  • Overview — identity (name, email, username, phones, personal email, date of birth, gender, blood group, address, emergency contact, Aadhaar and PAN as the last four only), employment (designation, code, department, joined on, reports to and who reports to them, each a link to that person's record, roles, how many permissions the login holds, notes), the groups they lead, and Edit profile (the same form as before, src/components/admin/EmployeeProfileDialog.tsx → PATCH /users/:id/profile) — shown with admin.users.edit or hr.profiles.edit, and only where the record's profileEdit says the database would accept the save (ACC-084) — and, with admin.users.edit on a login you can manage (profileEdit.canManage), Pause / Resume (ACC-070); otherwise the record says "You can't manage this login's access". A partner's login carries a link to its partner record.
  • Documents — the files on the company drive with who filed each and when, a link to the drive, the office's review (OK, or Reject with a note — POST /users/:id/documents/:docId/review → employee_document_review, admin.users.edit; the person is told) and Remove.
  • Access — active / paused, authenticator enrolled, last sign-in with the count in 30 days and failed attempts this week, sessions (device, browser, IP, open or ended), devices registered for push alerts, the last sign-ins.
  • Activity — one timeline, newest first, at most 100: bookings they created, payments they recorded and verified, approvals they decided, check-ins they recorded, documents, and what was done to their account (roles, pauses, profile edits, tour-leader appointments — marked "about them"), plus their other audit rows (action, changed field names and actor — never the audit values, AUD-004). Rows link to the booking or group.

The profile fields the office edits (EmployeeProfile, admin.users.edit to save). HR (hr.profiles.edit) edits the profile details of any staff login that is not an IT Admin or Super Admin — CEOs, the GM and managers included — through the same dialog: designation, department, reports to, joined on, father's / guardian's name, the personal fields and the Aadhaar / PAN last four. For HR the full name and work phone are shown read-only and the dialog says so; roles, email, username, pause and deactivation are not on this form. On an IT Admin or Super Admin record HR sees no Edit profile, and the database refuses the save: "HR can't edit an IT Admin or Super Admin profile." (ACC-084). HR changes their own details on My profile.

  • the login: email, username, roles, active / inactive, and whether the account is paused;
  • Work phone — the login's phone (User.phone). Changing it also changes the phone on the same person's customer or partner record, if they have one; the dialog says so under the field. A customer or partner record never changes a staff login's phone (ACC-076);
  • the office's fields: designation (typed, with the designations already in use and the role titles suggested — EA-012), employee code (unique), department (free text — the Department table was dropped in April), joined on;
  • Reports to — picked from a searchable list of the active staff, each with their designation and employee code; never the person themself or anyone who reports to them, directly or down the line. The database refuses anything else, including a loop; the one exception is two CEOs, who may report to each other (ACC-081). It is never typed;
  • personal fields: father's / guardian's name (the agreement's "son / daughter of …", EA-013), date of birth, gender, blood group, personal email, address, district, state, PIN, emergency contact (name, relation, phone), notes;
  • Aadhaar and PAN as the last four characters only. The dialog will not take more, and the database refuses a longer value rather than trimming it (AUD-020). The scanned card is a private file on the drive;
  • documents on the company drive (EmployeeDocument, Employees / "<name> (<id>)"): listed on the record's Documents tab and in this dialog — type, file, who filed it and when, the office's review — each with Open, which shows the file in a viewer on the page through drive-file (admin.users.view; no Google account needed, ACC-074). The office (admin.users.edit) uploads for the person here through upload-employee-doc (with userId) and can remove the record (the file stays on the drive). The person uploads from their own My profile page — the desktop /me page (Settings menu → My profile) or the app's More → My profile.

Saving sends only the fields that changed (PATCH /users/:id/profile → admin_update_employee_profile), so the audit row names the real edit. Staff change their own contact fields on My profile (below), on the desktop or in the app (profile_update_me).

My profile (/me)

src/pages/settings/MyProfile.tsx, opened from the Settings menu in the header (My profile, above Security). One page for "me" on the desktop, the same profile the native app shows under More → My profile (ACC-071). It reads GET /me/profile (profile_me()); no staff permission — the function takes the caller from the session.

For a member of staff (and a tour leader who is not a partner):

  • Your login — full name, email and username, read-only; phone, editable. A new phone also goes on your customer record, if you have one, and the page says so (ACC-076).
  • Employee details — designation, employee code, department, joined on, reports to, and Aadhaar / PAN as **** 1234 (the last four only, AUD-020). Read-only: the office changes these from the employee record (Edit profile).
  • Personal details and address — date of birth, gender, blood group, personal email, address, district, state, PIN. Emergency contact — name, relation, phone. These are the allow-list of profile_update_me; the page sends only the fields that changed (PATCH /me/profile { patch }) after a Yes/No, and the audit row names them. A field outside the list is refused by the database with "changed by the office, not from your profile".
  • Documents — the person's EmployeeDocument rows, each with Open (the file in a viewer on the page, through drive-file), and an upload: a JPEG, PNG, WebP or HEIC photo or a PDF under 10 MB, sent as base64 to upload-employee-doc, which puts it under Employees / "<name> (<id>)" and records it through employee_add_document as the caller. The page does not remove a document; the office does.
  • Roles and access — the login's roles and department, active / inactive, and Paused (read-only) when it is. Two-step verification and sessions stay on Security.

A paused login (ACC-070) sees a line at the top ("Your account is paused (read-only) since …") and every field as before; a save or an upload is refused by the database and the refusal is shown as a toast. The page does not hide the buttons — the screen is not the boundary.

A partner login sees their agency on the same page — see Partners → My profile. A traveller has My details in the customer portal; /me is not on their route list.

Pausing an account

Pause account (read-only) in a row's ⋯ menu, or Pause account on the record (admin.users.edit, on a login you can manage), asks for a reason and puts the login in read-only mode; Resume asks Yes/No. A paused person signs in and reads as before; every write is refused by the database — PATCH /users/:id → admin_pause_user / admin_resume_user, ACC-070. This is not deactivation: use Deactivate when someone leaves (ACC-068); use Pause for leave, an investigation, or handover, when they should still see their work but touch nothing.

Account deletions (/people/account-deletions)

src/pages/people/AccountDeletions.tsx (AUD-027). Every request to delete an account — nobody erases their own account; travellers and partners ask, and the office approves or declines (AUD-025) — and the deletions done. The route needs customers.view; the list comes from account_deletion_requests(), which shows travellers' requests to customers.edit and partners' to partners.edit and refuses anyone with neither.

  • Waiting — each request with the name, the party code, when and from where it was sent, the answer-by date (30 days after the request), and what is still in the way, worked out again on every load — including money the company holds for the person (an overpayment, a refund due or waiting, an unused advance, an overpaid invoice). A partner's request with nothing in the way says to review the agency first. Settle each item the usual way (cancel the trip at the person's wish, record the payment, pay the refund, allocate or return the advance, close the request), then Complete deletion — the approval (enabled only when nothing is in the way; an optional note is kept) — the account-delete function erases the details, sends the goodbye email, moves the Drive files to the trash and deletes the login. Or Decline with a note — the note is required and the person reads it in the app and on the website.
  • Deleted — no name, only the party code; who approved it (before 1 Oct 2026 a person could also delete their own account, shown as "The account holder"), and the follow-up: login deleted, Drive files in the trash, goodbye email sent. When the login or a file is not done yet, Finish runs the follow-up again (the email is not sent again — the address is gone).
  • Declined — the note and who wrote it.

Nothing here edits the audit trail. Runbook: Account deletion.

  • Customers — one of the three populations counted on /people.
  • Partners — another counted population.
  • Admin module — /admin hosts the Permissions matrix UI where role grants and user overrides are edited.
  • PERMISSIONS.md §3, §6.15, §6.16 — role catalog, page matrix, admin surface.