Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 4 additions & 37 deletions react/src/components/LoginView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
import {
createBackendAIClient,
connectViaGQL,
tokenLogin,
loadConfigFromWebServer,
loginWithSAML,
loginWithOpenID,
Expand Down Expand Up @@ -452,42 +451,10 @@ const LoginView: React.FC<{
block(t('login.PleaseWait'), t('login.ConnectingToCluster'));
}

// Check for SSO token
const urlParams = new URLSearchParams(window.location.search);
const sToken = urlParams.get('sToken');
if (sToken) {
try {
document.cookie = `sToken=${sToken}; expires=Session; path=/; Secure; SameSite=Lax`;
const updatedEndpoints = await tokenLogin(
client,
sToken,
configRef.current,
endpoints,
);
setEndpoints(updatedEndpoints);

// tokenLogin already called connectViaGQL internally; reuse the
// shared post-connect helper to stay in sync with the regular
// session-login path (login_attempt/last_login, clearSavedLoginInfo,
// forceLoginApprovedRef reset, panel close, endpoint persistence).
postConnectSetup(client);

// Strip the sToken from the URL so it doesn't leak into browser
// history or get re-processed on refresh.
window.history.replaceState({}, '', '/');
return;
} catch (err) {
logger.error('tokenLogin failed', err);
notification(t('eduapi.CannotAuthorizeSessionByToken'));
// Previously a hard reload cleared the UI; now we must restore
// the login panel ourselves so the user isn't stuck in a loading
// or blocked state.
setIsBlockPanelOpen(false);
open();
setIsLoading(false);
return;
}
}
// sToken (SSO) URL entry is handled entirely by STokenLoginBoundary
// at the route level (see routes.tsx `STokenGuard`). LoginView no
// longer reads sToken from the URL; when a token is present the
// boundary mounts LoginView only after authentication succeeds.
Comment thread
nowgnuesLee marked this conversation as resolved.

// Do session login
// client.login() returns only on success (authenticated === true).
Expand Down
42 changes: 42 additions & 0 deletions react/src/helper/loginSessionAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,48 @@ export async function tokenLogin(
return connectViaGQL(client, cfg, endpoints);
}

/**
* Persist the state a successful login should leave behind, independent of
* which UI surface performed the login (LoginView panel, STokenLoginBoundary,
* etc.). Centralized here so every successful login path keeps the same
* side effects in lockstep:
*
* - `last_login` timestamp + reset of `login_attempt` counter
* - drop any saved username / password / keypair credentials from prior
* signed-out sessions on this device
* - persist the resolved API endpoint into `localStorage` so the next
* cold start can re-use it
* - mark the client `ready` so `useLoginOrchestration` short-circuits on
* subsequent mounts within the same page load
*
* Callers: the `STokenLoginBoundary` `onSuccess` route handlers (route-level
* sToken flow for `/`, `/interactive-login`, `/edu-applauncher`, `/applauncher`).
* LoginView's panel-based login still runs its own `postConnectSetup` inline —
* unifying both paths through this helper is tracked as a follow-up refactor
* once the boundary migrations settle (see PR #6861 review discussion). Kept
* separate from `connectViaGQL` because the GQL step also runs for the
* non-authenticated paths (e.g. an already-logged-in session refresh) where
* the counter and credential-cleanup side effects would be incorrect.
*/
export function persistPostLoginState(client: any): void {
const options = (globalThis as any).backendaioptions;
if (options) {
options.set('last_login', Math.floor(Date.now() / 1000), 'general');
options.set('login_attempt', 0, 'general');
}
localStorage.removeItem('backendaiwebui.login.api_key');
localStorage.removeItem('backendaiwebui.login.secret_key');
localStorage.removeItem('backendaiwebui.login.user_id');
localStorage.removeItem('backendaiwebui.login.password');
const endpoint = client?._config?.endpoint;
if (typeof endpoint === 'string' && endpoint) {
localStorage.setItem('backendaiwebui.api_endpoint', endpoint);
}
if (client) {
client.ready = true;
}
}

/**
* Load webserver config when the api_endpoint differs from current URL origin.
* Uses React's fetchAndParseConfig instead of the Lit shell's _parseConfig.
Expand Down
115 changes: 83 additions & 32 deletions react/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ import FlexActivityIndicator from './components/FlexActivityIndicator';
import LocationStateBreadCrumb from './components/LocationStateBreadCrumb';
import LoginView from './components/LoginView';
import MainLayout from './components/MainLayout/MainLayout';
import { STokenLoginBoundary } from './components/STokenLoginBoundary';
import WebUINavigate from './components/WebUINavigate';
import { persistPostLoginState } from './helper/loginSessionAuth';
import { useSuspendedBackendaiClient } from './hooks';
import { useAutoDiagnostics } from './hooks/useAutoDiagnostics';
import { useBAISettingUserState } from './hooks/useBAISetting';
import { LogoutEventHandler } from './hooks/useLogout';
import { useSToken } from './hooks/useSToken';
import { useWebUIMenuItems } from './hooks/useWebUIMenuItems';
import { pluginApiEndpointState } from './hooks/useWebUIPluginState';
// High priority to import the component
import ComputeSessionListPage from './pages/ComputeSessionListPage';
import LegacyModelStoreListPage from './pages/LegacyModelStoreListPage';
Expand All @@ -26,6 +30,7 @@ import ServingPage from './pages/ServingPage';
import VFolderNodeListPage from './pages/VFolderNodeListPage';
import { Skeleton, theme } from 'antd';
import { BAIFlex, BAICard } from 'backend.ai-ui';
import { useSetAtom } from 'jotai';
import React, { Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { RouteObject, useLocation } from 'react-router-dom';
Expand Down Expand Up @@ -692,6 +697,48 @@ const AutoDiagnosticsEffect = () => {
return null;
};

/**
* Route-level gate that delegates to `STokenLoginBoundary` when an sToken
* is present in the URL (transparently passes through otherwise). Sourced
* here so the regular `LoginView` + `MainLayout` tree never re-reads the
* URL query for authentication — see the spec "URL 파라미터 파싱 규약
* (nuqs)" section for the invariant.
*
* On boundary success, the route-level `onSuccess` persists the login
* state (`last_login`, `login_attempt`, saved-credential cleanup,
* `api_endpoint`, `client.ready`) and nulls both `sToken` / `stoken` keys
* from the URL via the nuqs setter so the token doesn't leak into browser
* history or referer headers.
*/
const STokenGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
'use memo';
const [sToken, clearSToken] = useSToken();
const setPluginApiEndpoint = useSetAtom(pluginApiEndpointState);
if (!sToken) {
return <>{children}</>;
}
return (
<STokenLoginBoundary
sToken={sToken}
onSuccess={(client) => {
persistPostLoginState(client);
// Mirror LoginView's postConnectSetup so `PluginLoader` (which gates
// on this atom) loads plugins on the sToken entry paths too. Without
// this set, Electron and plugin-enabled deployments would leave
// plugins permanently unloaded on any sToken-based login.
const endpoint = (client as { _config?: { endpoint?: unknown } })
?._config?.endpoint;
if (typeof endpoint === 'string' && endpoint) {
setPluginApiEndpoint(endpoint);
}
clearSToken(null);
}}
Comment thread
nowgnuesLee marked this conversation as resolved.
>
{children}
</STokenLoginBoundary>
);
};

