@@ -23,6 +23,21 @@ import { useBackendAIAppLauncherFragment$key } from 'src/__generated__/useBacken
2323interface EduAppLauncherProps {
2424 apiEndpoint : string ;
2525 active : boolean ;
26+ /**
27+ * sToken captured by the route-level `STokenLoginBoundary` wrapper before
28+ * the URL was cleaned. The boundary already authenticated with this token;
29+ * the value is only plumbed through so customer-specific code paths that
30+ * still reference the token (e.g. `eduApp.get_user_credential` inside
31+ * `_createEduSession`) can continue to function after the URL cleanup.
32+ * `null` when the page is opened without an sToken URL.
33+ */
34+ sToken ?: string | null ;
35+ /**
36+ * Remaining URL query parameters (all keys except `sToken` / `stoken`)
37+ * captured at route mount. Carries app / session_id / resource hints that
38+ * drive the launch sequence without re-parsing `window.location`.
39+ */
40+ extraParams ?: Record < string , string > ;
2641}
2742
2843/**
@@ -43,13 +58,19 @@ export type EduAppSessionErrorCategory =
4358 * Staged state machine for the EduAppLauncher flow.
4459 *
4560 * Stages progress:
46- * idle -> auth -> session -> launching -> done
61+ * idle -> session -> launching -> done
4762 * Any stage may transition to `error` with a step label. The Card UI
4863 * (FR-2487) consumes this state; FR-2484 introduces the machine only.
64+ *
65+ * Authentication is owned by the route-level `STokenLoginBoundary` wrapper —
66+ * by the time this component mounts, `globalThis.backendaiclient` is already
67+ * authenticated via sToken (or an existing session) and `connectViaGQL` has
68+ * resolved `groups` / `groupIds` / `current_group`. The legacy `auth`
69+ * stage is therefore removed; the launcher only visualizes session and
70+ * launch work.
4971 */
5072export type EduAppLaunchStage =
5173 | { name : 'idle' }
52- | { name : 'auth' }
5374 | {
5475 name : 'session' ;
5576 sessionRowId : string ;
@@ -64,7 +85,7 @@ export type EduAppLaunchStage =
6485 | { name : 'done' ; appConnectUrl : string }
6586 | {
6687 name : 'error' ;
67- step : 'auth' | ' session' | 'launch' ;
88+ step : 'session' | 'launch' ;
6889 category ?: EduAppSessionErrorCategory ;
6990 message : string ;
7091 } ;
@@ -94,6 +115,8 @@ const g = globalThis as any;
94115const EduAppLauncher : React . FC < EduAppLauncherProps > = ( {
95116 apiEndpoint,
96117 active,
118+ sToken = null ,
119+ extraParams = { } ,
97120} ) => {
98121 'use memo' ;
99122
@@ -164,103 +187,19 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
164187 } ;
165188
166189 /**
167- * Initialize the backend.ai client with session-based auth mode.
168- *
169- * The caller (`EduAppLauncherPage`) is responsible for providing a
170- * non-empty endpoint via `useResolvedApiEndpoint()`, which reads from
171- * `config.toml` and suspends until resolved. If the endpoint is still
172- * empty here, client initialization will be rejected and the outer
173- * catch in `_launch` will surface the error via notification.
190+ * Attach the wsproxy URL from `config.toml` to the already-authenticated
191+ * Backend.AI client set up by `STokenLoginBoundary` (route-level). The
192+ * boundary owns client creation and authentication (sToken login,
193+ * `get_manager_version`, `client.ready`); EduAppLauncher only layers on
194+ * the proxyURL needed for the app-launch step, which is not part of the
195+ * boundary's narrow scope.
174196 */
175- const _initClient = async ( endpoint : string ) => {
176- const resolvedEndpoint = endpoint . trim ( ) ;
177-
178- if ( ! resolvedEndpoint ) {
179- throw new Error ( 'API endpoint is empty; cannot initialize client.' ) ;
180- }
181-
182- const clientConfig = new g . BackendAIClientConfig (
183- '' ,
184- '' ,
185- resolvedEndpoint ,
186- 'SESSION' ,
187- ) ;
188- g . backendaiclient = new g . BackendAIClient (
189- clientConfig ,
190- 'Backend.AI Web UI.' ,
191- ) ;
197+ const _attachProxyURL = async ( ) => {
192198 const configPath = g . isElectron ? './config.toml' : '../../config.toml' ;
193199 const { config : tomlConfig } = await fetchAndParseConfig ( configPath ) ;
194- if ( tomlConfig ?. wsproxy ?. proxyURL ) {
200+ if ( tomlConfig ?. wsproxy ?. proxyURL && g . backendaiclient ?. _config ) {
195201 g . backendaiclient . _config . _proxyURL = tomlConfig . wsproxy . proxyURL ;
196202 }
197- await g . backendaiclient . get_manager_version ( ) ;
198- g . backendaiclient . ready = true ;
199- } ;
200-
201- /**
202- * Authenticate via token from URL query parameters.
203- */
204- const _token_login = async ( ) : Promise < boolean > => {
205- const urlParams = new URLSearchParams ( window . location . search ) ;
206- const sToken = urlParams . get ( 'sToken' ) || urlParams . get ( 'stoken' ) || null ;
207-
208- if ( sToken !== null ) {
209- document . cookie = `sToken=${ encodeURIComponent ( sToken ) } ; path=/; Secure; SameSite=Lax` ;
210- }
211-
212- const extraParams : Record < string , string > = { } ;
213- for ( const [ key , value ] of urlParams . entries ( ) ) {
214- if ( key !== 'sToken' && key !== 'stoken' ) {
215- extraParams [ key ] = value ;
216- }
217- }
218-
219- try {
220- const alreadyLoggedIn = await g . backendaiclient . check_login ( ) ;
221- if ( ! alreadyLoggedIn ) {
222- const loginSuccess = await g . backendaiclient . token_login (
223- sToken ,
224- extraParams ,
225- ) ;
226- if ( ! loginSuccess ) {
227- notify ( t ( 'eduapi.CannotAuthorizeSessionByToken' ) ) ;
228- return false ;
229- }
230- }
231- return true ;
232- } catch ( err ) {
233- logger . error ( 'Token login failed:' , err ) ;
234- notify ( t ( 'eduapi.CannotAuthorizeSessionByToken' ) ) ;
235- return false ;
236- }
237- } ;
238-
239- /**
240- * Fetch and cache group/project information for the current user.
241- */
242- const _prepareProjectInformation = async ( ) => {
243- const fields = [ 'email' , 'groups {name, id}' ] ;
244- const query = `query { user{ ${ fields . join ( ' ' ) } } }` ;
245- const response = await g . backendaiclient . query ( query , { } ) ;
246-
247- g . backendaiclient . groups = response . user . groups
248- . map ( ( item : { name : string } ) => item . name )
249- . sort ( ) ;
250- g . backendaiclient . groupIds = response . user . groups . reduce (
251- ( acc : Record < string , string > , group : { name : string ; id : string } ) => {
252- acc [ group . name ] = group . id ;
253- return acc ;
254- } ,
255- { } ,
256- ) ;
257- const currentProject = g . backendaiutils . _readRecentProjectGroup ( ) ;
258- g . backendaiclient . current_group = currentProject
259- ? currentProject
260- : g . backendaiclient . groups [ 0 ] ;
261- g . backendaiclient . current_group_id = ( ) => {
262- return g . backendaiclient . groupIds [ g . backendaiclient . current_group ] ;
263- } ;
264203 } ;
265204
266205 /**
@@ -396,12 +335,11 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
396335 return ;
397336 }
398337
399- const urlParams = new URLSearchParams ( window . location . search ) ;
400- const requestedApp = urlParams . get ( 'app' ) || 'jupyter' ;
338+ const requestedApp = extraParams . app || 'jupyter' ;
401339 let parsedAppName = requestedApp ;
402340 const sessionTemplateName =
403- urlParams . get ( ' session_template' ) ||
404- urlParams . get ( ' sessionTemplate' ) ||
341+ extraParams . session_template ||
342+ extraParams . sessionTemplate ||
405343 requestedApp ;
406344
407345 if ( eduAppNamePrefix !== '' && requestedApp . startsWith ( eduAppNamePrefix ) ) {
@@ -619,8 +557,10 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
619557 } ) ;
620558
621559 // 4c. credential bootstrap script (only when an sToken is present
622- // AND the customer-specific endpoint is available).
623- const sToken = urlParams . get ( 'sToken' ) || urlParams . get ( 'stoken' ) ;
560+ // AND the customer-specific endpoint is available). The sToken
561+ // prop comes from the route-level `STokenLoginBoundary` wrapper
562+ // and is captured before the URL is cleaned on successful auth,
563+ // so this call still receives the original token.
624564 logger . info ( '[_createEduSession] step 4c: get_user_credential' , {
625565 has_sToken : ! ! sToken ,
626566 } ) ;
@@ -737,78 +677,44 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
737677 } ;
738678
739679 /**
740- * Main launch sequence: init client, token login, prepare project,
741- * then start or reuse session and launch the app.
680+ * Main launch sequence: attach proxy URL, then start or reuse a session
681+ * and launch the app. sToken authentication and project/group bootstrap
682+ * are handled upstream by `STokenLoginBoundary` (route wrapper in
683+ * `EduAppLauncherPage`); by the time this function runs
684+ * `globalThis.backendaiclient` is already authenticated, `ready`, and
685+ * has `groups` / `current_group` populated by `connectViaGQL`. URL
686+ * parameters are passed via `extraParams` / `sToken` props rather than
687+ * re-parsing `window.location` (spec "URL 파라미터 파싱 규약 (nuqs)").
742688 */
743689 const _launch = async ( endpoint : string ) => {
744690 logger . info ( '[EduAppLauncher] _launch() start' , { endpoint } ) ;
745- transition ( { name : 'auth' } ) ;
746691 try {
747- await _initClient ( endpoint ) ;
692+ await _attachProxyURL ( ) ;
748693 } catch ( err ) {
749- logger . error ( 'Failed to initialize client :' , err ) ;
694+ logger . error ( 'Failed to attach wsproxy URL :' , err ) ;
750695 transition ( {
751696 name : 'error' ,
752- step : 'auth ' ,
697+ step : 'session ' ,
753698 message : t ( 'eduapi.CannotInitializeClient' ) ,
754699 } ) ;
755700 notify ( t ( 'eduapi.CannotInitializeClient' ) , undefined , true ) ;
756701 return ;
757702 }
758703
759- const urlParams = new URLSearchParams ( window . location . search ) ;
760704 const resources : Record < string , string | null > = {
761- cpu : urlParams . get ( ' cpu' ) ,
762- mem : urlParams . get ( ' mem' ) ,
763- shmem : urlParams . get ( ' shmem' ) ,
764- 'cuda.shares' : urlParams . get ( 'cuda-shares' ) ,
765- 'cuda.device' : urlParams . get ( 'cuda-device' ) ,
705+ cpu : extraParams . cpu ?? null ,
706+ mem : extraParams . mem ?? null ,
707+ shmem : extraParams . shmem ?? null ,
708+ 'cuda.shares' : extraParams [ 'cuda-shares' ] ?? null ,
709+ 'cuda.device' : extraParams [ 'cuda-device' ] ?? null ,
766710 } ;
767711
768- const loginSuccess = await _token_login ( ) ;
769- if ( ! loginSuccess ) {
770- transition ( {
771- name : 'error' ,
772- step : 'auth' ,
773- message : t ( 'eduapi.CannotAuthorizeSessionByToken' ) ,
774- } ) ;
775- return ;
776- }
777-
778- // Dispatch `backend-ai-connected` so any descendant component using
779- // `useSuspendedBackendaiClient()` (e.g. `useBackendAIAppLauncher` deep
780- // inside `EduAppSessionLauncher`) can resolve. The shared
781- // `backendaiClientPromise` is created at module load time and only
782- // resolves on this event — `LoginView` dispatches it after a normal
783- // login, but the EduAppLauncher token-login flow bypasses LoginView
784- // entirely, so we have to dispatch it ourselves once the client is
785- // authenticated and ready.
786- logger . info ( '[EduAppLauncher] dispatching backend-ai-connected' ) ;
787- document . dispatchEvent (
788- new CustomEvent ( 'backend-ai-connected' , {
789- detail : g . backendaiclient ,
790- } ) ,
791- ) ;
792-
793- try {
794- await _prepareProjectInformation ( ) ;
795- } catch ( err ) {
796- logger . error ( 'Failed to prepare project information:' , err ) ;
797- transition ( {
798- name : 'error' ,
799- step : 'auth' ,
800- message : ( err as any ) ?. message ?? String ( err ) ,
801- } ) ;
802- _handleError ( err ) ;
803- return ;
804- }
805-
806- const sessionId = urlParams . get ( 'session_id' ) || null ;
712+ const sessionId = extraParams . session_id || null ;
807713 if ( sessionId ) {
808714 logger . info ( '[EduAppLauncher] _launch: Path A (session_id provided)' , {
809715 sessionId,
810716 } ) ;
811- const requestedApp = urlParams . get ( ' app' ) || 'jupyter' ;
717+ const requestedApp = extraParams . app || 'jupyter' ;
812718 transition ( {
813719 name : 'session' ,
814720 sessionRowId : sessionId ,
@@ -849,53 +755,45 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
849755 } , [ active , apiEndpoint ] ) ;
850756
851757 // Map the state machine stage to the visual Steps component:
852- // step 0 = Authentication
853- // step 1 = Session (lookup or create)
854- // step 2 = Launch (proxy + window.open)
758+ // step 0 = Session (lookup or create)
759+ // step 1 = Launch (proxy + window.open)
760+ // - Authentication is handled upstream by `STokenLoginBoundary` and is
761+ // not represented in this stepper at all.
855762 // - Stages strictly before the current step are 'finish'.
856763 // - The current step is 'process', or 'error' when stage.name === 'error'
857764 // and the error step matches.
858765 // - Stages after the current step are 'wait'.
859- // - On 'done', all three steps are 'finish'.
766+ // - On 'done', both steps are 'finish'.
860767 // No `useMemo` needed: `'use memo'` directive at the top of this
861768 // component lets the React Compiler memoize derived values automatically.
862- const STEP_AUTH = 0 ;
863- const STEP_SESSION = 1 ;
864- const STEP_LAUNCH = 2 ;
865- let currentStep = STEP_AUTH ;
769+ const STEP_SESSION = 0 ;
770+ const STEP_LAUNCH = 1 ;
771+ let currentStep = STEP_SESSION ;
866772 let stepStatuses : Array < 'wait' | 'process' | 'finish' | 'error' > = [
867773 'wait' ,
868774 'wait' ,
869- 'wait' ,
870775 ] ;
871776 switch ( stage . name ) {
872777 case 'idle' :
873- case 'auth' :
874- currentStep = STEP_AUTH ;
875- stepStatuses = [ 'process' , 'wait' , 'wait' ] ;
876- break ;
877778 case 'session' :
878779 currentStep = STEP_SESSION ;
879- stepStatuses = [ 'finish' , ' process', 'wait' ] ;
780+ stepStatuses = [ 'process' , 'wait' ] ;
880781 break ;
881782 case 'launching' :
882783 currentStep = STEP_LAUNCH ;
883- stepStatuses = [ 'finish' , 'finish' , ' process'] ;
784+ stepStatuses = [ 'finish' , 'process' ] ;
884785 break ;
885786 case 'done' :
886787 currentStep = STEP_LAUNCH ;
887- stepStatuses = [ 'finish' , 'finish' , 'finish' ] ;
788+ stepStatuses = [ 'finish' , 'finish' ] ;
888789 break ;
889790 case 'error' : {
890- if ( stage . step === 'auth' ) {
891- currentStep = STEP_AUTH ;
892- stepStatuses = [ 'error' , 'wait' , 'wait' ] ;
893- } else if ( stage . step === 'session' ) {
791+ if ( stage . step === 'session' ) {
894792 currentStep = STEP_SESSION ;
895- stepStatuses = [ 'finish' , ' error', 'wait' ] ;
793+ stepStatuses = [ 'error' , 'wait' ] ;
896794 } else {
897795 currentStep = STEP_LAUNCH ;
898- stepStatuses = [ 'finish' , 'finish' , ' error'] ;
796+ stepStatuses = [ 'finish' , 'error' ] ;
899797 }
900798 break ;
901799 }
@@ -984,17 +882,13 @@ const EduAppLauncher: React.FC<EduAppLauncherProps> = ({
984882 orientation = "vertical"
985883 current = { currentStep }
986884 items = { [
987- {
988- title : t ( 'eduapi.CheckingAuthentication' ) ,
989- status : stepStatuses [ 0 ] ,
990- } ,
991885 {
992886 title : t ( 'eduapi.PreparingSession' ) ,
993- status : stepStatuses [ 1 ] ,
887+ status : stepStatuses [ 0 ] ,
994888 } ,
995889 {
996890 title : t ( 'eduapi.LaunchingAppStep' ) ,
997- status : stepStatuses [ 2 ] ,
891+ status : stepStatuses [ 1 ] ,
998892 } ,
999893 ] }
1000894 />
0 commit comments