~bigbes/lethe

f2376137d0f2f8cd5978839c3be663ad85527576 — Eugene Blikh a month ago 5d910e8
web: /login + /auth/callback routes + auth context + config reader

- web/src/lib/config.ts: readConfig() reads window.__LETHE_CONFIG__ (IF1),
  converts client_id -> clientId, throws on absent config (GPC6)
- web/src/lib/authContext.tsx: AuthProvider subscribes to tokenStore, parses
  ID token name claim, exposes signIn/signOut/reportAuthError via context;
  hasBeenAuthenticated flag supports IV7 cold-vs-session distinction
- web/src/routes/login.tsx: /login route calls signIn(return_to) on mount
- web/src/routes/auth.callback.tsx: validates state+TTL, exchanges code via
  raw fetch to OP /token (allowed exception), stores access_token via
  tokenStore.set, anti-loop guard using countCallbackFailures (IV6)
- web/src/routes/__root.tsx: wraps tree in AuthProvider above
  KeyboardCursorContext
- web/src/routeTree.gen.ts: regenerated by Vite with /login and /auth/callback
- internal/server/web/dist/index.html: rebuilt artifact with new asset hashes
M internal/server/web/dist/index.html => internal/server/web/dist/index.html +1 -1
@@ 13,7 13,7 @@
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap"
      rel="stylesheet"
    />
    <script type="module" crossorigin src="/assets/index-C9v5ApPX.js"></script>
    <script type="module" crossorigin src="/assets/index-U6lAUr2z.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-D-7MmxJh.css">
  </head>
  <body class="density-compact">

A web/src/lib/authContext.tsx => web/src/lib/authContext.tsx +226 -0
@@ 0,0 1,226 @@
import React, { useState, useEffect, useRef, useContext, useCallback } from 'react'
import { tokenStore } from './auth'
import { generatePKCEPair, generateState } from './auth'
import { readConfig } from './config'

// ── Types ─────────────────────────────────────────────────────────────────────

export interface AuthState {
  token: string | null
  user: { name?: string } | null
  status: 'unauthenticated' | 'authenticated' | 'auth_error'
  error?: string
  /** True once a token has been set at least once this page-load (supports IV7). */
  hasBeenAuthenticated: boolean
}

export interface AuthContextValue {
  state: AuthState
  signIn(returnTo?: string): void
  signOut(): void
  reportAuthError(error: string): void
}

// ── PKCE pending state shape stored in localStorage ──────────────────────────

interface PendingAuth {
  verifier: string
  state: string
  returnTo: string
  expiresAt: number // ms epoch
}

const PENDING_KEY = 'lethe_auth_pending'

// ── ID token parsing (cosmetic name claim only) ───────────────────────────────

function parseNameFromToken(token: string): string | undefined {
  try {
    const parts = token.split('.')
    if (parts.length < 2) return undefined
    // base64url → base64 → decode
    const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
    const json = atob(payload)
    const claims = JSON.parse(json) as Record<string, unknown>
    if (typeof claims['name'] === 'string') {
      return claims['name']
    }
    return undefined
  } catch {
    // Malformed token — ignore; still treat as authenticated (token is what
    // middleware checks; name is purely cosmetic).
    return undefined
  }
}

// ── Context ───────────────────────────────────────────────────────────────────

export const AuthContext = React.createContext<AuthContextValue | null>(null)

/**
 * useAuth — typed accessor for AuthContextValue.
 * Throws if used outside AuthProvider.
 */
export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext)
  if (ctx === null) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return ctx
}

// ── AuthProvider ──────────────────────────────────────────────────────────────

