Skip to content

Authentication

All auth routes are dispatched to handleAuth at src/lib/api.ts:2311 from the router branch at src/lib/api.ts:27829.

Auth is built on Supabase Auth (email/password with optional OTP 2FA). There is no custom session layer — the Supabase JWT issued by signInWithPassword is the single session token, and handlers read the user through supabase.auth.getUser().


Endpoint catalog

Route Method Permission Notes
/auth/login POST public Email + password → JWT + user profile
/auth/register POST public Customer self-signup; creates User + Customer + CUSTOMER role
/auth/me GET authenticated Returns { user } for the current JWT
/auth/partner-signup POST public B2B partner self-signup (creates Agent row, status=pending)
/auth/resolve-identifier POST public Email-or-phone → canonical email for sign-in
/auth/2fa/status POST public Pre-login: does this email have 2FA enabled?
/auth/2fa/settings GET, PATCH authenticated (self) Read/update own 2FA settings
/auth/otp/send POST public Dispatch a 6-digit OTP by email or WhatsApp
/auth/otp/verify POST public Verify the OTP against OTPVerification

Login flow

POST /auth/login

Cite: src/lib/api.ts:2314

Input{ email, password }.

Work: 1. supabase.auth.signInWithPassword({ email, password }) — Supabase validates and issues a session. 2. Resolve the user's primary role via getPrimaryRoleName(user.id). 3. Return { token, user: { id, email, role, fullName } }.

The frontend stashes token on the Supabase client so subsequent apiFetch calls automatically carry the JWT.

Errors401 for bad credentials or missing session; 400 for missing input.

2FA-gated login

The frontend calls POST /auth/2fa/status before calling /auth/login to check whether the user has 2FA enabled. If enabled:

  1. Call POST /auth/otp/send with { email, channel: 'email' | 'whatsapp' }.
  2. User enters the 6-digit code.
  3. Call POST /auth/otp/verify with { email, code }.
  4. Only then call POST /auth/login with the password.

The OTP table is OTPVerification (see src/lib/api.ts:2693-2720). Codes expire after 10 minutes, lock after 5 bad attempts, and use exponential backoff between retries.

Pre-login routes are un-gated by design

/auth/login, /auth/register, /auth/partner-signup, /auth/resolve-identifier, /auth/2fa/status, /auth/otp/send, /auth/otp/verify all execute without a session. They must treat input as untrusted. The OTP handler in particular guards against enumeration (resolve-identifier returns { email: null } on miss instead of revealing existence) and brute force (attempt cap + lockout).


Registration flows

POST /auth/register — customer self-signup

Cite: src/lib/api.ts:2342

Input{ email, password, fullName, phone?, passportNo?, nationality?, passportExpiry?, address? }.

Work: 1. supabase.auth.signUp creates the auth user. 2. Upsert a User row mirroring the auth account (so role/permission queries work). 3. Insert a UserRole row granting CUSTOMER (looked up in the Role table by name). 4. Insert a Customer row keyed by userId. 5. Send a welcome email via sendMailer('customer_welcome', ...).

Returns{ id, email }.

POST /auth/partner-signup

Cite: src/lib/api.ts:2462

Input{ email, password, company, name, phone?, panCard, gstNumber?, address?, agreementAccepted, documents? }.

Work: 1. Validate password length (≥8), PAN card (exactly 10 chars), and email uniqueness across Agent. 2. supabase.auth.signUp creates the auth user. 3. Upsert User, assign the AGENT role. 4. Insert an Agent row with status='pending' (back-office must approve before the partner can act on gated flows). 5. Persist any uploaded partner documents into AgentDocument. 6. Strip auto-assigned EMPLOYEE / MANAGER / CUSTOMER roles that Supabase triggers sometimes seed (defensive cleanup).

Returns{ ok: true, agentId, status: 'pending' }.

Partner approval

New partners are status='pending' until an admin approves via PATCH /agents/:id (see Customers + Partners). The login still succeeds for a pending partner, but UI flows gate certain actions until status='active'.

POST /auth/resolve-identifier

Cite: src/lib/api.ts:2577

Lets staff / partners / customers sign in with an email or phone number.

Input{ identifier } (email or digits).

Work: - If the identifier contains @, return it as-is (lowercased). - Else strip to digits, take the last 10, and ilike-search Agent.phone, Customer.phone, and User.phone. The first match wins and returns the canonical email.

Returns{ email: string | null }. Never reveals whether an unknown identifier exists.


GET /auth/me

Cite: src/lib/api.ts:2437

Returns the current session's user profile, role, and fullName. Called on page load by the auth provider to hydrate useUser() state.

Errors401 if no JWT.


OTP / 2FA endpoints

POST /auth/2fa/status

Cite: src/lib/api.ts:2617. Given an email, returns { enabled: boolean, channel: 'email' | 'whatsapp' }. Called pre-login.

GET | PATCH /auth/2fa/settings

Cite: src/lib/api.ts:2637. Authenticated endpoint to read/update the signed-in user's own twoFactorEnabled / twoFactorChannel flags on the User row.

POST /auth/otp/send

Cite: src/lib/api.ts:2675.

Input{ email, phone?, channel: 'email' | 'whatsapp' }.

Work: 1. Look up the User row; cryptographically generate a 6-digit code via crypto.getRandomValues(). 2. Invalidate any previous OTPVerification rows for the same email + purpose='login_2fa'. 3. Insert a new row with expiresAt = now + 10min, verified=false, attempts=0. 4. Dispatch the code via sendMailer({ type: 'otp_code', to | phone, channel }).

Returns{ sent: true, channel, expiresInMinutes: 10 }.

POST /auth/otp/verify

Cite: src/lib/api.ts:2747.

Input{ email, code }.

Work: 1. Fetch the newest unverified OTPVerification for this email + purpose='login_2fa'. 2. Check expiry → 400. 3. Check attempts (cap at 5) → 400. 4. Check exponential-backoff lockout (lockedUntil) → 429. 5. Increment attempts, set lockedUntil = now + 2^attempts seconds. 6. Compare code; mark verified on match; otherwise 400.

Returns{ verified: true }.

OTP rate-limit

Each wrong code doubles the backoff: 2s, 4s, 8s, 16s, 32s. After 5 attempts the row is invalidated and the user must request a new code.


Password management

Staff password resets are out-of-band — there is no /auth/reset-password route in the monolith. Password changes for admin-managed users go through PATCH /users/:id/password (see Admin), which requires admin.users.edit.

For customer-portal password resets, the frontend calls Supabase's built-in supabase.auth.resetPasswordForEmail() directly. The handler for the post-reset confirmation is Supabase's hosted flow, not this API.

Customer password via admin

Staff can reset a customer portal password through PATCH /sales/customers/:id/password (see Customers).


Partner + customer portal separation

The portals (/portals/agent/*, /portals/customer/*) share the same Supabase JWT as staff logins but use row-scoped handlers instead of permission checks. Example — resolveAgentForCurrentUser() returns the single Agent row tied to the current JWT, and every partner-portal query pre-filters by agentId so one partner cannot see another's bookings/payments.

These routes are documented under Bookings and Admin.