Skip to content

Commit 8a99fb1

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 8a99fb1

24 files changed

Lines changed: 603 additions & 45 deletions

react/src/components/STokenLoginBoundary.tsx

Lines changed: 306 additions & 28 deletions
Large diffs are not rendered by default.

react/src/helper/loginSessionAuth.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,26 +169,77 @@ export async function connectViaGQL(
169169
return updatedEndpoints;
170170
}
171171

172+
/**
173+
* Structured error thrown by `tokenLogin` when the webserver reports
174+
* `authenticated === false`. Carries the raw `fail_reason` string and the
175+
* authenticated-probe `type` when available so callers (the
176+
* `STokenLoginBoundary` classifier) can discriminate `require-totp-*`,
177+
* `active-login-session-exists`, and generic invalid-token cases without
178+
* string-matching the user-visible message.
179+
*
180+
* The prior implementation collapsed every non-success result into a
181+
* generic `Error('Cannot authorize session by token.')`, which silently
182+
* broke for `{ fail_reason }` returns (`client.token_login` returns an
183+
* object in that case — truthy, so the old `!loginSuccess` check passed
184+
* through and `connectViaGQL` ran against an unauthenticated client).
185+
*/
186+
export class TokenLoginFailedError extends Error {
187+
readonly failReason: string | null;
188+
readonly failType: string | null;
189+
readonly raw: unknown;
190+
constructor(
191+
failReason: string | null,
192+
failType: string | null,
193+
raw: unknown,
194+
) {
195+
super(failReason ?? 'Cannot authorize session by token.');
196+
this.name = 'TokenLoginFailedError';
197+
this.failReason = failReason;
198+
this.failType = failType;
199+
this.raw = raw;
200+
}
201+
}
202+
172203
/**
173204
* Perform token-based login (SSO).
174205
*
175206
* `extraParams` are forwarded to `client.token_login` as-is. This is used by
176207
* token URL entry points that need to pass additional query parameters
177208
* collected from the URL (for example, EduAppLauncher forwards `app`,
178-
* `session_id`, resource hints) to the server-side token handler.
179-
* LoginView callers that do not need to forward anything can omit the
180-
* argument.
209+
* `session_id`, resource hints) to the server-side token handler. The
210+
* `STokenLoginBoundary` also folds interactive inputs (`otp`, `force`)
211+
* into this object when retrying after a TOTP or concurrent-session
212+
* challenge. LoginView callers that do not need to forward anything can
213+
* omit the argument.
181214
*/
182215
export async function tokenLogin(
183216
client: any,
184217
sToken: string,
185218
cfg: LoginConfigState,
186219
endpoints: string[],
187-
extraParams?: Record<string, string>,
220+
extraParams?: Record<string, string | boolean>,
188221
): Promise<string[]> {
189-
const loginSuccess = await client.token_login(sToken, extraParams ?? {});
190-
if (!loginSuccess) {
191-
throw new Error('Cannot authorize session by token.');
222+
const result = await client.token_login(sToken, extraParams ?? {});
223+
// `client.token_login` returns:
224+
// - truthy check_login result object → authenticated
225+
// - `{ fail_reason: string, fail_type: string }` → authenticated: false
226+
// - `false` → authenticated: false, no envelope
227+
// Only the first is a successful login.
228+
const failed =
229+
result === false ||
230+
result == null ||
231+
(typeof result === 'object' &&
232+
('fail_reason' in result || 'fail_type' in result));
233+
if (failed) {
234+
const envelope =
235+
typeof result === 'object' && result
236+
? (result as { fail_reason?: string; fail_type?: string })
237+
: null;
238+
throw new TokenLoginFailedError(
239+
envelope?.fail_reason ?? null,
240+
envelope?.fail_type ?? null,
241+
result,
242+
);
192243
}
193244
return connectViaGQL(client, cfg, endpoints);
194245
}

resources/i18n/de.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,6 +2068,17 @@
20682068
"SharedMemory": "Gemeinsamer Speicher",
20692069
"Updated": "Ressourcenvoreinstellung aktualisiert"
20702070
},
2071+
"sTokenLoginBoundary": {
2072+
"AuthenticatingDescription": "Ihr Anmelde-Token wird verifiziert…",
2073+
"ConnectingDescription": "Verbindung mit dem Backend.AI-Server wird hergestellt…",
2074+
"ErrorConcurrentSessionDescription": "Sie sind bereits an einem anderen Ort angemeldet. Möchten Sie die bestehende Anmeldung beenden und sich hier anmelden?",
2075+
"ErrorConcurrentSessionTitle": "An anderer Stelle angemeldet",
2076+
"ErrorTotpInvalidHint": "Der eingegebene Code wurde nicht akzeptiert. Bitte versuchen Sie es erneut.",
2077+
"ErrorTotpRequiredDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein, um fortzufahren.",
2078+
"ErrorTotpRequiredTitle": "Zwei-Faktor-Authentifizierung erforderlich",
2079+
"SubmitOtp": "Bestätigen",
2080+
"TotpPlaceholder": "Authentifizierungscode"
2081+
},
20712082
"scanArtifactModelsFromHuggingFaceModal": {
20722083
"EnterAModelID": "Geben Sie die Modell-ID ein. (z. B.: openai/gpt-oss-20b)",
20732084
"EnterAVersion": "Geben Sie die Version ein. (Standard: main)",

resources/i18n/el.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,17 @@
20662066
"SharedMemory": "Κοινόχρηστη μνήμη",
20672067
"Updated": "Η προεπιλογή πόρων ενημερώθηκε"
20682068
},
2069+
"sTokenLoginBoundary": {
2070+
"AuthenticatingDescription": "Το διακριτικό σύνδεσής σας επαληθεύεται…",
2071+
"ConnectingDescription": "Σύνδεση με τον διακομιστή Backend.AI…",
2072+
"ErrorConcurrentSessionDescription": "Έχετε ήδη συνδεθεί αλλού. Θέλετε να τερματίσετε την υπάρχουσα σύνδεση και να συνδεθείτε εδώ;",
2073+
"ErrorConcurrentSessionTitle": "Συνδεδεμένος/η από άλλη τοποθεσία",
2074+
"ErrorTotpInvalidHint": "Ο κωδικός που εισαγάγατε δεν έγινε αποδεκτός. Παρακαλώ δοκιμάστε ξανά.",
2075+
"ErrorTotpRequiredDescription": "Εισαγάγετε τον κωδικό από την εφαρμογή ελέγχου ταυτότητας για να συνεχίσετε.",
2076+
"ErrorTotpRequiredTitle": "Απαιτείται έλεγχος ταυτότητας δύο παραγόντων",
2077+
"SubmitOtp": "Υποβολή",
2078+
"TotpPlaceholder": "Κωδικός αυθεντικοποίησης"
2079+
},
20692080
"scanArtifactModelsFromHuggingFaceModal": {
20702081
"EnterAModelID": "Εισαγάγετε το ID του μοντέλου. (π.χ.: openai/gpt-oss-20b)",
20712082
"EnterAVersion": "Εισαγάγετε την έκδοση. (Προεπιλογή: main)",

resources/i18n/en.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2077,11 +2077,12 @@
20772077
"Updated": "Resource preset updated"
20782078
},
20792079
"sTokenLoginBoundary": {
2080-
"AuthenticatingDescription": "Authenticating with your single sign-on token. This usually takes only a moment.",
2080+
"AuthenticatingDescription": "Verifying your sign-in token",
20812081
"AuthenticatingTitle": "Signing you in",
2082+
"ConnectingDescription": "Connecting to the Backend.AI server…",
20822083
"CopyErrorDetails": "Copy error details",
2083-
"ErrorConcurrentSessionDescription": "This account is already signed in somewhere else. Please close the other session and try again.",
2084-
"ErrorConcurrentSessionTitle": "Another active session was detected.",
2084+
"ErrorConcurrentSessionDescription": "You are already logged in elsewhere. Would you like to end the existing session and log in here?",
2085+
"ErrorConcurrentSessionTitle": "Logged in elsewhere",
20852086
"ErrorDetailsCopied": "Error details copied to clipboard.",
20862087
"ErrorEndpointUnresolvedDescription": "The Backend.AI server address could not be resolved from the configuration file. Please try again in a moment or contact your administrator.",
20872088
"ErrorEndpointUnresolvedTitle": "Server configuration is unavailable.",
@@ -2091,9 +2092,14 @@
20912092
"ErrorServerUnreachableTitle": "Cannot reach the Backend.AI server.",
20922093
"ErrorTokenInvalidDescription": "The sign-in token may have expired or been revoked. Please restart the sign-in flow from your original application.",
20932094
"ErrorTokenInvalidTitle": "Your sign-in token is not valid.",
2095+
"ErrorTotpInvalidHint": "The code you entered was not accepted. Please try again.",
2096+
"ErrorTotpRequiredDescription": "Enter the code from your authenticator app to continue.",
2097+
"ErrorTotpRequiredTitle": "Two-factor authentication required",
20942098
"ErrorUnknownDescription": "An unexpected error occurred while signing in. Please try again, and copy the error details below if the problem persists.",
20952099
"ErrorUnknownTitle": "Sign-in failed unexpectedly.",
2096-
"Retry": "Retry"
2100+
"Retry": "Retry",
2101+
"SubmitOtp": "Submit",
2102+
"TotpPlaceholder": "Authenticator code"
20972103
},
20982104
"scanArtifactModelsFromHuggingFaceModal": {
20992105
"EnterAModelID": "Enter a model ID. (e.g. openai/gpt-oss-20b)",

resources/i18n/es.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,17 @@
20662066
"SharedMemory": "Memoria compartida",
20672067
"Updated": "Preajuste de recursos actualizado"
20682068
},
2069+
"sTokenLoginBoundary": {
2070+
"AuthenticatingDescription": "Verificando su token de inicio de sesión…",
2071+
"ConnectingDescription": "Conectando al servidor Backend.AI…",
2072+
"ErrorConcurrentSessionDescription": "Ya ha iniciado sesión en otro lugar. ¿Desea cerrar la sesión existente e iniciar sesión aquí?",
2073+
"ErrorConcurrentSessionTitle": "Sesión iniciada en otro lugar",
2074+
"ErrorTotpInvalidHint": "El código introducido no fue aceptado. Por favor, inténtelo de nuevo.",
2075+
"ErrorTotpRequiredDescription": "Ingrese el código de su aplicación de autenticación para continuar.",
2076+
"ErrorTotpRequiredTitle": "Se requiere autenticación de dos factores",
2077+
"SubmitOtp": "Enviar",
2078+
"TotpPlaceholder": "Código de autenticación"
2079+
},
20692080
"scanArtifactModelsFromHuggingFaceModal": {
20702081
"EnterAModelID": "Introduzca el ID del modelo. (p. ej.: openai/gpt-oss-20b)",
20712082
"EnterAVersion": "Introduzca la versión. (Predeterminado: main)",

resources/i18n/fi.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,17 @@
20662066
"SharedMemory": "Jaettu muisti",
20672067
"Updated": "Resurssien esiasetus päivitetty"
20682068
},
2069+
"sTokenLoginBoundary": {
2070+
"AuthenticatingDescription": "Kirjautumistunnistettasi varmennetaan…",
2071+
"ConnectingDescription": "Yhdistetään Backend.AI-palvelimeen…",
2072+
"ErrorConcurrentSessionDescription": "Olet jo kirjautunut sisään toisessa paikassa. Haluatko lopettaa nykyisen istunnon ja kirjautua sisään täällä?",
2073+
"ErrorConcurrentSessionTitle": "Kirjautunut muualta",
2074+
"ErrorTotpInvalidHint": "Antamasi koodi ei kelpaa. Yritä uudelleen.",
2075+
"ErrorTotpRequiredDescription": "Syötä koodi todennussovelluksestasi jatkaaksesi.",
2076+
"ErrorTotpRequiredTitle": "Kaksivaiheinen todennus vaaditaan",
2077+
"SubmitOtp": "Lähetä",
2078+
"TotpPlaceholder": "Todennuskoodi"
2079+
},
20692080
"scanArtifactModelsFromHuggingFaceModal": {
20702081
"EnterAModelID": "Syötä mallin tunnus (esim. openai/gpt-oss-20b)",
20712082
"EnterAVersion": "Syötä versio. (Oletus: main)",

resources/i18n/fr.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,6 +2068,17 @@
20682068
"SharedMemory": "Mémoire partagée",
20692069
"Updated": "Préréglage de ressource mis à jour"
20702070
},
2071+
"sTokenLoginBoundary": {
2072+
"AuthenticatingDescription": "Vérification de votre jeton de connexion…",
2073+
"ConnectingDescription": "Connexion au serveur Backend.AI…",
2074+
"ErrorConcurrentSessionDescription": "Vous êtes déjà connecté ailleurs. Voulez-vous déconnecter la session existante et vous connecter ici ?",
2075+
"ErrorConcurrentSessionTitle": "Connecté ailleurs",
2076+
"ErrorTotpInvalidHint": "Le code saisi n'a pas été accepté. Veuillez réessayer.",
2077+
"ErrorTotpRequiredDescription": "Saisissez le code de votre application d'authentification pour continuer.",
2078+
"ErrorTotpRequiredTitle": "Authentification à deux facteurs requise",
2079+
"SubmitOtp": "Valider",
2080+
"TotpPlaceholder": "Code d'authentification"
2081+
},
20712082
"scanArtifactModelsFromHuggingFaceModal": {
20722083
"EnterAModelID": "Saisissez l'ID du modèle. (ex : openai/gpt-oss-20b)",
20732084
"EnterAVersion": "Saisissez la version. (Par défaut : main)",

resources/i18n/id.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,6 +2069,17 @@
20692069
"SharedMemory": "Memori bersama",
20702070
"Updated": "Preset sumber daya diperbarui"
20712071
},
2072+
"sTokenLoginBoundary": {
2073+
"AuthenticatingDescription": "Memverifikasi token masuk Anda…",
2074+
"ConnectingDescription": "Menghubungkan ke server Backend.AI…",
2075+
"ErrorConcurrentSessionDescription": "Anda sudah masuk di tempat lain. Ingin mengakhiri sesi yang ada dan masuk di sini?",
2076+
"ErrorConcurrentSessionTitle": "Sedang masuk di tempat lain",
2077+
"ErrorTotpInvalidHint": "Kode yang Anda masukkan tidak diterima. Silakan coba lagi.",
2078+
"ErrorTotpRequiredDescription": "Masukkan kode dari aplikasi autentikasi Anda untuk melanjutkan.",
2079+
"ErrorTotpRequiredTitle": "Autentikasi dua faktor diperlukan",
2080+
"SubmitOtp": "Kirim",
2081+
"TotpPlaceholder": "Kode autentikasi"
2082+
},
20722083
"scanArtifactModelsFromHuggingFaceModal": {
20732084
"EnterAModelID": "Masukkan ID model. (Contoh: openai/gpt-oss-20b)",
20742085
"EnterAVersion": "Masukkan versi. (Nilai bawaan: main)",

resources/i18n/it.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,17 @@
20662066
"SharedMemory": "Memoria condivisa",
20672067
"Updated": "Preimpostazione delle risorse aggiornata"
20682068
},
2069+
"sTokenLoginBoundary": {
2070+
"AuthenticatingDescription": "Verifica del token di accesso in corso…",
2071+
"ConnectingDescription": "Connessione al server Backend.AI…",
2072+
"ErrorConcurrentSessionDescription": "Sei già connesso altrove. Vuoi terminare la sessione esistente e accedere qui?",
2073+
"ErrorConcurrentSessionTitle": "Accesso da un altro dispositivo",
2074+
"ErrorTotpInvalidHint": "Il codice inserito non è stato accettato. Riprova.",
2075+
"ErrorTotpRequiredDescription": "Inserisci il codice dell'app di autenticazione per continuare.",
2076+
"ErrorTotpRequiredTitle": "Autenticazione a due fattori richiesta",
2077+
"SubmitOtp": "Invia",
2078+
"TotpPlaceholder": "Codice di autenticazione"
2079+
},
20692080
"scanArtifactModelsFromHuggingFaceModal": {
20702081
"EnterAModelID": "Inserisci l'ID del modello. (es.: openai/gpt-oss-20b)",
20712082
"EnterAVersion": "Inserisci la versione. (Predefinito: main)",

0 commit comments

Comments
 (0)