Skip to content

Commit cc085c9

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 741c222 commit cc085c9

1 file changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
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 (!sToken) {
146+
surfaceError({ kind: 'missing-token' });
147+
return;
148+
}
149+
if (!apiEndpoint) {
150+
surfaceError({ kind: 'endpoint-unresolved' });
151+
return;
152+
}
153+
154+
// Defensive cookie set. The primary `/server/token-login` path reads
155+
// the token from the JSON body, but manager-side hooks (e.g. OpenID)
156+
// fall back to the cookie. Always encode — JWT-shaped tokens are
157+
// `encodeURIComponent`-invariant in practice; see FR-2635 investigation.
158+
document.cookie = `sToken=${encodeURIComponent(sToken)}; path=/; Secure; SameSite=Lax`;
159+
160+
const { client } = createBackendAIClient('', '', apiEndpoint, 'SESSION');
161+
162+
try {
163+
await client.get_manager_version();
164+
} catch (cause) {
165+
logger.error('[STokenLoginBoundary] server unreachable', cause);
166+
surfaceError({ kind: 'server-unreachable', cause });
167+
return;
168+
}
169+
170+
// Idempotency check: if the browser still holds a valid session, skip
171+
// `token_login` and reuse it. The webserver returns 400 "You have
172+
// already logged in" otherwise, which would be misclassified as
173+
// `token-invalid`. Matches the fast-path in the legacy LoginView and
174+
// EduAppLauncher flows this boundary replaces.
175+
let alreadyLoggedIn = false;
176+
try {
177+
alreadyLoggedIn = !!(await client.check_login());
178+
} catch {
179+
alreadyLoggedIn = false;
180+
}
181+
182+
// Prefer the live atom state; fall back to an empty-object shape that
183+
// `tokenLogin`/`connectViaGQL` tolerate. `loadConfigFromWebServer` is
184+
// intentionally NOT invoked here — see spec Q2.
185+
const cfg =
186+
loginConfig ?? jotaiStore.get(loginConfigState) ?? ({} as never);
187+
const endpoints =
188+
((
189+
globalThis as { backendaioptions?: { get: (k: string) => unknown } }
190+
).backendaioptions?.get('endpoints') as string[] | undefined) ?? [];
191+
192+
try {
193+
if (alreadyLoggedIn) {
194+
// Session already exists — wire up the GraphQL client / groups /
195+
// endpoint history the same way `tokenLogin` would, without
196+
// re-authenticating. `backend-ai-connected` is still dispatched
197+
// below so Relay and plugin subscribers unblock even on this
198+
// fast-path.
199+
await connectViaGQL(client, cfg, endpoints);
200+
} else {
201+
await tokenLogin(client, sToken, cfg, endpoints, extraParams);
202+
}
203+
} catch (cause) {
204+
// `concurrent-session` detection is deferred (spec Q6); all
205+
// `token_login` failures map to `token-invalid` for now, with a TODO
206+
// pointing at the sibling concurrent-login-guard spec.
207+
// TODO(FR-2616 Q6): classify `concurrent-session` once the backend
208+
// signal from `.specs/draft-concurrent-login-guard/` lands.
209+
logger.error('[STokenLoginBoundary] token_login failed', cause);
210+
surfaceError({ kind: 'token-invalid', cause });
211+
return;
212+
}
213+
214+
if (!eventDispatchedRef.current) {
215+
eventDispatchedRef.current = true;
216+
document.dispatchEvent(
217+
new CustomEvent('backend-ai-connected', { detail: client }),
218+
);
219+
}
220+
221+
setPhase({ name: 'success' });
222+
onSuccess?.(client);
223+
});
224+
225+
useEffect(() => {
226+
if (startedForKeyRef.current === retryKey) {
227+
return;
228+
}
229+
startedForKeyRef.current = retryKey;
230+
runLoginSequence();
231+
}, [retryKey]);
232+
233+
const retry = useCallback(() => {
234+
setPhase({ name: 'pending' });
235+
setRetryKey((k) => k + 1);
236+
}, []);
237+
238+
if (phase.name === 'error') {
239+
if (errorFallback) {
240+
return <>{errorFallback(phase.error, retry)}</>;
241+
}
242+
return <DefaultErrorCard error={phase.error} onRetry={retry} />;
243+
}
244+
245+
if (phase.name === 'success') {
246+
return <>{children}</>;
247+
}
248+
249+
// pending — show fallback while the sequence runs.
250+
return <>{fallback ?? <DefaultFallback />}</>;
251+
};
252+
253+
/**
254+
* Placeholder connecting card. FR-2632 replaces this with the polished
255+
* BAICard-based version + i18n keys.
256+
*/
257+
const DefaultFallback: React.FC = () => {
258+
const { t } = useTranslation();
259+
return (
260+
<BAIFlex
261+
direction="column"
262+
align="center"
263+
justify="center"
264+
style={{ minHeight: '60vh' }}
265+
>
266+
<Card>{t('login.ConnectingToCluster')}</Card>
267+
</BAIFlex>
268+
);
269+
};
270+
271+
/**
272+
* Placeholder error card. FR-2632 replaces this with the full BAICard +
273+
* BAIButton UI (Retry with async loading state, Copy error details).
274+
*/
275+
const DefaultErrorCard: React.FC<{
276+
error: STokenLoginError;
277+
onRetry: () => void;
278+
}> = ({ error, onRetry }) => {
279+
return (
280+
<BAIFlex
281+
direction="column"
282+
align="center"
283+
justify="center"
284+
style={{ minHeight: '60vh', padding: 24 }}
285+
>
286+
<Alert
287+
type="error"
288+
title={`sToken login failed: ${error.kind}`}
289+
description={
290+
'cause' in error && error.cause
291+
? String((error.cause as Error)?.message ?? error.cause)
292+
: undefined
293+
}
294+
style={{ maxWidth: 520, marginBottom: 16 }}
295+
/>
296+
<Button type="primary" onClick={onRetry}>
297+
Retry
298+
</Button>
299+
</BAIFlex>
300+
);
301+
};

0 commit comments

Comments
 (0)