src/features/auth.
Endpoints
client is the trainee record in one studio. studios is a list of { clientId, studio } memberships. One phone number can be a trainee in several studios, and each membership has its own clientId.
Sign in flow
Phone screen
useLoginController validates with validatePhone from src/lib/validators.ts: the value must contain at least 9 digits. On submit it calls requestOtp(phone.trim()) and the screen pushes /verify.
AuthPhoneField supports numbers from outside Israel. usePhoneCountry in src/components/phone splits the value into a country and a national part with helpers from src/lib/phone/phoneNumber.ts. An Israeli number is sent exactly as typed. A foreign number is sent as +<country code><digits>. The home country constant is HOME_COUNTRY = 'IL'. The digits row is wrapped in physicalLtr so it never mirrors.
Code screen
useOtpController strips non-digits and caps the code at 6. verify.tsx submits by itself once six digits are present, and remembers the last submitted code in a ref so one code is not sent twice. The resend link calls requestOtp again with pendingPhone.
OtpInput is one hidden TextInput with textContentType="oneTimeCode" and autoComplete="sms-otp", drawn as six cells.
In development builds only, when the API returns devCode, the store keeps it and the controller pre-fills the field. The store ignores devCode outside __DEV__.
If pendingPhone is null, for example after a reload, /verify redirects to /login.
Error messages
requestOtpError and verifyOtpError map by error code first, then by HTTP status.
The auth store
useAuthStore is a plain Zustand store with no persist middleware. It persists by hand through secureStore.ts.
AuthUser is built by toAuthUser(client, studio). Besides the trainee fields it carries studioId, studioName, calendarEnabled, mbpEnabled, mbpAnchors and mbpUnitLabel, so screens read studio settings without another request.
Actions
hydrate() wraps everything in try/catch and falls back to guest. The comment in the code explains why: status staying on unknown would hold the app on the splash forever, because SplashGate waits on it.
restoreSession() covers a launch where the token could not be read from storage but is readable later. canRestore() is checked twice, before and after the async reads, so it cannot overwrite a sign in that started in between.
Clearing account state
clearAccountState() runs on sign in, studio switch and sign out:
Studio choice
AfterverifyOtp the server has already signed the trainee into their most recent studio. When studios.length > 1 the store sets needsStudioChoice, and both (auth)/_layout.tsx and (app)/_layout.tsx hold the trainee on /choose-studio until they confirm.
ChooseStudioScreen lists the memberships with the studio logo or initial. Choosing one calls switchStudio(clientId). If the chosen membership is the current one, the store only clears the flag and makes no request. On failure it shows the switchStudioFailed toast.
The profile row for the studio pushes the same screen when the trainee has more than one studio. In that case needsStudioChoice is false and the screen shows a back button.
After a switch the store calls resetPushRegistration() and registerPushToken(). Nothing remounts on a switch, so without this the new studio’s coach would have no push token for the trainee.
Session storage
src/lib/storage/secureStore.ts wraps expo-secure-store. On web it uses localStorage.
Keychain accessibility on iOS
On iOS every read and write passes these options:AFTER_FIRST_UNLOCK keeps items readable from the first unlock after a reboot.
Items written by older builds sit in the default service. getItem handles the move:
- Read from the new service.
- If the value is null on iOS, read the legacy location.
- If found, write it to the new service and delete the legacy copy. If the move fails, still return the value so the trainee stays signed in.
removeItem deletes from both services on iOS so a signed-out token cannot come back through the migration path.
Unreadable storage on Android
getItem catches errors and returns null. Android throws when an entry cannot be decrypted, for example after the keystore was invalidated or a build signed with a different key was installed over the same package. Treating that as “not there” sends the trainee to the login screen instead of stranding the app on the splash.
Session lifetime
The app never refreshes or rotates the token. The session ends when the server answers 401 for the current token, when the trainee signs out, or when the account is deleted. The 401 path is described in API client.Frozen access
Trainee carries planFrozen, appAccessWhileFrozen and appLocked. The server sends all three. When appLocked is true, (app)/_layout.tsx renders FrozenAccessScreen instead of the stack and disables notification routing.
The screen shows the paused message and the studio name, with three actions: message the coach on WhatsApp (when coachWhatsapp is set), sign out, and delete account with a confirmation alert.
SubscriptionPausedBanner is the softer variant. It renders on the home screen when planFrozen and appAccessWhileFrozen are true and appLocked is false.
Because refreshSession() runs on every foreground, a coach who unfreezes a trainee unlocks the app the next time it is opened.
Sign out
logout() runs in this order:
resetPushRegistration()first, so the next sign in always re-registers even if a later step fails.clearAccountState().unregisterPushToken()while the bearer token is still set, raced against a 3 second timer. A dead network cannot hold up sign out.- Remove token, user and health sync state from storage.
- Clear the in-memory token and reset the brand store.
- Set
statustoguest.
Preview mode
WhenIS_PREVIEW is true, hydrate() skips storage completely. It asks the parent window for a token with requestPreviewToken(), keeps it in memory only, marks onboarding as finished and calls refreshSession(). See Web preview export.