export function AuthProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
  // Try reading config once at provider mount; if absent, surface a config-error card.
  const [configError, setConfigError] = React.useState<string | null>(null)
  const configRef = useRef<ReturnType<typeof readConfig> | null>(null)

  if (configRef.current === null && configError === null) {
    try {
      configRef.current = readConfig()
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e)
      // We can't call setState during render directly, so we schedule it.
      // But for the initial render we use a lazy-init approach:
      // configRef stays null and configError is set below.
      // Since this runs synchronously before first paint, use a flag instead.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      ;(configRef as any)._error = msg
    }
  }

  // Initialise from tokenStore current value so SSR/hydration is consistent.
  const initialToken = tokenStore.get()

  const [state, setState] = useState<AuthState>(() => {
    if (initialToken != null) {
      const name = parseNameFromToken(initialToken)
      return {
        token: initialToken,
        user: { name },
        status: 'authenticated',
        hasBeenAuthenticated: true,
      }
    }
    return {
      token: null,
      user: null,
      status: 'unauthenticated',
      hasBeenAuthenticated: false,
    }
  })

  // hasBeenAuthenticated ref: stays true once first token received (IV7).
  const hasBeenAuthenticatedRef = useRef(state.hasBeenAuthenticated)

  useEffect(() => {
    // Propagate config error detected during render into React state.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const errFromRender = (configRef as any)._error as string | undefined
    if (errFromRender != null) {
      setConfigError(errFromRender)
      return
    }

    const unsubscribe = tokenStore.subscribe((token) => {
      if (token != null) {
        hasBeenAuthenticatedRef.current = true
        const name = parseNameFromToken(token)
        setState({
          token,
          user: { name },
          status: 'authenticated',
          hasBeenAuthenticated: true,
        })
      } else {
        setState({
          token: null,
          user: null,
          status: 'unauthenticated',
          hasBeenAuthenticated: hasBeenAuthenticatedRef.current,
        })
      }
    })

    return unsubscribe
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []) // mount-only

  const signIn = useCallback((returnTo?: string): void => {
    const cfg = configRef.current
    if (cfg === null) {
      // Config missing — cannot sign in; surface error.
      setState((prev) => ({
        ...prev,
        status: 'auth_error',
        error: 'lethe config missing',
      }))
      return
    }

    void (async () => {
      const { verifier, challenge } = await generatePKCEPair()
      const stateParam = generateState()
      const rt = returnTo ?? '/'

      const pending: PendingAuth = {
        verifier,
        state: stateParam,
        returnTo: rt,
        expiresAt: Date.now() + 5 * 60 * 1000, // 5 min TTL
      }
      localStorage.setItem(PENDING_KEY, JSON.stringify(pending))

      const origin = window.location.origin
      const params = new URLSearchParams({
        response_type:         'code',
        client_id:             cfg.clientId,
        redirect_uri:          `${origin}/auth/callback`,
        state:                 stateParam,
        code_challenge:        challenge,
        code_challenge_method: 'S256',
      })
      window.location.assign(`${cfg.issuer}/authorize?${params.toString()}`)
    })()
  }, [])

  const signOut = useCallback((): void => {
    tokenStore.set(null)
    // localStorage PKCE pending is left alone (harmless; will expire naturally).
    // IV4: no tokens in localStorage — nothing to clear there.
  }, [])

  const reportAuthError = useCallback((error: string): void => {
    setState((prev) => ({
      ...prev,
      status: 'auth_error',
      error,
      hasBeenAuthenticated: hasBeenAuthenticatedRef.current,
    }))
  }, [])

  if (configError != null) {
    return (
      <div
        className="body body-pad"
        style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
      >
        <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
          <div className="uppercase-mono" style={{ marginBottom: 8 }}>auth-config missing</div>
          <div className="muted">
            window.__LETHE_CONFIG__ was not injected. Reload or contact your administrator.
          </div>
        </div>
      </div>
    )
  }

  const value: AuthContextValue = { state, signIn, signOut, reportAuthError }

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

A web/src/lib/config.ts => web/src/lib/config.ts +39 -0
@@ 0,0 1,39 @@
// config.ts — reads OIDC configuration injected by the Go server into
// window.__LETHE_CONFIG__ (IF1). The injected JSON uses snake_case field names
// (Go struct json tags); this module converts to camelCase for ergonomic use
// throughout the SPA.

export interface LetheConfig {
  issuer: string
  clientId: string
}

// Raw shape as injected by the Go server (json tags: issuer, client_id).
interface RawLetheConfig {
  issuer: string
  client_id: string
}

declare global {
  interface Window {
    __LETHE_CONFIG__?: RawLetheConfig
  }
}

/**
 * Read the OIDC configuration from window.__LETHE_CONFIG__.
 *
 * Throws Error('lethe config missing') if the property is absent — fail-fast
 * per GPC6. The caller (auth provider mount) should catch this and render an
 * "auth-config missing" error card rather than swallowing the exception.
 */
export function readConfig(): LetheConfig {
  const raw = window.__LETHE_CONFIG__
  if (raw == null) {
    throw new Error('lethe config missing')
  }
  return {
    issuer:   raw.issuer,
    clientId: raw.client_id,
  }
}

M web/src/routeTree.gen.ts => web/src/routeTree.gen.ts +42 -0
@@ 13,9 13,11 @@ import { Route as StatsRouteImport } from './routes/stats'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SearchRouteImport } from './routes/search'
import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as LoginRouteImport } from './routes/login'
import { Route as HealthRouteImport } from './routes/health'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ProjectSplatRouteImport } from './routes/project.$'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as SessionToolHostIdRouteImport } from './routes/session.$tool.$host.$id'

