@@ -19,7 +19,7 @@ import {
1919import { useResolvedApiEndpoint } from '../hooks/useResolvedApiEndpoint' ;
2020import { loginConfigState } from '../hooks/useWebUIConfig' ;
2121import { jotaiStore } from './DefaultProviders' ;
22- import { App , Spin , Typography } from 'antd' ;
22+ import { App , Form , Input , Spin , Typography } from 'antd' ;
2323import { BAIButton , BAICard , BAIFlex , useBAILogger } from 'backend.ai-ui' ;
2424import { useAtomValue } from 'jotai' ;
2525import {
@@ -42,9 +42,86 @@ export type STokenLoginError =
4242 | { kind : 'endpoint-unresolved' ; cause ?: unknown }
4343 | { kind : 'server-unreachable' ; cause : unknown }
4444 | { kind : 'token-invalid' ; cause : unknown }
45+ /**
46+ * Webserver responded `require-totp-authentication` (or the equivalent
47+ * legacy detail string). The default error card swaps its action area
48+ * for an inline OTP input; submitting retries `token_login` with
49+ * `{ otp }` folded into `extraParams`. `invalidOtp` is set true when the
50+ * retry itself returned a TOTP rejection, so the card can show an
51+ * invalid-code hint above the input without re-classifying the error.
52+ */
53+ | { kind : 'totp-required' ; cause : unknown ; invalidOtp ?: boolean }
54+ /**
55+ * Webserver reported an existing active session for this user
56+ * (`active-login-session-exists`). The default error card swaps its
57+ * action area for a "terminate previous session?" confirm pair;
58+ * confirming retries `token_login` with `force: true` folded into
59+ * `extraParams` (sticky for subsequent retries within the same mount,
60+ * mirroring LoginView's `forceLoginApprovedRef`).
61+ */
4562 | { kind : 'concurrent-session' ; cause : unknown }
4663 | { kind : 'unknown' ; cause : unknown } ;
4764
65+ /**
66+ * Classify a `tokenLogin` failure into the appropriate `STokenLoginError`
67+ * kind.
68+ *
69+ * Uses duck-typed extraction (not `instanceof TokenLoginFailedError`) so
70+ * that error objects crossing module boundaries — e.g. Jest mocked
71+ * imports, or HMR where two copies of the helper module coexist —
72+ * classify the same as direct throws.
73+ *
74+ * TODO(user-tunable): once `client.token_login` surfaces the authenticated
75+ * probe `type` (see `TokenLoginFailedError.failType`), replace the
76+ * substring checks with strict `type` comparisons. The substrings below
77+ * are copied verbatim from `LoginView.handleLoginError`'s legacy fallback
78+ * so the two code paths classify identically until the structured type
79+ * plumbing lands.
80+ */
81+ const classifyTokenLoginFailure = (
82+ err : unknown ,
83+ submittedOtp : string | null ,
84+ ) : STokenLoginError => {
85+ const bag =
86+ typeof err === 'object' && err !== null
87+ ? ( err as Record < string , unknown > )
88+ : { } ;
89+ const failReason =
90+ typeof bag . failReason === 'string'
91+ ? bag . failReason
92+ : typeof ( err as Error | undefined ) ?. message === 'string'
93+ ? ( err as Error ) . message
94+ : '' ;
95+ const failType = typeof bag . failType === 'string' ? bag . failType : '' ;
96+ const needle = `${ failType } \n${ failReason } ` ;
97+
98+ const isTotpRequired =
99+ needle . includes ( 'require-totp-authentication' ) ||
100+ needle . includes ( 'You must authenticate using Two-Factor Authentication' ) ||
101+ needle . includes ( 'OTP not provided' ) ;
102+ const isInvalidTotp =
103+ needle . includes ( 'Invalid TOTP code provided' ) ||
104+ needle . includes ( 'Failed to validate OTP' ) ;
105+ if ( isTotpRequired || isInvalidTotp ) {
106+ return {
107+ kind : 'totp-required' ,
108+ cause : err ,
109+ // If we submitted an OTP and the server still rejected with a TOTP
110+ // signal, the code must have been wrong. Surface the invalid hint.
111+ invalidOtp : ! ! submittedOtp && ( isInvalidTotp || isTotpRequired ) ,
112+ } ;
113+ }
114+
115+ if (
116+ needle . includes ( 'active-login-session-exists' ) ||
117+ needle . includes ( 'existing active login session' )
118+ ) {
119+ return { kind : 'concurrent-session' , cause : err } ;
120+ }
121+
122+ return { kind : 'token-invalid' , cause : err } ;
123+ } ;
124+
48125export interface STokenLoginBoundaryProps {
49126 /**
50127 * Canonical sToken value sourced by the caller via nuqs. Required — the
@@ -135,6 +212,15 @@ const STokenLoginBoundaryInner: React.FC<STokenLoginBoundaryProps> = ({
135212 // most once per successful login; downstream subscribers (Relay, plugin
136213 // endpoint wiring) assume idempotency does not hold for them.
137214 const eventDispatchedRef = useRef ( false ) ;
215+ // OTP pending submission for the next retry, if any. Cleared once the
216+ // retry sequence reads it — TOTP codes are single-use and should not be
217+ // replayed silently on an unrelated retry.
218+ const pendingOtpRef = useRef < string | null > ( null ) ;
219+ // Sticky force-login approval: once the user confirms "terminate previous
220+ // session", every subsequent retry in this mount includes `force: true`
221+ // (mirrors LoginView's `forceLoginApprovedRef` so a TOTP challenge that
222+ // follows a force approval does not silently drop the force flag).
223+ const forceApprovedRef = useRef ( false ) ;
138224
139225 const surfaceError = useEffectEvent ( ( error : STokenLoginError ) => {
140226 setPhase ( { name : 'error' , error } ) ;
@@ -195,6 +281,17 @@ const STokenLoginBoundaryInner: React.FC<STokenLoginBoundaryProps> = ({
195281 globalThis as { backendaioptions ?: { get : ( k : string ) => unknown } }
196282 ) . backendaioptions ?. get ( 'endpoints' ) as string [ ] | undefined ) ?? [ ] ;
197283
284+ // Read and consume interactive-retry state once per sequence run. OTP
285+ // is single-use (cleared regardless of outcome); `force` is sticky
286+ // across retries once approved.
287+ const submittedOtp = pendingOtpRef . current ;
288+ pendingOtpRef . current = null ;
289+ const effectiveParams : Record < string , string | boolean > = {
290+ ...extraParams ,
291+ ...( submittedOtp ? { otp : submittedOtp } : { } ) ,
292+ ...( forceApprovedRef . current ? { force : true } : { } ) ,
293+ } ;
294+
198295 try {
199296 if ( alreadyLoggedIn ) {
200297 // Session already exists — wire up the GraphQL client / groups /
@@ -204,16 +301,11 @@ const STokenLoginBoundaryInner: React.FC<STokenLoginBoundaryProps> = ({
204301 // fast-path.
205302 await connectViaGQL ( client , cfg , endpoints ) ;
206303 } else {
207- await tokenLogin ( client , sToken ! , cfg , endpoints , extraParams ) ;
304+ await tokenLogin ( client , sToken ! , cfg , endpoints , effectiveParams ) ;
208305 }
209306 } catch ( cause ) {
210- // `concurrent-session` detection is deferred (spec Q6); all
211- // `token_login` failures map to `token-invalid` for now, with a TODO
212- // pointing at the sibling concurrent-login-guard spec.
213- // TODO(FR-2616 Q6): classify `concurrent-session` once the backend
214- // signal from `.specs/draft-concurrent-login-guard/` lands.
215307 logger . error ( '[STokenLoginBoundary] token_login failed' , cause ) ;
216- surfaceError ( { kind : 'token-invalid' , cause } ) ;
308+ surfaceError ( classifyTokenLoginFailure ( cause , submittedOtp ) ) ;
217309 return ;
218310 }
219311
@@ -241,11 +333,32 @@ const STokenLoginBoundaryInner: React.FC<STokenLoginBoundaryProps> = ({
241333 setRetryKey ( ( k ) => k + 1 ) ;
242334 } ;
243335
336+ // Fold a user-supplied OTP into the next retry. Called from the inline
337+ // TOTP form inside `DefaultErrorCard`.
338+ const retryWithOtp = ( otp : string ) => {
339+ pendingOtpRef . current = otp ;
340+ retry ( ) ;
341+ } ;
342+
343+ // Approve force-login for all subsequent retries in this mount and
344+ // immediately retry. Called from the inline concurrent-session confirm.
345+ const retryWithForce = ( ) => {
346+ forceApprovedRef . current = true ;
347+ retry ( ) ;
348+ } ;
349+
244350 if ( phase . name === 'error' ) {
245351 if ( errorFallback ) {
246352 return < > { errorFallback ( phase . error , retry ) } </ > ;
247353 }
248- return < DefaultErrorCard error = { phase . error } onRetry = { retry } /> ;
354+ return (
355+ < DefaultErrorCard
356+ error = { phase . error }
357+ onRetry = { retry }
358+ onSubmitOtp = { retryWithOtp }
359+ onConfirmForce = { retryWithForce }
360+ />
361+ ) ;
249362 }
250363
251364 if ( phase . name === 'success' ) {
@@ -300,15 +413,28 @@ const DefaultFallback: React.FC = () => {
300413
301414/**
302415 * Built-in error card rendered when `errorFallback` is not provided.
303- * Offers two actions: Retry (runs the sequence again via BAIButton's
304- * async `action` prop so the loading state appears automatically) and
305- * Copy details (serializes the `{ kind, cause }` payload to JSON and
306- * writes it to the clipboard for support follow-up).
416+ *
417+ * For most error kinds the card shows a description + {Copy details,
418+ * Retry} action pair. Two kinds swap the action area inline (per design:
419+ * no separate modal — the user stays on the same card layout and only
420+ * the lower half changes):
421+ *
422+ * - `totp-required` → OTP input + Submit button; on submit the
423+ * parent retries `token_login` with the `otp`
424+ * folded into `extraParams`.
425+ * - `concurrent-session` → "terminate previous session?" confirm pair;
426+ * Login button retries with `force: true`.
427+ *
428+ * Card `status` color also shifts for these two kinds (`warning` vs.
429+ * `error`) so the user reads the required follow-up action, not a
430+ * terminal failure.
307431 */
308432const DefaultErrorCard : React . FC < {
309433 error : STokenLoginError ;
310434 onRetry : ( ) => void ;
311- } > = ( { error, onRetry } ) => {
435+ onSubmitOtp : ( otp : string ) => void ;
436+ onConfirmForce : ( ) => void ;
437+ } > = ( { error, onRetry, onSubmitOtp, onConfirmForce } ) => {
312438 'use memo' ;
313439 const { t } = useTranslation ( ) ;
314440 const { message } = App . useApp ( ) ;
@@ -321,6 +447,11 @@ const DefaultErrorCard: React.FC<{
321447 ? String ( ( error . cause as Error ) ?. message ?? error . cause )
322448 : null ;
323449
450+ const status : 'error' | 'warning' =
451+ error . kind === 'totp-required' || error . kind === 'concurrent-session'
452+ ? 'warning'
453+ : 'error' ;
454+
324455 // Wrap in a Promise so BAIButton.action triggers its async loading
325456 // state; the synchronous state reset completes before the next render,
326457 // which is visually indistinguishable from the live sequence restart.
@@ -346,32 +477,101 @@ const DefaultErrorCard: React.FC<{
346477 style = { { minHeight : '60vh' , padding : 24 } }
347478 >
348479 < BAICard
349- status = "error"
480+ status = { status }
350481 title = { title }
351482 style = { { maxWidth : 520 , width : '100%' } }
352483 >
353484 < BAIFlex direction = "column" gap = "md" align = "stretch" >
354485 < Typography . Paragraph style = { { margin : 0 } } >
355486 { description }
356487 </ Typography . Paragraph >
357- { causeDetail && (
488+ { causeDetail && error . kind !== 'totp-required' && (
358489 < Typography . Paragraph
359490 type = "secondary"
360491 style = { { margin : 0 , fontSize : 12 , whiteSpace : 'pre-wrap' } }
361492 >
362493 { causeDetail }
363494 </ Typography . Paragraph >
364495 ) }
365- < BAIFlex direction = "row" gap = "sm" justify = "end" >
366- < BAIButton onClick = { handleCopy } >
367- { t ( 'sTokenLoginBoundary.CopyErrorDetails' ) }
368- </ BAIButton >
369- < BAIButton type = "primary" action = { handleRetry } >
370- { t ( 'sTokenLoginBoundary.Retry' ) }
371- </ BAIButton >
372- </ BAIFlex >
496+
497+ { error . kind === 'totp-required' ? (
498+ < TotpInlineForm
499+ invalidOtp = { ! ! error . invalidOtp }
500+ onSubmit = { onSubmitOtp }
501+ />
502+ ) : error . kind === 'concurrent-session' ? (
503+ < BAIFlex direction = "row" gap = "sm" justify = "end" >
504+ < BAIButton onClick = { handleCopy } >
505+ { t ( 'sTokenLoginBoundary.CopyErrorDetails' ) }
506+ </ BAIButton >
507+ < BAIButton
508+ type = "primary"
509+ action = { async ( ) => {
510+ await Promise . resolve ( ) ;
511+ onConfirmForce ( ) ;
512+ } }
513+ >
514+ { t ( 'sTokenLoginBoundary.ForceLogin' ) }
515+ </ BAIButton >
516+ </ BAIFlex >
517+ ) : (
518+ < BAIFlex direction = "row" gap = "sm" justify = "end" >
519+ < BAIButton onClick = { handleCopy } >
520+ { t ( 'sTokenLoginBoundary.CopyErrorDetails' ) }
521+ </ BAIButton >
522+ < BAIButton type = "primary" action = { handleRetry } >
523+ { t ( 'sTokenLoginBoundary.Retry' ) }
524+ </ BAIButton >
525+ </ BAIFlex >
526+ ) }
373527 </ BAIFlex >
374528 </ BAICard >
375529 </ BAIFlex >
376530 ) ;
377531} ;
532+
533+ /**
534+ * Inline OTP input + Submit button rendered inside `DefaultErrorCard`
535+ * when the boundary classifies the failure as `totp-required`. Trimmed
536+ * locally — the webserver ignores whitespace but users routinely paste a
537+ * code with trailing spaces from authenticator apps.
538+ */
539+ const TotpInlineForm : React . FC < {
540+ invalidOtp : boolean ;
541+ onSubmit : ( otp : string ) => void ;
542+ } > = ( { invalidOtp, onSubmit } ) => {
543+ 'use memo' ;
544+ const { t } = useTranslation ( ) ;
545+ const [ form ] = Form . useForm < { otp : string } > ( ) ;
546+ return (
547+ < Form
548+ form = { form }
549+ layout = "vertical"
550+ onFinish = { ( values ) => {
551+ const trimmed = ( values . otp ?? '' ) . trim ( ) ;
552+ if ( ! trimmed ) return ;
553+ onSubmit ( trimmed ) ;
554+ } }
555+ >
556+ < Form . Item
557+ name = "otp"
558+ validateStatus = { invalidOtp ? 'error' : undefined }
559+ help = { invalidOtp ? t ( 'sTokenLoginBoundary.ErrorTotpInvalidHint' ) : null }
560+ style = { { marginBottom : 12 } }
561+ >
562+ < Input
563+ autoFocus
564+ autoComplete = "one-time-code"
565+ inputMode = "numeric"
566+ maxLength = { 8 }
567+ placeholder = { t ( 'sTokenLoginBoundary.TotpPlaceholder' ) }
568+ />
569+ </ Form . Item >
570+ < BAIFlex direction = "row" gap = "sm" justify = "end" >
571+ < BAIButton type = "primary" htmlType = "submit" >
572+ { t ( 'sTokenLoginBoundary.SubmitOtp' ) }
573+ </ BAIButton >
574+ </ BAIFlex >
575+ </ Form >
576+ ) ;
577+ } ;
0 commit comments