Skip to content

Commit 6c16c60

Browse files
committed
feat(FR-2626): migrate LoginView sToken path to STokenLoginBoundary
Story 2 of Epic FR-2616. Wires up the reusable STokenLoginBoundary component introduced in Story 1 as the canonical sToken entry point for the main app (/ and /interactive-login). Changes: - Add STokenGuard component in routes.tsx: reads sToken via useSToken (nuqs), conditionally wraps the downstream tree with STokenLoginBoundary when a token is present, passthrough otherwise. Applied to / and /interactive-login routes. - Add persistPostLoginState helper in helper/loginSessionAuth.ts consolidating the non-LoginView-specific post-login side effects (last_login / login_attempt counters, clear saved credentials, persist api_endpoint, set client.ready) so the route-level onSuccess callback and LoginView's panel-based postConnectSetup can keep these behaviors in lockstep. client.ready = true is what allows useLoginOrchestration to short-circuit on subsequent LoginView mounts. - Remove the sToken branch from LoginView.connectUsingSession (previously at lines 455-490). The component no longer reads window.location.search for sToken; URL parsing is the route wrapper's responsibility via nuqs (spec 'URL 파라미터 파싱 규약'). Drops the now-unused tokenLogin import. Behavior parity: - Already-logged-in users arriving with ?sToken=... skip token_login via the boundary's check_login idempotency fast-path (FR-2631). - Successful sToken auth persists the same localStorage / backendaioptions / cookie state the legacy branch did. - URL cleanup still strips both sToken and the deprecated stoken key, now via nuqs setter rather than history.replaceState({}). Resolves FR-2636, FR-2637, FR-2638 (bundled — same two files, tightly-coupled change). FR-2639 (E2E regression) ships in the follow-up PR. Refs FR-2616, FR-2626
1 parent 3edbd01 commit 6c16c60

3 files changed

Lines changed: 114 additions & 69 deletions

File tree

