Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/adr/0109-oidc-deep-link-state-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ when the member's OIDC session is otherwise valid.
recovery fallback, not an authentication or authorization store.
- On callback, remove the key from both stores and use session storage before
local storage. Reject external and protocol-relative URLs.
- Validate the browser-parsed origin as well as the leading slash: backslashes
and embedded whitespace must not turn a path into another authority. Invalid
URL syntax fails closed. Strip authorization success and error response
parameters before storing, restoring, or sharing a path (RFC 6749 sections
4.1.2 and 4.1.2.1); retain application parameters and fragments.
- A failed sign-in keeps an explicit retry action (ADR 0220). Before retrying,
consume the existing return-path fallback and remember its sanitized value
again so a failed callback does not replace the intended post destination.
- Keep member language preference account-scoped in
`user_account.preferred_locale`; this ADR does not move locale state into the
post URL, browser storage, or a `user_account + post_id` key.
Expand All @@ -33,3 +41,10 @@ Opening a shared post link survives a missing OIDC state payload or a changed
storage context without losing the post. A stale internal return path is
removed at callback, and authorization still comes only from the authenticated
OIDC token and backend ABAC checks.

## References — APA 7th

Hardt, D. (Ed.). (2012). *The OAuth 2.0 authorization framework* (RFC 6749).
Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6749.html

WHATWG. (n.d.). *URL standard*. https://url.spec.whatwg.org/
25 changes: 25 additions & 0 deletions docs/adr/0220-token-backed-status-notice.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ The first migrated product flow is the Calendar Naruon fail-closed path.
Do not copy closed-branch exception classes or Storybook inventories from
PR #490. Later unavailable flows migrate one at a time.

### Sign-in recovery

A failed sign-in or an incomplete authenticated session keeps the existing
login card, with product heading, a bounded retry notice, and one **Log in**
action. Never display the authentication library's error message or credential
details. Reuse the existing translated failure message and button label; add
no translation catalog or identity transport. The action retries the existing
OIDC flow with the sanitized destination from ADR 0109. Loading retains the
existing live region and offers no duplicate submission action; successful
authentication continues into the authorized workspace.

Reuse the login layout and `StatusNotice` tokens, semantic alert, native
button, keyboard behavior, and minimum control size. Storybook records the
sign-in retry variant, and browser checks cover desktop and narrow layouts.
The login card includes padding and borders in its declared width, so the
page's overflow rule cannot hide clipped card edges on narrow screens.
The existing Figma file `1Su3lDRmiZdcUs47t1QwIX`, page `0:1`, was inspected on
2026-09-07: its Event Lineage and Ask Agent frames do not define a sign-in
screen. This repair therefore makes no sign-in Figma parity claim.

This migration supplies a recovery action for the existing login journey.
Product-owned login, enrollment, and account recovery forms still require the
released Keyverse contracts specified by the product goal; a retry button
does not establish those contracts.

## Consequences

- Calendar names the missing Naruon projection and the next action in one
Expand Down
70 changes: 70 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,75 @@
# Product & Technical Gap Baseline

## 2026-09-07: Recover interrupted sign-in without losing the destination

This dated observation supersedes earlier loop summaries for this scope only.
Protected `main` was `83eba56149eb802cd63642c507c324c9976ec78e` at inspection.
The repair is proposed on `codex/signin-recovery-20260907`; the original
translation-ledger checkout and its uncommitted work were left intact.

**Observed failure.** In an isolated browser session, an unavailable loopback
issuer reduced the entire login screen to `Failed to fetch`, with no action
to recover. The other incomplete-session branch exposed credential plumbing.
The shared return-path helper also retained authorization error parameters
and accepted malformed paths that browser URL parsing interpreted as another
authority. This helper serves callback restoration, login, and shared post
links, so the correction belongs at that common boundary (ADR 0109).

**Repair and KPI.** Test revision `fb4d11435` reproduced six failing URL cases
out of twelve and two failing App recovery cases out of four. The first
combined baseline encountered a worker-start timeout; a separate App run with
the installed thread pool reproduced both failures. Implementation
`4f17693b9` passes all sixteen focused cases: failure count **8 → 0**.
It reuses ADR 0220's retry notice, existing translated copy, and the real OIDC
redirect. A retry consumes and re-saves the sanitized destination; it does
not replace a remembered post with a failed callback. Loading retains its
single live region. No new dependency, issuer, or translation catalog is added.

```sh
corepack pnpm --dir frontend exec vitest run src/App.test.tsx src/oidcReturnUrl.test.ts --pool=threads --maxWorkers=1 -t 'App, unauthenticated|OIDC return URL|stripOidcCallbackParams'
```

