Skip to content

Commit ddc7039

Browse files
committed
feat(FR-2631): implement STokenLoginBoundary component core
Introduce the new boundary component described in the Epic FR-2616 spec. The component authenticates via client.token_login using the caller-supplied sToken prop, dispatches backend-ai-connected exactly once on success, and only then renders children. Key invariants (acceptance-criteria mapped): - No URL APIs referenced anywhere in the file. Callers supply sToken via nuqs and pass it as a prop. - StrictMode-safe: a per-retryKey started flag prevents the dev double invocation from firing the sequence twice. - backend-ai-connected event dispatched at most once per component lifetime, regardless of retries. - Error kinds: missing-token, endpoint-unresolved, server-unreachable, token-invalid, concurrent-session (deferred to Q6), unknown. - errorFallback wins for all kinds when provided (Q4 policy). - loadConfigFromWebServer NOT invoked (Q2 policy); callers may invoke it from onSuccess if needed. Default fallback and error card are placeholders; FR-2632 replaces them with polished BAICard-based UI and i18n keys. Unit tests arrive in FR-2633, and the CI grep rule guarding URL-API usage arrives in FR-2634. Refs FR-2616
1 parent 61a25de commit ddc7039

1 file changed

Lines changed: 307 additions & 0 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)