Skip to content

Commit c80fed0

Browse files
committed
feat(FR-2616): handle totp and concurrent-session inline in STokenLoginBoundary
- STokenLoginError gains `totp-required` (with `invalidOtp` hint); the existing `concurrent-session` kind is now wired up end-to-end. - `tokenLogin` helper throws a structured `TokenLoginFailedError` instead of a generic `Error`, fixing a latent bug where a `{ fail_reason }` return value (truthy object) was treated as success by the caller. - `STokenLoginBoundary` keeps a pending OTP ref (single-use) and a sticky `forceApprovedRef`; both fold into `extraParams` on the next retry. - `DefaultErrorCard` swaps its action area in place for `totp-required` (OTP input + Submit) and `concurrent-session` (Copy details + Sign in anyway) — no separate modal, no layout split. Card status shifts from `error` to `warning` for these two kinds so the user reads them as required follow-ups, not terminal failures. - Classification uses duck-typed field extraction instead of `instanceof TokenLoginFailedError` so cross-module-instance errors (Jest mocks, HMR reloads) classify the same. - TODO(user-tunable): once `client.token_login` surfaces the probe `type`, replace the string-matching classifier with `type` comparisons. The current substrings mirror LoginView's legacy fallback.
1 parent a231db5 commit c80fed0

4 files changed

Lines changed: 298 additions & 35 deletions

File tree

react/src/components/STokenLoginBoundary.tsx

Lines changed: 224 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { useResolvedApiEndpoint } from '../hooks/useResolvedApiEndpoint';
2020
import { loginConfigState } from '../hooks/useWebUIConfig';
2121
import { jotaiStore } from './DefaultProviders';
22-
import { App, Spin, Typography } from 'antd';
22+
import { App, Form, Input, Spin, Typography } from 'antd';
2323
import { BAIButton, BAICard, BAIFlex, useBAILogger } from 'backend.ai-ui';
2424
import { useAtomValue } from 'jotai';
2525
import {
@@ -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+
48125
export 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
*/
308432
const 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

Comments
 (0)