-
Notifications
You must be signed in to change notification settings - Fork 79
feat(FR-2631): implement STokenLoginBoundary component core #6853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
graphite-app
merged 1 commit into
main
from
04-21-feat_fr-2631_implement_stokenloginboundary_component_core
Apr 23, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| /** | ||
| @license | ||
| Copyright (c) 2015-2026 Lablup Inc. All rights reserved. | ||
|
|
||
| IMPORTANT — URL-API prohibition invariant (spec FR-2616 acceptance): | ||
| This file and any module imported from it MUST NOT reference | ||
| `window.location`, `window.history`, `document.location`, or | ||
| `URLSearchParams`. The `sToken` value is supplied by callers via prop | ||
| (sourced through `useSToken` or equivalent nuqs-based hook). See | ||
| `.specs/draft-stoken-login-boundary/spec.md` section | ||
| "URL 파라미터 파싱 규약 (nuqs)". A static assertion in the accompanying | ||
| unit test (`STokenLoginBoundary.test.tsx`) enforces this in CI. | ||
| */ | ||
| import { getDefaultLoginConfig } from '../helper/loginConfig'; | ||
| import { | ||
| connectViaGQL, | ||
| createBackendAIClient, | ||
| tokenLogin, | ||
| } from '../helper/loginSessionAuth'; | ||
| import { useResolvedApiEndpoint } from '../hooks/useResolvedApiEndpoint'; | ||
| import { loginConfigState } from '../hooks/useWebUIConfig'; | ||
| import { jotaiStore } from './DefaultProviders'; | ||
| import { Alert, Button, Card } from 'antd'; | ||
| import { BAIFlex, useBAILogger } from 'backend.ai-ui'; | ||
|
nowgnuesLee marked this conversation as resolved.
nowgnuesLee marked this conversation as resolved.
|
||
| import { useAtomValue } from 'jotai'; | ||
| import { | ||
| Suspense, | ||
| useCallback, | ||
| useEffect, | ||
| useEffectEvent, | ||
| useRef, | ||
| useState, | ||
| type ReactNode, | ||
| } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| /** | ||
| * Error classification surfaced by `STokenLoginBoundary`. Callers receive | ||
| * this via `onError`; the default error card branches on `kind` to render a | ||
| * classification-specific message. | ||
| */ | ||
| export type STokenLoginError = | ||
| | { kind: 'missing-token' } | ||
| | { kind: 'endpoint-unresolved'; cause?: unknown } | ||
| | { kind: 'server-unreachable'; cause: unknown } | ||
| | { kind: 'token-invalid'; cause: unknown } | ||
| | { kind: 'concurrent-session'; cause: unknown } | ||
| | { kind: 'unknown'; cause: unknown }; | ||
|
|
||
| export interface STokenLoginBoundaryProps { | ||
| /** | ||
| * Canonical sToken value sourced by the caller via nuqs. Required — the | ||
| * boundary does not read URL state on its own. Pass an empty string when | ||
| * the caller intends to surface `missing-token`; usually callers should | ||
| * conditionally mount the boundary only when a token is present (see | ||
| * spec scenario A for LoginView). | ||
| */ | ||
| sToken: string; | ||
| children: ReactNode; | ||
| /** | ||
| * Additional parameters forwarded to `client.token_login(sToken, extraParams)` | ||
| * verbatim. Used by EduAppLauncher to pass `app`, `session_id`, resource | ||
| * hints, etc. Callers collect these via nuqs and pass a plain object. | ||
| */ | ||
| extraParams?: Record<string, string>; | ||
| /** | ||
| * Invoked after successful authentication with the connected client. This | ||
| * is where callers perform their own post-setup work: panel close, | ||
| * last_login counters, URL cleanup via the `clear` tuple returned from | ||
| * `useSToken`, etc. | ||
| */ | ||
| onSuccess?: (client: unknown) => void; | ||
| /** | ||
| * Invoked whenever the state machine transitions to an error state. The | ||
| * error is also surfaced in the default error card unless `errorFallback` | ||
| * is provided. | ||
| */ | ||
| onError?: (error: STokenLoginError) => void; | ||
| /** | ||
| * Rendered while the authentication sequence is in progress (endpoint | ||
| * resolve → ping → token_login → GQL connect). Defaults to a simple | ||
| * connection indicator card. | ||
| */ | ||
| fallback?: ReactNode; | ||
| /** | ||
| * When provided, replaces the built-in error card for every error kind | ||
| * (Q4 — errorFallback wins). Receives the current error and a `retry` | ||
| * callback that restarts the sequence from the idle state. | ||
| */ | ||
| errorFallback?: (error: STokenLoginError, retry: () => void) => ReactNode; | ||
| } | ||
|
|
||
| type Phase = | ||
| | { name: 'pending' } | ||
| | { name: 'success' } | ||
| | { name: 'error'; error: STokenLoginError }; | ||
|
|
||
| /** | ||
| * sToken-based login boundary. Authenticates via `client.token_login` using | ||
| * the caller-supplied `sToken`, dispatches `backend-ai-connected` exactly | ||
| * once on success, and only then renders `children`. See spec section | ||
| * "컴포넌트 설계" and "내부 동작 시퀀스" for the full contract. | ||
| */ | ||
| export const STokenLoginBoundary: React.FC<STokenLoginBoundaryProps> = ( | ||
| props, | ||
| ) => { | ||
| return ( | ||
| <Suspense fallback={props.fallback ?? <DefaultFallback />}> | ||
| <STokenLoginBoundaryInner {...props} /> | ||
| </Suspense> | ||
| ); | ||
| }; | ||
|
|
||
| const STokenLoginBoundaryInner: React.FC<STokenLoginBoundaryProps> = ({ | ||
| sToken, | ||
| children, | ||
| extraParams, | ||
| onSuccess, | ||
| onError, | ||
| fallback, | ||
| errorFallback, | ||
| }) => { | ||
| 'use memo'; | ||
| const { logger } = useBAILogger(); | ||
| const apiEndpoint = useResolvedApiEndpoint(); | ||
| const loginConfig = useAtomValue(loginConfigState); | ||
|
|
||
| const [phase, setPhase] = useState<Phase>({ name: 'pending' }); | ||
| const [retryKey, setRetryKey] = useState(0); | ||
|
|
||
| // Guard against React StrictMode's dev double-invoke of effects. Once the | ||
| // sequence has started for a given retryKey, a second fire is ignored. | ||
| const startedForKeyRef = useRef<number | null>(null); | ||
| // Guard against duplicate `backend-ai-connected` dispatch across the | ||
| // component lifetime, including after retries. The event is broadcast at | ||
| // most once per successful login; downstream subscribers (Relay, plugin | ||
| // endpoint wiring) assume idempotency does not hold for them. | ||
| const eventDispatchedRef = useRef(false); | ||
|
|
||
| const surfaceError = useEffectEvent((error: STokenLoginError) => { | ||
| setPhase({ name: 'error', error }); | ||
| onError?.(error); | ||
| }); | ||
|
|
||
| const runLoginSequence = useEffectEvent(async () => { | ||
| if (!apiEndpoint) { | ||
| surfaceError({ kind: 'endpoint-unresolved' }); | ||
| return; | ||
| } | ||
|
|
||
| // Defensive cookie set when a token is present. Primary auth reads | ||
| // the token from the JSON body, but manager-side hooks (e.g. OpenID) | ||
| // fall back to the cookie. Always encode — JWT-shaped tokens are | ||
| // `encodeURIComponent`-invariant in practice; see FR-2635. | ||
| if (sToken) { | ||
| document.cookie = `sToken=${encodeURIComponent(sToken)}; path=/; Secure; SameSite=Lax`; | ||
| } | ||
|
|
||
| const { client } = createBackendAIClient('', '', apiEndpoint, 'SESSION'); | ||
|
|
||
| try { | ||
| await client.get_manager_version(); | ||
| } catch (cause) { | ||
| logger.error('[STokenLoginBoundary] server unreachable', cause); | ||
| surfaceError({ kind: 'server-unreachable', cause }); | ||
| return; | ||
| } | ||
|
|
||
| // Idempotency / cookie-session fast-path: if the browser already | ||
| // holds a valid session (from a prior login in the same browser), we | ||
| // skip `token_login` entirely. This also covers the case where a | ||
| // caller mounts the boundary without a URL token — an existing | ||
| // session alone is enough to reach the success state. | ||
| let alreadyLoggedIn = false; | ||
| try { | ||
| alreadyLoggedIn = !!(await client.check_login()); | ||
| } catch { | ||
| alreadyLoggedIn = false; | ||
| } | ||
|
|
||
| // Only after the session check do we surface `missing-token`: a bare | ||
| // `?sToken=` URL with no cookie session still fails, but a session | ||
| // cookie alone (no sToken in the URL) proceeds through the GQL wiring. | ||
| if (!alreadyLoggedIn && !sToken) { | ||
| surfaceError({ kind: 'missing-token' }); | ||
| return; | ||
| } | ||
|
|
||
| // Prefer the live atom state; fall back to the documented defaults so | ||
| // `applyConfigToClient(cfg)` downstream of `connectViaGQL` does not write | ||
| // `undefined` into `backendaiclient._config`. `loadConfigFromWebServer` | ||
| // is intentionally NOT invoked here — see spec Q2. | ||
| const cfg = | ||
| loginConfig ?? | ||
| jotaiStore.get(loginConfigState) ?? | ||
| getDefaultLoginConfig(); | ||
| const endpoints = | ||
| (( | ||
| globalThis as { backendaioptions?: { get: (k: string) => unknown } } | ||
| ).backendaioptions?.get('endpoints') as string[] | undefined) ?? []; | ||
|
|
||
| try { | ||
| if (alreadyLoggedIn) { | ||
| // Session already exists — wire up the GraphQL client / groups / | ||
| // endpoint history the same way `tokenLogin` would, without | ||
| // re-authenticating. `backend-ai-connected` is still dispatched | ||
| // below so Relay and plugin subscribers unblock even on this | ||
| // fast-path. | ||
| await connectViaGQL(client, cfg, endpoints); | ||
| } else { | ||
| await tokenLogin(client, sToken!, cfg, endpoints, extraParams); | ||
| } | ||
| } catch (cause) { | ||
| // `concurrent-session` detection is deferred (spec Q6); all | ||
| // `token_login` failures map to `token-invalid` for now, with a TODO | ||
| // pointing at the sibling concurrent-login-guard spec. | ||
| // TODO(FR-2616 Q6): classify `concurrent-session` once the backend | ||
| // signal from `.specs/draft-concurrent-login-guard/` lands. | ||
| logger.error('[STokenLoginBoundary] token_login failed', cause); | ||
| surfaceError({ kind: 'token-invalid', cause }); | ||
| return; | ||
| } | ||
|
|
||
| if (!eventDispatchedRef.current) { | ||
| eventDispatchedRef.current = true; | ||
| document.dispatchEvent( | ||
| new CustomEvent('backend-ai-connected', { detail: client }), | ||
| ); | ||
| } | ||
|
|
||
| setPhase({ name: 'success' }); | ||
| onSuccess?.(client); | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (startedForKeyRef.current === retryKey) { | ||
| return; | ||
| } | ||
| startedForKeyRef.current = retryKey; | ||
| runLoginSequence(); | ||
| }, [retryKey]); | ||
|
|
||
| const retry = useCallback(() => { | ||
| setPhase({ name: 'pending' }); | ||
| setRetryKey((k) => k + 1); | ||
| }, []); | ||
|
|
||
| if (phase.name === 'error') { | ||
| if (errorFallback) { | ||
| return <>{errorFallback(phase.error, retry)}</>; | ||
| } | ||
| return <DefaultErrorCard error={phase.error} onRetry={retry} />; | ||
| } | ||
|
|
||
| if (phase.name === 'success') { | ||
| return <>{children}</>; | ||
| } | ||
|
|
||
| // pending — show fallback while the sequence runs. | ||
| return <>{fallback ?? <DefaultFallback />}</>; | ||
| }; | ||
|
|
||
| /** | ||
| * Placeholder connecting card. FR-2632 replaces this with the polished | ||
| * BAICard-based version + i18n keys. | ||
| */ | ||
| const DefaultFallback: React.FC = () => { | ||
| const { t } = useTranslation(); | ||
| return ( | ||
| <BAIFlex | ||
| direction="column" | ||
| align="center" | ||
| justify="center" | ||
| style={{ minHeight: '60vh' }} | ||
| > | ||
| <Card>{t('login.ConnectingToCluster')}</Card> | ||
| </BAIFlex> | ||
| ); | ||
| }; | ||
|
|
||
| /** | ||
| * Placeholder error card. FR-2632 replaces this with the full BAICard + | ||
| * BAIButton UI (Retry with async loading state, Copy error details). | ||
| */ | ||
| const DefaultErrorCard: React.FC<{ | ||
| error: STokenLoginError; | ||
| onRetry: () => void; | ||
| }> = ({ error, onRetry }) => { | ||
| return ( | ||
| <BAIFlex | ||
| direction="column" | ||
| align="center" | ||
| justify="center" | ||
| style={{ minHeight: '60vh', padding: 24 }} | ||
| > | ||
| <Alert | ||
| type="error" | ||
| title={`sToken login failed: ${error.kind}`} | ||
| description={ | ||
| 'cause' in error && error.cause | ||
| ? String((error.cause as Error)?.message ?? error.cause) | ||
| : undefined | ||
| } | ||
| style={{ maxWidth: 520, marginBottom: 16 }} | ||
| /> | ||
| <Button type="primary" onClick={onRetry}> | ||
| Retry | ||
| </Button> | ||
|
nowgnuesLee marked this conversation as resolved.
|
||
| </BAIFlex> | ||
| ); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.