ERA-13478: Post-PKCE account-linking gate consumption + finalizing flag#1603
Conversation
🚀 PR Environment Deployed
Access: https://era-13478.dev.pamdas.org |
There was a problem hiding this comment.
Pull request overview
Implements the client-side “Phase 2” account-linking gate for Auth0 PKCE logins on common-DB (require_idp && !idp_org_id) sites, including a short-lived view.auth0CallbackInProgress flag so route-guards show a loading overlay during post-callback finalization instead of bouncing to /login.
Changes:
- Added an account-linking gate client (
GET /api/v1.0/user/linked) with result classification and a reducer-backed “callback in progress” flag. - Updated Auth0 PKCE callback handling to consult the gate before entering authenticated SPA state, including handoff to a server-supplied link-accounts URL and retryable login error surfacing.
- Updated
RequireAccessTokenand tests to respect the finalization flag and show the loading overlay during the in-flight window; added new i18n string across all locales.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/navigation.js | Adds a wrapper for full-page redirects to support unit testing. |
| src/RequireAccessToken/index.js | Uses auth0CallbackInProgress to avoid deep-link clobber and show loading overlay during finalization. |
| src/RequireAccessToken/index.test.js | Adds coverage ensuring the overlay is shown while the gate is in-flight. |
| src/reducers/index.js | Wires view.auth0CallbackInProgress into the root reducer (non-persisted). |
| src/Login/index.js | Surfaces a retryable “sign-in incomplete” alert via router state. |
| src/ducks/account-linking.js | Introduces gate request/classification logic plus auth0CallbackInProgress action+reducer. |
| src/ducks/account-linking.test.js | Tests gate classification behavior and reducer toggling. |
| src/Auth0TokenManager/index.js | Orchestrates post-PKCE token acquisition + gate consult + redirect/login outcomes, managing the finalizing flag lifecycle. |
| src/Auth0TokenManager/index.test.js | Adds tests for 204/200/400/transient/org-scoped behaviors and flag lifecycle. |
| public/locales/en-US/login.json | Adds errorAlert.signInIncomplete. |
| public/locales/es/login.json | Adds errorAlert.signInIncomplete. |
| public/locales/fr/login.json | Adds errorAlert.signInIncomplete. |
| public/locales/ne-NP/login.json | Adds errorAlert.signInIncomplete. |
| public/locales/pt/login.json | Adds errorAlert.signInIncomplete. |
| public/locales/sw/login.json | Adds errorAlert.signInIncomplete. |
New checkAccountLinked() consults GET /user/linked and classifies the response as LINKED (204), UNLINKED (200 + link URL), INVALID (400), or TRANSIENT (network/5xx/timeout/cancellation). The JWT is attached per-call and the request bypasses the global 401 handler. Also adds the non-persisted view.auth0CallbackInProgress flag and wires its reducer into the store. Unused by the UI until later commits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RequireAccessToken now renders the loading overlay while view.auth0CallbackInProgress is set, and skips writing the intended post-login route during that window. This covers the post-callback gate round-trip added later so the user is not bounced to /login (clobbering their deep link) before the SPA enters its authenticated state. No-op until the flag is set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Login reads location.state.authLinkingError and shows a retryable message; adds errorAlert.signInIncomplete to every locale. Inert until the gate routes unlinked/transient cases here in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the common-DB path (require_idp and no IdP org), Auth0TokenManager consults the account-linking gate before entering the authenticated state: 204 proceeds, 200 hands off to the server-owned link page, 400 clears the SDK and SPA token state and restarts login, and a transient failure leaves a retryable error at /login. Org-scoped (rcuksa) logins skip the gate. The finalizing flag is set at branch entry and cleared in a finally so it can never hang the SPA on the loading overlay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
luixlive
left a comment
There was a problem hiding this comment.
A bunch of comments, some of them are nits and very simple. But some require a second pass. Let's reduce the amount of comments. They're a lot 😅 Was even complicated to follow the code flow without getting distracted by them.
| import { useDispatch, useSelector } from 'react-redux'; | ||
| import { useLocation } from 'react-router'; | ||
| import { POST_AUTH_SUCCESS } from '../ducks/auth'; | ||
| import { clearAuth, POST_AUTH_SUCCESS } from '../ducks/auth'; |
There was a problem hiding this comment.
nit: Imports and hooks in this file have grown, and they have became a big block. We can organize them a bit better by separating external from internal dependencies and sorting alphabetically:
import { useEffect, useRef } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router';
import appConfig from '../config';
import { checkAccountLinked, GATE_RESULT, setAuth0CallbackInProgress } from '../ducks/account-linking';
import { clearAuth, POST_AUTH_SUCCESS } from '../ducks/auth';
import {
clearIntendedPostAuth0SuccessRoute,
getIntendedPostAuth0SuccessRoute,
isValidTokenFormat,
stripAuth0Params,
} from '../utils/auth';
import { hasAuth0CallbackParams } from '../utils/auth0';
import { REACT_APP_ROUTE_PREFIX } from '../constants';
import { redirectToExternalUrl } from '../utils/navigation';
import useNavigate from '../hooks/useNavigate';Same with hooks, separating by responsibilities:
const dispatch = useDispatch();
const location = useLocation();
const navigate = useNavigate();
const existingToken = useSelector((state) => state.data.token?.access_token);
const idpOrgId = useSelector((state) => state.view.systemConfig?.idp_org_id);
const requireIdp = useSelector((state) => !!state.view.systemConfig?.require_idp);
const { isAuthenticated, getAccessTokenSilently, logout } = useAuth0();
const hasHandledCallback = useRef(false);
const sawAuth0Params = useRef(false);There was a problem hiding this comment.
Done in d0ecd9b3 — imports grouped external-then-internal (alphabetical by path), and the hooks grouped by responsibility per your suggestion.
| navigate(`${REACT_APP_ROUTE_PREFIX}login`, { replace: true, state: { authLinkingError: true } }); | ||
| return; | ||
| } | ||
| // result === GATE_RESULT.LINKED → fall through and proceed. |
There was a problem hiding this comment.
Good eye — it reads like dead code, but it's clarifying the LINKED fall-through (none of the gate branches return, so control falls through to the sign-in below). Reworded to prose in d0ecd9b3 so it no longer looks like a commented-out expression.
| expect(mockLogout).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('200 (unlinked) with a missing URL: routes to login with a retryable error', async () => { |
There was a problem hiding this comment.
Wrong test: UNLINKED is only returned when isHttpUrl passes. We are testing an impossible path using mocks, which can lead to confusion.
There was a problem hiding this comment.
Right. Once the gate validates the URL, UNLINKED only ever carries a usable http(s) URL, so that else branch and its test were unreachable. Removed both in 2b9b38e5 — a bad/absent URL now lands at /login via the TRANSIENT path.
| // token.access_token, which has not run yet at the gate-call point (the SPA has | ||
| // not dispatched POST_AUTH_SUCCESS). skipAuth keeps the gate off the global 401 | ||
| // recovery in RequestConfigManager — the gate owns its own error handling. | ||
| export const checkAccountLinked = async (accessToken) => { |
There was a problem hiding this comment.
Hmm this file has some anti-patterns. It's a duck file, which is supposed to hold a piece of Redux state and its handling, but we have this method here checkAccountLinked that is totally unrelated to the store. It doesn't read redux, it doesn't dispatch actions. The response of the axios request is just returned, not even stored. This should either be an independent utility, or properly store the retrieved data in the store, but only if it's truly necessary.
All the reducer does is handle a boolean inProgress, which I'm not sure if it really needs to be handled through Redux. If it does, it probably belongs in src/ducks/auth.js.
There was a problem hiding this comment.
Agreed. The finalizing-flag reducer is gone now (3c426a6c — see the RequireAccessToken thread), so there's no Redux left in the module; moved checkAccountLinked to src/utils/account-linking.js in de268489.
| // Surface a retryable error when the post-Auth0 account-linking gate could | ||
| // not complete — a transient gate failure, or a missing link URL on the | ||
| // unlinked branch. Auth0TokenManager routes those cases here via router state. | ||
| useEffect(() => { |
There was a problem hiding this comment.
Right now the dependency array of this useEffect listens to all changes to location.state. We should scope it only to what we need:
const authLinkingError = location.state?.authLinkingError;
useEffect(() => {
if (authLinkingError) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAlertMessage(t('errorAlert.signInIncomplete'));
}
}, [authLinkingError, t]);Now, the eslint warning tells us that probably we don't need a useEffect for this. We could simply initialize the error message from the location state and completely remove this useEffect:
const [alertMessage, setAlertMessage] = useState(() =>
location.state?.authLinkingError ? t('errorAlert.signInIncomplete') : null
);This code reads better and is more performant because it doesn't cause rerenders or flickering from the alert message since it's set immediately.
There was a problem hiding this comment.
Done in 610ba9e8 — alertMessage now initializes from location.state via a useState initializer, so the effect (and its react-hooks/set-state-in-effect disable) are gone and there's no extra render / flicker.
| }; | ||
|
|
||
| const mapStateToProps = ({ data: { token }, view: { systemConfig } }) => ({ token, systemConfig }); | ||
| const mapStateToProps = ({ data: { token }, view: { systemConfig, auth0CallbackInProgress } }) => |
There was a problem hiding this comment.
Probably this is a good time to get rid of mapStateToProps. Let's use useSelector instead for all these variables. If you ask Claude it will know what to do 😄
There was a problem hiding this comment.
Removing the finalizing flag reverted this file to its original develop form, so this PR no longer touches it. I'd rather not pull an untouched component into the PR as a pure refactor, so I'm leaving the connect→useSelector conversion out of scope here.
|
|
||
| // Drive a full-page (non-SPA) browser navigation. Wrapped so callers can be | ||
| // unit-tested without depending on jsdom's non-configurable window.location. | ||
| export const redirectToExternalUrl = (url) => { |
There was a problem hiding this comment.
We should add a simple unit test to avoid breaking the simple functionality of this function 👍
There was a problem hiding this comment.
Added in 0411a49d. (jsdom locks down window.location — it's non-configurable and assign isn't spyable — so the test asserts the href assignment via an observable hash fragment.)
| if ((requireIdp && auth0Loading) || hasAuth0Params) { | ||
| // Show loading during Auth0 callback, while Auth0 is processing, or while the | ||
| // post-callback account-linking gate is in flight. | ||
| if ((requireIdp && auth0Loading) || hasAuth0Params || auth0CallbackInProgress) { |
There was a problem hiding this comment.
From a Claude review:
The dispatch(setAuth0CallbackInProgress(true)) runs in a useEffect (after paint), so there is one committed render where auth0Loading, hasAuth0Params, and auth0CallbackInProgress are all false simultaneously — RequireAccessToken renders in that frame, and the setIntendedPostAuth0SuccessRoute effect fires with the cleaned callback URL, overwriting the original deep link in localStorage before the gate resolves.
Seems like a possible bug that will navigate the user to login unexpectedly. Not sure if it's a real bug, but worth asking an agent to investigate and fix if necessary.
There was a problem hiding this comment.
Investigated this closely — it's not a real bounce, and the reason is subtle.
The premise is that hasAuth0Params is false during the gate's in-flight window. It isn't: react-router's useLocation never observes the Auth0 SDK's param strip. The SDK clears ?code&state with a raw window.history.replaceState (no popstate), and react-router only re-syncs on popstate / its own navigate() — so the params persist in useLocation() until we navigate ourselves. That keeps RequireAccessToken on the LoadingOverlay (the || hasAuth0Params branch) for the whole round-trip: no <Navigate to="/login">, no intended-route clobber.
That said, your instinct was half-right — a flag set in an effect genuinely can't cover the first post-auth render, so auth0CallbackInProgress was redundant (hasAuth0Params was already doing the work). Removed it entirely in 3c426a6c (which also addresses the "does this need Redux" thread). Added two tests: a unit test pinning the react-router-vs-replaceState behavior the guarantee rests on, and an integration test (real router + store) proving no /login bounce or deep-link clobber across the gate window — verified falsifiable (it fails if the overlay guard regresses).
The post-Auth0-callback overlay is held by hasAuth0Params for the whole gate round-trip: react-router never observes the SDK's raw history.replaceState (no popstate), so ?code&state persist in useLocation() until the client itself navigates. The auth0CallbackInProgress flag (and the handingOff guard) were therefore redundant for bounce-prevention, so remove them — from the account-linking module, the store wiring, Auth0TokenManager, and RequireAccessToken (which reverts to its prior form). Adds an integration test (real router + store) proving the post-callback window holds the overlay, never bounces to /login, preserves the deep link, and proceeds on 204; plus a unit test pinning the react-router vs. raw-replaceState behavior the guarantee rests on. Addresses PR #1603 review: the possible-bounce concern and the flag-necessity question. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
checkAccountLinked touches no Redux (no store read, no dispatch — it just returns the classified gate response), so it doesn't belong in a duck. With the finalizing-flag reducer gone, the module has no store state at all; move it (and its test) to src/utils/account-linking.js and repoint Auth0TokenManager's imports. Addresses PR #1603 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…andler checkAccountLinked classifies a 200 with a non-http(s) body as TRANSIENT, so it only ever returns UNLINKED with a validated URL. Auth0TokenManager's else-branch for a missing linkUrl was therefore dead, and its unit test mocked an impossible result; remove both. (A genuinely bad/absent URL still lands at /login via the TRANSIENT path.) Addresses PR #1603 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group imports external-then-internal (alphabetical by path) and group the hooks by responsibility, per review. Reword the LINKED fall-through comment so it no longer reads like commented-out code, drop a couple of comments that just restated the code, and condense the gate-branch comments to one line each (keeping the WHY). No behavior change. Addresses PR #1603 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ffect Initialize alertMessage with a useState initializer reading location.state.authLinkingError instead of a setState-in-effect. Removes the effect (and its react-hooks/set-state-in-effect disable) and avoids the extra render / alert flicker. Addresses PR #1603 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards the navigation wrapper's one job — assigning its argument to window.location. jsdom only applies same-document (hash) navigations observably, so the test asserts the href assignment via a hash fragment. Addresses PR #1603 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| if (result === GATE_RESULT.INVALID) { | ||
| logout({ openUrl: false }).catch(() => {}); | ||
| dispatch(clearAuth()); | ||
| navigate(`${REACT_APP_ROUTE_PREFIX}login`, { replace: true }); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Not an issue on the installed version: @auth0/auth0-react@2.17.0 types logout as (options?: LogoutOptions) => Promise<void> (dist/auth0-context.d.ts), so .catch() is valid here. (v1's logout returned void — that's likely the mismatch.) Leaving the code as-is.
| // Account-linking gate — common-DB path only; org-scoped (rcuksa) sites skip it. | ||
| if (requireIdp && !idpOrgId?.trim()) { | ||
| const { result, linkUrl } = await checkAccountLinked(safe); |
There was a problem hiding this comment.
Good catch — the description was stale. Updated it. The view.auth0CallbackInProgress flag was removed in 3c426a6c; as you note, the no-bounce behavior relies on hasAuth0Params staying truthy because react-router never observes the SDK's raw history.replaceState.
|
Thanks for the thorough review @luixlive (and Copilot) 🙏 — recap of what changed since the review. All pushed; The substantive one: the
|
luixlive
left a comment
There was a problem hiding this comment.
A couple more comments, but nothing to block since are suppositions by just reading the code. I trust on your own testing of the implementation. But worth taking a look. Approved 👍
|
|
||
| // Auth0 callback path: process when we saw params AND user is now authenticated | ||
| if (sawAuth0Params.current && isAuthenticated && !hasHandledCallback.current) { | ||
| hasHandledCallback.current = true; |
There was a problem hiding this comment.
I think we could leave the user in a locked in certain scenarios.
We set hasHandledCallback.current = true;, but then we have some scenarios like TRANSIENT gate result, isValidTokenFormat returning false, getAccessTokenSilently throwing, where we expect a retry, but it would be impossible since hasHandledCallback.current is already true. Am I missing something?
There was a problem hiding this comment.
Not a lock-in — the only retry from those failure paths is the /login "Sign in" button → loginWithRedirect, which is a full-page navigation to Auth0 and back. That reloads the SPA and remounts Auth0TokenManager, so hasHandledCallback (a useRef) reinitializes to false and the callback is processed fresh.
The ref intentionally stays true on the in-SPA failure paths: if we reset it instead, the effect would re-enter immediately on the /login navigation (sawAuth0Params and isAuthenticated are both still true) → re-run the gate → fail → re-navigate → loop. So keeping it true is the correct behavior; the remount is what enables the retry.
| // Unusable token: clear the SDK session + SPA token state, restart login. | ||
| if (result === GATE_RESULT.INVALID) { | ||
| logout({ openUrl: false }).catch(() => {}); | ||
| dispatch(clearAuth()); |
There was a problem hiding this comment.
Shouldn't we await for clearAuth to finish before navigating away?
There was a problem hiding this comment.
Left as-is here, on purpose: this branch runs before we enter the authenticated state (the gate is consulted before any POST_AUTH_SUCCESS), so there's no redux/token state to flush — clearAuth is purely defensive and its cookie delete is synchronous, and /login re-clears on mount regardless. handle401Errors awaits because it fires after a genuine authenticated session, which is a different situation. So the logout() / clearAuth() cleanup can run in the background; the redirect doesn't need to block on it.
| TRANSIENT: 'TRANSIENT', // network error / 5xx / timeout / cancellation | ||
| }; | ||
|
|
||
| // A gate hand-off drives a full-page navigation, so only an http(s) URL is a |
There was a problem hiding this comment.
Claude detected this method to be a re-implementation of getIsValidWebUrl from src/utils/string.js.
Also, I still see a lot of comments in this file.
There was a problem hiding this comment.
Good catch — isHttpUrl was byte-for-byte the same as getIsValidWebUrl (src/utils/string.js, already used by the SchemaForm Text element), so I dropped it and reused the shared util (and trimmed this file's comments) in 5676d78. Coverage is unchanged: the account-linking tests already exercise the validation (200 + non-http / non-URL → TRANSIENT), and getIsValidWebUrl has its own unit tests.
isHttpUrl was identical to the existing getIsValidWebUrl (src/utils/string.js, already used elsewhere), so drop it and reuse the shared util. Also trim the comments in this file per review. No behavior change — account-linking and Auth0TokenManager suites still green. Addresses PR #1603 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What does this PR do?
Implements the web side of the Phase 2 account-linking gate (subtask of ERA-13475). After a successful Auth0 PKCE round-trip on a common-DB site (
require_idp && idp_org_id == null), the SPA consultsGET /api/v1.0/user/linkedbefore entering its authenticated state and routes the user accordingly:POST_AUTH_SUCCESS+ navigate)text/plainURL in body) → full-page navigation to the server-supplied Link Accounts URL (the SPA does not hardcode/auth/link-accounts/)logout({ openUrl: false })) + SPA token state, return to/login/loginwith a retryable error, token untouchedDuring the gate's in-flight window the user stays on the loading overlay rather than being bounced to
/login:RequireAccessTokenkeeps showing it because?code&stateare still present in react-router's location (react-router doesn't observe the Auth0 SDK's rawhistory.replaceState, so the params persist inuseLocation()until the SPA itself navigates). Org-scoped (rcuksa) logins skip the gate entirely.Evidence
yarn test --watchAll=false— 278 suites, 2461 passed, 1 skipped, 0 failuresyarn lintclean on all changed filesaccount-linkingutil (204 / 200-with-URL-validation / 400 / transient classification),Auth0TokenManager(204 / 200 / 400 / transient / org-scoped), an integration test (real router + store) proving no/loginbounce or deep-link clobber across the gate window, and a unit test pinning the react-router-vs-replaceStatebehavior the guarantee rests on.non-admin-linking.mov
Relevant link(s)
Notes
/user/linkedendpoint (tracked under parent ERA-13475) must be deployed to the target env for end-to-end behavior. This web change is self-contained and inert on non-common-DB flows until then.${API_URL}user/linked(no trailing slash, mirroringCURRENT_USER_API_URL); the server route accepts the no-slash form.view.auth0CallbackInProgressRedux flag was found redundant —hasAuth0Paramsalready holds the overlay through the gate window — and was removed; the investigation is captured as the integration + react-router tests above. The gate client also moved fromducks/toutils/(it's not Redux), the unreachable missing-URL branch was dropped, the login error now initializes from router state (no effect), andAuth0TokenManagerimports/comments were tidied.