Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.

Authentication — Sign In

Internal QA + V&V document — not customer-facing

This page lives under /docs/qa/ and is the authoritative reference for how we test and validate the sign-in flow. It exposes test-runner internals (testIDs, fixture names, runner-step captions) by design — the audience is engineers writing flows, reviewing test failures, or reasoning about coverage. Customer-facing sign-in documentation is generated separately from this same spec; do not link readers here from public marketing or onboarding content.

One-sentence purpose. Sign in to Appello on either desktop or mobile so the rest of the app — schedule, jobs, timesheets, forms — is reachable.

Why this exists

Appello is multi-tenant: each customer gets their own subdomain ({slug}.useappello.app) and a dedicated GraphQL endpoint. Authentication has two jobs — first, identify which tenant the user belongs to, and second, prove which user within that tenant they are. Desktop encodes the tenant in the URL so the landing page can go straight to credentials. Mobile can't rely on a URL, so the app asks for the company slug first, resolves it to a tenant API URL, and then collects credentials. Field crews without corporate email also need a way in — that's the PIN + last-name flow, which only mobile exposes.

Personas

PersonaFrequencyWhat they do with it
Foreman / field workerDaily, mobileEmail + password sign-in on the iPhone in the truck or on site
Office admin / PMDaily, desktopEmail + password sign-in at the desk, often via SSO-managed Google account
Crew member without emailDaily, mobilePIN + last-name sign-in (the assigned PIN is short, the last name disambiguates)
IT / customer adminOnce per tenantKnows and distributes the company slug; sets up the user's email and PIN

Surfaces

Desktop

  • Entry point: https://{slug}.useappello.app — the landing page is the sign-in page. There is no slug-entry step; the URL encodes the tenant.
  • Form fields: email, password, sign-in button. Branded with the company logo.
  • On success: lands the user on the desktop home (Jobs/Projects view by default for admins; Crew view for non-admin users).
  • Forgot password: link present on the login page, sends a reset email.

Mobile

  • Entry point: cold-launch of the Expo app → company-slug screen if no slug remembered, otherwise jumps straight to the sign-in screen for the remembered tenant.
  • Company slug screen (app/company-select.tsx, testID: company-select-screen)
    • company-slug-input — text input (autoCapitalize off, autoCorrect off)
    • continue-button — submits; calls resolveCompany(slug) which validates the tenant URL and writes {slug, url, graphqlUrl} to companyStore
    • language-toggle — EN/ES switcher
  • Sign-in screen (app/sign-in.tsx, testID: sign-in-screen) — shown only after a successful company resolve
    • switch-company-button — top-left link that clears the slug and returns to the company-slug screen
    • tab-email / tab-pin — toggle between the two auth methods
    • Email tab: email-input + password-input (SecureTextField) + sign-in-button
    • PIN tab: last-name-input + pin-input (SecureTextField) + sign-in-button
    • language-toggle — same EN/ES switcher
    • On success: writes {token, user} to authStore, and the app router redirects to the authenticated stack (Schedule tab by default).

User stories

  1. US-1 — As a foreman on a new device, I enter my company slug, then my email and password, so the app loads my company's data and lands me on the Schedule tab.
  2. US-2 — As an office admin at my desk, I open {slug}.useappello.app and sign in with email + password, so I can manage jobs and projects from the desktop home.
  3. US-3 — As a crew member without a corporate email, I switch to the PIN tab on the mobile sign-in screen, enter my PIN and last name, and land on the Schedule tab.
  4. US-4 — As a user switching between two Appello instances, I tap "Switch company" on the mobile sign-in screen, change the slug, and sign into the other tenant without uninstalling the app.

Capabilities matrix

