~bigbes/lethe

13113b8c4674cd767c94798875471f64b11e656d — Eugene Blikh a month ago f237613
web: AuthGate consolidates three "not authenticated" cards

Create AuthGate component as the single source of truth for the
unauthenticated UI. Cold renders show a manual sign-in button (IV7);
mid-session expiry auto-redirects via useEffect (IV7); auth_error shows
the distinct error card with a "Try again" button and never auto-retries
(IV6). Swap the inline AuthError cards at index, projects, and
SavedSearchesSection call sites.
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>
  )
}