# Module: `src/features/login/LoginPage.tsx` > **Source**: `src/features/login/LoginPage.tsx` (95 lines) > **Topo batch**: B4 (depends on B3: `auth/AuthContext`) ## Purpose The single public route of the SPA. Collects email + password, calls `AuthContext.login(...)`, and on success runs a four-step "unlock" animation (download key → decrypting → starting services → ready) before navigating to `/flights`. Replaces the WPF `LoginWindow.xaml` including its multi-step progress UI (`_docs/legacy/wpf-era.md` §3 / §4). ## Public interface ```ts export default function LoginPage(): JSX.Element ``` No props. Reads `useAuth().login` and `react-router-dom`'s `useNavigate`. ## Internal logic - **State machine** — `step: UnlockStep` cycles through: `idle → authenticating → downloadingKey → decrypting → startingServices → ready`, with `idle` also reachable on error from `authenticating`. ```ts type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready' ``` - **Local state**: - `email: string`, `password: string` — controlled inputs. - `error: string` — empty unless the previous attempt failed. - `step: UnlockStep` — drives both the form/spinner branch and the spinner caption. - **`handleSubmit(e)`**: 1. `preventDefault`, clear `error`, set `step = 'authenticating'`. 2. `await login(email, password)` — this throws on bad credentials (`AuthContext.login` rethrows the `api.post` error). 3. On success: call `runUnlockSequence()`. 4. On failure: reset `step` to `'idle'`, set `error = t('login.error')`. - **`runUnlockSequence()`** — sequentially sets `step` to each of `downloadingKey`, `decrypting`, `startingServices`, `ready`, with a 600ms `setTimeout` delay between each. After `ready`, navigates to `/flights`. The unlock steps are **purely presentational** — there is no real downloadKey/decrypt work happening; the SPA's bearer token is already set inside `await login(...)`. The animation reproduces the WPF "vault unlocking" UX for continuity, not for any cryptographic operation. Total minimum wait after a successful auth: 4 × 600 ms = 2.4 s. Document explicitly to avoid future "what does this decrypt?" confusion. - **`STEP_KEYS`**: a `Record` mapping each step to a translation key. `'idle'` maps to the empty string (the spinner caption is not rendered when idle). - **Render**: when `step === 'idle'`, the form is shown (email + password + error + submit). Otherwise the form is hidden and a centered spinner with the localized step caption is shown. Note the spinner is rendered even during `authenticating` (real network), so the user sees a single uninterrupted spinner across the entire auth → animation flow. ## Dependencies - **Internal**: `../../auth/AuthContext` — `useAuth().login`. - **External**: `react` (`useState`, `FormEvent` type), `react-router-dom` (`useNavigate`), `react-i18next` (`useTranslation`). ## Consumers (intra-repo) - `src/App.tsx` — mounted at the public `/login` route, *outside* `AuthProvider`. (Verify in B8 — currently `App.tsx` puts `AuthProvider` inside the protected branch only, so `LoginPage` reaches `useAuth()` only because `App.tsx` actually wraps everything in `AuthProvider` at a higher level. Confirmed via the §7a graph edge `App → AuthProvider`.) ## Data models `UnlockStep` and `STEP_KEYS` are module-private. No DTOs or shared types. ## Configuration - **i18n keys**: `login.title`, `login.email`, `login.password`, `login.submit`, `login.error`, `login.authenticating`, `login.downloadingKey`, `login.decrypting`, `login.startingServices`, `login.ready`. (All present in `src/i18n/en.json` and `ua.json` — confirmed.) - **Tailwind tokens**: `bg-az-bg`, `bg-az-panel`, `border-az-border`, `text-az-orange`, `text-az-text`, `text-az-muted`, `text-az-red`. Defined in `src/index.css`. - **Hardcoded animation timing**: `600 ms` per step. No env override. ## External integrations None directly. Indirect: `AuthContext.login` calls `POST /api/admin/auth/login` (routed by `nginx.conf` to the `admin/` service). ## Security - Both inputs use the right `type` attribute (`email`, `password`), giving the browser the chance to mask the password and offer password-manager autofill. - The HTML form does NOT have `autoComplete="off"` — autofill is intentionally allowed. - The component does NOT log credentials anywhere. The `error` state carries only the localized "Invalid credentials" string, never the raw backend error. - After successful auth, the bearer is already in memory (`AuthContext` set it). The 2.4s "unlock" animation does NOT extend the auth window — if the bearer expires server-side during the animation the next request retries via `client.ts`'s 401 → refresh path. - The `setError(t('login.error'))` shows a single generic error for every failure mode (wrong password, account locked, server down). Acceptable for security (no user-enumeration leak), but logs an observability flag — backend should keep specific reasons in its audit log. ## Tests None. ## Notes / open questions - **The unlock animation is theatrical** — it suggests cryptographic work that is NOT happening. If a future phase actually adds client-side key derivation, the animation should reflect real progress (`'decrypting'` would block on actual work). Keep the names but document the placeholder status in `solution.md` (Step 5). - **No "loading" while `authenticating`** beyond hiding the form — Submit button shows no spinner of its own; the form simply hides the moment `step` leaves `'idle'`. Mild UX gap (a quick auth failure shows the form blink). Defer to Step 8. - **Caps Lock indicator missing** — the WPF version had one (`_docs/legacy/wpf-era.md`). Not currently a goal; flag if the UX spec calls for it. - **No "Forgot password" link** — out of scope; the `admin/` service may or may not support that flow. Verify during Step 6 problem extraction. - **Form does not disable Submit while authenticating** — but the form is unmounted as soon as `step !== 'idle'`, so a double-click cannot fire a second `login(...)` call. Acceptable. - **`runUnlockSequence` resolution race**: if the user navigates away mid-animation (e.g. closes the tab), the pending `setTimeout`s still fire but harmlessly. React's StrictMode double-invokes the effect-mount path in dev — but `runUnlockSequence` is invoked from `handleSubmit`, not an effect, so no duplication.