Skip to content

Identity

Sign-up, sign-in, security, and account deletion — every flow that determines who is using the app and what they can change about themselves. Admin and partner shells are covered briefly at the end because they're identity-adjacent (gated on account_type/role or partner membership), but their day-to-day surfaces live in their own UX pages when they grow.

Entry points

From Action
Cold launch, not signed in Auth gate at app/_layout.tsx redirects to /auth/login
Public share page (/events/:id, /u/:handle) Stays public; CTA buttons route to /auth/login
Hub → Account Logout and "Delete account" actions
Hub → Security Password, email, username, passkey, connected providers
Hub → Profile → edit Display name, bio, avatar, country, gender, DOB, language
Hub → Preferences Notifications, map preferences, activity status, read receipts
Admin shell (admin) route group, hidden unless account_type=tomoda AND role=admin
Partner shell (partner) group; picker for users with multiple partners, scoped sub-routes per partner

Primary flow — first-time sign-up

  1. Land on /auth/login. The user sees email/username + password fields plus four social buttons: Google, Apple, LINE, passkey. A "Sign up" link below sends them to /auth/register.
  2. Open register. Email, password, name, username, and a marketing-consent checkbox. The user fills the form.
  3. Tap "Continue". The client posts /auth/otp/send (purpose=signup) to the entered email; a 6-digit code lands within seconds.
  4. Enter the OTP. The verification screen shows a 6-digit input with a 60-second resend cooldown. Wrong code: inline error, the input clears. Correct code: client posts /auth/register with the embedded otpToken.
  5. Auto-login. The register response includes both token and refresh_token. The client stores them, sets the in-memory user, and routes to /auth/complete-profile.
  6. Complete profile. Required fields: username (re-confirm, unique check), date of birth. Optional: gender, country, avatar. Submitting routes to /discover.
  7. Landed. The user is on the discover map; the auth gate now considers them authenticated and profile-complete.