**Browser and design evidence.** The local frontend ran on port 15274 with
`VITE_KEYVERSE_ISSUER=http://127.0.0.1:15999` intentionally unavailable. No
credentials or source records were supplied. After the correction, the Korean
retry notice remained available after repeated failures; Tab reached Log in
and Enter retried. At 390px, geometry inspection found the existing login card
clipped eight pixels on each side despite no document-level horizontal
overflow. Revision `5970c4270` includes padding and borders in the card width.
At 320px, its bounds are 21–299px, with readable wrapping and no horizontal
overflow. Desktop and narrow screenshots were inspected in the actual browser.
The existing Figma page has no sign-in frame (ADR 0220 records the file/page
IDs); no sign-in design-parity claim is made. `Chrome/StatusNotice/SignInRetry`
records the recovery action; its three interaction steps passed in the built
Storybook browser. Lint, production build, Storybook build, and five
documentation checks passed. The expanded recovery checks pass in all five
current locales, including keyboard activation. The three-file regression run
at `221df2281` completed with **53 passed, 73 failed (126 total)**: the failures
are in existing authenticated journeys, predominantly test deadlines, with
additional element-lookup failures. The run took 938 seconds; concurrent host
load exceeded 60 and swap use exceeded 44 GB. Contention is a hypothesis, not
proof that the failures are harmless. A four-case recheck passed three and
retained one timeout. That remaining case also failed in a paired experiment
using protected-main App and return-path source; temporary experiment files
were removed. The repository skips Tests jobs for drafts and has no manual
dispatch, so the ready-for-review event admits the normal checks. It does not
establish merge readiness. Required checks must verify the authenticated
journeys; do not raise their limits, skip them, or claim a full regression pass.

