9:30●●●● 🔋
Account access
Phone number
+995 511 100 000
Email & password
jdoe@mail.com
Apple
Connected
Google
Connected
Phone number
+995 511 100 000
Email & password
jdoe@mail.com
Apple
Not connected
Google
Not connected
Phone number
Not set up
Email & password
Not set up
Apple
Connected
Google
Connected
Phone number
+995 511 100 000
Email & password
Not set up
Apple
Not connected
Google
Not connected
Active sessions
Manage where you're signed in
Soon
Two-factor authentication
Extra layer of security
Soon
Delete account
Permanently remove your data
Changes to your sign-in methods require verification for security.
This is your only sign-in method
Changing your phone number without a backup means if something goes wrong — wrong code, lost SIM — you could lose access. We recommend adding another method first.
Disconnect Google?
You'll no longer be able to sign in with Google. You can reconnect it later at any time.
Can't disconnect Apple
This is your only sign-in method. Add a phone number, email, or another provider first — then you can disconnect Apple.
Delete your account forever?
This can't be undone. You'll be signed out and your data will be permanently removed.
Phone updated
Your new number is +995 511 100 000.
9:30●●●● 🔋
Verify it's you
To change your phone number, confirm with one of your active sign-in methods.
Use password
Enter your current password
Email a link
jdoe@mail.com
Continue with Apple
Re-sign in to confirm
Continue with Google
Re-sign in to confirm
9:30●●●● 🔋
New phone number
Current: +995 511 100 000
Add phone number
Use it to sign in and recover your account.
New phone number
Current: +995 511 100 000
New phone number
Current: +995 511 100 000
🇬🇪 +995
🇬🇪 +995
🇬🇪 +995
This number is already in use by another account.
🇬🇪 +995
Enter a valid phone number.
9:30●●●● 🔋
Enter the code
We texted a 6-digit code to +995 511 200 300.
4
8
2
1
1
1
1
1
1
Code is incorrect. Try again.
4
8
2
9
1
5
Code expired. Request a new one.
Resend in 0:27 Resend code Resend code
9:30●●●● 🔋
Country or region
🇬🇪 Georgia +995
🇺🇸 United States +1
🇬🇧 United Kingdom +44
🇩🇪 Germany +49
🇦🇢 Armenia +374
🇦🇿 Azerbaijan +994
🇫🇷 France +33
🇮🇹 Italy +39
🇰🇿 Kazakhstan +7
🇳🇱 Netherlands +31
🇵🇱 Poland +48
🇷🇺 Russia +7
🇪🇸 Spain +34
🇹🇷 Turkey +90
🇺🇦 Ukraine +380
Don't see your country? Contact support
9:30●●●● 🔋
Email & password
Email
jdoe@mail.com
Change email
Verify + replace current email
Change password
Last changed 2 months ago
A verification link will be sent for any change.
9:30●●●● 🔋
New email
Current: jdoe@mail.com
Add email & password
Use it to sign in and recover your account.
New email
Current: jdoe@mail.com
New email
Current: jdoe@mail.com
At least 8 characters
Not a common password
Not all numbers
This email is already in use by another account.
Enter a valid email address.
9:30●●●● 🔋
Check your email
We sent a verification link to new.email@gmail.com. Tap it to confirm.
Resend link in 0:27
Wrong address? Edit email
9:30●●●● 🔋
Verifying your link…
9:30●●●● 🔋
This link has expired
Verification links expire after 30 minutes. Request a new one to try again.
9:30●●●● 🔋
Set a new password
Choose something you'll remember. You'll use it to sign in.
New password
Confirm new password
At least 8 characters
Not a common password
Not all numbers
New password
Confirm new password
At least 8 characters
Not a common password
Not all numbers
New password
Confirm new password
At least 8 characters
Not a common password
Not all numbers
New password
Confirm new password
Passwords don't match.
At least 8 characters
Not a common password
Not all numbers
New password
Confirm new password
Pick something different from your current password.
9:30●●●● 🔋
Delete account
Delete your account?
This can't be undone. We'll permanently remove the following:
Your profile, photo, and personal info
Your training history and reviews
Your messages and chat history
Your saved payment methods
Some data may be retained for legal or financial reasons. See our Privacy Policy.
9:30●●●● 🔋
Delete account
Cancel your upcoming sessions first
You have 3 upcoming sessions with athletes. Cancel them before deleting your account so your athletes aren't left hanging.
Mon, Apr 28 · 10:00
Padel with Anna M.
Tue, Apr 29 · 18:30
Group · Tennis (4 athletes)
Thu, May 1 · 09:00
Padel with John D.
Withdraw your pending balance first
You have €320.50 pending payout. Withdraw it, or wait for Monday's automatic payout, before deleting your account.
Available balance
€245.50 · can withdraw now
Pending (24h hold)
€75.00 · releases in 18h
Cancel your upcoming bookings first
You have 2 upcoming bookings with coaches. Cancel them before deleting your account — refunds depend on each coach's policy.
Mon, Apr 28 · 10:00
Padel with Coach Maria
Sat, May 3 · 14:00
Tennis with Coach Vik
9:30●●●● 🔋
Deleting your account…
9:30●●●● 🔋
Your account has been deleted
Thanks for trying 321Fit. You've been signed out on all devices. Your data will be removed within 30 days per our Privacy Policy.
9:30●●●● 🔋
Contact support
We're here to help
We typically reply within 24 hours. Include your user ID and what you were trying to do.
Email us at
support@321.fit
User ID usr_7G8H9K2L
App version 1.0.0 (build 42)
Device iOS 17.4 · iPhone 15
In-app ticket form coming soon.
Copied support@321.fit to clipboard
Shared · Coach + Athlete

