|
| 1 | +/** |
| 2 | + @license |
| 3 | + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. |
| 4 | +
|
| 5 | + IMPORTANT — URL-API prohibition invariant (spec FR-2616 acceptance): |
| 6 | + This file and any module imported from it MUST NOT reference |
| 7 | + `window.location`, `window.history`, `document.location`, or |
| 8 | + `URLSearchParams`. The `sToken` value is supplied by callers via prop |
| 9 | + (sourced through `useSToken` or equivalent nuqs-based hook). See |
| 10 | + `.specs/draft-stoken-login-boundary/spec.md` section |
| 11 | + "URL 파라미터 파싱 규약 (nuqs)". A static assertion in the accompanying |
| 12 | + unit test (`STokenLoginBoundary.test.tsx`) enforces this in CI. |
| 13 | + */ |
| 14 | +import { |
| 15 | + connectViaGQL, |
| 16 | + createBackendAIClient, |
| 17 | + tokenLogin, |
| 18 | +} from '../helper/loginSessionAuth'; |
| 19 | +import { useResolvedApiEndpoint } from '../hooks/useResolvedApiEndpoint'; |
| 20 | +import { loginConfigState } from '../hooks/useWebUIConfig'; |
| 21 | +import { jotaiStore } from './DefaultProviders'; |
| 22 | +import { Alert, Button, Card } from 'antd'; |
| 23 | +import { BAIFlex, useBAILogger } from 'backend.ai-ui'; |
| 24 | +import { useAtomValue } from 'jotai'; |
| 25 | +import { |
| 26 | + Suspense, |
| 27 | + useCallback, |
| 28 | + useEffect, |
| 29 | + useEffectEvent, |
| 30 | + useRef, |
| 31 | + useState, |
| 32 | + type ReactNode, |
| 33 | +} from 'react'; |
| 34 | +import { useTranslation } from 'react-i18next'; |
| 35 | + |
| 36 | +/** |
| 37 | + * Error classification surfaced by `STokenLoginBoundary`. Callers receive |
| 38 | + * this via `onError`; the default error card branches on `kind` to render a |
| 39 | + * classification-specific message. |
| 40 | + */ |
| 41 | +export type STokenLoginError = |
| 42 | + | { kind: 'missing-token' } |
| 43 | + | { kind: 'endpoint-unresolved'; cause?: unknown } |
| 44 | + | { kind: 'server-unreachable'; cause: unknown } |
| 45 | + | { kind: 'token-invalid'; cause: unknown } |
| 46 | + | { kind: 'concurrent-session'; cause: unknown } |
| 47 | + | { kind: 'unknown'; cause: unknown }; |
| 48 | + |
| 49 | +export interface STokenLoginBoundaryProps { |
| 50 | + /** |
| 51 | + * Canonical sToken value sourced by the caller via nuqs. Required — the |
| 52 | + * boundary does not read URL state on its own. Pass an empty string when |
| 53 | + * the caller intends to surface `missing-token`; usually callers should |
| 54 | + * conditionally mount the boundary only when a token is present (see |
| 55 | + * spec scenario A for LoginView). |
| 56 | + */ |
| 57 | + sToken: string; |
| 58 | + children: ReactNode; |
| 59 | + /** |
| 60 | + * Additional parameters forwarded to `client.token_login(sToken, extraParams)` |
| 61 | + * verbatim. Used by EduAppLauncher to pass `app`, `session_id`, resource |
| 62 | + * hints, etc. Callers collect these via nuqs and pass a plain object. |
| 63 | + */ |
| 64 | + extraParams?: Record<string, string>; |
| 65 | + /** |
| 66 | + * Invoked after successful authentication with the connected client. This |
| 67 | + * is where callers perform their own post-setup work: panel close, |
| 68 | + * last_login counters, URL cleanup via the `clear` tuple returned from |
| 69 | + * `useSToken`, etc. |
| 70 | + */ |
| 71 | + onSuccess?: (client: unknown) => void; |
| 72 | + /** |
| 73 | + * Invoked whenever the state machine transitions to an error state. The |
| 74 | + * error is also surfaced in the default error card unless `errorFallback` |
| 75 | + * is provided. |
| 76 | + */ |
| 77 | + onError?: (error: STokenLoginError) => void; |
| 78 | + /** |
| 79 | + * Rendered while the authentication sequence is in progress (endpoint |
| 80 | + * resolve → ping → token_login → GQL connect). Defaults to a simple |
| 81 | + * connection indicator card. |
| 82 | + */ |
| 83 | + fallback?: ReactNode; |
| 84 | + /** |
| 85 | + * When provided, replaces the built-in error card for every error kind |
| 86 | + * (Q4 — errorFallback wins). Receives the current error and a `retry` |
| 87 | + * callback that restarts the sequence from the idle state. |
| 88 | + */ |
| 89 | + errorFallback?: (error: STokenLoginError, retry: () => void) => ReactNode; |
| 90 | +} |
| 91 | + |
| 92 | +type Phase = |
| 93 | + | { name: 'pending' } |
| 94 | + | { name: 'success' } |
| 95 | + | { name: 'error'; error: STokenLoginError }; |
| 96 | + |
| 97 | +/** |
| 98 | + * sToken-based login boundary. Authenticates via `client.token_login` using |
| 99 | + * the caller-supplied `sToken`, dispatches `backend-ai-connected` exactly |
| 100 | + * once on success, and only then renders `children`. See spec section |
| 101 | + * "컴포넌트 설계" and "내부 동작 시퀀스" for the full contract. |
| 102 | + */ |
| 103 | +export const STokenLoginBoundary: React.FC<STokenLoginBoundaryProps> = ( |
| 104 | + props, |
| 105 | +) => { |
| 106 | + return ( |
| 107 | + <Suspense fallback={props.fallback ?? <DefaultFallback />}> |
| 108 | + <STokenLoginBoundaryInner {...props} /> |
| 109 | + </Suspense> |
| 110 | + ); |
| 111 | +}; |
| 112 | + |
| 113 | +const STokenLoginBoundaryInner: React.FC<STokenLoginBoundaryProps> = ({ |
| 114 | + sToken, |
| 115 | + children, |
| 116 | + extraParams, |
| 117 | + onSuccess, |
| 118 | + onError, |
| 119 | + fallback, |
| 120 | + errorFallback, |
| 121 | +}) => { |
| 122 | + 'use memo'; |
| 123 | + const { logger } = useBAILogger(); |
| 124 | + const apiEndpoint = useResolvedApiEndpoint(); |
| 125 | + const loginConfig = useAtomValue(loginConfigState); |
| 126 | + |
| 127 | + const [phase, setPhase] = useState<Phase>({ name: 'pending' }); |
| 128 | + const [retryKey, setRetryKey] = useState(0); |
| 129 | + |
| 130 | + // Guard against React StrictMode's dev double-invoke of effects. Once the |
| 131 | + // sequence has started for a given retryKey, a second fire is ignored. |
| 132 | + const startedForKeyRef = useRef<number | null>(null); |
| 133 | + // Guard against duplicate `backend-ai-connected` dispatch across the |
| 134 | + // component lifetime, including after retries. The event is broadcast at |
| 135 | + // most once per successful login; downstream subscribers (Relay, plugin |
| 136 | + // endpoint wiring) assume idempotency does not hold for them. |
| 137 | + const eventDispatchedRef = useRef(false); |
| 138 | + |
| 139 | + const surfaceError = useEffectEvent((error: STokenLoginError) => { |
| 140 | + setPhase({ name: 'error', error }); |
| 141 | + onError?.(error); |
| 142 | + }); |
| 143 | + |
| 144 | + const runLoginSequence = useEffectEvent(async () => { |
| 145 | + if (!apiEndpoint) { |
| 146 | + surfaceError({ kind: 'endpoint-unresolved' }); |
| 147 | + return; |
| 148 | + } |
| 149 | + |
| 150 | + // Defensive cookie set when a token is present. Primary auth reads |
| 151 | + // the token from the JSON body, but manager-side hooks (e.g. OpenID) |
| 152 | + // fall back to the cookie. Always encode — JWT-shaped tokens are |
| 153 | + // `encodeURIComponent`-invariant in practice; see FR-2635. |
| 154 | + if (sToken) { |
| 155 | + document.cookie = `sToken=${encodeURIComponent(sToken)}; path=/; Secure; SameSite=Lax`; |
| 156 | + } |
| 157 | + |
| 158 | + const { client } = createBackendAIClient('', '', apiEndpoint, 'SESSION'); |
| 159 | + |
| 160 | + try { |
| 161 | + await client.get_manager_version(); |
| 162 | + } catch (cause) { |
| 163 | + logger.error('[STokenLoginBoundary] server unreachable', cause); |
| 164 | + surfaceError({ kind: 'server-unreachable', cause }); |
| 165 | + return; |
| 166 | + } |
| 167 | + |
| 168 | + // Idempotency / cookie-session fast-path: if the browser already |
| 169 | + // holds a valid session (from a prior login in the same browser), we |
| 170 | + // skip `token_login` entirely. This also covers the case where a |
| 171 | + // caller mounts the boundary without a URL token — an existing |
| 172 | + // session alone is enough to reach the success state. |
| 173 | + let alreadyLoggedIn = false; |
| 174 | + try { |
| 175 | + alreadyLoggedIn = !!(await client.check_login()); |
| 176 | + } catch { |
| 177 | + alreadyLoggedIn = false; |
| 178 | + } |
| 179 | + |
| 180 | + // Only after the session check do we surface `missing-token`: a bare |
| 181 | + // `?sToken=` URL with no cookie session still fails, but a session |
| 182 | + // cookie alone (no sToken in the URL) proceeds through the GQL wiring. |
| 183 | + if (!alreadyLoggedIn && !sToken) { |
| 184 | + surfaceError({ kind: 'missing-token' }); |
| 185 | + return; |
| 186 | + } |
| 187 | + |
| 188 | + // Prefer the live atom state; fall back to an empty-object shape that |
| 189 | + // `tokenLogin`/`connectViaGQL` tolerate. `loadConfigFromWebServer` is |
| 190 | + // intentionally NOT invoked here — see spec Q2. |
| 191 | + const cfg = |
| 192 | + loginConfig ?? jotaiStore.get(loginConfigState) ?? ({} as never); |
| 193 | + const endpoints = |
| 194 | + (( |
| 195 | + globalThis as { backendaioptions?: { get: (k: string) => unknown } } |
| 196 | + ).backendaioptions?.get('endpoints') as string[] | undefined) ?? []; |
| 197 | + |
| 198 | + try { |
| 199 | + if (alreadyLoggedIn) { |
| 200 | + // Session already exists — wire up the GraphQL client / groups / |
| 201 | + // endpoint history the same way `tokenLogin` would, without |
| 202 | + // re-authenticating. `backend-ai-connected` is still dispatched |
| 203 | + // below so Relay and plugin subscribers unblock even on this |
| 204 | + // fast-path. |
| 205 | + await connectViaGQL(client, cfg, endpoints); |
| 206 | + } else { |
| 207 | + await tokenLogin(client, sToken!, cfg, endpoints, extraParams); |
| 208 | + } |
| 209 | + } catch (cause) { |
| 210 | + // `concurrent-session` detection is deferred (spec Q6); all |
| 211 | + // `token_login` failures map to `token-invalid` for now, with a TODO |
| 212 | + // pointing at the sibling concurrent-login-guard spec. |
| 213 | + // TODO(FR-2616 Q6): classify `concurrent-session` once the backend |
| 214 | + // signal from `.specs/draft-concurrent-login-guard/` lands. |
| 215 | + logger.error('[STokenLoginBoundary] token_login failed', cause); |
| 216 | + surfaceError({ kind: 'token-invalid', cause }); |
| 217 | + return; |
| 218 | + } |
| 219 | + |
| 220 | + if (!eventDispatchedRef.current) { |
| 221 | + eventDispatchedRef.current = true; |
| 222 | + document.dispatchEvent( |
| 223 | + new CustomEvent('backend-ai-connected', { detail: client }), |
| 224 | + ); |
| 225 | + } |
| 226 | + |
| 227 | + setPhase({ name: 'success' }); |
| 228 | + onSuccess?.(client); |
| 229 | + }); |
| 230 | + |
| 231 | + useEffect(() => { |
| 232 | + if (startedForKeyRef.current === retryKey) { |
| 233 | + return; |
| 234 | + } |
| 235 | + startedForKeyRef.current = retryKey; |
| 236 | + runLoginSequence(); |
| 237 | + }, [retryKey]); |
| 238 | + |
| 239 | + const retry = useCallback(() => { |
| 240 | + setPhase({ name: 'pending' }); |
| 241 | + setRetryKey((k) => k + 1); |
| 242 | + }, []); |
| 243 | + |
| 244 | + if (phase.name === 'error') { |
| 245 | + if (errorFallback) { |
| 246 | + return <>{errorFallback(phase.error, retry)}</>; |
| 247 | + } |
| 248 | + return <DefaultErrorCard error={phase.error} onRetry={retry} />; |
| 249 | + } |
| 250 | + |
| 251 | + if (phase.name === 'success') { |
| 252 | + return <>{children}</>; |
| 253 | + } |
| 254 | + |
| 255 | + // pending — show fallback while the sequence runs. |
| 256 | + return <>{fallback ?? <DefaultFallback />}</>; |
| 257 | +}; |
| 258 | + |
| 259 | +/** |
| 260 | + * Placeholder connecting card. FR-2632 replaces this with the polished |
| 261 | + * BAICard-based version + i18n keys. |
| 262 | + */ |
| 263 | +const DefaultFallback: React.FC = () => { |
| 264 | + const { t } = useTranslation(); |
| 265 | + return ( |
| 266 | + <BAIFlex |
| 267 | + direction="column" |
| 268 | + align="center" |
| 269 | + justify="center" |
| 270 | + style={{ minHeight: '60vh' }} |
| 271 | + > |
| 272 | + <Card>{t('login.ConnectingToCluster')}</Card> |
| 273 | + </BAIFlex> |
| 274 | + ); |
| 275 | +}; |
| 276 | + |
| 277 | +/** |
| 278 | + * Placeholder error card. FR-2632 replaces this with the full BAICard + |
| 279 | + * BAIButton UI (Retry with async loading state, Copy error details). |
| 280 | + */ |
| 281 | +const DefaultErrorCard: React.FC<{ |
| 282 | + error: STokenLoginError; |
| 283 | + onRetry: () => void; |
| 284 | +}> = ({ error, onRetry }) => { |
| 285 | + return ( |
| 286 | + <BAIFlex |
| 287 | + direction="column" |
| 288 | + align="center" |
| 289 | + justify="center" |
| 290 | + style={{ minHeight: '60vh', padding: 24 }} |
| 291 | + > |
| 292 | + <Alert |
| 293 | + type="error" |
| 294 | + title={`sToken login failed: ${error.kind}`} |
| 295 | + description={ |
| 296 | + 'cause' in error && error.cause |
| 297 | + ? String((error.cause as Error)?.message ?? error.cause) |
| 298 | + : undefined |
| 299 | + } |
| 300 | + style={{ maxWidth: 520, marginBottom: 16 }} |
| 301 | + /> |
| 302 | + <Button type="primary" onClick={onRetry}> |
| 303 | + Retry |
| 304 | + </Button> |
| 305 | + </BAIFlex> |
| 306 | + ); |
| 307 | +}; |
0 commit comments