08 · Access control
Who can see and do what. Detailed permission names and page-by-page gates stay in ../PERMISSIONS.md; this file holds the rules that catalogue must satisfy.
Why this file exists (findings, 2026-09-17)
Verified against the code and migrations:
- CRITICAL — the
admin-usersedge function ignores its own authentication failure and runs with full service privileges whileverify_jwtis off. Anyone who can reach it can list users and set any user's password. - CRITICAL — database policies are combined with OR.
is_staff_user()(true for every role except CUSTOMER, including external AGENT users) is an alternative to the real permission onUserRole,Permission,Role,Booking,Customer,Paymentand others — so any staff or agent user can grant themselves CEO or edit any booking or payment directly. - CRITICAL — core tables (Booking, Customer, Payment, VisaCase, User, …) can be read by any signed-in user, including self-registered customers;
BookingPassengerand several finance tables can be written by any signed-in user. - HIGH — maker-checker is only checked in the browser; finance approval accepts the approver's ID from the request.
- HIGH — ADMIN_HR holds every permission; deactivating a user does not end their sessions; MFA is never required; roles
DIRECTOR,EMPLOYEE,MANAGER,ADMINare referenced but do not exist. - The API layer (
src/lib/api.ts) runs in the browser, so its permission checks only shape the screen.
Enforcement
ACC-001 · The database is the security boundary
Status: LAW-grade (non-negotiable) · Owner: IT Every rule in this file is enforced by database policies, constraints, SECURITY DEFINER functions or edge functions. A check that exists only in the browser does not count.
ACC-002 · Deny by default
Status: PROPOSED · Owner: IT No table allows reading or writing to "any signed-in user". Every table has explicit rules per action tied to a permission or to ownership. Anonymous access is only for deliberately public endpoints (public enquiry, visa intake), through dedicated functions.
ACC-003 · Every privileged function checks the caller
Status: PROPOSED · Owner: IT Every edge function verifies the caller's identity and permission and fails closed. Functions called by outside services (webhooks) verify a signature instead.
Who is who
ACC-010 · Three kinds of user
Status: PROPOSED · Owner: Management Staff (employees), Partners (B2B agents) and Customers are separate kinds of user. Partners and customers can only ever see and change their own records through the portals; no staff permission applies to them.
ACC-011 · Role catalogue
Status: DECIDED 2026-09-17 (super admin, CA) · Owner: Management Roles must match real job functions. Catalogue (current roles in plain text, new in bold):
| Area | Roles |
|---|---|
| Administration | Super Admin (the only role with full rights) |
| Leadership | Director, CEO, GM |
| Sales | Sales Manager, Sales Executive, B2B Manager, B2B Executive |
| Operations | Operations Manager, Operations Executive, Branch Manager |
| Ticketing | Ticketing Manager, Ticketing Executive |
| Visa | Visa Officer |
| Finance | Finance Manager, Accountant, Cashier, Chartered Accountant (external) |
| People & IT | HR (no finance access), IT Admin (system settings, no business approvals) |
| Oversight | Auditor (read-only everything, including finance and audit trail) |
| External | Partner (agent), Customer |
Decided 2026-09-17 (owner): Super Admin holds every permission and is the only role that does; Chartered Accountant is the external CA's login — reads all finance, reports and the audit trail, prepares journals, adjusting entries and supplier-transaction corrections that someone else approves, and handles no cash. Still open: is Director separate from CEO? Do you need Branch Manager (i.e. do you have branches)?
Enforced by: 20260919100100_role_types_super_admin_ca.sql, 20260919100200_finance_role_bundles.sql (apply_role_bundles(), assign_super_admin()); test supabase/tests/finance_controls.sql.
ACC-012 · Least privilege
Status: DECIDED 2026-09-17 · Owner: Management
Each role gets only the permissions its job needs. Only SUPER_ADMIN holds every permission (new permissions are granted to it automatically); everyone else — CEO and GM included — holds a bundle, and extra rights are granted by the super admin and recorded. CEO/GM: every *.view and *.export, the business approvals (bookings, cancellations, refunds, journals, group invoices, airline and B2B cancellations, period lock) and supplier-transaction correction. IT Admin: system settings only (users, MFA reset, integrations, audit) — no business approvals and no finance write rights. HR holds no finance, approval, invoice or permission-editing rights. Cashier records receipts only; Accountant prepares and verifies other people's receipts; Finance Manager approves. Break-glass (finance.journals.approve_own, finance.journals.reverse_own), ledger rebuild, year close, period close, account delete and permission/role editing belong to the super admin.
Enforced by: 20260919100200_finance_role_bundles.sql (fin_role_bundle, fin_role_finance_family, fin_role_revokes, apply_role_bundles); grants listed in ../PERMISSIONS.md §5; test supabase/tests/finance_controls.sql. A thin role stays thin at the row level too: since 20260929170000_a_tour_leader_reads_only_its_field.sql the staff-wide read policies on User, UserRole, UserPermission, the ledger tables (Account, AccountingPeriod, ApprovalLimit, FinanceConfig, FinancialAccount, FinancialAccountTransaction, FinancialYear), internal chat (Chat*), CommunicationsLog, CommunicationSetting and the WhatsApp tables ask auth_reads_beyond_field() — staff holding at least one permission outside field.* — instead of auth_is_staff(); a login whose only rights are field.* (FLD-001) reads its own rows and none of those tables. Every seeded staff bundle holds such a permission, so nothing changes for the office. Test: supabase/tests/a_tour_leader_reads_only_its_field.sql.
Leadership (CEO/GM) is the one documented balance point: after the 2026-09-18 owner decision they hold every permission except the super-admin-only set — they cannot approve or reverse their own vouchers, rebuild or reset the ledger, close a year or period, delete a GL account, user or partner, or edit roles and permissions. Everything else they need for daily work is theirs, and the database still enforces maker ≠ checker on every approval they make.
ACC-013 · Data scope
Status: PROPOSED (working default adopted 2026-09-18, owner to confirm) · Owner: Management Access is scoped by ownership, with branch scoping deferred until branches exist as records:
| Level | Default scope |
|---|---|
| Executive (Sales, B2B, Ops, Visa, Ticketing) | Own records by default (ownerId / createdBy), with an explicit All toggle that is recorded in the audit trail when used for an action |
| Manager | Everything in their function |
| Finance, Auditor, CA | Everything in their function; no ownership filter |
| Leadership, Super Admin | Everything |
Industry practice: executives work a personal pipeline, managers supervise, finance needs the full set to balance the books. Until the ownership columns exist (Lead.ownerId, Quotation.createdBy, Agent.accountManagerId), screens default to All and say so. Enforcement follows the same rule as everything else: in the database, not the browser.
Segregation of duties
ACC-020 · Maker ≠ checker
Status: DECIDED (enforced in the database, 2026-09-18 onwards) · Owner: Management
For booking approvals, vouchers, payment verification, refunds, supplier payments, fare acceptance and seat release: the person who creates cannot approve. The approver is always the signed-in user.
Reversals (owner, 01/10/2026): a reversal is approved only by someone who made neither the voucher it reverses nor the reversal. The control is at approval, not at creation: anyone with the right may ask for a reversal — including of a voucher they made (cancelling their own group invoice, editing or deleting their own supplier transaction, deleting a booking all reverse vouchers on their behalf) — and the maker's reversal never posts on its own; it waits pending. Enforced by: approve_journal_entries skips a reversal whose original the approver made (maker_checker_original_maker, "Maker-checker: you created the voucher this reverses (ACC-020). Ask a different person.") unless they hold finance.journals.approve_own (Super Admin), and — as before — one the approver made (maker_checker_self_approval); fin_post_reversal_as posts a reversal approved straight away only for an approver who did not make the original. The website's Reverse action also turns the original's maker away (finance.journals.reverse_own to override) — screen-level guidance, not the control. A cancellation credit keeps its own rule (below). 20261006120000; test supabase/tests/finance_guards_in_the_database.sql.
Cancellations (owner, 30/09/2026; #495): approve_passenger_cancellation (20261004170000) is the only writer of a passenger's approved cancellation — it needs bookings.cancel.approve and a pending request (the passenger's or the booking's) made by someone else, keeps the refund within the passenger's price and the policy (an override needs a reason), and keeps the booking's refunds within what it bills. The trigger "BookingPassenger_cancellation_guard" refuses a browser write of the status, refund, charge or approver; test supabase/tests/cancellation_approval_in_the_database.sql.
Cancellation requests (owner, 01/10/2026): the request says who asked, and only the database writes it. request_passenger_cancellation and request_booking_cancellation record the signed-in user and the time; the triggers "BookingPassenger_write_guard" and "Booking_write_guard" refuse a browser write that sets the status to pending or changes who asked or when. An approval refuses a request that names nobody — approve_passenger_cancellation for a passenger's or a booking's request, "Booking_write_guard" for the website's booking-level approval — and a request written that way before the fix is asked for again. 20261007100000; test supabase/tests/booking_db_guards.sql.
Enforced by (each refuses the maker, or leaves the item pending for someone else): verify_payment and approve_refund (20260918110000; supabase/tests/money_integrity.sql); approve_journal_entries, post_journal_reversal and decide_b2b_cancellation (20260918110000, 20260919100000; supabase/tests/finance_controls.sql); ops_decide_booking, finance_decide_booking (LC-010) and, for a whole booking's cancellation, the trigger "Booking_lifecycle_guard" (20260918120000; supabase/tests/booking_lifecycle.sql); approve_passenger_cancellation (above); approve_seat_release and the SeatRelease check that the approver is not the requester (20260920110100, 20260930000000; supabase/tests/ticketing_owns_its_blocks.sql); decide_ticket_exception (20260918130000, 20260925100000; supabase/tests/ticketing_without_sync.sql); a ticketing payment to the airline lands pending through record_airline_block_payment (FIN-044). Fare acceptance has no approval step yet, so nothing enforces this rule there. The refusals, screen by screen: Finance → Maker-checker.
ACC-021 · Conflicting duties
Status: DECIDED 2026-09-18 (standard segregation of duties) · Owner: Finance
No single person both records and verifies money. Cashier records receipts only; accountant verifies receipts recorded by someone else; finance manager approves refunds and journals; nobody approves their own work. CASHIER therefore does not hold finance.payments.verify (it was granted in the original seed — removed by the finance-controls workstream). Any exception is a time-limited, recorded emergency grant by the Super Admin, never a permanent bundle.
Enforced by: the role bundles in 20260919100200_finance_role_bundles.sql (CASHIER = finance.view + finance.payments.record only; ACCOUNTANT verifies but cannot approve; FINANCE_MANAGER approves) together with the maker-checker checks in verify_payment, approve_refund, approve_journal_entries and post_journal_reversal; tests supabase/tests/finance_controls.sql, supabase/tests/money_integrity.sql.
ACC-030 · Approval limits by amount
Status: PROPOSED (working default adopted 2026-09-18, owner to confirm the numbers) · Owner: Management · Source: airline spec §8, §18 Approvals route by value. Amounts are configuration, not code, and every limit has one owner. Working defaults:
| Decision | Up to | Then | Above |
|---|---|---|---|
| Discount off the rate sheet | 5% — Sales/B2B Manager | 10% — Finance Manager | CEO/GM |
| Customer refund | ₹50,000 — Finance Manager | ₹2,00,000 — CEO/GM | CEO/GM + second approver |
| Supplier payment | ₹2,00,000 — Finance Manager | ₹10,00,000 — CEO/GM | CEO/GM |
| Manual journal / adjustment | ₹25,000 — Finance Manager | above — CEO/GM | — |
| Airline fare acceptance / block commitment | ₹5,00,000 — Ticketing Manager with Finance Manager | above — CEO/GM | — |
| Seat release (penalty exposure) | 10 seats — Ticketing Manager | above — CEO/GM | — |
| Writing off a balance | — | any amount — CEO/GM | — |
A request above a limit is not blocked in the browser; it is routed to the next approver and recorded (FIN-032). The approver of record is always the person the database saw.
Manual journal / adjustment means every voucher a person writes (owner, 01/10/2026): journals (website and phone), contras (the website's contra_transfer and the phone's contra), on-account receipts (customer_on_account, agent_on_account), and reversals whose lines are not a mirror of the voucher they reverse. System postings — payments and receipts recorded against a booking, refunds, booking revenue, cancellation credits, B2B decision vouchers, operational costs — keep their own rules. Above ₹25,000 the refusal reads, for example, "₹30,000 is above your approval limit for journals (₹25,000). It needs CEO or GM."
Enforced by (partial): the limits live in ApprovalLimit — one row per decision with its unit, tiers and named owner — and fin_check_approval_limit() refuses an approval above the tier the approver holds; tier 2 and above is finance.approvals.high_value (CEO / GM / Super Admin). Wired today for customer refunds (approve_refund) and every voucher a person writes (approve_journal_entries → the trigger "JournalEntry_limit_guard", which asks fin_voucher_is_hand_written() and fin_check_journal_limit(); a voucher of these kinds inserted already approved is checked at the end of its transaction by "JournalEntry_limit_on_insert" — no path does that today). The other rows are seeded as configuration and enforced by the workstreams that own those actions (discount PRC-002, supplier payment F2, fare commitment and seat release AIR §8/§18, write-off). The "second approver above ₹2,00,000" step is recorded on the row but not built yet. 20260919100700_finance_approval_limits.sql, 20261006120000_finance_guards_in_the_database.sql; tests supabase/tests/finance_controls.sql, supabase/tests/finance_guards_in_the_database.sql.
Access lifecycle (joiners, movers, leavers)
ACC-040 · Every grant is recorded
Status: PROPOSED · Owner: HR Granting or removing a role records who granted it, when, why, and optionally until when. Nobody can change their own access. Granting admin or finance-approval rights needs a second approver. Built for staff roles by ACC-077 … ACC-080 and ACC-082 (who, when, why and two approvers for every staff role, not only admin and finance). Not built: an end date ("until when", ACC-043), and per-user permission overrides still change in one step (below).
ACC-041 · Leaving removes access immediately
Status: PROPOSED · Owner: HR Deactivating a user blocks sign-in, ends every active session, and closes their role grants in the same step.
ACC-042 · Access review
Status: PROPOSED · Owner: Management Every 90 days each manager confirms their team's access; unconfirmed privileged access is flagged.
ACC-043 · Temporary access
Status: PROPOSED · Owner: HR Temporary staff and emergency grants have an end date and expire automatically.
Role changes
The owner, 2026-09-30: "HR, GM, or even CEOs or anyone else shouldn't have rights to change roles directly, it should first go to CEO for approval, if CEO approves, then it should become a task for an admin to accept it or reject it. Only admin can finally change the role, otherwise system is vulnerable."
ACC-077 · A staff role changes only with two approvals
Status: DECIDED 2026-09-30 (owner, quoted above) · Owner: Management
Adding a staff role to a login, or taking one away, takes three steps: someone requests it with a reason (roles.request — HR, the GM, a CEO, IT); a CEO approves it (roles.approve); an admin applies it (roles.apply — IT Admin and Super Admin only). An administrative role (CEO, GM, HR Admin, IT Admin, Super Admin today) is requested — added or removed — only by an admin (ACC-083). Only the admin's accept changes the role, in the same step. A CEO or an admin may reject with a note instead, and the requester may withdraw an open request with a reason. A request to add a role the person already holds, or to remove one they do not, is refused, and there is one open request per person, role and direction. When a CEO approves, every admin who may apply it gets a job in the work inbox (WRK-018) that opens the request and closes when an admin decides. The person, the requester and (after approval) the approver are told at each step. Every step is written to the audit trail and the request itself is never changed after it is decided, or deleted (AUD-001). Existing roles stayed as they were when this was built; nobody lost a role.
A role that carries the right to edit everyone's access (admin.permissions.edit — today only SUPER_ADMIN) is applied only by an admin who already holds every permission of it, as before (the no-outranking rule of 20260917150000). When both CEOs are away the super admin approves: it holds roles.approve like every other permission.
Enforced by: 20261003165000_a_role_changes_with_two_approvals.sql — the table RoleChangeRequest (read by the people in it and by the three permissions; written only by the functions; guard trigger and no_truncate), role_change_request_create(), role_change_ceo_decide(), role_change_admin_decide(), role_change_withdraw(), role_change_requests(); the web page /admin/role-requests and the Users list's Request role change; the phone's More → Role requests (approve and apply; asking and withdrawing are web-only). Test: supabase/tests/a_role_changes_with_two_approvals.sql.
Not built: an end date on a role (ACC-043). Per-user permission overrides in Admin → Permissions still change in one step by the super admin (admin.permissions.edit); this rule covers roles.
ACC-078 · Nobody decides their own role change
Status: DECIDED 2026-09-30 (owner: "otherwise system is vulnerable") · Owner: Management
The approver is never the requester and never the person the change is about. The admin who applies is never the requester, never the person, and never the one who approved. So a CEO's own request goes to the other CEO (or to the super admin), and a change to a CEO's own roles is approved by someone else. An admin who asks for an administrative role (ACC-083) never applies it: another admin does — the IT Admin applies the Super Admin's request and the reverse. If the requester is the only active holder of roles.apply, the request waits until there is another; there is no bypass. The screens offer a button only when the database says this login may use it.
Enforced by: the checks in role_change_ceo_decide() and role_change_admin_decide(), again as table constraints (RoleChangeRequest_ceo_not_requester, RoleChangeRequest_admin_separate); the admins' jobs are never given to the requester, the person or the approver; role_change_row() works out canApprove / canApply / canWithdraw for the caller. A paused login does none of it (ACC-070).
ACC-079 · The database refuses any other change of a staff role
Status: DECIDED 2026-09-30 · Owner: IT
No browser session, edge function (service role) or other database function can add, remove or move a staff role. Only role_change_admin_decide(), applying an approved request in its own transaction, gets past. A login that has been deleted may have its role rows cleared. A person with direct database access (the migration runner, a maintenance session) may change a role only by switching app.role_change_maintenance on for that one transaction; an API session cannot use the switch (runbook). Removing a role that would leave nobody able to approve or apply a role change is refused.
Enforced by: the trigger UserRole_staff_role_lock (BEFORE INSERT/UPDATE/DELETE) and UserRole_no_truncate; the write policies on UserRole were dropped and INSERT, UPDATE, DELETE revoked from anon and authenticated. admin-users assign_role / remove_role file a request for a staff role instead of writing it. POST /users/:id/roles and roleIds on PUT /permissions/users/:id answer 410.
ACC-080 · Customer, partner and tour-leader roles keep their own flows
Status: DECIDED 2026-09-30 · Owner: Management
A role request covers every role except three: CUSTOMER (a customer signs up, or the office makes the customer's login), AGENT (a partner registers and is approved on the partner record, PTR-001) and TOUR_LEADER (appointed and dismissed on the departure, FLD-007). Those flows are unchanged. A customer or partner login cannot be given a staff role; staff need their own company login.
Enforced by: role_change_in_scope(); self_assign_portal_role(), appoint_tour_leader(), dismiss_tour_leader(), partner-signup, customer-signup and admin-users for a portal login still write those three roles; role_change_request_create() refuses them and refuses a staff role for a portal login.
ACC-082 · A new login starts with no staff role
Status: DECIDED 2026-09-30 · Owner: Management
Creating a staff login gives it no role. A role chosen when the login is created becomes a role request filed by the person who created the login, with the reason they typed; it takes effect only when a CEO approves it and an admin applies it (ACC-077). Choosing a role needs roles.request as well as admin.users.create.
Enforced by: admin-users create_user (the login is made first, then role_change_request_create_as() per role, service role only); the lock (ACC-079) refuses anything else. An administrative role chosen by a creator without roles.apply is refused before the login is made (ACC-083).
ACC-083 · Only an admin asks for an administrative role
Status: DECIDED 2026-09-30 (owner: "Yes IT Admin or super admin only. Also when creating a user by other than admin, they shouldn't be able to set any role which has administrative rights, such as GM, CEOs, and admin obviously") · Owner: Management
"Admin" means a holder of roles.apply — IT Admin and Super Admin, and nobody else. A role is administrative when its bundle holds any permission that controls users, access, settings or role changes: admin.users.create, admin.users.edit, admin.users.delete, admin.users.reset_password, admin.mfa.reset, admin.permissions.edit, admin.roles.create / edit / delete, roles.request, roles.approve, roles.apply, admin.config.edit, admin.integrations.edit or admin.edit. It is decided by the permissions, not the name, so a role given one of them later becomes administrative on its own. Today that is CEO, GM, HR Admin (ADMIN_HR), IT Admin and Super Admin. Reference data (cities, currencies) and read-only rights do not count.
Only an admin may request to add an administrative role, or pick one when creating a login (ACC-082). Anyone else is refused with "Only IT Admin or Super Admin can ask for the CEO role." (with that role's name). Only an admin may request to remove one either, so HR or the GM cannot start taking a CEO's role away ("Only IT Admin or Super Admin can ask to remove the CEO role."). HR, the GM and a CEO still request every other staff role. The rest is unchanged: a CEO approves (never their own request), and a different admin applies (ACC-078). The Request role change and Create User dialogs offer a non-admin only the roles they may ask for, and say why the others are missing.
Enforced by: 20261003190000_admin_roles_are_asked_by_admins.sql — role_administrative_permissions(), role_is_administrative(role), and the trigger RoleChangeRequest_admin_roles_by_admins (BEFORE INSERT on RoleChangeRequest, so both role_change_request_create() and role_change_request_create_as() pass through it); role_change_roles() returns { name, administrative, requestable } for the caller; admin-users create_user asks role_is_administrative() and refuses before the login exists. Tests: supabase/tests/admin_roles_are_asked_by_admins.sql, src/services/adminUsersRoleRequests.test.ts, src/components/admin/UserManagement.test.tsx.
Not built: requests filed before 2026-09-30 are not re-checked (none were open; the flow had not been released).
Authentication
ACC-050 · MFA for privileged work
Status: PROPOSED · Owner: IT Two-factor authentication is required for admin, finance, approvals and exports. Removing a user's authenticator requires an already-verified second factor or a second administrator.
ACC-051 · Account self-service limits
Status: PROPOSED · Owner: IT Public sign-up creates customer accounts only. Staff and partner accounts are created by an administrator (or an approved registration request) with a company email domain for staff.
ACC-052 · Secrets are never readable
Status: PROPOSED · Owner: IT Password hashes, PIN hashes, reset tokens and session tokens are not readable by any application user.
ACC-053 · A database function is not public
Status: DECIDED (2026-09-24, widened 2026-09-25) · Owner: IT · Source: existing practice, made explicit after it was missed
The anonymous key the website uses is a visitor. It can execute exactly the functions on an allow-list — the website's own reads (public_departures, public_groups, resolve_departure_poster), portal_context, and the predicates row-level security evaluates on its behalf (auth_*, is_staff_user, is_finance_user, has_role_type, bkg_scope) — and nothing else. A function a migration creates is closed to the anonymous key from birth; a function meant for the website grants anon on purpose, in its own migration, with a line saying why. authenticated is granted only where a signed-in screen calls the function. A function only triggers call is granted to nobody. A SECURITY DEFINER function that writes checks who is calling it (fin_require) unless its own trigger says the call came from inside.
Enforced by: 20260928140000_the_anonymous_key_is_a_visitor.sql — the schema's default privileges no longer hand the anonymous key (or PUBLIC) EXECUTE on a new function, every existing function outside the allow-list is closed to it, and every trigger function is closed to every client role; supabase/tests/the_anonymous_key_is_a_visitor.sql fails the build if the anonymous key can execute anything outside the allow-list, if a trigger function is granted to a client role, or if a newly created function is born open. Earlier: the convention in every posting migration since 20260918110000; 20260927120000_the_inventory_functions_are_not_public.sql and its test close the six inventory functions that shipped without it.
Before this the meal-plan repost and refresh, and two reads, answered the anonymous key on production for about fifteen minutes on 2026-09-24. An audit on 2026-09-25 then found 153 functions the anonymous key could execute — 91 trigger functions and a handful of readers that answer anyone who knows an id (a passenger's booking, the ids behind an audit row, a departure's ledger cost) — none of them a write, all of them the default rather than a decision. The rule now holds by construction rather than by remembering.
Confirmation
UX-001 · Every state-changing action is confirmed
Status: DECIDED 2026-09-17 (owner) · Owner: Management
Every action that changes state or moves money asks the user — staff, partner or customer — to confirm with an explicit Yes/No before it runs. Money, access and status changes show a summary of what will change and ask for a reason; deletions, voids and role grants require typing the record number. The reason is stored with the change in the audit trail.
Enforced by: src/components/common/ConfirmProvider.tsx (useConfirm, three levels) used by every state-changing action in the staff screens — inventory in ../operations/ux-001-coverage.md; the reason is passed to the API call (or added to the request body) so the audit trail records it. Portal actions: Wave 3.
Screens and formats
UX-020 · Every date reads DD/MM/YYYY
Status: DECIDED 2026-09-27 (owner) · Owner: Management · Source: the owner, 27 Sep 2026: "we use dates in DD/MM/YYYY format."
Every date a person reads — on the website, the staff screens, the customer and partner portals, the app, a printed or PDF document, a dashboard line built by the database, a confirmation question, an error message — is written DD/MM/YYYY: 27/09/2026. A date with a time is DD/MM/YYYY HH:mm, 24-hour: 27/09/2026 21:40. Staff screens read a timestamp in India time; the app reads it in the phone's time.
Where a person types a date, they type DD/MM/YYYY. The slashes go in by themselves; a date that does not exist (31/02/2026) is refused with a message that says how to write it, never silently turned into another day or dropped. The API and the database keep ISO YYYY-MM-DD; only what a person reads or types changes.
A day of the week may stand in front of a date (Sun 27/09/2026). A month on its own (a month picker, "partner since", a chart axis) is not a date and may keep the month's name. Codes that contain a date (a group code such as UMR-29AUG, an airline name-list code, a file name) keep their own form, and so does a file another system reads (the GST portal's DD-MM-YYYY).
Enforced by: the helpers every screen uses — src/lib/dateFormat.ts (formatDate, formatDateTime, formatDayOf, parseDisplayDate; no library, so the sign-in page and the dashboard carry no extra weight), src/lib/date.ts (safeFormatDate with DATE_FORMAT / DATE_TIME_FORMAT), src/components/ui/date-input.tsx (the web date box) and, in the app, apps/mobile/src/lib/format.ts (fmtDate, parseDateInput, maskDateInput) with DateField in apps/mobile/src/components/ui.tsx. Printed documents: supabase/functions/issue-document (e-ticket, hotel voucher) and the src/lib/print*.ts files. Database text: 20261001105000_dates_read_dd_mm_yyyy.sql rewrites the format in every function that wrote DD Mon. Tests: src/lib/dateFormat.test.ts, src/lib/date.test.ts, src/components/ui/date-input.test.tsx, apps/mobile/src/lib/format.test.ts, supabase/tests/dates_read_dd_mm_yyyy.sql.
Not changed: a native date picker on the phone-sized booking page (/m/booking) and the date-and-time pickers (incident time, hold expiry) show the date the way the browser is set; exports meant to be read back by a program (CSV exports, bank files) keep ISO dates.
UX-021 · Every password box has an eye
Status: DECIDED 2026-09-27 (owner) · Owner: Management · Source: the owner, 27 Sep 2026: "password fields should have eye also."
Every box where a person types a password, a PIN or a secret key has an eye at its right-hand end. Pressing it shows what was typed; pressing it again hides it. The eye says what it will do ("Show password", "Hide password"; "Show PIN" for the finance PIN), can be reached by keyboard, is at least 44 px square in the app, and never submits the form. The box starts hidden, and the browser's or phone's password manager still fills it.
Enforced by: src/components/ui/password-input.tsx (PasswordInput) on the web — staff, customer and partner sign-in and sign-up, reset password, creating a user, a partner's login, integration keys and the finance PIN — and PasswordField in apps/mobile/src/components/ui.tsx in the app — sign-in, create an account and register your agency. Tests: src/components/ui/password-input.test.tsx, apps/mobile/src/lib/passwordToggle.test.ts.
UX-022 · The keyboard never covers the box being typed in
Status: DECIDED 2026-10-01 (owner) · Owner: Management · Source: the owner, testing the Play Store build 0.2.0 (2), 1 Oct 2026: "wherever in the app if there is text field, and you want to write, it stays under the keyboard and hides that field, so you can't see what you are writing."
In the app, when the keyboard opens, the box with the cursor stays in sight above it, with the button that sends the form reachable by scrolling. This holds on Android and iPhone, on full screens and in bottom sheets. A tap on a button while the keyboard is open presses the button; dragging the page puts the keyboard away.
Enforced by: KeyboardSafeView and KeyboardScroll in apps/mobile/src/components/KeyboardSafe.tsx (the screen is padded by the keyboard's height on Android as well as iOS, because the app draws edge to edge and Android no longer shrinks the window; the focused box is scrolled into view when the keyboard opens and when Field, PasswordField or DateField gets the cursor), used by Sheet and every form screen. The arithmetic is apps/mobile/src/lib/keyboard.ts. Tests: apps/mobile/src/lib/keyboard.test.ts.
Not covered: the web pages (the browser moves the page itself).
UX-023 · The app has one look; phones upright, tablets turn
Status: DECIDED 2026-10-03 (owner) · Owner: Management · Source: issue #559 — the owner approved the redesign mockups (https://claude.ai/artifact/EcLrKURBDC8dbL9gYkMqQY) and asked for landscape on iPad and tablets.
Every screen of the app is drawn from one set of colours, type and building blocks, so the same thing looks and behaves the same everywhere: a sand page with white cards, burgundy for the one main action, a status shown as a coloured word on a pale chip, Manrope type, tap targets of at least 44 points. A list waiting for its first answer shows placeholders the shape of its rows, never a spinner. A read that fails says why in plain words and offers Try again; with no connection the screen says it is showing what was saved, and when. Phones are held upright. A tablet (shorter side 600 points or more) turns to any side, and its content stays at a readable width, centred, in two or three columns where the screen has them.
Enforced by: the tokens in apps/mobile/src/theme.ts and the blocks in apps/mobile/src/components/ui.tsx (Screen, useLayout, Loading, ErrorState, OfflineBanner …); the fonts in apps/mobile/src/lib/fonts.ts; the orientation in apps/mobile/app.json (iPhone portrait, iPad all four) and apps/mobile/src/lib/orientation.ts (Android phones locked upright at start). The decisions are pure functions in apps/mobile/src/lib/layout.ts. Tests: apps/mobile/src/lib/layout.test.ts. See The native app → Look and feel.
Not covered yet: the screens other than staff Home keep their own layouts until steps 2–10 of #559; the web pages.
UX-024 · The staff tab bar has five buttons at most
Status: PROPOSED · Owner: Management · Source: issue #559 step 2 — the approved plan names the staff tabs "Home · Bookings · Groups · Operations · More".
The staff app's tab bar never has more than five buttons. Home and More are always there. The three places between them go to Bookings, Groups, Operations and Approvals, in that order of priority, each only for a person who holds its permission: Bookings bookings.view; Groups groups.view; Operations any inventory.*, hotels.*, food.* or suppliers.* permission (the old More → Inventory row's test), visa.view, tickets.view, or a seat-release right (inventory.release.*, finance.create); Approvals any of the nine rights dash_approvals_inbox accepts (ACC-020). A person who holds all four keeps Bookings, Groups and Operations, and Approvals becomes a row on Home with the number waiting. Dashboard is never a button: Home opens it. A screen without a button keeps its route, so every link to it still works.
Operations is one tab for what a departure needs: a tile each for airline blocks, hotels, food, ground, visa and tickets with one line of what needs attention, the week's deadlines on top, and deadlines, holds, seat releases and suppliers under the tiles. Each tile and row shows only with the permission its screen opens with; a person with no tile sees only the rows they can open, never an empty tile. The numbers come from the database, each only for the permission that reads it; the deadlines count only those nobody has acknowledged.
Enforced by: apps/mobile/src/lib/operations.ts (staffTabs, tabAccess, approvalsOnHome, hubTiles, hubRows), apps/mobile/src/app/(staff)/_layout.tsx, (staff)/operations.tsx and Home; the numbers by app_operations_summary() (20261008235000_app_operations.sql), which refuses a login without inventory.view, hotels.view, food.view, tickets.view or finance.view and leaves out each section the caller may not read. Tests: apps/mobile/src/lib/operations.test.ts (no mix of permissions gives more than five tabs), supabase/tests/app_operations.sql. See The native app → Staff.
Not covered: the partner, traveller and tour-leader tab bars (their own steps of #559).
UX-030 · A list narrows to dates and sorts the way you ask
Status: DECIDED 2026-09-29 (owner) · Owner: Management · Source: the owner, 29 Sep 2026: "let's do paging and also add sorting options and dates from and to, and make all pages more professional."
A list of records has, above it, one toolbar: the search box, its filters, a date range and a sort. The date range reads the list's own date — booked on, received, added, created, submitted — and offers quick periods (today, yesterday, this week, last 7 days, this month, last month, last 30 days, this quarter, this year, next 30 days) or a from and a to typed as DD/MM/YYYY (UX-020). Both ends are included: "to 29/09/2026" includes the whole of the 29th. Days are Indian days: a record made at 23:59 IST on the 28th belongs to the 28th. Either end may be left empty. A from later than the to is put the right way round.
The sort names what the list is ordered by and which way; clicking a column that can be sorted does the same, and a second click reverses it. Only orders the server offers are shown (PLT-056).
Filtering, the date range and the order are applied by the database to the whole list, never to the page on screen (PLT-051), and the paging bar under the list says how many of how many are shown (PLT-050). The search, filters, range and order are kept in the page address, so a narrowed list survives a refresh and can be sent to a colleague. A range that is not a date is refused with a message, never ignored — ignoring it would show the whole list as if it were the answer.
Under every list on the web is one paging bar: rows per page (25, 50 or 100, remembered for that list in that browser), which rows are on screen out of how many ("1–25 of 312 bookings"; "about" when the count is the database's estimate; "50+" while the end is not yet known), the page number, and first, previous, next and last page. It is shown even when every row fits on one page, so the reader always sees how much there is. The server still sends the list a page at a time; the bar fetches more as the reader moves on, and a changed search, filter, range or order goes back to page 1. "Select all" ticks the rows on the page being shown. The phone app keeps its Load more button.
Enforced by: src/lib/dateRange.ts (the periods, on the Indian calendar, and istDayStartUtc), src/components/common/DateRangeFilter.tsx, FilterBar (dateRange, sort), DataTable (serverSort) and SortHeader, src/hooks/useListParams.ts (the address), src/hooks/usePageWindow.ts and src/components/common/ListPager.tsx (the paging bar); on the server applyDateRange / pagingDateRange in src/lib/api.ts and, for the one-call lists, customers_list_page and leads_list_page (20261003130000_lists_have_dates_and_order.sql, to exclusive of the day after) and visa_list_screen (20261003140000_the_visa_queue_has_dates_and_order.sql). Tests: src/lib/dateRange.test.ts, src/hooks/usePageWindow.test.tsx, src/lib/api.bookingsPaging.test.ts, supabase/tests/lists_have_dates_and_order.sql, supabase/tests/the_visa_queue_has_dates_and_order.sql.
Where it is: Bookings, Customers, Leads, Quotations, Customer requests, Visa applications, the visa queue, the operations approval queue, the corrections queue and the audit log, paged on the server. Departures, partners, airline blocks, B2B offers, hotels, food and ground services hold tens to hundreds of rows, all loaded; there the range and the order apply to the rows on screen (src/lib/listFilter.ts: a single date on the Indian day, and an allotment or availability window that touches the range). Finance's journal register and Transactions feed page on the server too (20261004130000_the_finance_feed_pages.sql, supabase/tests/the_finance_feed_pages.sql). The finance reports keep the date filters they had.
Role dashboards
UX-010 · Every employee has a dashboard for their own role
Status: DECIDED 2026-09-17 (owner) · Owner: Management Every staff user lands on a dashboard built for their job, chosen from the permissions they hold (never from a role name, ACC-001). Someone who holds several roles sees the sections for each, with their main role first. A dashboard shows only data the user may see (ACC-013) and contains: 1. My work now: the queues this role must clear (items waiting for them, sorted by deadline and value), each item one click from the action. 2. Signals: alerts, risks and the next best action for this role (INT-001, INT-160 daily brief). 3. My numbers: 3–5 KPIs for the role, each clickable down to the records behind it (INT-004). 4. Shortcuts: the role's most frequent actions.
Today, today's travellers, upcoming deadlines and open incidents appear on every operational dashboard when they concern the user's groups.
Enforced by: supabase/migrations/20260919140000_role_dashboards.sql (every number and list comes from a dash_* function that checks the caller's permissions in the database and returns the rule and facts behind it); src/lib/dashboardProfiles.ts (profiles detected from permissions, never role names; re-exported by src/components/dashboard/widgets/profiles.ts, and copied byte for byte to apps/mobile/src/lib/dashboardProfiles.ts for the phone), registry.ts, roleWidgets.tsx, DailyBrief.tsx, src/pages/RoleDashboard.tsx (route /app); tests supabase/tests/role_dashboards.sql, src/components/dashboard/widgets/profiles.test.ts, registry.test.ts, brief.test.ts, src/pages/RoleDashboard.test.tsx.
UX-011 · Dashboard contents by role
Status: PROPOSED · Owner: Management | Role | My work now | Signals | Numbers | |---|---|---|---| | CEO / GM / Director | Decisions awaiting approval above limits (ACC-030) | Daily brief, critical incidents, groups at risk, margin warnings, cash low point | Bookings and pax this season vs last, collections, receivables overdue, group margin, cash position | | Sales Manager / Executive | Leads to follow up, quotations expiring, bookings in draft, customer requests | Seats running out, duplicate customers, customers with dues near departure | Leads → bookings conversion, bookings this month, my targets | | B2B Manager / Executive | Partner applications, partner bookings pending, partner requests | Partners near credit limit, overdue statements, pace vs last season | Partner sales, receivables by partner | | Operations Manager / Executive | Approvals, readiness blockers, rooming and transport to finish, closeouts | Departure readiness per group, deadline radar, live trip watch, incidents | Departures next 30 days, readiness %, open incidents | | Ticketing | Name lists due, tickets to issue, voids/refunds, airline cancellations to file | Block deadlines, unused seats vs release date, name/passport mismatches | Seats held / sold / released, tickets issued | | Visa Officer | Cases to submit, documents missing, rejections to act on, intake queue | Visa cut-offs, passports expiring, issued visas for cancelled travellers | Cases by stage, turnaround | | Finance Manager | Approvals inbox (payments, journals, refunds, cancellations), close checklist | Anomalies, cash-flow forecast, FX exposure, tax deadlines | Cash & bank, receivables overdue, payables due, refunds pending | | Accountant | Receipts to verify (made by others), bank lines to match, supplier payments due, TDS to deduct | Unreconciled items, drift checks | Unverified receipts, unmatched bank lines | | Cashier | Today's collections, receipts I recorded awaiting verification | Customers at the counter with dues | Collected today by mode | | Auditor | Evidence search | Anomaly feed, changes to posted/locked items | Exceptions by type (read-only) | | HR / IT Admin | Joiners/leavers, access requests, MFA not enrolled | Dormant accounts, privileged grants | Active users by role | | Tour leader (field app, FLD) | Today's schedule, check-ins, headcount | Missing travellers, incidents | Group headcount |
Enforced by: src/components/dashboard/widgets/profiles.ts (widget list per profile) + roleWidgets.tsx (one dash_* function per widget), built 2026-09-18 (Wave 2D). Built against this table, with these gaps: approval routing by limit needs ACC-030 (OPEN), so the approvals inbox shows everything the user may decide; sales targets, lead owner and next-follow-up date, partner account manager, partner statement due dates, transport gaps and per-deadline supplier instalments have no columns yet (each widget names the missing data in its "why" panel); margin warnings, FX exposure, cash-flow forecast and duplicate detection arrive with Wave 4 (INT-122, INT-151..153); the tour-leader dashboard is Wave 3 (field app); live trip watch and incidents come from the duty-of-care workstream.
The phone follows this table (owner, 2026-09-27). The native app's Dashboard tab is not a separate dashboard: it uses the same twelve profiles, detectors, suppression, priority and widget lists (src/lib/dashboardProfiles.ts, copied byte for byte to apps/mobile/src/lib/dashboardProfiles.ts; src/test/dashboardProfiles.drift.test.ts and apps/mobile/src/lib/dashboardProfiles.drift.test.ts fail while the copies or the widget definitions differ) and loads each tab with the same dashboard_screen call. A change to a role's dashboard is made once and reaches both. The phone draws each widget by its kind and leaves out the shortcuts, the daily brief and the journey board (The Alhuda Travels app → Dashboard). Collected today and the customer / partner split of the receivables are part of the Collections and Receivables widgets on both (20261001180000).
Signing in
The owner asked on 2026-09-23 for staff to sign in with a username or either company address, without weakening security. Reviewing sign-in to do that found it weaker than it looked: the second factor was enforced only by the browser, the captcha on every sign-in page was never verified by a server, backup codes could be deleted by a password-only session, and phone-number sign-in for customers and partners had silently returned nothing since the 17 September lockdown. These rules replace all of that.
ACC-060 · Staff sign in with a username or their work email
Status: DECIDED 2026-09-23 (owner) · Owner: Management
Every member of staff has a username: lowercase, 3–30 characters of letters, digits, dots, dashes or underscores, starting with a letter or digit, never containing @, unique. It is created from the email when the account is made and can be changed by anyone with admin.users.edit. On the staff sign-in page a person types their username or their work email, in any case.
Enforced by: User.username with the User_username_format check and User_username_key unique index (20260927180000_one_door_for_signing_in.sql); admin-users create_user / update_user; test supabase/tests/one_door_for_signing_in.sql.
ACC-061 · The two company domains are one person
Status: DECIDED 2026-09-23 (owner) · Owner: Management
@alhudatravels.in and @alhuda.co.in are the same mailbox. ali@alhudatravels.in and ali@alhuda.co.in sign in to the same account, and the same person can never be registered under both. Any other domain is only case-folded.
Enforced by: auth_canonical_email() and the User_company_email_canonical_key unique index; admin-users refuses an alias before creating a login, so a refused profile never leaves an orphaned sign-in behind.
ACC-062 · Signing in cannot be used to find out who has an account
Status: DECIDED · Owner: Engineering
What was typed is resolved to an account on the server (the auth-login edge function, through auth_identifier_resolve(), which only the service role may call). The browser is never told which email a username or phone number belongs to. Every refusal — no such account, wrong password, inactive, wrong portal — gets the same message after the same minimum delay (700 ms). A password-reset request always answers "if an account matches, a link is on its way". Customers and partners may sign in with their phone number; a phone shared by two accounts matches neither. A WhatsApp sign-up code request always answers "if this number can be used for a new account, a code is on its way", and a number that has an account gets no code (TRV-016). One exception, by design: a person who writes "sign up" to the business number on WhatsApp from a number that already has an account is told so in the chat (TRV-017) — the answer goes only to that number's own WhatsApp, in reply to a message WhatsApp delivered from it, so nobody learns anything about a number they do not hold.
Enforced by: auth-login; auth_identifier_resolve(); tests one_door_for_signing_in.sql, src/services/authLoginLogic.test.ts.
ACC-063 · Attempts are throttled and the captcha is real
Status: DECIDED · Owner: Engineering
The captcha on every sign-in, reset and recovery form is verified with Cloudflare by auth-login. Five failures for one identifier within 15 minutes lock it for 15 minutes after the latest; a correct password clears earlier typos. Thirty failures from one address within 15 minutes lock the address. Resets and recoveries are throttled the same way. Every attempt is recorded in AuthLoginAttempt, keyed by a hash of what was typed — never the identifier itself.
Enforced by: auth_login_throttle(), auth_login_record(), AuthLoginAttempt (service role only).
Not enforced: somebody who calls Supabase Auth directly with an email address bypasses auth-login's captcha and throttle and meets only Supabase's own per-address rate limit. Closing that needs Supabase's project-wide captcha switched on, which also requires customer self-sign-up to send a token — see V1.1 in the change report.
ACC-064 · The second factor is required by the database
Status: DECIDED · Owner: Engineering
A session belonging to somebody with a verified authenticator holds no permissions and is not staff until it has passed the authenticator challenge (aal2). This is checked in user_has_permission() and auth_is_staff() — the two functions every row-level policy and every guarded function already goes through — so skipping the TOTP screen, or calling Supabase directly with only the password, gets nobody anywhere. Checking whether another person holds a permission (to notify them, say) is unaffected. AuthPolicy.require_aal2_for_enrolled switches this off in an emergency; a missing row means on.
Enforced by: auth_session_meets_mfa(); test one_door_for_signing_in.sql (both through the function and through a real policy).
ACC-065 · The second factor is an authenticator app
Status: DECIDED 2026-09-23 · Owner: Management
Two-step verification means a TOTP authenticator app (Google Authenticator, Microsoft Authenticator, 1Password and the like). The earlier email and WhatsApp "codes" were generated, stored and compared in the browser after the password had already produced a full session, so they protected nothing; they are retired, and nobody in production had them switched on. So is the switch that marked an account "2FA enabled" without any factor at all.
Enforced by: SecuritySettings offers only the authenticator; 20260927180000 switched the old channel off for anyone who had it; 20260928160000 dropped the retired table that held those codes in clear text under a policy any signed-in user could read.
ACC-066 · Backup codes belong to a session that has passed the second factor
Status: DECIDED · Owner: Engineering
Ten single-use backup codes are generated in the database and only for an aal2 session. No browser session can read, change or delete them — before this, a password alone could delete a person's backup codes.
Enforced by: mfa_regenerate_backup_codes(), mfa_backup_codes_remaining(); MfaBackupCode has no client grants.
ACC-067 · Recovering a lost authenticator takes the password and a backup code
Status: DECIDED · Owner: Engineering
Somebody who has lost their phone signs in with their password, chooses "use a backup code", and completes a fresh captcha. auth-login proves the password again, spends the code exactly once, removes the lost authenticator through the admin API and returns a session. They then set up a new one. An administrator can still reset MFA (admin.mfa.reset) for somebody who has lost both.
Enforced by: auth-login (recover), mfa_consume_backup_code() (service role only).
ACC-069 · A password reset code on WhatsApp is the backup
Status: DECIDED 2026-09-25 (owner: "email first, WhatsApp backup") · Owner: Management A customer or partner resets their password by the emailed link first. If the email does not reach them, the reset page offers a six-digit code on WhatsApp. When they typed a phone number and it matched their account, the code goes to that number and to no other — even when the login, the customer record and the partner record hold different numbers. When they typed an email address or a username, it goes to the number the system holds for the account: the login's own, else the customer's, else the partner's. A number is compared on its last ten digits; a number held by two accounts matches neither (ACC-062), and a number that is not the account's is never used. The request is answered with the same words whether or not an account or a number matched (ACC-062); when WhatsApp codes are not switched on, the page says so before anyone is looked up. The code is made on the server, sent through Meta's authentication template, stored as a hash, lives ten minutes, is issued at most once a minute, and locks for good after five wrong guesses; a new code supersedes the old. The code and the new password are sent together; a right code sets the password through the admin API and the person signs in through the one door. Every request and every guess is an attempt under the sign-in throttle (ACC-063). Staff reset by email only: a code on a phone is not a factor for a staff account (ACC-065).
When WhatsApp refuses a code, the attempt keeps Meta's error code and its words (every run of five or more digits masked, 300 characters at most; never the number or the code). The office reads the refused codes of the last 30 days on Admin → Integrations → WhatsApp → WhatsApp delivery problems (admin.integrations.view), with a plain hint for the common codes (131030 the app is in Development mode and the number is not a test number; 132001 the template does not match; 131026 not on WhatsApp; 190 the token expired). The person asking is still told the same words (ACC-062).
Note 2026-10-01 (TRV-016): the same authentication template carries the six-digit sign-up code, with the same protections (hash, ten minutes, five guesses, one a minute, the throttle, one answer). A sign-up code WhatsApp refuses is listed on the same WhatsApp delivery problems list as "Sign-up code (new customer)" — nobody has an account yet, so there is no name. A sign-up code and a reset code never pass for each other.
Amended 2026-10-02: a partner typed their new number, which the office had just put on the partner record; the code went to the older number on their login, and Meta's refusal was only in the function logs.
The emailed link now signs the person in when it is opened: the reset page reads the token from the link and adopts the session. Before this the app ignored the token (detectSessionInUrl: false), so the link led to "this recovery link is missing or has expired" for everyone.
Enforced by: auth-login (reset_code, reset_code_verify; typedPhone() in its logic.ts); auth_reset_contact(p_user_id, p_typed_phone), auth_reset_code_issue(), auth_reset_code_check() and PasswordResetCode (20260928160000_a_password_reset_code_on_whatsapp.sql, 20261002110000_a_reset_code_goes_to_the_number_typed.sql, service role only); Meta's refusal through describeWhatsAppFailure() (supabase/functions/_shared/whatsappError.ts) into AuthLoginAttempt."deliveryErrorCode" / "deliveryError", read by whatsapp_delivery_failures(); ResetPassword page; WhatsAppDeliveryProblems on Admin → Integrations. Tests: supabase/tests/a_password_reset_code_on_whatsapp.sql, supabase/tests/a_reset_code_goes_to_the_number_typed.sql, src/services/authLoginLogic.test.ts, src/services/whatsappError.test.ts, src/lib/authLogin.test.ts, src/services/phone.test.ts, src/pages/auth/ResetPassword.test.tsx, src/components/admin/WhatsAppDeliveryProblems.test.tsx.
Needs from the owner: an approved Meta authentication template (copy-code button) named in the WHATSAPP_OTP_TEMPLATE_NAME secret; until then the page says WhatsApp codes are not switched on.
ACC-068 · Deactivating an account also blocks its sign-in
Status: DECIDED · Owner: Engineering
Deactivating a member of staff bans their Supabase login, so they cannot sign in or refresh a session; reactivating lifts it. The database already refused an inactive user every permission, but the login itself kept working. If blocking the login fails, the administrator is told so in plain words — never shown a plain success.
Enforced by: PATCH /users/:id (deactivate / reactivate) calling admin-users update_user, which sets ban_duration.
Pausing an account, and profiles
ACC-072 · A staff reset link goes to the Workspace mailbox
Status: DECIDED 2026-09-26 (owner: "they should get the reset link on alhudatravels.in emails") · Owner: IT · Source: ACC-061, ACC-069
Staff mailboxes live on Google Workspace at @alhudatravels.in; the old @alhuda.co.in mailboxes are being retired. A password-reset link for a company account is therefore always delivered to name@alhudatravels.in, whichever of the two domains the account was created with (the two are one person, ACC-061). A partner's or traveller's link goes to their own address, as before.
Enforced by: resetDeliveryAddress() in supabase/functions/auth-login/logic.ts (tested in src/services/authLoginLogic.test.ts); for a company address auth-login makes the recovery link itself (auth.admin.generateLink, type recovery) and sends it through Resend to that mailbox, because Supabase's own mailer can only write to the account's stored address. The same one message is returned whether or not an account matched (ACC-062).
Not built: until alhuda.co.in is an alias domain on Workspace, mail to the old @alhuda.co.in addresses still goes to Yandex; the reset link does not depend on it.
Amended 2026-10-02 (owner: "they get the link in email but that link doesn't open new password dialogue"): every reset link opens the new-password form. The link returns to the reset page in recovery mode (/reset-password?mode=recovery&returnTo=…) on one of our own domains — alhudatravels.in, www.alhudatravels.in and travel.alhuda.co.in are always allowed; the SITE_URL and AUTH_ALLOWED_ORIGINS secrets only add to them. A redirect asked for on any other origin, or on another path, is replaced with SITE_URL (default https://alhudatravels.in) /reset-password?mode=recovery&returnTo=/auth (/customer/auth for the portals); an allowed one without mode=recovery has it added. The reset page reads the token whenever the address carries one, with or without mode, and any page opened with a #…type=recovery hash (Supabase sends the link to the Site URL root when the redirect is not on its allow-list) moves to the reset page before the app renders. Before this, with the secrets unset the link went to the retired pages.dev host without mode=recovery, and the page showed "request a link" instead of the form.
Enforced by: resetRedirectTo() in supabase/functions/auth-login/logic.ts; carriesRecoveryToken() and recoveryLandingTarget() in src/lib/recoveryLanding.ts (called from src/main.tsx and the ResetPassword page). Tests: src/services/authLoginLogic.test.ts, src/lib/recoveryLanding.test.ts, src/pages/auth/ResetPassword.test.tsx.
Needs from the owner: the Supabase Auth redirect allow-list must hold https://alhudatravels.in/**, https://www.alhudatravels.in/** and https://travel.alhuda.co.in/** (Security → auth-login). The code cannot set it.
ACC-075 · A staff login's email can be changed by the office; staff move to @alhudatravels.in
Status: DECIDED 2026-09-28 (owner: "can you change email of all employees to @alhudatravels.in domain?") · Owner: IT · Source: ACC-060, ACC-061, ACC-072
The office (admin.users.edit) changes the sign-in email of a staff login, one at a time or all at once. The all-at-once move takes every live staff login whose email ends in @alhuda.co.in to the same name at @alhudatravels.in. A customer's or partner's login is never changed here: they keep their own address, and the request is refused in words. The new address must be a company address. A change is refused, and nothing is changed for that login, when another user (a deleted one included) or another sign-in already holds the address, or when a live company login is the same mailbox under the other domain (ACC-061). Nobody changes the email of a login whose permissions they do not all hold.
Both the sign-in (Supabase Auth, confirmed, so no confirmation mail is needed) and the profile (User.email) change together; if the profile cannot be written, the sign-in goes back to its old address and the office is told. Open sessions stay valid and the username does not change. The person keeps signing in with their username, the new address or the old one: the one door matches a company address on its canonical form (auth_canonical_email(), inside auth_identifier_resolve()) and signs in with the address the sign-in now holds. Every change needs a reason and writes an audit row (user_email_changed, with the reason, old and new address, and the office member as actor). The person gets a short email at the new address saying their sign-in email is now X and their password is unchanged. The move can first be run as a preview that changes nothing; running it twice changes nothing the second time.
Enforced by: admin-users change_email and move_staff_to_workspace_domain (dryRun), with the rules in supabase/functions/admin-users/emailChange.ts (tested in src/services/staffEmailChange.test.ts); canManageUser() for each login; the User_email_key and User_company_email_canonical_key unique indexes. Web: Admin → Users.
Not built: the phone app has no email change; an employee's personal email on their profile and addresses kept elsewhere (a customer record of the same person, mailing lists, Google Workspace itself) are not changed; a mailbox is not created on Workspace — IT creates it first.
ACC-076 · A person's phone is one number
Status: DECIDED 2026-09-28 (owner: "if phone numbers are updated, it doesn't get updated properly") · Owner: IT · Source: ACC-069, ACC-062
A person's phone is one number. Changing it on the login (User.phone), on the partner record (Agent.phone, where Agent.userId is the login) or on the customer record (Customer.phone, where Customer.userId is the login — the login's own customer record) changes it on the other two, whoever makes the change and from whichever screen. The office changing a partner's phone therefore changes where that partner's WhatsApp reset code goes (ACC-069).
- One number, any spelling. Numbers are compared as the sign-in compares them: digits only, the last ten digits.
9906272405,09906272405,919906272405and+91 99062 72405are one number. A change of spelling only is not a change: nothing is copied and no audit row is written on the other records. - Copied as typed. The new number is copied exactly as it was entered; the other records are not reformatted. (WhatsApp sending normalises the number itself.)
- A blank never overwrites a number. Clearing the phone on one record leaves the others as they are.
- A staff login's number is changed on the login only — by the person (My profile) or by the office (
admin.users.edit, employee record). It can carry the second factor, so a change on a customer or partner record linked to a staff login is not copied to the login. The login's number still flows to those records. - Linking fills a blank only. When a partner or customer record is first linked to a login (created with it, relinked, or restored), a blank side is filled; two different numbers are left for a person to decide.
- Not the same person, not synced: a partner's contacts (
PartnerContact, PTR-085 — each contact is its own person, the primary one included), the customers a partner booked (linked byagentId, notuserId), an employee's emergency contact, leads, passengers, visa cases and suppliers. The employee profile has no phone of its own: its Work phone isUser.phone. - No constraint is broken. No phone column carries a unique constraint, so a copy is never refused. A number held by two accounts still matches neither at the sign-in (ACC-062); copying inside one person's records does not change which account a number belongs to.
- Audited. Each record that changes writes its own audit row through the table's audit trigger, with the person who made the original change as actor and
metadata.reason=phone_sync from Agent/from Customer/from User. - Existing data. Where the numbers already differ, nothing is changed automatically — nobody can tell which is right. The migration fills only a blank side (
phone_fill_blanks(), audited asphone_sync backfill (blank filled)), andphone_mismatches()lists the rest, masked to the last four digits, for the office to fix by hand (runbook).
Enforced by: the triggers User_phone_sync, Agent_phone_sync, Customer_phone_sync → phone_sync_trigger() / phone_sync_person() (SECURITY DEFINER, recursion held off by the transaction-local setting app.phone_sync), phone_match_key(), phone_fill_blanks() and phone_mismatches() (service role only), and audit_table_change() reading app.audit_reason — migration 20261002120000_one_phone_per_person.sql. Test: supabase/tests/phone_stays_in_step.sql. Web: a one-line note under the phone on the partner Edit dialog, the customer form, the employee profile and My profile.
Not built: an email address is not kept in step this way (ACC-075 changes a staff login's email only); the list of mismatches is a runbook query, not a screen.
ACC-073 · A document on Drive is never public
Status: DECIDED 2026-09-26 (owner, setting up the company Shared Drive; amended the same day: "staff should see the documents inside the ERP") · Owner: IT · Source: AUD-020, TRV-011
Passports, visas and other customer, partner and employee papers are kept in the company Shared Drive (Alhuda-Shared-Drive). A file saved there is shared with nobody: not "anyone with the link", not a person outside alhudatravels.in. Everyone — staff, traveller, partner — opens a file inside the ERP or the app, through the drive-file function, which checks their right to that file and hands out a link that lives ten minutes (TRV-011). Staff need no Google account to read a document. Membership of the Shared Drive is only for administering the drive; "Open in Google Drive" is a secondary action for those members. The Workspace sharing setting that lets users make files visible to anyone with the link is off.
Enforced by: every upload function (upload-customer-doc, upload-partner-doc, upload-employee-doc, drive-upload, lead-intake, visa-intake, chat-upload, issue-document) uploads with the service account and creates no sharing permission — until 26 Sep 2026 the first two made every file readable by anyone with its link. drive-file for every read: staff are held to the view permission of that kind of record (the table is in ACC-074). The desktop's document viewer (src/components/common/DocumentViewer.tsx) shows an image or a PDF inside the page. The Shared Drive's members and the Workspace setting are kept by IT (Google Drive).
Not built: nothing removes a member from the Shared Drive when their ERP login is deactivated; IT does it by hand.
ACC-074 · Files live on the company Shared Drive, never in Supabase Storage
Status: DECIDED 2026-09-26 (owner: "don't use supabase storage because we have only 1 gb") · Owner: IT · Source: ACC-073, AUD-021
Every file the system keeps — customer, partner and employee documents, visa documents and visa applications' files, ticket documents, issued e-tickets and hotel vouchers, group documents, voucher attachments, TDS certificates, incident documents, lead and enquiry files, customer request attachments and payment receipts, chat attachments — is stored on the Shared Drive Alhuda-Shared-Drive by the server's service account, in a folder per kind and record, and nowhere else. Nothing is uploaded from a browser or a phone straight to storage: the file goes to an edge function, which checks the caller first. A website visitor's files (an enquiry, a visa application) travel with the form and are stored only after the captcha has passed. A file is opened through drive-file only (ACC-073).
| Kind | Who may add one | Who opens one (staff) | Folder |
|---|---|---|---|
| Customer document | customers.edit / customers.create, or the traveller for their own record |
customers.view |
Customers/<name (id)> |
| Partner document | the partner, for their own agency | row security: agents.view |
Partners/<agency (id)> |
| Employee document | the person; admin.users.edit for anyone |
row security: admin.users.view |
Employees/<name (id)> |
| Visa document | visa.edit |
visa.view |
Visa/<case> |
| Ticket document | tickets.edit |
tickets.view |
Tickets/<ticket> |
| Issued e-ticket / voucher | tickets.view or hotels.view, with bookings.view |
row security: bookings.view |
Issued/<booking> |
| Payment receipt (FIN-045) | finance.view or bookings.view; the booking's customer or partner |
row security: bookings.view, or finance.view for a receipt |
Issued/<booking> |
| Group document | groups.edit |
groups.view |
Groups/<group> |
| Voucher attachment | finance.edit |
finance.view |
Finance/Vouchers/<voucher> |
| TDS certificate | finance.tds.deduct |
finance.tds.view |
Finance/TDS/<deduction> |
| Incident document | incidents.manage |
incidents.view |
Incidents/<incident> |
| Customer request attachment | the customer, for their own record | requests.view |
Customers/<name (id)>/Requests |
| Payment receipt | the customer, for their own record | finance.view |
Finance/Receipts/<name (id)> |
| Enquiry file (website) | a visitor who passed the captcha | leads.view |
Enquiries/<name (lead)> |
| Visa application file (website) | a visitor who passed the captcha | visa.intake.view |
Visa/Intake/<name (request)> |
| Chat attachment | a staff login | the channel's or conversation's members | Chat/<month> |
Whoever uploaded a file may always open it again. A PDF or a photo (JPEG, PNG, WebP, HEIC), 10 MB at most; a website form carries 20 MB at most in all.
Enforced by: drive-upload and drive-file read one table of kinds (supabase/functions/_shared/driveKinds.ts, tested in supabase/functions/drive-file/index.test.ts); every stored file of a registry kind is recorded in DriveFile with the kind the server gave it (migration 20261001090000_files_live_on_the_shared_drive.sql, service role only, tested in supabase/tests/files_live_on_the_shared_drive.sql), so drive-file never takes the kind from the caller. drive-file's link is signed with DRIVE_LINK_SECRET (supabase/functions/_shared/driveLink.ts). No code writes to or reads from Supabase Storage; the three buckets on production (erp-storage-bucket, incident-documents, traveller-documents) are unused.
Not built: the unused buckets are not deleted (the owner's call); a file removed from a record stays on the drive (a service account cannot delete from a Shared Drive — it can only move a file to the trash). Staff with partners.edit file a partner's document through upload-partner-doc with the agency's id since 2026-09-28 (PTR-087).
ACC-070 · A paused account is read-only
Status: DECIDED 2026-09-25 (owner: "an option to pause, which would put their account in read-only mode") · Owner: Management Any login — staff, partner or traveller — can be paused. A paused person signs in as before, keeps their session and their phone's alerts, and reads everything they could read before. Nothing they do changes anything: every write is refused with one sentence, This account is paused (read-only). Pausing is not deactivating (ACC-068): the door stays open, the pen is taken away.
The read set. A paused account keeps exactly the permissions whose last segment is view, export or read (bookings.view, finance.reports.aging.export, field.view, admin.audit.view …) and loses every other one (bookings.edit, field.checkin, finance.payments.record, approvals.approve, admin.users.edit …). perm_is_read_only(name) is the one definition.
Who pauses whom. admin.users.edit pauses and resumes anyone; partners.edit may pause a partner login and customers.edit a traveller login (the same split admin-users makes for portal accounts). Nobody pauses themselves, and nobody pauses someone whose permissions they do not all hold — so only a super admin pauses a super admin. A paused administrator can pause nobody, because admin.users.edit is a write. A pause needs a reason, kept with the audit row; the person is told the account is paused, not the reason.
Enforced by (20260929140000_profiles_and_a_paused_account.sql), in one place per door and never in a screen:
user_has_permission()— the function every row policy,fin_require(),cxl_require_any_permission(),tvc_require_permission(),inv_require*(),work_require(),auth_user_has_permission(),auth_has_any_permission()and the edge functions'requirePermissiongo through — answers false for a paused user unless the permission is in the read set.is_staff_user()— the predicate on the blanket "staff may write" policies — now means staff who may write (auth_is_staff() AND NOT auth_is_paused()). The twelve read policies that also used it were re-pointed atauth_is_staff(), so reading is unchanged.portal_require_active_agent(p_for_write)andportal_require_customer(p_for_write)— the two portal doors — refuse a paused login;public_b2b_offers, the one read that walks through the partner door, passesfalse. The writers that check ownership instead of a permission (partner_add_document,partner_update_profile,partner_submit_payment,trv_set_location_sharing,trv_submit_passport,trv_report_location) callauth_assert_can_write()themselves.- The
Userself-update policy addsNOT auth_is_paused(); the four pause columns (accessMode,pausedAt,pausedBy,pauseReason) are changed byadmin_pause_user/admin_resume_useronly (user_protect_columns).push_subscriptions,UserSession, the notification inbox (ntf_mark_read) and sign-in (auth_login) are untouched on purpose. - Edge functions:
getAuthContext()carriesaccessMode;assertCanWrite()refuses a paused caller inadmin-users(every action butlist_users),upload-customer-doc,upload-partner-doc,upload-employee-doc,mailerandwhatsapp-send(every action butstatus). Functions that write behind a permission inherit the first bullet. - The web shows a banner ("Read-only: your account is paused. Contact the office."); the app shows one at the foot of every screen. Buttons stay where they are — the database refuses, the screen does not pretend.
Test: supabase/tests/a_paused_account_is_read_only.sql — a paused ops exec reads bookings and finance and is refused create_booking, record_payment, ops_decide_booking, fld_record_checkin, trv_post_notice, log_action_reason, profile_update_me, a booking update and their own User row; a paused partner reads their agency, bookings and offers and is refused partner_create_booking, partner_submit_payment, partner_add_document, partner_create_request, partner_update_profile; a paused traveller reads their trip and is refused customer_create_request, trv_set_location_sharing, trv_report_location, trv_submit_passport, customer_update_profile; resume restores all three; self-pause, pausing without the right, pausing above your station, a paused admin, a table write and the anonymous key are refused; the audit rows and notifications are counted.
ACC-071 · Employee and partner profiles
Status: DECIDED 2026-09-25 (owner: "a comprehensive profile system of employees and business partners") · Owner: HR
Every staff login has one employee profile (EmployeeProfile): designation, employee code, department (free text — the Department table was dropped in 20260401190000), joined-on and birth dates, gender, blood group, personal email, address, emergency contact, who they report to, notes, and their files on the company drive (EmployeeDocument, under Employees / "<name> (<id>)": photo, Aadhaar, PAN, passport, address proof, qualification, contract, other). A partner's profile is their agency (Agent) and its registration documents (AgentDocument, PTR-080); it is read on the partner record (PTR-083).
One record per employee (amended 2026-09-25). Every login has one record the office reads in one place (admin.users.view): the login and its access (active or paused, authenticator enrolled, last sign-in, sign-ins in 30 days, failed attempts, sessions, devices with alerts), the employee profile, roles and how many permissions the login holds, who they report to and who reports to them, the documents with the office's review — OK, or rejected with a note the person reads (admin.users.edit, audited, the person told) — the groups they lead, and one activity timeline: 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, appointments), with their other audit rows as the action, the changed field names and the actor — never the old and new values (AUD-004). Everything that names an employee (a tour leader on a group, the Users list) links to it. Identity numbers appear as their last four only, and no secret is ever part of the record.
What is never kept. The full Aadhaar and PAN numbers. The profile holds the last four characters only (aadhaarLast4, panLast4); a longer value is refused, not trimmed. The scanned card lives on the drive as a private file (AUD-020, AUD-021; DPDP data minimisation).
Who changes what. A member of staff changes their own contact fields (profile_update_me: phone, personal email, address, district, state, PIN, emergency contact, date of birth, blood group, gender) and files their own documents; the office (admin.users.edit) changes everything else through admin_update_employee_profile, files and removes documents, and never above its station (can_manage_user). HR (hr.profiles.edit) changes the profile details of any staff login that is not an admin account (ACC-084, amended 2026-09-30). admin.users.view reads any profile; everyone else reads only their own row. A paused login changes nothing, contact details included (ACC-070). A partner changes their trading name and contact person as before (partner_update_profile); the rest is the office's on /agents. Every change is an audit row naming the fields.
Enforced by: EmployeeProfile / EmployeeDocument with RLS (own row or admin.users.view; no client write policy), profile_of(), profile_me(), profile_update_me(), admin_user_profile(), admin_users_list(), admin_update_employee_profile(), employee_add_document(), employee_remove_document(); the upload-employee-doc edge function; the record: employee_record() and employee_document_review() (20260929230000_a_record_links_everything.sql), the desktop page /admin/users/:id and the app's admin/users/[id] screen. Tests: supabase/tests/profiles.sql, supabase/tests/a_record_links_everything.sql.
ACC-081 · A reporting officer is one of the staff, and there are no loops
Status: DECIDED 2026-09-30 (owner: "if we have to put reporting officer to employee, it should list it from the already listed staff"; "There are two CEOs, keep them reporting officers of each other") · Owner: HR · Source: ACC-071, EA-012 Wherever a person's reporting officer is set — the employee record's Edit profile, and the Issue agreement dialog — it is picked from a searchable list of the staff, never typed. The list shows each person's name, designation (their profile's, else their role's title, EA-012) and employee code.
- Staff only. A reporting officer is an active login that is not a partner or traveller login and holds a staff role other than tour leader. A super-admin login counts.
- Never oneself, never someone below. The list leaves out the person and everyone who reports to them, directly or down the line. The database refuses the same, so a loop (A → B → C → A) can never be saved.
- The one exception: two CEOs. Two logins that both hold the CEO role may report to each other. Only that pair: a longer loop through them is still refused, and a CEO and a GM (or anyone else) may not report to each other. For leave and for the agreement, each CEO's reporting officer is simply the other CEO.
- Checked on a change only. A profile that already holds a value the rule would refuse is not touched; the next change to its reporting officer is checked.
- Who reads the list.
admin.users.view(the employee record) orhr.agreements.issue(the agreement). Writing the value needs the right to edit that profile:admin.users.editandcan_manage_user(ACC-071), orhr.profiles.editfor a staff login that is not an admin account (ACC-084).
Enforced by: the trigger EmployeeProfile_reports_to_guard (employee_profile_reports_to_guard(), on insert and on a change of reportsToUserId), staff_can_be_manager(), staff_reports_below(), staff_reporting_allowed(), staff_is_ceo(); the list: staff_reporting_options() (GET /users/:id/reporting-options) — migration 20261003195000_the_appointment_comes_from_the_staff.sql. Test: supabase/tests/the_appointment_comes_from_the_staff.sql (ACC-081). Web: src/components/staff/StaffPicker.tsx, used by EmployeeProfileDialog and IssueAgreementDialog.
Not built: the phone app shows the reporting officer but does not set it; setting it is on the web.
ACC-084 · HR edits the profile details of the staff, except an admin's
Status: DECIDED 2026-09-30 (owner: "except for admin, syed HR can edit profile details") · Owner: HR · Source: ACC-071, EA-013
How the owner's words are read. "HR" is whoever holds the HR profile right, hr.profiles.edit — ADMIN_HR today; it is granted by permission, never by person or role name. "Admin" is an admin account: a login that holds roles.apply or admin.permissions.edit, through a role or an allowed override — IT_ADMIN and SUPER_ADMIN today. It stays an admin account while paused or deactivated. The CEOs, the GM, the managers, other HR officers and every other staff login are not admin accounts, and HR edits their profile details.
Profile details are the employee profile fields: designation, department, reporting officer (ACC-081), joined on, father's / guardian's name, date of birth, gender, blood group, personal email, address, district, state, PIN, emergency contact (name, relation, phone), notes, and the Aadhaar and PAN last four (AUD-020). They are not:
- the login's full name or work phone. The name is the login's identity, and the phone carries the password reset code (ACC-069, ACC-076); both stay with the office's full edit;
- the employee code, which is given by the system and never changes (PTY-002);
- anything about access: roles (ACC-077), sign-in email (ACC-075), username, password, second factor, pause (ACC-070), deactivation. These are unchanged: they still need
admin.users.edit(or their own permission) and the no-outranking check (can_manage_user).
Probation and notice dates are HR's already, through the leave record (hr.leave.admin, LV-011).
Who may edit a profile. Either the office's full edit, as before — admin.users.edit over someone whose every right the editor holds, or one's own row — or HR's details edit — hr.profiles.edit over a staff login (not a partner, traveller or tour-leader-only login) that is not an admin account and is not the editor. HR changes their own details on My profile. A refused save says why: "HR can't edit an IT Admin or Super Admin profile." A save that names a field outside the details is refused whole. Every save is the same audit row as before (employee_profile_updated, naming the fields and the scope). A paused HR officer edits nothing (ACC-070).
HR's agreement issue saves the appointment back to the profile on the same terms (EA-013).
Enforced by: profile_edit_rights() (with employee_profile_hr_fields(), user_is_admin_account(), admin_account_permissions(), user_is_staff_login()) in admin_update_employee_profile() and agreement_profile_block(); admin_user_profile() and employee_record() return it as profileEdit — migration 20261003235500_hr_edits_profile_details.sql. Test: supabase/tests/hr_edits_profile_details.sql (ACC-084, EA-013). Web: the employee record shows Edit profile only where profileEdit.allowed; the Employees list's ⋯ menu offers Edit profile to admin.users.edit or hr.profiles.edit, and the dialog it opens reads profileEdit and says why when it refuses; the dialog keeps the name and work phone read-only for HR. The access actions (pause, email, username, password, second factor, deactivation) show only where the caller may manage the login — canManage, which is can_manage_user(caller, person): on each row of the list (computed field employee_can_manage) and in profileEdit on the record — migration 20261004100000_screens_know_whose_access_you_manage.sql, test supabase/tests/screens_know_whose_access_you_manage.sql. Elsewhere the screen says "You can't manage this login's access".
Not built: the phone app does not edit another person's profile; HR edits on the web.