Account Access

Account Access is the single hub for everything related to identity, sign-in methods, and account lifecycle. Entered from Settings.

Full behavior spec

Email & password row

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.

Right-side control

Depends on state:
Set & changeable (phone) → pencil icon
Set with sub-nav (email, Apple connected) → chevron
Not set / Not connectedAdd / Connect pill

Android variant

Apple row is hidden on Android. Google row stays. All other rows identical.

Platform parity

Identical for coach and athlete. No role-specific variants here — identity is identity.

Log out

Intentionally not on this screen. Lives on Settings root (top-level CTA). Account Access is about editing identity; logging out is a session action.

Empty / skeleton / offline

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.

API

GET /me/auth-methods — returns { phone, email, has_password, apple_linked, google_linked }
Later: DELETE endpoints per method, POST link endpoints for social.

Shared · Component

Re-auth Picker

Universal “Verify it's you” step triggered before sensitive changes: change phone, change email, change password, disconnect social, delete account.

Rules

Why this exists

Prevents session-hijack attacks where a stolen session could change phone/email/password without re-verifying ownership. Industry standard (Apple ID, Google, Stripe).

Which methods show

All methods the user has set up, except the target of the current flow. Example: changing phone → phone OTP is excluded.

Target is the only method?

Routes to Last-method variant (soft warning, not picker). Uses active session as implicit auth.

Timing window

Re-auth valid for 15 min after success. Further sensitive actions in that window skip the picker. After 15 min → picker shown again.

Support fallback

Opens Contact Support screen (hybrid C: Open Mail CTA + Copy address fallback, prefilled with user ID).

Shared · Flow step

Enter phone number

Enter phone number screen — shared between Change and Add flows. Same layout, different title + optional reference to current number.

Validation + errors

Client-side validation

Format check only: valid phone length for selected country. No libphonenumber heavy dep — simple regex. CTA disabled until format passes.

Server-side errors

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.

API

POST /auth/phone/request-otp → { phone: "+995511200300" }
Success: 202 + request_id. Error: 409 (taken), 422 (invalid), 429 (rate limit).

Same screen, two contexts

Copy difference between Change and Add: the "Current: +995..." subtitle is shown only for change. Everything else identical. Keeps the codebase DRY.

Shared · Flow step

Enter OTP code

6-digit OTP entry. Same component used across: change phone, add phone, change password via phone, re-auth via phone.

Error handling

Wrong code

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.

Expired code

Default TTL: 5 min. After expiry or exhaustion, show Expired state. Only option: Resend code.

Resend rate limit

