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>
+ )
+}