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-U6lAUr2z.js"></script>
+ <script type="module" crossorigin src="/assets/index-DEdJhFGo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D-7MmxJh.css">
</head>
<body class="density-compact">
M web/src/features/settings/SavedSearchesSection.tsx => web/src/features/settings/SavedSearchesSection.tsx +22 -32
@@ 1,5 1,6 @@
import React, { useState } from 'react'
import { EmptyState, Sub } from '../../primitives'
+import { AuthGate } from '../../shell/AuthGate'
import { AuthError, APIError } from '../../api/client'
import type { SavedSearch } from '../../api/adapters'
import {
@@ 175,20 176,7 @@ export function SavedSearchesSection(): React.JSX.Element {
return <Sub>loading…</Sub>
}
- if (error != null) {
- if (error instanceof AuthError) {
- 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 }}>not authenticated</div>
- <div className="muted">Sign in to manage your saved searches.</div>
- </div>
- </div>
- )
- }
+ if (error != null && !(error instanceof AuthError)) {
const detail = error instanceof APIError ? error.message : String(error)
return (
<div
@@ 204,24 192,26 @@ export function SavedSearchesSection(): React.JSX.Element {
}
return (
- <div className="saved-searches-section">
- <div className="saved-searches-heading">Saved searches</div>
- <AddForm />
- {data != null && data.length === 0 ? (
- <EmptyState glyph="∅" copy="no saved searches yet" />
- ) : (
- <div className="saved-searches-table">
- <div className="saved-searches-thead">
- <span>Name</span>
- <span>Query</span>
- <span>Updated</span>
- <span></span>
+ <AuthGate>
+ <div className="saved-searches-section">
+ <div className="saved-searches-heading">Saved searches</div>
+ <AddForm />
+ {data != null && data.length === 0 ? (
+ <EmptyState glyph="∅" copy="no saved searches yet" />
+ ) : (
+ <div className="saved-searches-table">
+ <div className="saved-searches-thead">
+ <span>Name</span>
+ <span>Query</span>
+ <span>Updated</span>
+ <span></span>
+ </div>
+ {data?.map((row) => (
+ <SavedSearchRow key={row.name} row={row} />
+ ))}
</div>
- {data?.map((row) => (
- <SavedSearchRow key={row.name} row={row} />
- ))}
- </div>
- )}
- </div>
+ )}
+ </div>
+ </AuthGate>
)
}
M web/src/routes/index.tsx => web/src/routes/index.tsx +15 -22
@@ 2,6 2,7 @@ import React, { useEffect, useCallback } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { SubBar } from '../shell/SubBar'
import { Sub } from '../primitives'
+import { AuthGate } from '../shell/AuthGate'
import { FilterChips } from '../features/home/FilterChips'
import { SessionsTable } from '../features/home/SessionsTable'
import { useSessions } from '../features/home/useSessions'
@@ 86,17 87,7 @@ function HomeRoute(): React.JSX.Element {
)
}
- if (error != null) {
- if (error instanceof AuthError) {
- 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 }}>not authenticated</div>
- <div className="muted">Sign in to view your sessions.</div>
- </div>
- </div>
- )
- }
+ if (error != null && !(error instanceof AuthError)) {
const detail = error instanceof APIError ? error.message : String(error)
return (
<div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
@@ 109,16 100,18 @@ function HomeRoute(): React.JSX.Element {
}
return (
- <>
- <SubBar>
- <FilterChips value={filters} onChange={handleFilterChange} />
- </SubBar>
- <SessionsTable
- sessions={sessions ?? []}
- cursor={cursor}
- onCursor={jumpTo}
- onOpen={handleOpen}
- />
- </>
+ <AuthGate>
+ <>
+ <SubBar>
+ <FilterChips value={filters} onChange={handleFilterChange} />
+ </SubBar>
+ <SessionsTable
+ sessions={sessions ?? []}
+ cursor={cursor}
+ onCursor={jumpTo}
+ onOpen={handleOpen}
+ />
+ </>
+ </AuthGate>
)
}
M web/src/routes/projects.tsx => web/src/routes/projects.tsx +13 -20
@@ 2,6 2,7 @@ import React, { useEffect, useCallback } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { SubBar } from '../shell/SubBar'
import { Sub } from '../primitives'
+import { AuthGate } from '../shell/AuthGate'
import { ProjectsTable } from '../features/projects/ProjectsTable'
import { useProjects } from '../features/projects/useProjects'
import { useProjectsCursor } from '../features/projects/useProjectsCursor'
@@ 115,17 116,7 @@ function ProjectsRoute(): React.JSX.Element {
)
}
- if (error != null) {
- if (error instanceof AuthError) {
- 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 }}>not authenticated</div>
- <div className="muted">Sign in to view your projects.</div>
- </div>
- </div>
- )
- }
+ if (error != null && !(error instanceof AuthError)) {
const detail = error instanceof APIError ? error.message : String(error)
return (
<div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
@@ 138,14 129,16 @@ function ProjectsRoute(): React.JSX.Element {
}
return (
- <>
- {subBar}
- <ProjectsTable
- projects={projects ?? []}
- cursor={cursor}
- onCursor={jumpTo}
- onOpen={handleOpen}
- />
- </>
+ <AuthGate>
+ <>
+ {subBar}
+ <ProjectsTable
+ projects={projects ?? []}
+ cursor={cursor}
+ onCursor={jumpTo}
+ onOpen={handleOpen}
+ />
+ </>
+ </AuthGate>
)
}
A web/src/shell/AuthGate.tsx => web/src/shell/AuthGate.tsx +110 -0
@@ 0,0 1,110 @@
+import React, { useEffect } from 'react'
+import { useAuth } from '../lib/authContext'
+
+// ── AuthGate ──────────────────────────────────────────────────────────────────
+//
+// Single source of truth for the "not authenticated" UI (IV6, IV7).
+//
+// - authenticated → renders children normally.
+// - unauthenticated (cold, hasBeenAuthenticated === false)
+// → shows a manual "Sign in with OIDC" card (IV7).
+// - unauthenticated (mid-session, hasBeenAuthenticated === true)
+// → auto-redirects via useEffect, shows a placeholder (IV7).
+// - auth_error → shows the distinct error card with a manual "Try again"
+// button (IV6). Never auto-retries.
+
+export function AuthGate({ children }: { children: React.ReactNode }): React.JSX.Element {
+ const { state, signIn } = useAuth()
+ const { status, hasBeenAuthenticated, error } = state
+
+ // Mid-session expiry: auto-redirect on mount only (IV7).
+ useEffect(() => {
+ if (status === 'unauthenticated' && hasBeenAuthenticated) {
+ signIn(window.location.pathname)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [status, hasBeenAuthenticated]) // intentionally omits signIn (stable callback)
+
+ if (status === 'authenticated') {
+ return <>{children}</>
+ }
+
+ if (status === 'auth_error') {
+ 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 }}>couldn't sign you in</div>
+ <div className="muted" style={{ marginBottom: 16 }}>
+ {error ?? 'An unknown error occurred.'}
+ </div>
+ <button
+ type="button"
+ style={{
+ padding: '4px 14px',
+ fontSize: 11,
+ fontFamily: 'var(--mono)',
+ border: '1px solid var(--rule)',
+ borderRadius: 4,
+ background: 'transparent',
+ color: 'var(--ink-2)',
+ cursor: 'pointer',
+ }}
+ onClick={() => signIn(window.location.pathname)}
+ >
+ Try again
+ </button>
+ </div>
+ </div>
+ )
+ }
+
+ // status === 'unauthenticated'
+ if (hasBeenAuthenticated) {
+ // Mid-session expiry — auto-redirect is in progress (useEffect above).
+ 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 }}>session expired</div>
+ <div className="muted">Redirecting to sign in…</div>
+ </div>
+ </div>
+ )
+ }
+
+ // Cold first render — show manual sign-in button (IV7).
+ 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 }}>not authenticated</div>
+ <div className="muted" style={{ marginBottom: 16 }}>
+ Sign in to continue.
+ </div>
+ <button
+ type="button"
+ style={{
+ padding: '4px 14px',
+ fontSize: 11,
+ fontFamily: 'var(--mono)',
+ border: '1px solid var(--rule)',
+ borderRadius: 4,
+ background: 'transparent',
+ color: 'var(--ink-2)',
+ cursor: 'pointer',
+ }}
+ onClick={() => signIn(window.location.pathname)}
+ >
+ Sign in with OIDC
+ </button>
+ </div>
+ </div>
+ )
+}