Account Access is the single hub for everything related to identity, sign-in methods, and account lifecycle. Entered from Settings.
Single row (not two). Tap pushes to Email & password sub-screen with two actions: Change email / Change password. Rationale: keeps the hub compact; password without email doesn't exist anyway.
Depends on state:
• Set & changeable (phone) → pencil icon
• Set with sub-nav (email, Apple connected) → chevron
• Not set / Not connected → Add / Connect pill
Apple row is hidden on Android. Google row stays. All other rows identical.
Identical for coach and athlete. No role-specific variants here — identity is identity.
Intentionally not on this screen. Lives on Settings root (top-level CTA). Account Access is about editing identity; logging out is a session action.
Skeleton rows on first fetch (one-time when app opens this screen fresh). Offline banner above the list if no connection, using cached values. No network → all write actions disabled.
GET /me/auth-methods — returns { phone, email, has_password, apple_linked, google_linked }
Later: DELETE endpoints per method, POST link endpoints for social.
Universal “Verify it's you” step triggered before sensitive changes: change phone, change email, change password, disconnect social, delete account.
Prevents session-hijack attacks where a stolen session could change phone/email/password without re-verifying ownership. Industry standard (Apple ID, Google, Stripe).
All methods the user has set up, except the target of the current flow. Example: changing phone → phone OTP is excluded.
Routes to Last-method variant (soft warning, not picker). Uses active session as implicit auth.
Re-auth valid for 15 min after success. Further sensitive actions in that window skip the picker. After 15 min → picker shown again.
Opens Contact Support screen (hybrid C: Open Mail CTA + Copy address fallback, prefilled with user ID).
Enter phone number screen — shared between Change and Add flows. Same layout, different title + optional reference to current number.
Format check only: valid phone length for selected country. No libphonenumber heavy dep — simple regex. CTA disabled until format passes.
Taken — number is registered to another account. Copy: "This number is already in use by another account." No hint about who owns it (privacy). User can retry with different number.
Invalid — format rejected server-side (edge case, client usually catches this). Generic copy.
Rate limited — too many OTP requests in short time. Shown as inline banner with retry timer.
POST /auth/phone/request-otp → { phone: "+995511200300" }
Success: 202 + request_id. Error: 409 (taken), 422 (invalid), 429 (rate limit).
Copy difference between Change and Add: the "Current: +995..." subtitle is shown only for change. Everything else identical. Keeps the codebase DRY.
6-digit OTP entry. Same component used across: change phone, add phone, change password via phone, re-auth via phone.
textContentType=.oneTimeCode (native)Backend returns 401 with attempts_remaining. Show red borders + inline error. Do NOT clear entered digits — let user edit. After 5 wrong attempts → OTP invalidated → Expired state, forcing resend.
Default TTL: 5 min. After expiry or exhaustion, show Expired state. Only option: Resend code.
Max 3 resends per 15 min window. After that, 429 error with countdown until next resend allowed.
Back = returns to phone entry (same typed number preserved). OTP session cancelled server-side.
POST /auth/phone/verify-otp → { request_id, code }
Success: 200 + new token / confirmation. Error: 401 (wrong), 410 (expired).
Full-screen pusher for selecting phone country/region. Triggered by tapping the country chip on any phone input.
Country list bundled in app (static JSON) — no API call. Includes ISO code, flag emoji, name, dial code, length rule for validation.
1st pass: device locale country on top. Then 3 app-level defaults (Georgia, US, UK) filling the rest. Grows smarter as we collect usage data.
Same component used in: onboarding signup, add phone, change phone, re-auth via phone. Keep a single source in CountryPicker.
List of 200+ items with search — needs space. Sheet feels cramped. Full-screen matches WhatsApp, Apple ID, Stripe patterns.
After selection, phone input re-validates using country-specific length rule (Georgia: 9 digits, US: 10, UK: 10-11). Updates CTA enabled/disabled state.
Intermediate screen reached by tapping the Email & password row on the hub. Splits into two sensitive flows.
Why a sub-hub and not 2 hub rows: keeps the main hub compact (4 rows instead of 5), and password without email doesn't exist anyway — so the split is semantic.
Why not a sheet: this is push navigation, not a confirmation. Needs back button, persistent in nav stack. Sheet would feel wrong for a pass-through destination.
Shared between Change Email (1 field) and Add Email & Password (2 fields: email + password with inline rules).
From password_validator.py: min 8 chars, not in common-list, not all digits, not similar to email. Inline checklist shows first rule actively; other three validated on blur / submit.
Debounced GET to /auth/email-available?email= on typing pause (400ms). Taken state shown inline. Privacy: never disclose who owns the email.
When change succeeds, backend sends a notification to the old address: "Your email was changed. If this wasn't you, click here to revert." — gives the real owner a recovery path if compromised.
POST /auth/email/request-change → sends link to new email + alert to oldPOST /auth/email/add-with-password → for add flow
Static "we sent it" screen. Not auto-advancing — waits for the user to click the link in their email (async, out-of-app).
mailto: as a fallback to open the default mail clientMagic link anatomy: Backend generates token (30min TTL), emails https://321.fit/verify-email?token=…. Link opens app via universal link (iOS) / app link (Android). If app missing → web page shows "Install the app to continue".
What if user doesn't receive it: in real impl, show troubleshooting link "Didn't get the email?" → expandable help (check spam, try different address, resend).
Shown for ~1–2s after user taps the verification link in their email. Backend validates token, applies change, issues new session.
Loading spinner pattern per our loading_states rule: centered spinner for first fetch / empty-first-load, not skeleton. Token validation qualifies.
Reached when the verification link is clicked after its 30-min TTL, or after being used once (one-shot tokens).
Why a full screen, not a toast: this is a blocking state — the flow cannot proceed. Toast would be missed on the redirect. Full screen with actions lets user pick the recovery path.
Final step in Change password. Reached after re-auth (phone OTP / email link / social re-sign) completes.
MIN_PASSWORD_LENGTH = 8. Blocklist (password, qwerty123, etc.). Not entirely numeric. Not similar to email local-part.
Three visible: 8+ chars, not common, not all digits. The “not similar to email” check runs server-side only (it's email-dependent and hard to phrase well inline).
Triggers when confirm field has the same length as new field and differs. Debounced 200ms to avoid flicker as user types.
Checked client-side only if we cache the password hash locally (we don't — so this check is server-side on submit). Shown as inline error under the first field with actionable copy.
All other sessions on other devices get invalidated. User stays logged in on current device. Toast: "Password updated. You're signed out on other devices."
POST /auth/password/change with reauth token → { new_password }. 200 on success, 422 with errors: […] for server validation failures.
Entry for user-initiated support requests. Reached from the re-auth picker's "Can't access any of these?" link, and directly from the Help flow (V2).
mailto: with prefilled subject Help [user_id] + bodyWe evaluated three options:
• A Native mail composer (MFMailComposeViewController) — 0 backend, but requires configured mail
• B In-app ticket form with POST endpoint — robust but new screen + endpoint + async flow
• C Screen with Open Mail CTA + Copy fallback — best of both; works without configured mail via copy path; zero backend work
MVP ships C. V2 evolves this screen into a proper ticket form without changing the entry points.
Subject: Help [usr_xxxx]
Body: blank lines for user text, then metadata block: user ID, app version, device/OS. Gives support team context to triage fast.
• Re-auth picker "Can't access any of these?" link
• Delete flow ambiguous states (future)
• Link expired / account locked screens (future)
• Settings "Help" entry (V2)
Important: this is a pressure-release screen, not a gate. Users hitting real problems shouldn't be told to "email support and wait". This exists for edge cases where no self-serve path works (e.g., lost all factors) — for common issues we build proper in-app recovery.
First step in delete flow. Explains consequences clearly before anything destructive happens.
Flow order: Info → [blocker check] → re-auth → confirm sheet → loading → done. Blockers checked on backend before re-auth; if any, this step is replaced with the relevant blocker screen.
Copy tone: informative, not guilt-trip. We accept the user's decision. No retention manipulation ("Are you sure? We'll miss you..."). Respect.
Shown instead of re-auth when a pre-check finds unresolved active state. Hard block — user must clear the blocker before continuing.
GET /me/delete-preflight → returns { blockers: [...] }. Empty array = proceed to re-auth. Non-empty = show blocker screen.
If multiple blockers fire, show in this order:
1. Upcoming sessions/bookings (user-visible commitment, highest priority)
2. Pending payout (coach only; financial)
3. Other disputes/holds (not in MVP)
Deletion is irreversible. A coach with pending athletes would leave them hanging. A coach with balance on hold loses money. Better to slow down the edge cases than regret later.
Athlete cancellations follow each coach's cancellation policy (24h / 48h / etc). We surface that in the cancel flow itself, not here — here we just say "cancel them first".
Transient spinner shown ~1–2s while backend performs the cascade (revoke sessions, mark account deleted, schedule data purge).
Final screen confirming deletion. User is signed out — in real app, hub below is replaced with login/welcome.
Real backend: account flag set to deleted_at, sessions revoked, data purge job scheduled per GDPR/privacy policy retention window. User can't re-register with same email for 7 days (cooldown to prevent impulse re-signup confusion).
Last chance bottom sheet after re-auth succeeds. Big red trash icon + stark title + single destructive button.
Why sheet, not screen: final Y/N after all the explanation was already delivered on the info screen. Sheet feels like the natural "click to execute" moment. Full screen would be overkill.
Bottom sheet shown when user taps a connected Apple or Google row on the hub. Single confirm step — destructive, but reversible (user can reconnect any time).
Yes, if user has other methods (phone, email, or other provider). Re-auth picker opens after Disconnect tap — excluding the provider being disconnected.
Pre-check: if target provider is the only sign-in method, open Disconnect block sheet instead (hard block, can't proceed).
After disconnect: token pair with Apple/Google is revoked. Row on hub returns to Not connected + Connect pill. Other methods unaffected.
POST /me/auth-methods/disconnect with { provider: "apple" | "google" } + re-auth token. 200 on success, 409 if last-method check fires.
Bottom sheet replacing the regular disconnect sheet when the target provider is the only active sign-in method. Unlike change-phone last-method (soft warn), disconnect last-method is a hard block.
Why hard block here, soft warn on change phone: changing phone leaves you with at least one method (the new number). Disconnecting leaves you with zero. Zero methods = locked out permanently.
Bottom sheet shown when user tries to change their only sign-in method. Soft warning, not hard block.
Why sheet, not screen: binary decision with risk — classic confirmation pattern. Keeps user anchored to hub underneath. Lighter than a full screen.
Why no re-auth: if this is the only method, there's no other factor to verify against. We fall back to the active session token as implicit auth — user accepts the risk.
Disconnect is different: for disconnect of last social method, we hard-block (separate screen, no override) — disconnecting leaves zero methods, unrecoverable.