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.
Errors — 401 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:
- Call
POST /auth/otp/sendwith{ email, channel: 'email' | 'whatsapp' }. - User enters the 6-digit code.
- Call
POST /auth/otp/verifywith{ email, code }. - Only then call
POST /auth/loginwith 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.
Errors — 401 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.