const StatsRoute = StatsRouteImport.update({


@@ 38,6 40,11 @@ const ProjectsRoute = ProjectsRouteImport.update({
  path: '/projects',
  getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
  id: '/login',
  path: '/login',
  getParentRoute: () => rootRouteImport,
} as any)
const HealthRoute = HealthRouteImport.update({
  id: '/health',
  path: '/health',


@@ 53,6 60,11 @@ const ProjectSplatRoute = ProjectSplatRouteImport.update({
  path: '/project/$',
  getParentRoute: () => rootRouteImport,
} as any)
const AuthCallbackRoute = AuthCallbackRouteImport.update({
  id: '/auth/callback',
  path: '/auth/callback',
  getParentRoute: () => rootRouteImport,
} as any)
const SessionToolHostIdRoute = SessionToolHostIdRouteImport.update({
  id: '/session/$tool/$host/$id',
  path: '/session/$tool/$host/$id',


@@ 62,20 74,24 @@ const SessionToolHostIdRoute = SessionToolHostIdRouteImport.update({
export interface FileRoutesByFullPath {
  '/': typeof IndexRoute
  '/health': typeof HealthRoute
  '/login': typeof LoginRoute
  '/projects': typeof ProjectsRoute
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
  '/auth/callback': typeof AuthCallbackRoute
  '/project/$': typeof ProjectSplatRoute
  '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}
export interface FileRoutesByTo {
  '/': typeof IndexRoute
  '/health': typeof HealthRoute
  '/login': typeof LoginRoute
  '/projects': typeof ProjectsRoute
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
  '/auth/callback': typeof AuthCallbackRoute
  '/project/$': typeof ProjectSplatRoute
  '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}


@@ 83,10 99,12 @@ export interface FileRoutesById {
  __root__: typeof rootRouteImport
  '/': typeof IndexRoute
  '/health': typeof HealthRoute
  '/login': typeof LoginRoute
  '/projects': typeof ProjectsRoute
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
  '/auth/callback': typeof AuthCallbackRoute
  '/project/$': typeof ProjectSplatRoute
  '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}


@@ 95,30 113,36 @@ export interface FileRouteTypes {
  fullPaths:
    | '/'
    | '/health'
    | '/login'
    | '/projects'
    | '/search'
    | '/settings'
    | '/stats'
    | '/auth/callback'
    | '/project/$'
    | '/session/$tool/$host/$id'
  fileRoutesByTo: FileRoutesByTo
  to:
    | '/'
    | '/health'
    | '/login'
    | '/projects'
    | '/search'
    | '/settings'
    | '/stats'
    | '/auth/callback'
    | '/project/$'
    | '/session/$tool/$host/$id'
  id:
    | '__root__'
    | '/'
    | '/health'
    | '/login'
    | '/projects'
    | '/search'
    | '/settings'
    | '/stats'
    | '/auth/callback'
    | '/project/$'
    | '/session/$tool/$host/$id'
  fileRoutesById: FileRoutesById


@@ 126,10 150,12 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
  IndexRoute: typeof IndexRoute
  HealthRoute: typeof HealthRoute
  LoginRoute: typeof LoginRoute
  ProjectsRoute: typeof ProjectsRoute
  SearchRoute: typeof SearchRoute
  SettingsRoute: typeof SettingsRoute
  StatsRoute: typeof StatsRoute
  AuthCallbackRoute: typeof AuthCallbackRoute
  ProjectSplatRoute: typeof ProjectSplatRoute
  SessionToolHostIdRoute: typeof SessionToolHostIdRoute
}


@@ 164,6 190,13 @@ declare module '@tanstack/react-router' {
      preLoaderRoute: typeof ProjectsRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/login': {
      id: '/login'
      path: '/login'
      fullPath: '/login'
      preLoaderRoute: typeof LoginRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/health': {
      id: '/health'
      path: '/health'


@@ 185,6 218,13 @@ declare module '@tanstack/react-router' {
      preLoaderRoute: typeof ProjectSplatRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/auth/callback': {
      id: '/auth/callback'
      path: '/auth/callback'
      fullPath: '/auth/callback'
      preLoaderRoute: typeof AuthCallbackRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/session/$tool/$host/$id': {
      id: '/session/$tool/$host/$id'
      path: '/session/$tool/$host/$id'


@@ 198,10 238,12 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
  IndexRoute: IndexRoute,
  HealthRoute: HealthRoute,
  LoginRoute: LoginRoute,
  ProjectsRoute: ProjectsRoute,
  SearchRoute: SearchRoute,
  SettingsRoute: SettingsRoute,
  StatsRoute: StatsRoute,
  AuthCallbackRoute: AuthCallbackRoute,
  ProjectSplatRoute: ProjectSplatRoute,
  SessionToolHostIdRoute: SessionToolHostIdRoute,
}

M web/src/routes/__root.tsx => web/src/routes/__root.tsx +10 -7
@@ 5,6 5,7 @@ import { createKeyboardController } from '../lib/keyboard'
import type { RouteName } from '../lib/keyboard'
import { TopBar } from '../shell/TopBar'
import { Palette } from '../shell/Palette'
import { AuthProvider } from '../lib/authContext'
import '../styles/tokens.css'
import '../styles/primitives.css'
import '../styles/shell.css'


@@ 78,12 79,14 @@ function RootComponent(): React.JSX.Element {
  }, []) // navigate is stable; intentional empty-dep mount effect

  return (
    <KeyboardCursorContext.Provider value={cursorRef}>
      <div className="app">
        <TopBar onPaletteOpen={() => setPaletteOpen(true)} />
        <Outlet />
        <Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
      </div>
    </KeyboardCursorContext.Provider>
    <AuthProvider>
      <KeyboardCursorContext.Provider value={cursorRef}>
        <div className="app">
          <TopBar onPaletteOpen={() => setPaletteOpen(true)} />
          <Outlet />
          <Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
        </div>
      </KeyboardCursorContext.Provider>
    </AuthProvider>
  )
}

A web/src/routes/auth.callback.tsx => web/src/routes/auth.callback.tsx +246 -0
@@ 0,0 1,246 @@
// auth.callback.tsx — handles the OIDC authorization code callback.
//
// NOTE: This file uses raw `fetch` to POST to the OP token endpoint.
// This is the ONE allowed exception to the invariant "no raw fetch outside
// client.ts": the OP /token endpoint is a cross-origin request to the issuer
// host and is not part of the /api/v1/* surface that apiFetch serves.

import React, { useEffect, useRef } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { tokenStore, countCallbackFailures } from '../lib/auth'
import { useAuth } from '../lib/authContext'
import { readConfig } from '../lib/config'

// ── Types ─────────────────────────────────────────────────────────────────────

interface PendingAuth {
  verifier: string
  state: string
  returnTo: string
  expiresAt: number
}

const PENDING_KEY   = 'lethe_auth_pending'
const FAILURES_KEY  = 'lethe_auth_failures'

// ── Route ─────────────────────────────────────────────────────────────────────

export const Route = createFileRoute('/auth/callback')({
  validateSearch: (s: Record<string, unknown>) => ({
    code:              typeof s['code']              === 'string' ? s['code']              : undefined,
    state:             typeof s['state']             === 'string' ? s['state']             : undefined,
    error:             typeof s['error']             === 'string' ? s['error']             : undefined,
    error_description: typeof s['error_description'] === 'string' ? s['error_description'] : undefined,
  }),
  component: CallbackRoute,
})

// ── Component ─────────────────────────────────────────────────────────────────

function CallbackRoute(): React.JSX.Element {
  const navigate = useNavigate()
  const { reportAuthError } = useAuth()
  const search = Route.useSearch()

  // Prevent double-execution in React StrictMode double-invoke.
  const ranRef = useRef(false)

  // blocked state: once set, render "please reload" card with no retry.
  const [blocked, setBlocked] = React.useState(false)
  const [authError, setAuthError] = React.useState<string | null>(null)

  useEffect(() => {
    if (ranRef.current) return
    ranRef.current = true

    void (async () => {
      // ── Helper: record a failure and check the anti-loop guard ──────────────
      function recordFailureAndCheck(): boolean {
        const raw = localStorage.getItem(FAILURES_KEY)
        let log: number[] = []
        try {
          if (raw != null) {
            log = JSON.parse(raw) as number[]
            if (!Array.isArray(log)) log = []
          }
        } catch {
          log = []
        }
        log.push(Date.now())
        localStorage.setItem(FAILURES_KEY, JSON.stringify(log))

        const { blocked: isBlocked } = countCallbackFailures(Date.now(), log)
        return isBlocked
      }

      // ── Helper: fail with a message ─────────────────────────────────────────
      function fail(msg: string): void {
        const isBlocked = recordFailureAndCheck()
        reportAuthError(msg)
        setAuthError(msg)
        if (isBlocked) {
          setBlocked(true)
        }
      }

      // ── 0. Check for OP-reported error in the callback URL ──────────────────
      if (search.error != null) {
        const desc = search.error_description ?? search.error
        fail(`Authorization error: ${desc}`)
        return
      }

      // ── 1. Validate that code and state are present ─────────────────────────
      if (search.code == null || search.state == null) {
        fail('Missing code or state in callback')
        return
      }

      // ── 2. Read and validate the pending auth entry from localStorage ────────
      const raw = localStorage.getItem(PENDING_KEY)
      if (raw == null) {
        fail('No pending authorization found (state may have expired)')
        return
      }

      let pending: PendingAuth
      try {
        pending = JSON.parse(raw) as PendingAuth
      } catch {
        fail('Corrupted pending authorization state')
        return
      }

      // State parameter must match.
      if (pending.state !== search.state) {
        fail('State mismatch — possible CSRF')
        return
      }

      // TTL check.
      if (Date.now() > pending.expiresAt) {
        fail('Authorization request expired — please try again')
        return
      }

      // Consume the pending entry immediately (single-use).
      localStorage.removeItem(PENDING_KEY)

      // ── 3. Read config ──────────────────────────────────────────────────────
      let cfg: ReturnType<typeof readConfig>
      try {
        cfg = readConfig()
      } catch (e) {
        fail('Auth config missing during token exchange')
        return
      }

      // ── 4. Exchange code for tokens via POST to OP /token ───────────────────
      const origin = window.location.origin
      const body = new URLSearchParams({
        grant_type:    'authorization_code',
        code:          search.code,
        code_verifier: pending.verifier,
        redirect_uri:  `${origin}/auth/callback`,
        client_id:     cfg.clientId,
      })

      let resp: Response
      try {
        resp = await fetch(`${cfg.issuer}/token`, {
          method:  'POST',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          body:    body.toString(),
        })
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e)
        fail(`Token endpoint unreachable: ${msg}`)
        return
      }

      if (!resp.ok) {
        let detail = resp.statusText
        try {
          const errBody = await resp.json() as Record<string, unknown>
          if (typeof errBody['error_description'] === 'string') {
            detail = errBody['error_description']
          } else if (typeof errBody['error'] === 'string') {
            detail = errBody['error']
          }
        } catch {
          // ignore JSON parse failure; use status text
        }
        fail(`Token exchange failed (${resp.status}): ${detail}`)
        return
      }

      // ── 5. Parse token response ─────────────────────────────────────────────
      let tokenResp: Record<string, unknown>
      try {
        tokenResp = await resp.json() as Record<string, unknown>
      } catch {
        fail('Token endpoint returned invalid JSON')
        return
      }

      const accessToken = tokenResp['access_token']
      if (typeof accessToken !== 'string' || accessToken === '') {
        fail('Token endpoint did not return an access_token')
        return
      }

      // ── 6. Store token and navigate to returnTo ─────────────────────────────
      // Clear anti-loop failures log on success.
      localStorage.removeItem(FAILURES_KEY)

      tokenStore.set(accessToken)
      await navigate({ to: pending.returnTo })
    })()
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []) // mount-only

  // ── Render ──────────────────────────────────────────────────────────────────

  if (blocked) {
    return (
      <div
        className="body body-pad"
        style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
      >
        <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
          <div className="uppercase-mono" style={{ marginBottom: 8 }}>sign-in blocked</div>
          <div className="muted" style={{ marginBottom: 16 }}>
            Too many failed sign-in attempts. Please reload the page to try again.
          </div>
        </div>
      </div>
    )
  }

  if (authError != null) {
    return (
      <div
        className="body body-pad"
        style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
      >
        <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
          <div className="uppercase-mono" style={{ marginBottom: 8 }}>auth error</div>
          <div className="muted">{authError}</div>
        </div>
      </div>
    )
  }

  // Default: processing the callback.
  return (
    <div
      className="body body-pad"
      style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
    >
      <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
        <div className="uppercase-mono" style={{ marginBottom: 8 }}>completing sign-in…</div>
        <div className="muted">Please wait.</div>
      </div>
    </div>
  )
}

A web/src/routes/login.tsx => web/src/routes/login.tsx +32 -0
@@ 0,0 1,32 @@
import React, { useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useAuth } from '../lib/authContext'

export const Route = createFileRoute('/login')({
  validateSearch: (s: Record<string, unknown>) => ({
    return_to: typeof s['return_to'] === 'string' ? s['return_to'] : '/',
  }),
  component: LoginRoute,
})

function LoginRoute(): React.JSX.Element {
  const { signIn } = useAuth()
  const { return_to } = Route.useSearch()

  useEffect(() => {
    signIn(return_to)
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []) // mount-only: trigger redirect once

  return (
    <div
      className="body body-pad"
      style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
    >
      <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
        <div className="uppercase-mono" style={{ marginBottom: 8 }}>redirecting…</div>
        <div className="muted">Taking you to the sign-in page.</div>
      </div>
    </div>
  )
}