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
- 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.
- Open register. Email, password, name, username, and a marketing-consent checkbox. The user fills the form.
- Tap "Continue". The client posts
/auth/otp/send (purpose=signup) to the entered email; a 6-digit code lands within seconds.
- 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.
- 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.
- Complete profile. Required fields: username (re-confirm, unique check), date of birth. Optional: gender, country, avatar. Submitting routes to
/discover.
- Landed. The user is on the discover map; the auth gate now considers them authenticated and profile-complete.
Primary flow — returning sign-in
- Open
/auth/login. Fields default empty (we don't persist email between sessions).
- Pick a method:
- Email/password. Type credentials, tap "Sign in". On success: tokens stored, route to
/discover.
- Google / Apple / LINE. Native sheet opens; on successful identity-token exchange the backend returns the same
{token, refresh_token, user} envelope.
- Passkey. "Sign in with passkey" prompts the OS biometric/security-key UI; the WebAuthn ceremony runs and the same envelope is returned.
- 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.
- 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
- Bible → Identity — narrative for who an account is and why deletion has a 30-day grace
- Bible → Partners — narrative for the partner concept
- Domains → Identity — account states, session model, account types and roles, invariants
- Domains → Partners — partner statuses, membership roles, lifecycle, "≥1 owner" rule
- Architecture → Authentication — six sign-in doors converging on one JWT, refresh-token state, account types and authorization
- Frontend → Routing —
/auth/*, the auth gate, public-route whitelist
- Backend → Auth service — endpoint reference, OTP internals, Apple JWKS, WebAuthn ceremony
- Backend → Partners service — partner CRUD, membership middleware, ownership invariant
- Profile & settings — profile-edit specifics (display name cooldown, language, theme, privacy)