react/src/components/LoginView.tsx

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
import {
2121
createBackendAIClient,
2222
connectViaGQL,
23-
tokenLogin,
2423
loadConfigFromWebServer,
2524
loginWithSAML,
2625
loginWithOpenID,
@@ -452,42 +451,10 @@ const LoginView: React.FC<{
452451
block(t('login.PleaseWait'), t('login.ConnectingToCluster'));
453452
}
454453

455-
// Check for SSO token
456-
const urlParams = new URLSearchParams(window.location.search);
457-
const sToken = urlParams.get('sToken');
458-
if (sToken) {
459-
try {
460-
document.cookie = `sToken=${sToken}; expires=Session; path=/; Secure; SameSite=Lax`;
461-
const updatedEndpoints = await tokenLogin(
462-
client,
463-
sToken,
464-
configRef.current,
465-
endpoints,
466-
);
467-
setEndpoints(updatedEndpoints);
468-
469-
// tokenLogin already called connectViaGQL internally; reuse the
470-
// shared post-connect helper to stay in sync with the regular
471-
// session-login path (login_attempt/last_login, clearSavedLoginInfo,
472-
// forceLoginApprovedRef reset, panel close, endpoint persistence).
473-
postConnectSetup(client);
474-
475-
// Strip the sToken from the URL so it doesn't leak into browser
476-
// history or get re-processed on refresh.
477-
window.history.replaceState({}, '', '/');
478-
return;
479-
} catch (err) {
480-
logger.error('tokenLogin failed', err);
481-
notification(t('eduapi.CannotAuthorizeSessionByToken'));
482-
// Previously a hard reload cleared the UI; now we must restore
483-
// the login panel ourselves so the user isn't stuck in a loading
484-
// or blocked state.
485-
setIsBlockPanelOpen(false);
486-
open();
487-
setIsLoading(false);
488-
return;
489-
}
490-
}
454+
// sToken (SSO) URL entry is handled entirely by STokenLoginBoundary
455+
// at the route level (see routes.tsx `STokenGuard`). LoginView no
456+
// longer reads sToken from the URL; when a token is present the
457+
// boundary mounts LoginView only after authentication succeeds.
491458

492459
// Do session login
493460
// client.login() returns only on success (authenticated === true).

react/src/helper/loginSessionAuth.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,45 @@ export async function tokenLogin(
206206
return connectViaGQL(client, cfg, endpoints);
207207
}
208208

209+
/**
210+
* Persist the state a successful login should leave behind, independent of
211+
* which UI surface performed the login (LoginView panel, STokenLoginBoundary,
212+
* etc.). Centralized here so every successful login path keeps the same
213+
* side effects in lockstep:
214+
*
215+
* - `last_login` timestamp + reset of `login_attempt` counter
216+
* - drop any saved username / password / keypair credentials from prior
217+
* signed-out sessions on this device
218+
* - persist the resolved API endpoint into `localStorage` so the next
219+
* cold start can re-use it
220+
* - mark the client `ready` so `useLoginOrchestration` short-circuits on
221+
* subsequent mounts within the same page load
222+
*
223+
* Callers: LoginView's `postConnectSetup` (for panel-based login) and the
224+
* STokenLoginBoundary `onSuccess` route handlers. Kept separate from
225+
* `connectViaGQL` because the GQL step also runs for the non-authenticated
226+
* paths (e.g. an already-logged-in session refresh) where the counter and
227+
* credential-cleanup side effects would be incorrect.
228+
*/
229+
export function persistPostLoginState(client: any): void {
230+
const options = (globalThis as any).backendaioptions;
231+
if (options) {
232+
options.set('last_login', Math.floor(Date.now() / 1000), 'general');
233+
options.set('login_attempt', 0, 'general');
234+
}
235+
localStorage.removeItem('backendaiwebui.login.api_key');
236+
localStorage.removeItem('backendaiwebui.login.secret_key');
237+
localStorage.removeItem('backendaiwebui.login.user_id');
238+
localStorage.removeItem('backendaiwebui.login.password');
239+
const endpoint = client?._config?.endpoint;
240+
if (typeof endpoint === 'string' && endpoint) {
241+
localStorage.setItem('backendaiwebui.api_endpoint', endpoint);
242+
}
243+
if (client) {
244+
client.ready = true;
245+
}
246+
}
247+
209248
/**
210249
* Load webserver config when the api_endpoint differs from current URL origin.
211250
* Uses React's fetchAndParseConfig instead of the Lit shell's _parseConfig.

react/src/routes.tsx

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ import FlexActivityIndicator from './components/FlexActivityIndicator';
1212
import LocationStateBreadCrumb from './components/LocationStateBreadCrumb';
1313
import LoginView from './components/LoginView';
1414
import MainLayout from './components/MainLayout/MainLayout';
15+
import { STokenLoginBoundary } from './components/STokenLoginBoundary';
1516
import WebUINavigate from './components/WebUINavigate';
17+
import { persistPostLoginState } from './helper/loginSessionAuth';
1618
import { useSuspendedBackendaiClient } from './hooks';
1719
import { useAutoDiagnostics } from './hooks/useAutoDiagnostics';
1820
import { useBAISettingUserState } from './hooks/useBAISetting';
1921
import { LogoutEventHandler } from './hooks/useLogout';
22+
import { useSToken } from './hooks/useSToken';
2023
import { useWebUIMenuItems } from './hooks/useWebUIMenuItems';
2124
// High priority to import the component
2225
import ComputeSessionListPage from './pages/ComputeSessionListPage';
@@ -692,6 +695,38 @@ const AutoDiagnosticsEffect = () => {
692695
return null;
693696
};
694697

698+
/**
699+
* Route-level gate that delegates to `STokenLoginBoundary` when an sToken
700+
* is present in the URL (transparently passes through otherwise). Sourced
701+
* here so the regular `LoginView` + `MainLayout` tree never re-reads the
702+
* URL query for authentication — see the spec "URL 파라미터 파싱 규약
703+
* (nuqs)" section for the invariant.
704+
*
705+
* On boundary success, the route-level `onSuccess` persists the login
706+
* state (`last_login`, `login_attempt`, saved-credential cleanup,
707+
* `api_endpoint`, `client.ready`) and nulls both `sToken` / `stoken` keys
708+
* from the URL via the nuqs setter so the token doesn't leak into browser
709+
* history or referer headers.
710+
*/
711+
const STokenGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
712+
'use memo';
713+
const [sToken, clearSToken] = useSToken();
714+
if (!sToken) {
715+
return <>{children}</>;
716+
}
717+
return (
718+
<STokenLoginBoundary
719+
sToken={sToken}
720+
onSuccess={(client) => {
721+
persistPostLoginState(client);
722+
clearSToken(null);
723+
}}
724+
>
725+
{children}
726+
</STokenLoginBoundary>
727+
);
728+
};
729+
695730
/**
696731
* Root routes configuration
697732
*/
@@ -702,13 +737,15 @@ export const routes: RouteObject[] = [
702737
element: (
703738
<BAIErrorBoundary>
704739
<DefaultProvidersForReactRoot>
705-
<Suspense>
706-
<LoginView waitForMainLayout={false} />
707-
</Suspense>
708-
<LogoutEventHandler />
709-
<Suspense fallback={<Skeleton active />}>
710-
<InteractiveLoginPage />
711-
</Suspense>
740+
<STokenGuard>
741+
<Suspense>
742+
<LoginView waitForMainLayout={false} />
743+
</Suspense>
744+
<LogoutEventHandler />
745+
<Suspense fallback={<Skeleton active />}>
746+
<InteractiveLoginPage />
747+
</Suspense>
748+
</STokenGuard>
712749
</DefaultProvidersForReactRoot>
713750
</BAIErrorBoundary>
714751
),
@@ -771,33 +808,35 @@ export const routes: RouteObject[] = [
771808
element: (
772809
<BAIErrorBoundary>
773810
<DefaultProvidersForReactRoot>
774-
<Suspense>
775-
<LoginView />
776-
</Suspense>
777-
{/*FYI, MainLayout has ErrorBoundaryWithNullFallback for <Outlet/> */}
778-
<MainLayout />
779-
<ErrorBoundaryWithNullFallback>
780-
<RoutingEventHandler />
781-
</ErrorBoundaryWithNullFallback>
782-
<Suspense>
783-
<ErrorBoundaryWithNullFallback>
784-
<AutoDiagnosticsEffect />
785-
</ErrorBoundaryWithNullFallback>
786-
</Suspense>
787-
<Suspense>
788-
<ErrorBoundaryWithNullFallback>
789-
<LoginViewLazy />
790-
</ErrorBoundaryWithNullFallback>
791-
<ErrorBoundaryWithNullFallback>
792-
<FolderExplorerOpener />
793-
</ErrorBoundaryWithNullFallback>
794-
<ErrorBoundaryWithNullFallback>
795-
<FolderInvitationResponseModalOpener />
796-
</ErrorBoundaryWithNullFallback>
811+
<STokenGuard>
812+
<Suspense>
813+
<LoginView />
814+
</Suspense>
815+
{/*FYI, MainLayout has ErrorBoundaryWithNullFallback for <Outlet/> */}
816+
<MainLayout />
797817
<ErrorBoundaryWithNullFallback>
798-
<FileUploadManager />
818+
<RoutingEventHandler />
799819
</ErrorBoundaryWithNullFallback>
800-
</Suspense>
820+
<Suspense>
821+
<ErrorBoundaryWithNullFallback>
822+
<AutoDiagnosticsEffect />
823+
</ErrorBoundaryWithNullFallback>
824+
</Suspense>
825+
<Suspense>
826+
<ErrorBoundaryWithNullFallback>
827+
<LoginViewLazy />
828+
</ErrorBoundaryWithNullFallback>
829+
<ErrorBoundaryWithNullFallback>
830+
<FolderExplorerOpener />
831+
</ErrorBoundaryWithNullFallback>
832+
<ErrorBoundaryWithNullFallback>
833+
<FolderInvitationResponseModalOpener />
834+
</ErrorBoundaryWithNullFallback>
835+
<ErrorBoundaryWithNullFallback>
836+
<FileUploadManager />
837+
</ErrorBoundaryWithNullFallback>
838+
</Suspense>
839+
</STokenGuard>
801840
</DefaultProvidersForReactRoot>
802841
</BAIErrorBoundary>
803842
),

0 commit comments

Comments
 (0)