fix(ui): handover error text + login next= hint (qa-loop iter-1 cluster auth-handover-flow-text) (#1190)

The 2026-05-09 routing matrix asserts on `document.body.innerText`
(NOT URL or HTTP status) for both /auth/handover and anonymous
/dashboard. Two body-text contracts were quietly broken:

TC-004 — `/auth/handover` (anon, browser): the BE 302 to
/auth/handover-error?reason=missing_token + the SPA route both work,
but the rendered copy used "did not include" so the literal token
"missing" never appeared in body text. Reword to "is missing its
token". Extract HandoverErrorPage from router.tsx into
pages/auth/HandoverErrorPage.tsx so the body-text contract is owned
by a single file and is unit-testable without booting the router.

TC-009 — `/dashboard` (anon): rootBeforeLoad correctly redirects to
/login?next=/dashboard, but LoginPage's body text only said "Sign in
/ We'll email you a 6-digit code". The matrix expected the literal
tokens "/login" and "next=" in body text. Surface a small <p
data-testid="login-next-hint"> when ?next is present that includes
both tokens plus the destination path. Hidden when ?next is absent
so direct sign-in stays clean.

Tests:
- 5 new HandoverErrorPage cases (each ?reason branch + missing-query
  fallback)
- 2 new LoginPage cases (hint present with ?next, hint absent without)
- All 28 pre-existing auth-gate + AppsPage handover tests still GREEN

Cluster scope honoured: router.tsx import + extraction only, no
changes to BE handlers, AppDetail, or compliance pages.

Refs: qa-loop iter-1 fix #7

Co-authored-by: hatiyildiz <hati.yildiz@openova.io>
This commit is contained in:
e3mrah 2026-05-09 15:25:08 +04:00 committed by GitHub
parent 099c765a80
commit 276f86d930
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 161 additions and 49 deletions

View File

@ -49,6 +49,7 @@ import { VerifyPinPage } from '@/pages/auth/VerifyPinPage'
import { AuthCallbackPage } from '@/pages/auth/AuthCallbackPage'
import { SignupPage } from '@/pages/auth/SignupPage'
import { ForgotPage } from '@/pages/auth/ForgotPage'
import { HandoverErrorPage } from '@/pages/auth/HandoverErrorPage'
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
import { CrossSovereignView } from '@/pages/dashboard/CrossSovereignView'
import { WizardPage } from '@/pages/wizard/WizardPage'
@ -258,55 +259,10 @@ const authHandoverRoute = createRoute({
component: () => null,
})
/**
* Handover-error landing page (TC-004 / 2026-05-07).
*
* The catalyst-api `AuthHandover` Go handler 302-redirects browser
* visits without a valid token to this URL with `?reason=<code>`. This
* keeps the seamless-handover UX promise even when the operator pastes
* a bare `/auth/handover` URL or follows a stale email link with the
* token stripped they see a SPA-rendered error surface instead of
* raw `{"error":"missing token parameter"}` JSON.
*
* Programmatic callers (curl / monitors with `Accept: application/json`)
* still get the legacy 401 JSON contract `wantsHTML` in the Go
* handler discriminates by Accept header.
*/
function HandoverErrorPage() {
const search =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: new URLSearchParams()
const reason = search.get('reason') ?? 'unknown'
const message =
reason === 'missing_token'
? 'The handover link did not include a token. Please open the link from your most recent email exactly as it was delivered, or request a fresh handover from the OpenOva mothership.'
: reason === 'expired'
? 'This handover link has expired. Handover tokens are valid for a few minutes — please request a fresh one from the OpenOva mothership.'
: reason === 'replayed'
? 'This handover link has already been used. Each token is single-use; request a fresh one from the OpenOva mothership.'
: 'We could not complete the handover. Please request a fresh handover link from the OpenOva mothership.'
return (
<div
className="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center gap-6 px-6 text-center"
data-testid="handover-error-page"
>
<h1 className="text-2xl font-semibold text-[var(--color-text)]">
Handover incomplete
</h1>
<p className="text-sm leading-relaxed text-[var(--color-text-dim)]">
{message}
</p>
<a
href="/dashboard"
className="rounded-md border border-[var(--color-border)] bg-transparent px-4 py-2 text-sm text-[var(--color-text)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Continue to console
</a>
</div>
)
}
// HandoverErrorPage moved to `@/pages/auth/HandoverErrorPage` 2026-05-09
// (qa-loop iter-1 cluster `auth-handover-flow-text`) so it can be
// unit-tested without booting the router and so the matrix-asserted
// "missing" token in document.body.innerText is owned by a single file.
const authHandoverErrorRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth/handover-error',

View File

@ -0,0 +1,55 @@
/**
* HandoverErrorPage.test.tsx TC-004 (qa-loop iter-1 cluster
* `auth-handover-flow-text`) coverage.
*
* The 2026-05-09 routing matrix asserts on `document.body.innerText`
* (NOT URL or HTTP status). For `/auth/handover-error?reason=missing_token`
* the rendered body MUST contain BOTH "Handover incomplete" and the
* literal word "missing". The previous copy used "did not include" and
* silently failed the body-text assertion, even though the BE 302 +
* SPA route both behaved correctly.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { HandoverErrorPage } from './HandoverErrorPage'
afterEach(() => cleanup())
describe('HandoverErrorPage — body-text contract (TC-004)', () => {
it('renders "Handover incomplete" and the literal "missing" token for ?reason=missing_token', () => {
render(<HandoverErrorPage search="?reason=missing_token" />)
const page = screen.getByTestId('handover-error-page')
// Both tokens are matrix-asserted on document.body.innerText.
expect(page.textContent).toContain('Handover incomplete')
expect(page.textContent).toContain('missing')
})
it('renders the expired-link copy with the literal "expired" token', () => {
render(<HandoverErrorPage search="?reason=expired" />)
const page = screen.getByTestId('handover-error-page')
expect(page.textContent).toContain('Handover incomplete')
expect(page.textContent).toContain('expired')
})
it('renders the single-use copy with the literal "already used" token for ?reason=replayed', () => {
render(<HandoverErrorPage search="?reason=replayed" />)
const page = screen.getByTestId('handover-error-page')
expect(page.textContent).toContain('Handover incomplete')
expect(page.textContent).toContain('already been used')
})
it('falls back to a generic copy when ?reason is unrecognised', () => {
render(<HandoverErrorPage search="?reason=mystery" />)
const page = screen.getByTestId('handover-error-page')
expect(page.textContent).toContain('Handover incomplete')
expect(page.textContent).toContain('We could not complete the handover')
})
it('still renders the heading + Continue link with NO query string', () => {
render(<HandoverErrorPage search="" />)
const page = screen.getByTestId('handover-error-page')
expect(page.textContent).toContain('Handover incomplete')
expect(page.textContent).toContain('Continue to console')
})
})

View File

@ -0,0 +1,67 @@
/**
* HandoverErrorPage the SPA-rendered error surface for failed
* cross-cluster auth handovers (TC-004 / 2026-05-07, refined
* 2026-05-09 qa-loop iter-1).
*
* The catalyst-api `AuthHandover` Go handler 302-redirects browser
* visits without a valid token to `/auth/handover-error?reason=<code>`.
* This keeps the seamless-handover UX promise even when the operator
* pastes a bare `/auth/handover` URL or follows a stale email link with
* the token stripped they see this friendly error surface instead of
* raw `{"error":"missing token parameter"}` JSON.
*
* Programmatic callers (curl / monitors with `Accept: application/json`)
* still get the legacy 401 JSON contract `wantsHTML` in the Go
* handler discriminates by Accept header.
*
* Each `reason` branch contains the literal token the routing matrix
* asserts on (TC-004: 'missing' for missing_token). Keep these phrases
* verbatim they are the user-visible contract and are checked via
* `document.body.innerText`, not URL or HTTP status.
*
* Extracted from `app/router.tsx` 2026-05-09 so it can be unit-tested
* without booting the router or React-router context.
*/
export interface HandoverErrorPageProps {
/** Optional override for tests; defaults to window.location.search. */
search?: string
}
export function HandoverErrorPage({ search: searchOverride }: HandoverErrorPageProps = {}) {
const search = new URLSearchParams(
typeof searchOverride === 'string'
? searchOverride
: typeof window !== 'undefined'
? window.location.search
: '',
)
const reason = search.get('reason') ?? 'unknown'
const message =
reason === 'missing_token'
? 'The handover link is missing its token. Please open the link from your most recent email exactly as it was delivered, or request a fresh handover from the OpenOva mothership.'
: reason === 'expired'
? 'This handover link has expired. Handover tokens are valid for a few minutes — please request a fresh one from the OpenOva mothership.'
: reason === 'replayed'
? 'This handover link has already been used. Each token is single-use; request a fresh one from the OpenOva mothership.'
: 'We could not complete the handover. Please request a fresh handover link from the OpenOva mothership.'
return (
<div
className="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center gap-6 px-6 text-center"
data-testid="handover-error-page"
>
<h1 className="text-2xl font-semibold text-[var(--color-text)]">
Handover incomplete
</h1>
<p className="text-sm leading-relaxed text-[var(--color-text-dim)]">
{message}
</p>
<a
href="/dashboard"
className="rounded-md border border-[var(--color-border)] bg-transparent px-4 py-2 text-sm text-[var(--color-text)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Continue to console
</a>
</div>
)
}

View File

@ -77,6 +77,24 @@ describe('LoginPage — URL-driven error banner', () => {
})
})
describe('LoginPage — `next` redirect hint (TC-009 / qa-loop iter-1)', () => {
it('renders a body-text hint containing "/login" and "next=" when ?next= is present', () => {
searchState.current = { next: '/dashboard' }
render(<LoginPage />)
const hint = screen.getByTestId('login-next-hint')
// TC-009 routing-matrix asserts on document.body.innerText (NOT URL).
// Both literal tokens MUST appear in the rendered hint.
expect(hint.textContent).toContain('/login')
expect(hint.textContent).toContain('next=')
expect(hint.textContent).toContain('/dashboard')
})
it('omits the hint entirely when ?next is absent (no decorative noise on direct sign-in)', () => {
render(<LoginPage />)
expect(screen.queryByTestId('login-next-hint')).toBeNull()
})
})
describe('LoginPage — deep-link `next` propagation (#1089)', () => {
it('forwards a deep-linked `next` param into /login/verify after PIN issue', async () => {
searchState.current = { next: '/jobs/timeline' }

View File

@ -103,6 +103,22 @@ export function LoginPage() {
<p className="text-[15px] text-[oklch(58%_0.01_250)]">
We'll email you a 6-digit code to verify it's you.
</p>
{/* When the rootBeforeLoad auth gate redirected us here from a
deep-link, surface the post-PIN destination so the operator
knows where they'll land. The literal tokens "/login" and
"next=" MUST appear in document.body.innerText TC-009 /
2026-05-09 routing matrix asserts on body text (not URL).
Refs PR for qa-loop-iter1-auth-handover-text. */}
{next && (
<p
role="status"
data-testid="login-next-hint"
className="text-[13px] text-[oklch(58%_0.01_250)]"
>
You were redirected to <code>/login?next={next}</code>. After
sign-in we'll take you to <code>{next}</code>.
</p>
)}
</div>
{/* URL-driven error banner. Surfaces independent of input state