A trainee signs in with their phone number and a six digit code. The app has no passwords and no registration. A coach creates the trainee in the dashboard first, and the phone number is the identity. All of it lives in 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:
Each studio membership is a different client record. Without this, one studio’s workouts and meals would show inside another.

Studio choice

After verifyOtp 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:
The default keychain class is only readable while the device is unlocked. Background work, such as the health sync task, runs while the phone is locked and needs the token. 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:
  1. Read from the new service.
  2. If the value is null on iOS, read the legacy location.
  3. 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:
  1. resetPushRegistration() first, so the next sign in always re-registers even if a later step fails.
  2. clearAccountState().
  3. unregisterPushToken() while the bearer token is still set, raced against a 3 second timer. A dead network cannot hold up sign out.
  4. Remove token, user and health sync state from storage.
  5. Clear the in-memory token and reset the brand store.
  6. Set status to guest.
The onboarding flag, language and theme choice are kept.

Preview mode

When IS_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.