Skip to content

Commit 082b3a1

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 082b3a1

1 file changed

Lines changed: 276 additions & 0 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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 { createBackendAIClient, tokenLogin } from '../helper/loginSessionAuth';
15+
import { useResolvedApiEndpoint } from '../hooks/useResolvedApiEndpoint';
16+
import { loginConfigState } from '../hooks/useWebUIConfig';
17+
import { jotaiStore } from './DefaultProviders';
18+
import { Alert, Button, Card } from 'antd';
19+
import { BAIFlex, useBAILogger } from 'backend.ai-ui';
20+
import { useAtomValue } from 'jotai';
21+
import {
22+
Suspense,
23+
useCallback,
24+
useEffect,
25+
useEffectEvent,
26+
useRef,
27+
useState,
28+
type ReactNode,
29+
} from 'react';
30+
import { useTranslation } from 'react-i18next';
31+
32+
/**
33+
* Error classification surfaced by `STokenLoginBoundary`. Callers receive
34+
* this via `onError`; the default error card branches on `kind` to render a
35+
* classification-specific message.
36+
*/
37+
export type STokenLoginError =
38+
| { kind: 'missing-token' }
39+
| { kind: 'endpoint-unresolved'; cause?: unknown }
40+
| { kind: 'server-unreachable'; cause: unknown }
41+
| { kind: 'token-invalid'; cause: unknown }
42+
| { kind: 'concurrent-session'; cause: unknown }
43+
| { kind: 'unknown'; cause: unknown };
44+
45+
export interface STokenLoginBoundaryProps {
46+
/**
47+
* Canonical sToken value sourced by the caller via nuqs. Required — the
48+
* boundary does not read URL state on its own. Pass an empty string when
49+
* the caller intends to surface `missing-token`; usually callers should
50+
* conditionally mount the boundary only when a token is present (see
51+
* spec scenario A for LoginView).
52+
*/
53+
sToken: string;
54+
children: ReactNode;
55+
/**
56+
* Additional parameters forwarded to `client.token_login(sToken, extraParams)`
57+
* verbatim. Used by EduAppLauncher to pass `app`, `session_id`, resource
58+
* hints, etc. Callers collect these via nuqs and pass a plain object.
59+
*/
60+
extraParams?: Record<string, string>;
61+
/**
62+
* Invoked after successful authentication with the connected client. This
63+
* is where callers perform their own post-setup work: panel close,
64+
* last_login counters, URL cleanup via the `clear` tuple returned from
65+
* `useSToken`, etc.
66+
*/
67+
onSuccess?: (client: unknown) => void;
68+
/**
69+
* Invoked whenever the state machine transitions to an error state. The
70+
* error is also surfaced in the default error card unless `errorFallback`
71+
* is provided.
72+
*/
73+
onError?: (error: STokenLoginError) => void;
74+
/**
75+
* Rendered while the authentication sequence is in progress (endpoint
76+
* resolve → ping → token_login → GQL connect). Defaults to a simple
77+
* connection indicator card.
78+
*/
79+
fallback?: ReactNode;
80+
/**
81+
* When provided, replaces the built-in error card for every error kind
82+
* (Q4 — errorFallback wins). Receives the current error and a `retry`
83+
* callback that restarts the sequence from the idle state.
84+
*/
85+
errorFallback?: (error: STokenLoginError, retry: () => void) => ReactNode;
86+
}
87+
88+
type Phase =
89+
| { name: 'pending' }
90+
| { name: 'success' }
91+
| { name: 'error'; error: STokenLoginError };
92+
93+
/**
94+
* sToken-based login boundary. Authenticates via `client.token_login` using
95+
* the caller-supplied `sToken`, dispatches `backend-ai-connected` exactly
96+
* once on success, and only then renders `children`. See spec section
97+
* "컴포넌트 설계" and "내부 동작 시퀀스" for the full contract.
98+
*/
99+
export const STokenLoginBoundary: React.FC<STokenLoginBoundaryProps> = (
100+
props,
101+
) => {
102+
return (
103+
<Suspense fallback={props.fallback ?? <DefaultFallback />}>
104+
<STokenLoginBoundaryInner {...props} />
105+
</Suspense>
106+
);
107+
};
108+
109+
const STokenLoginBoundaryInner: React.FC<STokenLoginBoundaryProps> = ({
110+
sToken,
111+
children,
112+
extraParams,
113+
onSuccess,
114+
onError,
115+
fallback,
116+
errorFallback,
117+
}) => {
118+
'use memo';
119+
const { logger } = useBAILogger();
120+
const apiEndpoint = useResolvedApiEndpoint();
121+
const loginConfig = useAtomValue(loginConfigState);
122+
123+
const [phase, setPhase] = useState<Phase>({ name: 'pending' });
124+
const [retryKey, setRetryKey] = useState(0);
125+
126+
// Guard against React StrictMode's dev double-invoke of effects. Once the
127+
// sequence has started for a given retryKey, a second fire is ignored.
128+
const startedForKeyRef = useRef<number | null>(null);
129+
// Guard against duplicate `backend-ai-connected` dispatch across the
130+
// component lifetime, including after retries. The event is broadcast at
131+
// most once per successful login; downstream subscribers (Relay, plugin
132+
// endpoint wiring) assume idempotency does not hold for them.
133+
const eventDispatchedRef = useRef(false);
134+
135+
const surfaceError = useEffectEvent((error: STokenLoginError) => {
136+
setPhase({ name: 'error', error });
137+
onError?.(error);
138+
});
139+
140+
const runLoginSequence = useEffectEvent(async () => {
141+
if (!sToken) {
142+
surfaceError({ kind: 'missing-token' });
143+
return;
144+
}
145+
if (!apiEndpoint) {
146+
surfaceError({ kind: 'endpoint-unresolved' });
147+
return;
148+
}
149+
150+
// Defensive cookie set. The primary `/server/token-login` path 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 investigation.
154+
document.cookie = `sToken=${encodeURIComponent(sToken)}; path=/; Secure; SameSite=Lax`;
155+
156+
const { client } = createBackendAIClient('', '', apiEndpoint, 'SESSION');
157+
158+
try {
159+
await client.get_manager_version();
160+
} catch (cause) {
161+
logger.error('[STokenLoginBoundary] server unreachable', cause);
162+
surfaceError({ kind: 'server-unreachable', cause });
163+
return;
164+
}
165+
166+
// Prefer the live atom state; fall back to an empty-object shape that
167+
// `tokenLogin`/`connectViaGQL` tolerate. `loadConfigFromWebServer` is
168+
// intentionally NOT invoked here — see spec Q2.
169+
const cfg =
170+
loginConfig ?? jotaiStore.get(loginConfigState) ?? ({} as never);
171+
const endpoints =
172+
((
173+
globalThis as { backendaioptions?: { get: (k: string) => unknown } }
174+
).backendaioptions?.get('endpoints') as string[] | undefined) ?? [];
175+
176+
try {
177+
await tokenLogin(client, sToken, cfg, endpoints, extraParams);
178+
} catch (cause) {
179+
// `concurrent-session` detection is deferred (spec Q6); all
180+
// `token_login` failures map to `token-invalid` for now, with a TODO
181+
// pointing at the sibling concurrent-login-guard spec.
182+
// TODO(FR-2616 Q6): classify `concurrent-session` once the backend
183+
// signal from `.specs/draft-concurrent-login-guard/` lands.
184+
logger.error('[STokenLoginBoundary] token_login failed', cause);
185+
surfaceError({ kind: 'token-invalid', cause });
186+
return;
187+
}
188+
189+
if (!eventDispatchedRef.current) {
190+
eventDispatchedRef.current = true;
191+
document.dispatchEvent(
192+
new CustomEvent('backend-ai-connected', { detail: client }),
193+
);
194+
}
195+
196+
setPhase({ name: 'success' });
197+
onSuccess?.(client);
198+
});
199+
200+
useEffect(() => {
201+
if (startedForKeyRef.current === retryKey) {
202+
return;
203+
}
204+
startedForKeyRef.current = retryKey;
205+
runLoginSequence();
206+
}, [retryKey]);
207+
208+
const retry = useCallback(() => {
209+
setPhase({ name: 'pending' });
210+
setRetryKey((k) => k + 1);
211+
}, []);
212+
213+
if (phase.name === 'error') {
214+
if (errorFallback) {
215+
return <>{errorFallback(phase.error, retry)}</>;
216+
}
217+
return <DefaultErrorCard error={phase.error} onRetry={retry} />;
218+
}
219+
220+
if (phase.name === 'success') {
221+
return <>{children}</>;
222+
}
223+
224+
// pending — show fallback while the sequence runs.
225+
return <>{fallback ?? <DefaultFallback />}</>;
226+
};
227+
228+
/**
229+
* Placeholder connecting card. FR-2632 replaces this with the polished
230+
* BAICard-based version + i18n keys.
231+
*/
232+
const DefaultFallback: React.FC = () => {
233+
const { t } = useTranslation();
234+
return (
235+
<BAIFlex
236+
direction="column"
237+
align="center"
238+
justify="center"
239+
style={{ minHeight: '60vh' }}
240+
>
241+
<Card>{t('login.ConnectingToCluster')}</Card>
242+
</BAIFlex>
243+
);
244+
};
245+
246+
/**
247+
* Placeholder error card. FR-2632 replaces this with the full BAICard +
248+
* BAIButton UI (Retry with async loading state, Copy error details).
249+
*/
250+
const DefaultErrorCard: React.FC<{
251+
error: STokenLoginError;
252+
onRetry: () => void;
253+
}> = ({ error, onRetry }) => {
254+
return (
255+
<BAIFlex
256+
direction="column"
257+
align="center"
258+
justify="center"
259+
style={{ minHeight: '60vh', padding: 24 }}
260+
>
261+
<Alert
262+
type="error"
263+
title={`sToken login failed: ${error.kind}`}
264+
description={
265+
'cause' in error && error.cause
266+
? String((error.cause as Error)?.message ?? error.cause)
267+
: undefined
268+
}
269+
style={{ maxWidth: 520, marginBottom: 16 }}
270+
/>
271+
<Button type="primary" onClick={onRetry}>
272+
Retry
273+
</Button>
274+
</BAIFlex>
275+
);
276+
};

0 commit comments

Comments
 (0)