- `useEmaIdpLoginState` attributed a read to the wrong issuer. A bare `EmaIdpLoginState` carries the previous issuer's answer over to a new one until a fresh read lands — and if that read fails, indefinitely, so the settings surface reports "logged in" for an IdP nobody authenticated against. Round 1's `.catch()` made that worse by design, and the test added with it asserted the wrong behaviour outright. State is now the pair (issuer, value) and `loginState` derives from it, so a mismatch reports "none". A monotonic read token — the device `useInitialConfig` already uses — drops completions that were overtaken, including a read still in flight when `logout` clears the session; issuer keying cannot catch those, since both carry the same issuer. Both guards are pinned by tests verified to fail without them. Deriving the value also removed the last synchronous commit on that path, so the `set-state-in-effect` suppression is gone — one fewer than the PR started with. - The draft-seeding change had no test that could tell it from the effect it replaced: the regression is a committed frame, and RTL flushes passive effects before any assertion on `result.current`. Both hooks now have a commit-level trace recorded from inside the tree, asserting the previous target's (or session's) draft is never committed for the new one. Both were verified to fail against the effect implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
108 lines
4.5 KiB
TypeScript
108 lines
4.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { OAuthStorage } from "../auth/storage.js";
|
|
import {
|
|
clearEmaIdpSession,
|
|
getEmaIdpLoginState,
|
|
normalizeIdpIssuer,
|
|
type EmaIdpLoginState,
|
|
} from "../auth/ema/idpSession.js";
|
|
|
|
export interface UseEmaIdpLoginStateResult {
|
|
loginState: EmaIdpLoginState;
|
|
refresh: () => Promise<void>;
|
|
logout: () => void;
|
|
}
|
|
|
|
/**
|
|
* What a completed read told us, and *which issuer* it was told about.
|
|
*
|
|
* The pair is stored together rather than as a bare `EmaIdpLoginState`
|
|
* because a login state is only ever meaningful for the issuer it was read
|
|
* for. With a bare value, changing issuer carries the previous issuer's
|
|
* answer over to the new one until a fresh read lands — and if that read
|
|
* fails, indefinitely, so the settings surface reports "logged in" for an
|
|
* IdP nobody has authenticated against.
|
|
*/
|
|
interface ReadResult {
|
|
issuer: string;
|
|
value: EmaIdpLoginState;
|
|
}
|
|
|
|
/**
|
|
* Read and clear EMA IdP session state for Client Settings UX.
|
|
* Pass `active: true` while the settings surface is open to refresh on open.
|
|
*/
|
|
export function useEmaIdpLoginState(
|
|
storage: OAuthStorage,
|
|
issuer: string | undefined,
|
|
active: boolean,
|
|
): UseEmaIdpLoginStateResult {
|
|
const normalizedIssuer = issuer ? normalizeIdpIssuer(issuer) : "";
|
|
const [read, setRead] = useState<ReadResult>({ issuer: "", value: "none" });
|
|
|
|
/**
|
|
* Monotonic read token — the same device `useInitialConfig` uses. Reads are
|
|
* unordered, so an issuer flipped A → B → A can have the first A read
|
|
* resolve last; a completion may commit only while it still holds the
|
|
* current token. `logout` claims one too, so a read already in flight when
|
|
* the session is cleared cannot land on top of the clear.
|
|
*/
|
|
const generation = useRef(0);
|
|
|
|
// Derived rather than stored: with no issuer there is nothing to be logged
|
|
// in to, and a result read for some *other* issuer says nothing about this
|
|
// one. A mismatch therefore reports "none" instead of the previous issuer's
|
|
// answer, which is also what makes the failure path below safe.
|
|
const loginState: EmaIdpLoginState =
|
|
normalizedIssuer && read.issuer === normalizedIssuer ? read.value : "none";
|
|
|
|
const refresh = useCallback(async () => {
|
|
const mine = ++generation.current;
|
|
if (!normalizedIssuer) return;
|
|
const value = await getEmaIdpLoginState(storage, normalizedIssuer);
|
|
if (generation.current !== mine) return;
|
|
setRead({ issuer: normalizedIssuer, value });
|
|
}, [storage, normalizedIssuer]);
|
|
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
// Reading the persisted IdP session *is* the external system this effect
|
|
// exists to synchronize with, and no commit happens here: `refresh`
|
|
// returns before its first `await` when there is no issuer, and otherwise
|
|
// commits in a continuation. That is also why this needs no
|
|
// `set-state-in-effect` suppression, where `useServers` and
|
|
// `useInitialConfig` do — deriving `loginState` from an issuer-keyed read
|
|
// took the last synchronous setState out of the path.
|
|
refresh().catch(() => {
|
|
// `refresh` does not own its failures: `getEmaIdpLoginState` awaits
|
|
// `storage.getIdpSession`, which rejects when the backend is
|
|
// unreachable. Swallow it here so it does not surface as an unhandled
|
|
// rejection, and commit nothing — the issuer keying above is what makes
|
|
// that safe. A failed read for a *new* issuer reports "none", because
|
|
// no result for that issuer exists; a failed re-read of the issuer
|
|
// already on screen keeps the last answer we actually got, which is the
|
|
// right reading of a transient storage outage.
|
|
});
|
|
}, [active, refresh]);
|
|
|
|
const logout = useCallback(() => {
|
|
if (!normalizedIssuer) return;
|
|
// Supersede any read in flight: it was started before the session was
|
|
// cleared, so its answer is already wrong by the time it resolves.
|
|
const mine = ++generation.current;
|
|
void clearEmaIdpSession(storage, normalizedIssuer)
|
|
.then(() => {
|
|
if (generation.current !== mine) return;
|
|
setRead({ issuer: normalizedIssuer, value: "none" });
|
|
})
|
|
.catch(() => {
|
|
// Clearing the persisted IdP session failed (e.g. the storage backend
|
|
// is unreachable). Swallow the rejection so it does not surface as an
|
|
// unhandled promise, and leave the last read in place so the UI keeps
|
|
// reflecting the still-present session rather than falsely showing
|
|
// signed-out.
|
|
});
|
|
}, [storage, normalizedIssuer]);
|
|
|
|
return { loginState, refresh, logout };
|
|
}
|