Files
ui/_docs/02_document/modules/src__auth__AuthContext.md
T
Oleksandr Bezdieniezhnykh 17d5bb45e7
ci/woodpecker/push/build-arm Pipeline was successful
[AZ-485] [AZ-486] Cycle 1 docs refresh (Step 13)
Phase B cycle 1 was a structural refactor only: F4 (barrel imports +
STC-ARCH-01) and F7 (endpoint builders + STC-ARCH-02). This commit
brings docs in line with source after the cycle, no code changes.

Module docs (12 consumers): swap every /api/<service>/... literal in
code snippets and integration tables for the matching endpoints.*
builder; note the barrel import migration in Dependencies.

New module doc: src__api__endpoints.md (public surface, F4 barrel
re-export note, STC-ARCH-02 enforcement, contract-test reference).

Architecture compliance baseline: mark F4 + F7 CLOSED with commit
hashes (23746ec, 8a461a2).

01_api-transport component description: add endpoints.ts + barrel to
Internal Interfaces, close the F7 caveat, extend Module Inventory.

ripple_log_cycle1.md: Task Step 0.5 reverse-dep analysis records the
import-graph closure (no extra docs needed beyond the direct set).

Carry-over reports landed alongside the docs:
- test_run_report_phase_b_cycle1.md (Step 11 outcome)
- implementation_report_refactor_phase_b_cycle1.md (cycle summary)

State file: trimmed to the autodev <30-line target; Steps 14 + 15
recorded as SKIPPED with rationale (no security or perf surface
changed in this cycle); pointer moved to Step 16 (Deploy).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:01:04 +03:00

6.8 KiB

Module: src/auth/AuthContext.tsx

Source: src/auth/AuthContext.tsx (54 lines) Topo batch: B3 (depends on B2 leaves: api/client, types/index)

Purpose

The single source of truth for the SPA's authentication state. Wraps the bearer-token plumbing from api/client.ts in a React context, exposes useAuth() for any descendant component, and bootstraps the session on app start by attempting a refresh. Together with ProtectedRoute.tsx and LoginPage.tsx, this is the WPF-era LoginWindow.xaml + auth service replacement (_docs/legacy/wpf-era.md §3 / §4).

Public interface

interface AuthState {
  user: AuthUser | null
  loading: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
  hasPermission: (perm: string) => boolean
}

export function useAuth(): AuthState
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element

AuthContext itself is module-private (createContext<AuthState>(null!)). Consumers must go through useAuth().

Internal logic

State:

  • user: AuthUser | nullnull when unauthenticated.
  • loading: booleantrue until the initial refresh attempt resolves (success or failure). Renders should gate on this.

Bootstrap effect (mount-only):

api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
  .then(data => { setToken(data.token); setUser(data.user) })
  .catch(() => {})
  .finally(() => setLoading(false))

The refresh endpoint is invoked with credentials: 'include' only inside client.ts's internal refreshToken() helper — but here we go through the public api.get() path, which does NOT include credentials. This is a real divergence: client.ts's internal refreshToken() (used in the 401 retry) sends the cookie; the bootstrap call in AuthContext does not. The endpoint must therefore accept the refresh either via cookie (then bootstrap fails silently for non-cookie clients — which is everyone after a hard reload) or via some other mechanism (a refresh token in localStorage, etc.). Flag for Step 4 verification against the admin/ service contract; this is likely a real bug masking by silent .catch. The path string itself is unaffected by AZ-486 — endpoints.admin.authRefresh() produces '/api/admin/auth/refresh' character-identically to the pre-refactor literal, so the divergence is structural, not URL-based.

login(email, password):

const data = await api.post<{ token; user }>(endpoints.admin.authLogin(), { email, password })
setToken(data.token); setUser(data.user)

Throws to caller (LoginPage) on bad credentials.

logout():

try { await api.post(endpoints.admin.authLogout()) } catch {}
setToken(null); setUser(null)

Network failure on logout is silently swallowed because we want to clear local auth state regardless.

hasPermission(perm): returns user?.permissions.includes(perm) ?? false. The permission strings are not constrained at the type level — any string passes. Backend-defined.

Dependencies

  • Internal:
    • ../api (barrel) — api, endpoints, setToken. (Since AZ-485 / F4 + AZ-486 / F7: barrel import + endpoint builders.)
    • ../typesAuthUser type.
  • External: react (createContext, useContext, useState, useCallback, useEffect, ReactNode).

Consumers (intra-repo)

From the §7a dependency graph:

  • src/auth/ProtectedRoute.tsx — gates routed children on user !== null.
  • src/components/Header.tsx — shows current user, exposes Logout.
  • src/features/login/LoginPage.tsx — calls login(...), redirects on success.
  • src/App.tsx — mounts AuthProvider near the root.

(Other features rely on the bearer token implicitly via api/client.ts — they don't import useAuth directly.)

Data models

AuthUser (from src/types/index.ts) — see _docs/02_document/modules/src__types__index.md. Carries at minimum id, email, permissions: string[].

Configuration

Endpoints (typed builders from ../api/endpoints, since AZ-486 / F7): endpoints.admin.authRefresh(), endpoints.admin.authLogin(), endpoints.admin.authLogout() — producing /api/admin/auth/refresh, .../login, .../logout respectively. Routed by nginx.conf to the admin/ service.

No env vars consumed directly — token storage policy is defined in client.ts (in-memory; not persisted to localStorage).

External integrations

admin/ (.NET) auth service via /api/admin/auth/{refresh,login,logout}.

Security

  • In-memory token only: the bearer is held by client.ts at module scope. Survives intra-tab navigation, lost on hard reload — at which point the refresh path must restore it (currently broken per the bootstrap-effect note above).
  • hasPermission runs client-side only: the server is the authority; hasPermission is for UI affordances (hide vs. show buttons). The backend MUST re-check permissions on every endpoint. Document in security_approach.md (Step 6).
  • Silent error swallowing on bootstrap and logout is intentional but obscures real failures. A dev-only console.error would help during the testability pass; do not add a user-visible toast (silent recovery is the correct UX).
  • No XSS exfiltration risk for the bearer: in-memory only, never written to localStorage or a non-HttpOnly cookie. (Confirmed in client.ts doc.)

Tests

None.

Notes / open questions

  • Bootstrap-vs-refresh divergence (above) — the highest-priority flag in this module. Either:
    1. The refresh endpoint accepts an Authorization-less, cookie-bearing call → confirm the admin/ service sets an HttpOnly cookie on /login and the cookie path matches /api/admin/auth/refresh. The api.get() path in client.ts does NOT send credentials: 'include', so this currently CANNOT work. → likely bug.
    2. Or the bootstrap should be calling the internal refreshToken() helper, which is currently not exported. Either way, this needs a Step 4 fix (export refreshToken() and call it here, or change api.get() to allow per-call credentials).
  • AuthContext = createContext<AuthState>(null!): the non-null assertion means useAuth() will throw at the destructuring site if it's used outside AuthProvider. Acceptable given App.tsx mounts AuthProvider at the top, but a guard if (!ctx) throw new Error(...) would be friendlier. Defer.
  • The loading flag is never re-set to true after the initial bootstrap. login and logout complete synchronously from the React tree's perspective (the await is inside the callback). If a future requirement demands a "logging in…" indicator, it would need its own state. Note for Step 8.
  • useAuth returns the raw context value (no memoisation wrapper). React 18+ behaviour means <AuthProvider> re-renders all useAuth consumers on every state update — fine here because there's no high-frequency state.