/**
* Root routes configuration
*/
Expand All @@ -702,13 +749,15 @@ export const routes: RouteObject[] = [
element: (
<BAIErrorBoundary>
<DefaultProvidersForReactRoot>
<Suspense>
<LoginView waitForMainLayout={false} />
</Suspense>
<LogoutEventHandler />
<Suspense fallback={<Skeleton active />}>
<InteractiveLoginPage />
</Suspense>
<STokenGuard>
<Suspense>
<LoginView waitForMainLayout={false} />
</Suspense>
<LogoutEventHandler />
<Suspense fallback={<Skeleton active />}>
<InteractiveLoginPage />
</Suspense>
</STokenGuard>
</DefaultProvidersForReactRoot>
</BAIErrorBoundary>
),
Expand Down Expand Up @@ -771,33 +820,35 @@ export const routes: RouteObject[] = [
element: (
<BAIErrorBoundary>
<DefaultProvidersForReactRoot>
<Suspense>
<LoginView />
</Suspense>
{/*FYI, MainLayout has ErrorBoundaryWithNullFallback for <Outlet/> */}
<MainLayout />
<ErrorBoundaryWithNullFallback>
<RoutingEventHandler />
</ErrorBoundaryWithNullFallback>
<Suspense>
<ErrorBoundaryWithNullFallback>
<AutoDiagnosticsEffect />
</ErrorBoundaryWithNullFallback>
</Suspense>
<Suspense>
<ErrorBoundaryWithNullFallback>
<LoginViewLazy />
</ErrorBoundaryWithNullFallback>
<ErrorBoundaryWithNullFallback>
<FolderExplorerOpener />
</ErrorBoundaryWithNullFallback>
<ErrorBoundaryWithNullFallback>
<FolderInvitationResponseModalOpener />
</ErrorBoundaryWithNullFallback>
<STokenGuard>
<Suspense>
<LoginView />
</Suspense>
{/*FYI, MainLayout has ErrorBoundaryWithNullFallback for <Outlet/> */}
<MainLayout />
<ErrorBoundaryWithNullFallback>
<FileUploadManager />
<RoutingEventHandler />
</ErrorBoundaryWithNullFallback>
</Suspense>
<Suspense>
<ErrorBoundaryWithNullFallback>
<AutoDiagnosticsEffect />
</ErrorBoundaryWithNullFallback>
</Suspense>
<Suspense>
<ErrorBoundaryWithNullFallback>
<LoginViewLazy />
</ErrorBoundaryWithNullFallback>
<ErrorBoundaryWithNullFallback>
<FolderExplorerOpener />
</ErrorBoundaryWithNullFallback>
<ErrorBoundaryWithNullFallback>
<FolderInvitationResponseModalOpener />
</ErrorBoundaryWithNullFallback>
<ErrorBoundaryWithNullFallback>
<FileUploadManager />
</ErrorBoundaryWithNullFallback>
</Suspense>
</STokenGuard>
</DefaultProvidersForReactRoot>
</BAIErrorBoundary>
),
Expand Down
Loading