Max 3 resends per 15 min window. After that, 429 error with countdown until next resend allowed.

Back navigation

Back = returns to phone entry (same typed number preserved). OTP session cancelled server-side.

API

POST /auth/phone/verify-otp → { request_id, code }
Success: 200 + new token / confirmation. Error: 401 (wrong), 410 (expired).

Shared · Picker

Country picker

Full-screen pusher for selecting phone country/region. Triggered by tapping the country chip on any phone input.

Data + reuse

Source of truth

Country list bundled in app (static JSON) — no API call. Includes ISO code, flag emoji, name, dial code, length rule for validation.

Popular section logic

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.

Reuse

Same component used in: onboarding signup, add phone, change phone, re-auth via phone. Keep a single source in CountryPicker.

Why full-screen, not sheet

List of 200+ items with search — needs space. Sheet feels cramped. Full-screen matches WhatsApp, Apple ID, Stripe patterns.

Validation integration

After selection, phone input re-validates using country-specific length rule (Georgia: 9 digits, US: 10, UK: 10-11). Updates CTA enabled/disabled state.

Shared · Flow entry

Email & password sub-hub

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 · Flow step

Enter new email

Shared between Change Email (1 field) and Add Email & Password (2 fields: email + password with inline rules).

Validation + security

Password rules (backend-authoritative)

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.

Uniqueness check

Debounced GET to /auth/email-available?email= on typing pause (400ms). Taken state shown inline. Privacy: never disclose who owns the email.

Old email notification

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.

API

POST /auth/email/request-change → sends link to new email + alert to old
POST /auth/email/add-with-password → for add flow

Shared · Flow step

Check your email

Static "we sent it" screen. Not auto-advancing — waits for the user to click the link in their email (async, out-of-app).


Magic 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).

Shared · Transient state

Loading link

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.

Shared · Error state

Link expired

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.

Shared · Flow step

Set new password

Final step in Change password. Reached after re-auth (phone OTP / email link / social re-sign) completes.

Validation rules (backend-authoritative)

From password_validator.py

MIN_PASSWORD_LENGTH = 8. Blocklist (password, qwerty123, etc.). Not entirely numeric. Not similar to email local-part.

Inline rules we show

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).

Mismatch check

Triggers when confirm field has the same length as new field and differs. Debounced 200ms to avoid flicker as user types.

Same as current check

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.

Post-success behavior

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."

API

POST /auth/password/change with reauth token → { new_password }. 200 on success, 422 with errors: […] for server validation failures.

Shared · Support

Contact support

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).

Hybrid C rationale

Why hybrid C (not A, not B)

We 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.

Prefill payload

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.

When to route here

• 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)

Not a blocker

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.

Shared · Flow entry

Delete account · info

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.

Shared · Blocker screen

Delete blockers

Shown instead of re-auth when a pre-check finds unresolved active state. Hard block — user must clear the blocker before continuing.

Precedence & priority

Pre-check API

GET /me/delete-preflight → returns { blockers: [...] }. Empty array = proceed to re-auth. Non-empty = show blocker screen.

Priority order

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)

Why block, not warn

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.

Refund policy for bookings

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".

Shared · Transient state

Deleting account

Transient spinner shown ~1–2s while backend performs the cascade (revoke sessions, mark account deleted, schedule data purge).

Shared · Terminal state

Account deleted

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).

Shared · Sheet overlay

Delete final confirm

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.

Shared · Sheet overlay

Disconnect Apple / Google

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).

Mechanics

Re-auth before disconnect?

Yes, if user has other methods (phone, email, or other provider). Re-auth picker opens after Disconnect tap — excluding the provider being disconnected.

Last-method guard

Pre-check: if target provider is the only sign-in method, open Disconnect block sheet instead (hard block, can't proceed).

What the user loses

After disconnect: token pair with Apple/Google is revoked. Row on hub returns to Not connected + Connect pill. Other methods unaffected.

API

POST /me/auth-methods/disconnect with { provider: "apple" | "google" } + re-auth token. 200 on success, 409 if last-method check fires.

Shared · Hard block

Disconnect block · last method

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.

Shared · Sheet overlay

Last-method warning

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.