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):
fetchCustomers()→ customer count + new-last-30d.fetchAgentsWithStats()→ partner count + new-last-30d.apiFetch('/users')→ staff count + new-last-30d. If the call 403s the staff KPI showsnullinstead 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:
- 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 theUserRolerow (role_change_admin_decide). Nothing else may write a staff role (ACC-077 … ACC-079). - The DB has a
RolePermissiontable populated by seed migrations (seesupabase/migrations/*permissions*.sql) that maps each role to its explicit list of permission rows. 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 fromUserPermission.- Code checks specific permissions via
<ProtectedRoute>,<PermissionGate>, orrequirePermission()— 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 withadmin.users.editorhr.profiles.edit, and only where the record'sprofileEditsays the database would accept the save (ACC-084) — and, withadmin.users.editon 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
Departmenttable 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 throughdrive-file(admin.users.view; no Google account needed, ACC-074). The office (admin.users.edit) uploads for the person here throughupload-employee-doc(withuserId) and can remove the record (the file stays on the drive). The person uploads from their own My profile page — the desktop/mepage (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
EmployeeDocumentrows, each with Open (the file in a viewer on the page, throughdrive-file), and an upload: a JPEG, PNG, WebP or HEIC photo or a PDF under 10 MB, sent as base64 toupload-employee-doc, which puts it underEmployees / "<name> (<id>)"and records it throughemployee_add_documentas 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-deletefunction 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.
Related
- Customers — one of the three populations counted on
/people. - Partners — another counted population.
- Admin module —
/adminhosts 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.