All files / src/components/auth/AuthCallbackPage AuthCallbackPage.tsx

97.22% Statements 35/36
88.88% Branches 16/18
100% Functions 6/6
100% Lines 32/32

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116                                        1x       1x   1x 10x 10x 10x                             10x   10x       10x 10x 4x 1x 1x 1x 1x     4x     10x     10x               6x 6x                   6x 10x 2x 1x 1x 1x       1x     4x     5x 5x     5x       10x                
/**
 * AuthCallbackPage — SSO federated-login callback handler (Story 12.7, SSO Phase 4)
 *
 * The Cognito hosted UI redirects back to `/auth/callback?code=...` after a Google
 * sign-in. Amplify processes the code; this component completes the session through
 * the SAME hydration path as password login (`AuthContext.completeFederatedSignIn`),
 * then routes the user on. It touches NO `fetch`/`axios` directly (project rule) —
 * it drives everything through the context layer. Text-free (BATbernLoader only), so
 * no i18n keys are required (AC8).
 */
 
import React, { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@hooks/useAuth';
import { useOptionalConfig } from '@/contexts/useConfig';
import { authService } from '@/services/auth/authService';
import { BATbernLoader } from '@components/shared/BATbernLoader';
 
// Story 12.8 F6: one-shot guard for the post-link auto-retry (see effect below).
// sessionStorage so it survives the round-trip to the hosted UI but never leaks across tabs.
const LINK_RETRY_KEY = 'batbern.link-retry';
 
// Config-race fix (2026-06-05): bounded escape if the runtime config never arrives
// (GET /api/v1/config failed) — don't strand the user on the loader forever.
const CONFIG_WAIT_TIMEOUT_MS = 15000;
 
export const AuthCallbackPage: React.FC = () => {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const { completeFederatedSignIn } = useAuth();
  /**
   * Config-race fix (2026-06-05, root cause of the unstable federated login): this page
   * raced ConfigProvider's background `GET /api/v1/config`. When this effect won,
   * `ensureAmplifyConfigured()` (inside every authService call) silently NO-OP'd —
   * `setAmplifyRuntimeConfig` hadn't been called yet — so `Amplify.configure()` never ran.
   * In Amplify v6 the configure call is the ONLY trigger of the OAuth listener that
   * exchanges `?code=` for tokens; without it the exchange never happens, the F7 wait
   * times out after 15s, and the user bounces to /login ("unexpected error"). Nothing
   * retried configure once the config landed, so the only rescue was an unrelated code
   * path (e.g. AuthContext session restore for users with leftover tokens — why it
   * "sometimes worked"). Same race class as the session-restore gate in AuthContext.tsx.
   * Fix: gate the whole completion flow (incl. the F6 auto-retry, which also needs a
   * configured Amplify) on the config being loaded; the effect re-runs when it lands.
   */
  const config = useOptionalConfig();
  // Guard against double-invocation (React 18 StrictMode dev double-effect).
  const startedRef = useRef(false);
 
  // Bounded escape: config never arrives (config endpoint down) → fail over to /login
  // instead of an infinite loader. Cleared as soon as config lands / completion starts.
  useEffect(() => {
    if (config || startedRef.current) return;
    const timer = setTimeout(() => {
      Eif (!startedRef.current) {
        startedRef.current = true;
        console.error('[AuthCallbackPage] Runtime config never arrived — aborting callback');
        navigate('/login', { replace: true });
      }
    }, CONFIG_WAIT_TIMEOUT_MS);
    return () => clearTimeout(timer);
  }, [config, navigate]);
 
  useEffect(() => {
    // Wait for the runtime config — without it Amplify cannot be configured and the
    // code exchange cannot run (see doc comment on `config` above).
    if (!config) return;
    // `startedRef` already guards against the React 18 StrictMode dev double-effect,
    // so the federated completion runs exactly once. We deliberately do NOT add a
    // `cancelled` cleanup flag here: under StrictMode the fake unmount would set it on
    // the only in-flight run, and the remount early-returns (startedRef is set) without
    // restarting — so a `cancelled` check would skip navigation entirely in dev, leaving
    // the user stuck on the loader. This is a transient redirect page, so navigating
    // after a (real) unmount is a harmless no-op.
    Iif (startedRef.current) return;
    startedRef.current = true;
 
    // Story 12.8 F6 (verified live 2026-06-04): when the account-linking PreSignUp trigger
    // merges a FIRST-TIME Google sign-in into an existing native user
    // (AdminLinkProviderForUser, Story 12.6), Cognito ABORTS that in-flight sign-in by
    // design — the hosted UI bounces back here with
    // `?error_description=Already found an entry for username …`. The identity IS linked
    // at that point; a second sign-in goes straight through. Auto-retry once (one-shot
    // sessionStorage guard against loops) so the user's first click still ends signed-in
    // instead of stranding them logged-out on the login page.
    const errorDescription = searchParams.get('error_description') ?? '';
    if (/already found an entry for username/i.test(errorDescription)) {
      if (!sessionStorage.getItem(LINK_RETRY_KEY)) {
        sessionStorage.setItem(LINK_RETRY_KEY, '1');
        void authService.signInWithFederated('Google');
        return; // the browser navigates to the hosted UI — nothing further to do here
      }
      // Retry already attempted and the abort recurred (unexpected) — clear the guard and
      // fall through to the normal failure path below; no retry loop.
      sessionStorage.removeItem(LINK_RETRY_KEY);
    } else {
      // Any non-abort outcome clears the one-shot guard.
      sessionStorage.removeItem(LINK_RETRY_KEY);
    }
 
    (async () => {
      const outcome = await completeFederatedSignIn();
      // hydrateUserFromDb is awaited inside completeFederatedSignIn, so isAuthenticated
      // and preferences.language are already in state before we navigate.
      navigate(outcome.kind === 'success' ? '/dashboard' : '/login', { replace: true });
    })();
  }, [config, completeFederatedSignIn, navigate, searchParams]);
 
  return (
    <div className="flex min-h-[50vh] items-center justify-center">
      <BATbernLoader size={96} />
    </div>
  );
};
 
export default AuthCallbackPage;