**Remaining acceptance gaps.** This failure-path browser check does not prove
successful authentication, deployed behavior, eight-locale database delivery,
or the all-page p95 ≤20ms goal. None is claimed. Keyverse protected `main`
`7d9151cd2da260e118020c938c7358e2ee75d541` had no published release or tag at
inspection. It implements an authorization-code/PKCE flow and server-mediated
signup enrollment, but no verified product-facing recovery contract. The
adjacent [Keyverse #128](https://github.com/ContextualWisdomLab/keyverse/pull/128)
remains a draft authentication migration; its fail-closed password endpoint
does not fulfill product-owned forms.
[Keyverse #100](https://github.com/ContextualWisdomLab/keyverse/pull/100) is the
separate LineageWeave claims prerequisite. Complete and release those owner
contracts before consumer adoption. The existing eight-locale translation
ledger work remains separate from this recovery repair.

> Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is
> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map
> explained leftover share, #775). Open ready PRs still lack independent
Expand Down
2 changes: 1 addition & 1 deletion docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ operator-facing control you can click before changing product CSS.
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
| `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` |
| `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` |
| `Chrome/StatusNotice` | Read success, unavailable, or retry copy, then take the named next action. Success and unavailable are a named region (not live `role=status`); Retry is `role=alert` and only on the retry kind. Calendar's missing Naruon projection uses unavailable. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` |
| `Chrome/StatusNotice` | Read success, unavailable, or retry copy, then take the named next action. Success and unavailable are a named region (not live `role=status`); Retry is `role=alert` and only on the retry kind. Calendar's missing Naruon projection uses unavailable. SignInRetry provides the Log in action after a failed sign-in. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
| `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` |
Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
}

.login-card {
box-sizing: border-box;
width: 100%;
max-width: 420px;
padding: 2.5rem 2rem;
Expand Down
35 changes: 33 additions & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App, { SurfaceBoundary } from "./App";
import { optionalKnowledgeCutoffIso } from "./api";
import { setLocale } from "./i18n";
import { setLocale, SUPPORTED_LOCALES, t } from "./i18n";
import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl";

const signinRedirect = vi.fn();
Expand Down Expand Up @@ -74,6 +74,36 @@ it("announces a lazy surface load failure with a recovery action", () => {


describe("App, unauthenticated", () => {
it.each(SUPPORTED_LOCALES)("offers a safe sign-in retry and retains the pre-callback destination (%s)", async (locale) => {
setLocale(locale);
const privateError = "synthetic-private-auth-response";
window.history.replaceState({}, "", `/?error=access_denied&error_description=${privateError}&state=stale`);
window.sessionStorage.setItem(OIDC_RETURN_URL_STORAGE_KEY, "/?post=remembered#evidence");
mockAuth = { ...mockAuth, error: new Error(privateError) };

render(<App />);

expect(screen.getByRole("alert")).toHaveTextContent(t("This request failed. Retry the same action."));
expect(screen.queryByText(privateError)).not.toBeInTheDocument();
await userEvent.tab();
expect(screen.getByRole("button", { name: t("Log in") })).toHaveFocus();
await userEvent.keyboard("{Enter}");
expect(signinRedirect).toHaveBeenCalledWith({ state: { returnUrl: "/?post=remembered#evidence" } });
expect(window.localStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY)).toBe("/?post=remembered#evidence");
});

it("offers sign-in recovery when an authenticated session has no access token", async () => {
window.history.replaceState({}, "", "/?post=abc&error=access_denied#evidence");
mockAuth = { ...mockAuth, isAuthenticated: true };

render(<App />);

expect(screen.getByRole("alert")).toHaveTextContent("This request failed. Retry the same action.");
expect(screen.queryByText(/access token/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Log in" }));
expect(signinRedirect).toHaveBeenCalledWith({ state: { returnUrl: "/?post=abc#evidence" } });
});

it("shows a login button that starts the real OIDC redirect", async () => {
window.history.replaceState({}, "", "/?post=abc#evidence");
render(<App showLabPanels />);
Expand All @@ -93,9 +123,10 @@ describe("App, unauthenticated", () => {
});

it("announces the app-root auth loading gate as a live region", () => {
mockAuth = { ...mockAuth, isLoading: true };
mockAuth = { ...mockAuth, isLoading: true, error: new Error("previous failure") };
render(<App showLabPanels />);
expect(screen.getByRole("status")).toHaveTextContent("Loading authentication state...");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});

Expand Down
35 changes: 19 additions & 16 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody";
import { LineageEntityPicker } from "./components/LineageEntityPicker";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { TeppAcceptedReceipt } from "./components/TeppAcceptedReceipt";
import { StatusNotice } from "./components/StatusNotice";
import { chatEvidenceKindLabel } from "./evidenceKindLabels";
import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav";
import { OccupationRatingProfile } from "./components/OccupationRatingProfile";
Expand All @@ -107,6 +108,7 @@ import { isFocusableVisible } from "./focusVisibility";
import { subgraphForPost } from "./lineageLayout";
import {
rememberOidcReturnUrl,
restoreOidcReturnUrl,
returnUrlFromLocation,
stripOidcCallbackParams,
} from "./oidcReturnUrl";
Expand Down Expand Up @@ -5301,11 +5303,13 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
return <p role="status">{t("Loading authentication state...")}</p>;
}

if (auth.error) {
return <p className="error">{t(auth.error.message)}</p>;
}

if (!auth.isAuthenticated) {
if (auth.error || !auth.isAuthenticated || !accessToken) {
const needsRetry = Boolean(auth.error || auth.isAuthenticated);
const signIn = () => {
const returnUrl = needsRetry ? restoreOidcReturnUrl(undefined) : returnUrlFromLocation();
rememberOidcReturnUrl(returnUrl);
void auth.signinRedirect({ state: { returnUrl } });
};
return (
<div className="app-shell">
<main className="login-screen">
Expand All @@ -5315,13 +5319,16 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
<p className="login-subtitle">Marketing & Operational Lineage Intelligence</p>
</div>
<div className="login-controls">
<button className="btn-primary" onClick={() => {
const returnUrl = returnUrlFromLocation();
rememberOidcReturnUrl(returnUrl);
void auth.signinRedirect({ state: { returnUrl } });
}}>
{t("Log in")}
</button>
{needsRetry ? (
<StatusNotice
kind="retry"
message={t("This request failed. Retry the same action.")}
retryLabel={t("Log in")}
onRetry={signIn}
/>
) : (
<button className="btn-primary" onClick={signIn}>{t("Log in")}</button>
)}
</div>
<div className="login-help">
<small>Enterprise SSO Authentication</small>
Expand All @@ -5340,10 +5347,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
);
}

if (!accessToken) {
return <p className="error">{t("Authenticated, but no access token was returned.")}</p>;
}

return (
<div className="app-shell">
<header className="app-header">
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/StatusNotice.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,18 @@ export const Retry: Story = {
await expect(args.onRetry).toHaveBeenCalledTimes(1);
},
};

export const SignInRetry: Story = {
args: {
kind: "retry",
message: "This request failed. Retry the same action.",
retryLabel: "Log in",
onRetry: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent("Retry needed");
await userEvent.click(canvas.getByRole("button", { name: "Log in" }));
await expect(args.onRetry).toHaveBeenCalledTimes(1);
},
};
Loading
Loading