Primary flow — returning sign-in

  1. Open /auth/login. Fields default empty (we don't persist email between sessions).
  2. Pick a method:
  3. Email/password. Type credentials, tap "Sign in". On success: tokens stored, route to /discover.
  4. Google / Apple / LINE. Native sheet opens; on successful identity-token exchange the backend returns the same {token, refresh_token, user} envelope.
  5. Passkey. "Sign in with passkey" prompts the OS biometric/security-key UI; the WebAuthn ceremony runs and the same envelope is returned.
  6. 30-day-grace re-activation. If the account was deactivated within the last 30 days, login succeeds and the row is reactivated transparently — the user sees no special UI, just lands on /discover.
  7. Pending profile completion. If username or date_of_birth is missing on the returned user (legacy account), the gate redirects to /auth/complete-profile before letting them through.

Sub-flows

Sub-flow Behavior
Forgot password /auth/forgot-password → email → OTP → new password. Three steps, no auth required. The OTP token is short-lived (15 min) and single-use.
Change password (authenticated) Hub → Security → "Change password" sends an OTP to the user's email, then routes to /hub/security/change-password with the verification flow. Current password is also required as a second factor.
Change email Hub → Security → "Change email" → enter new email → OTP sent to new address → confirm. Old email gets a notification. Email change is OTP-gated to prevent silent takeover.
Change username Inline on Hub → Security. Uniqueness checked on blur; submit applies it. There is no cooldown on username today (display-name does have a 14-day cooldown — that lives on the profile-edit screen).
Add a passkey Hub → Security → "Set up passkey" → WebAuthn registration ceremony. After registration, the user can pick passkey on future sign-ins.
Connect Google / Apple Hub → Security shows connection rows; "Connect" runs the OAuth flow and attaches the provider's ID to the existing account.
Logout Hub → Account → "Log out". Posts /auth/logout to revoke the refresh token, clears AsyncStorage (@auth_token, @refresh_token, @auth_user), routes to /auth/login.
Delete account Hub → Account → "Delete account". Modal confirmation with the 30-day-grace explanation. Confirm: DELETE /auth/profile soft-deactivates, revokes every session, clears local state, routes to /auth/login.
Re-activate by login If the user signs back in within 30 days, the backend transparently reactivates the row and they land on /discover as normal. No "welcome back" UX surface today.

States

State What the user sees
Login form, idle Empty fields with social buttons below
Login submitting The "Sign in" button shows a spinner; inputs are not disabled (so the user can still edit if they realize a typo)
Login failed (bad credentials) Toast: "Invalid email or password" — no field-level error to avoid leaking which field is wrong
Login rate-limited (429) Toast: "Too many attempts, try again in a few minutes"
OTP not yet received The OTP input is visible immediately; if no code arrives within 60s, a "Resend" link appears
OTP wrong code Inline red border on the input, clears after one second
Registration form invalid Field-level error under the offending input (password too short, username taken, email malformed)
Profile-completion required Gate redirects to /auth/complete-profile; the user cannot navigate back to /auth/login without logging out
Account deactivated After delete: routed to /auth/login with a one-time toast — "Your account was deactivated. Sign in within 30 days to reactivate."
Token expired mid-session API call returns 401 → AUTH_SESSION_EXPIRED event → AuthContext calls logout() → routed to /auth/login with a "Your session expired" toast

Edge cases & gotchas

  • OTP is required for registration, not for social sign-in. Google / Apple / LINE / passkey logins skip the OTP because the third-party identity verification stands in for it. Only email+password sign-up gates on OTP.
  • Auto-refresh runs every 4 minutes. frontend/utils/tokenManager.ts checks token expiry on a timer; if there's less than 5 minutes of life left, it posts /auth/refresh silently. Concurrent callers share one in-flight refresh via a mutex. The user never sees this unless it fails.
  • WebSocket auth uses the JWT in a query param. The chat upgrade URL embeds ?token=<jwt> because the browser WebSocket constructor can't set headers. The token is the same as Bearer-auth would use.
  • Sessions list is not yet a UI surface. The backend supports GET /auth/sessions and per-session revocation, but no Hub screen renders the list today. Logout from the current device works; remote-device revocation needs the API.
  • Disposable email domains are rejected. Registration returns a clear error if the email is on the disposable-email blocklist; the UI shows the message verbatim.
  • Under-18 accounts are blocked at register. If DOB on /auth/complete-profile makes the user under 18, the row is suspended server-side and the user sees a generic "Account suspended" state on next interaction. No specific under-18 messaging today.
  • Three-accounts-per-device cap. A single device fingerprint can create at most 3 accounts in 30 days; the 4th register attempt returns an error toast.
  • Username cooldown vs display-name cooldown. Username has no cooldown (changes apply immediately on uniqueness). Display name has a 14-day cooldown — the profile-edit screen disables the input and shows the next-eligible date.
  • Apple S2S revocation is silent. If the user removes the app from Apple ID → Sign in with Apple, the backend deactivates the account via consent-revoked notification. The next login attempt enters the 30-day-grace reactivation path; the user doesn't see a separate "Apple removed access" surface.
  • The auth gate is forward-looking. Routes are evaluated on segments, not pathname. Public share routes (/events/:id, /u/:handle) are explicitly whitelisted so unauthenticated visitors can preview shared content.
  • Refresh-token TTL differs by client. Web sessions expire after 7 days of disuse; native after 30. The user is never told this — it's only visible as "you got logged out" after a long enough idle period.

Admin and partner shells

Both are gated UIs that share the (admin) and (partner) route groups respectively. The gating policies match the backend exactly — see Architecture → Authorization.

Shell Visible to Entry behaviour
(admin) group account_type=tomoda AND role=admin _layout.tsx checks useAccess().isAdmin; non-admins are redirected to /discover. There is no nav entry — admins reach /(admin)/manager/dashboard directly via deep link or an admin-only menu item (not surfaced in default consumer UI).
(partner) group Any user who is a member of ≥1 partner (partner)/index.tsx lists the user's partners (or shows "you don't belong to any partners yet" if empty). Tapping a partner pushes /(partner)/[partner_id]. Within a partner, useLocalSearchParams().partner_id is the source of truth — switching partners is a router navigation, not a state mutation.

Both shells are intentionally minimal today. The admin shell renders one dashboard screen; the partner shell renders a placeholder home. As real admin / partner features land, they'll get their own UX pages.

See also