CapabilityDesktopMobileNotes
Email + password sign-inPrimary auth on both surfaces; verified end-to-end in flows/auth/login.yaml
PIN + last-name sign-inMobile-only; designed for crews without corporate email; not yet verified by a flow
Company slug entryn/a (URL)Mobile resolves slug via resolveCompany before showing credentials
Switch company (clear slug)n/a (close tab)"Switch company" link top-left of mobile sign-in; not yet verified by a flow
Language toggle (EN/ES)Desktop sign-in page does not expose a language toggle (verified 2026-05-19)
Forgot passwordDesktop has "Forgot your password?" link → reset email; mobile has no affordance
Show/hide passwordDesktop sign-in has a "Show" button next to the password field; mobile does not
Remember credentials✅ (browser)✅ (__DEV__ reads EXPO_PUBLIC_DEV_* envs)Production mobile relies on authStore token persistence, not remembered credentials
Remember me toggleDesktop has an explicit "Remember me" toggle next to the form
Mobile-app redirect linkn/aDesktop sign-in page has "Trying to log into the mobile app? Click here" — opens app store / deep link
Biometric unlockexpo-local-authentication is in the binary as of PR #24 but not wired into the sign-in flow yet
Sign-in error messagingTBDMobile surfaces signIn.errorEmailRequired / signIn.errorPasswordRequired etc. as red text under the form; desktop error UX not yet verified

Key takeaways

  • Authentication is always tenant-scoped — on desktop the tenant comes from the URL; on mobile the user types it in once and the app remembers it.
  • Mobile has two auth paths (email/password and PIN/last-name) that share one screen via a tab toggle. The PIN path exists specifically for crews without corporate email.
  • A successful mobile sign-in lands you on the Schedule tab. A successful desktop sign-in lands you on the tenant's home (Jobs/Projects for admins).
  • Mobile users who need to switch between two tenants do so via the "Switch company" link — they don't need to uninstall or sign out via the drawer.
  • Biometric unlock isn't wired yet; today every cold launch with no stored token requires a full email/password (or PIN/last-name) round-trip.

Coverage

Test flows that exercise this spec

FlowUser storiesStatus
flows/auth/login.yamlUS-1 + US-2✅ green
flows/auth/login-pin.yamlUS-3🟡 spec + YAML exist; awaiting first agent-driven run + PIN testcreds
flows/auth/switch-company.yamlUS-4🟡 spec + YAML exist; agent-driven run in progress

Doc pages generated from this spec

PageSurfaces shown
/qa/auth/logindesktop + mobile

What we verified end-to-end on 2026-05-19

  • US-2 (desktop) — drove canary.useappello.app from a signed-out state through email + password sign-in, landing on the real authenticated dashboard. Captured login-web-01-sign-in-page.png (signed-out form), login-web-02-credentials-filled.png (creds entered, password masked), login-web-03-landing.png (authenticated home with Appello logo, full nav rail, Company Metrics widgets, Active Notes feed, Upcoming Vacation/Leave panel).
  • US-1 (mobile) — cold-launched the freshly-cleared app on the iPhone 17 simulator. Dismissed the Expo dev launcher / dev-menu drawer, reached the company-select screen, entered canary, advanced to the sign-in screen, submitted email + password, dismissed the post-login "Stay in the loop" notification carousel, and landed on the Schedule tab. 17 runner steps, all green; the doc page collapses consecutive byte-identical frames so the reader sees 9 unique screens with merged captions.

Open issues (file these)

Issues surfaced while building this flow that still need product/engineering follow-up:

  1. Dev launcher friction is opaque — development builds always intercept a cold launch with the Expo dev launcher (http://localhost:8081 cell), then the React Native dev-menu onboarding card, then a persistent dev-menu drawer that re-opens on JS navigation. The login.yaml flow has four tapIfPresent steps just to dismiss them. Production-grade fix is to move this into the runner's ensureAppReady so every flow benefits.
  2. "Maybe later" testID is carousel-secondary — the post-login notification-permission carousel uses generic carousel-primary / carousel-secondary accessibility ids rather than semantic ones like notification-onboarding-skip. Renaming would make flows self-documenting (and would survive copy changes from "Maybe later" → "Not now" etc.).
  3. Ionicons.ttf cascade pollutes LogBox — every authenticated screen accumulates 20-30 unhandled-promise-rejection errors from Image.getSize failing on http://127.0.0.1:8081/assets/?unstable_path=...@expo+vector-icons.../Ionicons.ttf?platform=ios&hash=.... Pure dev-build noise, but it surfaces a LogBox pill on every screen and inflates page-source diffs. Likely a Metro asset-resolver quirk with the pnpm .pnpm flat tree.
  4. Runner logged the password in plaintextclearAndSetValue(password-input) ="1_Need_T0_5et_A_Pa55w0rd" appears in stdout. The runner should mask values whose target is password-input / pin-input / contains the substring password. Filing as a runner hardening task before the suite runs in CI.

Discovered during the switch-company agent run (2026-05-19)

  1. __DEV__ env preset re-fills the slug input on Switch Company round-tripcompanyStore.clearCompany() clears the persisted slug, but the company-select screen's local state initializes from process.env.EXPO_PUBLIC_DEV_COMPANY_SLUG whenever __DEV__ is true. So after tapping Switch Company you're back at the company-select screen with canary already in the input rather than empty. Cosmetic — the round-trip still works — but worth being explicit so reviewers don't read "input pre-filled" as a clearCompany bug. In a release build the input would be empty.

Resolved during this flow's build

Issues surfaced by this spec that have already been fixed — kept as a record so the same regression doesn't slip back in:

  1. Runner counted skip as not-pass — resolved in PR #31 commit ca6df49. tapIfPresent returns status: 'skip' by design when the target is absent, but runFlow previously only treated pass as ok, so any flow with a no-op defensive step reported red even with every real assertion green. Fix: stepsOk = stepResults.every(r => r.status === 'pass' || r.status === 'skip').
  2. Runner set APPELLO_REPORT_DIR after seed — resolved in PR #31 commit ca6df49. Desktop seed fixtures calling captureWebScreenshot silently no-op'd because the env var hadn't been set yet, so web evidence never made it onto the doc page. Fix: env vars now exported before the seed phase runs.
  3. Doc page repeated the same screenshot 4–5 times — resolved in PR #31 commit fdf6a63. The runner captures after every step regardless of whether the step changed the UI; the doc generator now SHA-256-hashes consecutive frames and collapses runs of identical bytes into one image with merged captions. Mobile section dropped from 17 frames to 9.
  4. Desktop "landing" screenshot was the redirect spinner — resolved in PR #31 commit fdf6a63. The auth.signInFromSignedOutDesktop fixture only waited on URL + networkidle, both of which fired before the auth handshake's "Redirecting to Login…" intermediate page resolved. Fix: also waitForFunction(() => !/Redirecting/i.test(body.innerText)) + 1s layout settle. login-web-03-landing.png went from 8.8 KB (spinner) to 123 KB (real dashboard).

Known gaps

  • Biometric unlock not wired into sign-in
  • No "Forgot password" on mobile
  • No 2FA / OTP path on either surface
  • No SSO (Google / Microsoft / SAML) wired today
  • PIN + last-name sign-in path (US-3) not yet covered by a flow
  • "Switch company" reset path (US-4) not yet covered by a flow
  • Sign-in error states (wrong slug, wrong creds, network down) not yet covered by a flow on either surface

Sources

Engineering — click through to the source of truth for this page:

Test evidence

Captured 2026-05-20 by the Appium suite running pnpm e2e:appium switch-company-v2. Last run status: ✅ green.

The screenshots below are auto-captured every suite run. The narrative above is hand-curated in e2e/appium/specs/auth/login.md — edit it there, not here.

Mobile

Performed by Appium against the iOS simulator running the Expo native app.

15 unique frames shown (11 consecutive duplicates collapsed — actions like tapIfPresent, hideKeyboard, and assertions that don't change the screen are merged into the frame they land on).

Steps 1–5 — Expo dev launcher — tap the localhost URL so the JS bundle loads (development builds only) · Wait for the JS bundle to download from Metro and mount · Dismiss the React Native dev-menu onboarding card (first-launch only) · Close the persistent Expo dev-menu drawer if it lingers · The app cold-launches on the company-select screen

Expo dev launcher — tap the localhost URL so the JS bundle loads (development builds only) · Wait for the JS bundle to download from Metro and mount · Dismiss the React Native dev-menu onboarding card (first-launch only) · Close the persistent Expo dev-menu drawer if it lingers · The app cold-launches on the company-select screen

Step 6 — Enter the FIRST tenant's slug (canary)

Enter the FIRST tenant's slug (canary)

Step 7 — Dismiss the soft keyboard so it doesn't cover the Continue button

Dismiss the soft keyboard so it doesn't cover the Continue button

Steps 8–11 — Tap Continue to resolve canary to a tenant API URL and advance to sign-in · Close the React Native dev-menu drawer if it auto-opened on navigation (development builds only) · The sign-in screen appears for the canary tenant · Wait for the deeper email-input field — signals the sign-in screen is fully composed (Pressables have handlers bound)

Tap Continue to resolve canary to a tenant API URL and advance to sign-in · Close the React Native dev-menu drawer if it auto-opened on navigation (development builds only) · The sign-in screen appears for the canary tenant · Wait for the deeper `email-input` field — signals the sign-in screen is fully composed (Pressables have handlers bound)

Step 12 — Tap Switch Company in a find→tap→check-source loop until the button is no longer in the page source (i.e. companyStore.clearCompany() ran and the router popped back to company-select). Plain tap flaked at 2 s and 4 s waits — the React Native Pressable's onPress race after the post-canary-sign-in transition is real and time-independent. tapUntilGone retries every 1500 ms up to 6 times (≈9 s ceiling), which has held 3-of-3 green so far.

Tap Switch Company in a find→tap→check-source loop until the button is no longer in the page source (i.e. `companyStore.clearCompany()` ran and the router popped back to company-select). Plain `tap` flaked at 2 s and 4 s waits — the React Native Pressable's onPress race after the post-canary-sign-in transition is real and time-independent. `tapUntilGone` retries every 1500 ms up to 6 times (≈9 s ceiling), which has held 3-of-3 green so far.

Step 13 — Enter the SECOND tenant's slug (release) — the actual multi-tenant assertion. clearAndSetValue is essential because the input pre-fills from EXPO_PUBLIC_DEV_COMPANY_SLUG (canary) in DEV builds.

Enter the SECOND tenant's slug (release) — the actual multi-tenant assertion. clearAndSetValue is essential because the input pre-fills from EXPO_PUBLIC_DEV_COMPANY_SLUG (canary) in __DEV__ builds.

Step 14 — Dismiss the soft keyboard

Dismiss the soft keyboard

Step 15 — Tap Continue to resolve release to a tenant API URL — this is the moment the app switches tenants

Tap Continue to resolve `release` to a tenant API URL — this is the moment the app switches tenants

Step 16 — Close the dev-menu drawer if it auto-opened

Close the dev-menu drawer if it auto-opened

Steps 17–19 — The sign-in screen appears again — but now for the RELEASE tenant · Confirm we're actually on release — the sign-in screen renders the slug as a subtitle under "Sign In", so the page source contains the literal string "release". If this fails we silently fell back to canary and the rest of the flow is invalid. · Wait for the email field to be in source — same full-mount signal as the canary side

The sign-in screen appears again — but now for the RELEASE tenant · Confirm we're actually on release — the sign-in screen renders the slug as a subtitle under "Sign In", so the page source contains the literal string "release". If this fails we silently fell back to canary and the rest of the flow is invalid. · Wait for the email field to be in source — same full-mount signal as the canary side

Step 20 — Type the admin email — same credentials work on both canary and release per the test-tenant convention

Type the admin email — same credentials work on both canary and release per the test-tenant convention

Steps 21–22 — Type the password — secure field, clearAndSetValue clears any pre-fill first · Dismiss the soft keyboard so it doesn't cover the Sign In button

Type the password — secure field, `clearAndSetValue` clears any pre-fill first · Dismiss the soft keyboard so it doesn't cover the Sign In button

Step 23 — Submit credentials against the release tenant's signIn mutation

Submit credentials against the release tenant's `signIn` mutation First sign-in on the release tenant after a fresh install shows the "Stay in the loop" notification-permission carousel Tap "Maybe later" in a find→tap→check-source loop until the carousel is dismissed. Same Pressable handler-bind race as switch-company-button — verified flaky in login-pin.yaml at 2s wait; tapUntilGone is the stable fix here. · The authenticated app router redirects to the Schedule tab on the RELEASE tenant — proving the full multi-tenant capability (auth tokens, store reset, GraphQL endpoint swap, navigation reset all completed cleanly).

Source

  • Spec (narrative): e2e/appium/specs/auth/login.md
  • Flow (test): e2e/appium/flows/auth/switch-company-v2.yaml
  • Last run status: ✅ green