diff --git a/client/src/pages/LoginPage.oidc-redirect.test.tsx b/client/src/pages/LoginPage.oidc-redirect.test.tsx index 37db7bfd5..2d0b8d005 100644 --- a/client/src/pages/LoginPage.oidc-redirect.test.tsx +++ b/client/src/pages/LoginPage.oidc-redirect.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '../../tests/helpers/render'; import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; import { resetAllStores } from '../../tests/helpers/store'; +import { buildAppConfig } from '../../tests/helpers/factories'; import { START_DESTINATION_ROUTE } from '../utils/startDestination'; import LoginPage from './LoginPage'; @@ -103,4 +104,44 @@ describe('LoginPage — OIDC redirect preservation', () => { }); }); }); + describe('FE-PAGE-LOGIN-025: an OIDC-only instance shows where it is going and nothing to fill in (#1167)', () => { + afterEach(() => { localStorage.removeItem('trek_app_config_cache'); }); + + it('names the provider and draws neither a password field nor a sign-in button', async () => { + setSearch(''); + server.use( + http.get('/api/auth/app-config', () => + HttpResponse.json(buildAppConfig({ password_login: false, oidc_configured: true, oidc_login: true, oidc_display_name: 'Keycloak' })), + ), + ); + render(); + + expect(await screen.findByText('Taking you to Keycloak…')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('your@email.com')).toBeNull(); + expect(screen.queryByText('Sign in with Keycloak')).toBeNull(); + }); + }); + + describe('FE-PAGE-LOGIN-026: no password form while the sign-in is still unknown (#1167)', () => { + afterEach(() => { localStorage.removeItem('trek_app_config_cache'); }); + + it('shows a wait instead of the form until the config answers', async () => { + localStorage.removeItem('trek_app_config_cache'); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + server.use( + http.get('/api/auth/app-config', async () => { + await gate; + return HttpResponse.json(buildAppConfig()); + }), + ); + render(); + + expect(screen.queryByPlaceholderText('your@email.com')).toBeNull(); + expect(screen.getByRole('status', { name: 'Loading...' })).toBeInTheDocument(); + + release(); + expect(await screen.findByPlaceholderText('your@email.com')).toBeInTheDocument(); + }); + }); }); \ No newline at end of file diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 9c8dff1b3..013d784cd 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -5,6 +5,7 @@ import { Fingerprint, Globe, KeyRound, + Loader2, Lock, Mail, Plane, @@ -14,7 +15,7 @@ import { import React from 'react'; import ToggleSwitch from '../components/Settings/ToggleSwitch'; import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'; -import { useLogin } from './login/useLogin'; +import { IDP_LOGIN_URL, useLogin } from './login/useLogin'; import LoginWorld from './login/LoginWorld'; import { clearSignedOut } from '../utils/signedOut' @@ -79,6 +80,10 @@ export default function LoginPage(): React.ReactElement { noRedirect, showRegisterOption, oidcOnly, + redirectScreen, + configWait, + idpSlow, + idpName, handleDemoLogin, handleSubmit, handlePasskeyLogin, @@ -466,7 +471,36 @@ export default function LoginPage(): React.ReactElement { boxShadow: '0 2px 16px rgba(0,0,0,0.06)', }} > - {oidcOnly ? ( + {redirectScreen ? ( + // On the way to the IdP: nothing here asks for input, the page only + // says where it is going (#1167). The link comes after a while, for + // a redirect that stalls. +
+
+ ) : configWait ? ( + // Which sign-in this instance offers is not known yet: no form it + // might not accept, only that something is on its way. +
+
+ ) : oidcOnly ? ( <>

{ expect(result.current.appConfig?.password_login).toBe(true); }); }); + +describe('useLogin — the wait before the sign-in is known (#1167)', () => { + const oidcOnlyConfig = () => + buildAppConfig({ password_login: false, oidc_configured: true, oidc_login: true, oidc_display_name: 'Keycloak' }); + + /** An app-config probe that answers only when the test lets it. */ + function heldConfig(answer: ReturnType | 'error'): () => void { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + server.use( + http.get('/api/auth/app-config', async () => { + await gate; + return answer === 'error' ? HttpResponse.error() : HttpResponse.json(answer); + }), + ); + return release; + } + + beforeEach(() => { localStorage.removeItem(CONFIG_CACHE_KEY); }); + afterEach(() => { localStorage.removeItem(CONFIG_CACHE_KEY); }); + + it('FE-LOGIN-HOOK-205: offers no form while the config is still out, only a wait', async () => { + const release = heldConfig(buildAppConfig()); + const { result } = renderLogin(); + + expect(result.current.configWait).toBe(true); + expect(result.current.redirectScreen).toBe(false); + + release(); + await ready(result); + expect(result.current.configWait).toBe(false); + }); + + it('FE-LOGIN-HOOK-206: an OIDC-only instance turns the page into the redirect, named after its provider', async () => { + server.use(http.get('/api/auth/app-config', () => HttpResponse.json(oidcOnlyConfig()))); + const { result } = renderLogin(); + + await waitFor(() => expect(window.location.href).toBe('/api/auth/oidc/login?remember=1')); + expect(result.current.redirectScreen).toBe(true); + expect(result.current.configWait).toBe(false); + expect(result.current.idpName).toBe('Keycloak'); + }); + + it('FE-LOGIN-HOOK-207: a cached OIDC-only config announces the redirect at once, and a failed probe takes it back', async () => { + localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(oidcOnlyConfig())); + const release = heldConfig('error'); + const { result } = renderLogin(); + + expect(result.current.redirectScreen).toBe(true); + expect(result.current.idpName).toBe('Keycloak'); + + release(); + await ready(result); + // A config from the cache never redirects, so the page falls back to its button. + await waitFor(() => expect(result.current.redirectScreen).toBe(false)); + expect(result.current.configWait).toBe(false); + expect(result.current.oidcOnly).toBe(true); + expect(window.location.href).toBe('http://localhost/login'); + }); + + it('FE-LOGIN-HOOK-208: a redirect that stalls offers the way on by hand after eight seconds', () => { + vi.useFakeTimers(); + localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(oidcOnlyConfig())); + heldConfig(oidcOnlyConfig()); + const { result } = renderLogin(); + + expect(result.current.redirectScreen).toBe(true); + act(() => { vi.advanceTimersByTime(7999); }); + expect(result.current.idpSlow).toBe(false); + act(() => { vi.advanceTimersByTime(1); }); + expect(result.current.idpSlow).toBe(true); + }); + + it('FE-LOGIN-HOOK-209: a failed sign-in at the IdP shows the OIDC-only screen and its error, without going back', async () => { + server.use(http.get('/api/auth/app-config', () => HttpResponse.json(oidcOnlyConfig()))); + setSearch('?oidc_error=token_failed'); + const { result } = renderLogin(); + await ready(result); + + expect(result.current.oidcOnly).toBe(true); + expect(result.current.configWait).toBe(false); + expect(result.current.redirectScreen).toBe(false); + expect(result.current.error).not.toBe(''); + expect(window.location.href).toBe('http://localhost/login'); + }); + + it('FE-LOGIN-HOOK-210: right after a sign-out the cached redirect is not announced', async () => { + localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(oidcOnlyConfig())); + const release = heldConfig(oidcOnlyConfig()); + const { result } = renderLogin([{ pathname: '/login', state: { noRedirect: true } }]); + + expect(result.current.redirectScreen).toBe(false); + expect(result.current.configWait).toBe(true); + + release(); + await ready(result); + expect(window.location.href).toBe('http://localhost/login'); + }); +}); diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index 93238361d..4a9bb6a0a 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -27,6 +27,24 @@ interface AppConfig { env_override_oidc_only: boolean } +const CONFIG_CACHE_KEY = 'trek_app_config_cache' + +/** Where an OIDC-only instance sends its users; the IdP owns the session policy (#1927). */ +export const IDP_LOGIN_URL = '/api/auth/oidc/login?remember=1' + +/** How long the redirect screen waits before it offers the way on by hand. */ +const IDP_SLOW_MS = 8000 + +function readCachedConfig(): AppConfig | null { + try { + const raw = localStorage.getItem(CONFIG_CACHE_KEY) + return raw ? (JSON.parse(raw) as AppConfig) : null + } catch { return null } +} + +const isOidcOnly = (config: AppConfig | null): boolean => + !!config && !config.password_login && config.oidc_login && config.oidc_configured + /** * Login data hook — owns the whole auth surface: login/register/demo, the MFA * step-up, the must-change-password step, the OIDC code exchange + error @@ -48,6 +66,19 @@ export function useLogin() { // the browser drops it, so we explain the fix instead of a bare 401 later. const [insecureCookie, setInsecureCookie] = useState(false) const [appConfig, setAppConfig] = useState(null) + // Until the config answers, the page cannot know which sign-in it offers, and + // on a slow line the password form it drew meanwhile was the first thing an + // OIDC-only user saw (#1167). What the last visit cached says it early: an + // instance that sends everyone to the IdP is announced as that redirect. + const [configLoaded, setConfigLoaded] = useState(false) + const [cachedConfig] = useState(readCachedConfig) + const [expectRedirect] = useState(() => { + const params = new URLSearchParams(window.location.search) + return isOidcOnly(cachedConfig) && !!cachedConfig?.has_users + && !params.get('invite') && !params.get('oidc_code') && !params.get('oidc_error') + }) + const [idpRedirect, setIdpRedirect] = useState(false) + const [idpSlow, setIdpSlow] = useState(false) const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) @@ -71,6 +102,17 @@ export function useLogin() { // ProtectedRoute's stateless , and to any full document // load. The per-tab marker survives both — see utils/signedOut (#2123). const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect || wasSignedOut() + const redirectScreen = idpRedirect || (!configLoaded && expectRedirect && !noRedirect) + const configWait = !configLoaded && !appConfig && !redirectScreen + const idpName = (appConfig ?? cachedConfig)?.oidc_display_name || 'SSO' + + // A redirect that has not left the page after a while offers the way on by + // hand, so a stalled IdP never strands anybody on a screen without a control. + useEffect(() => { + if (!redirectScreen) return + const timer = window.setTimeout(() => setIdpSlow(true), IDP_SLOW_MS) + return () => window.clearTimeout(timer) + }, [redirectScreen]) const redirectTarget = useMemo(() => { const params = new URLSearchParams(window.location.search) @@ -119,6 +161,36 @@ export function useLogin() { const oidcCode = params.get('oidc_code') const oidcError = params.get('oidc_error') + // Also after a failed sign-in at the IdP, only then without the redirect: an + // OIDC-only instance has to show its own screen and the error, not a password + // form nobody can use. + const loadConfig = (allowRedirect: boolean): void => { + const request = authApi.getAppConfig?.() + if (!request) { setConfigLoaded(true); return } + request + .then((config: AppConfig) => { + try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ } + return { config, fromCache: false } + }) + .catch(() => { + const cached = readCachedConfig() + return { config: cached, fromCache: !!cached } + }) + .then(({ config, fromCache }) => { + if (config) { + setAppConfig(config) + if (!config.has_users) setMode('register') + // Skip auto-redirect when config is from cache — network is unreliable + // and auto-redirecting to the IdP could loop if the proxy changed. + if (allowRedirect && !fromCache && isOidcOnly(config) && config.has_users && !invite && !noRedirect) { + setIdpRedirect(true) + window.location.href = IDP_LOGIN_URL + } + } + setConfigLoaded(true) + }) + } + if (invite) { setInviteToken(invite) setMode('register') @@ -144,11 +216,13 @@ export function useLogin() { navigate(savedRedirect, { replace: true }) } else { setError(data.error || t('login.oidcFailed')) + loadConfig(false) } }) .catch(() => { window.history.replaceState({}, '', '/login') setError(t('login.oidcFailed')) + loadConfig(false) }) .finally(() => setIsLoading(false)) return @@ -164,34 +238,11 @@ export function useLogin() { setError(errorMessages[oidcError] || oidcError) sessionStorage.removeItem('oidc_redirect') window.history.replaceState({}, '', '/login') + loadConfig(false) return } - const CONFIG_CACHE_KEY = 'trek_app_config_cache' - authApi.getAppConfig?.() - .then((config: AppConfig) => { - try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ } - return { config, fromCache: false } - }) - .catch(() => { - try { - const raw = localStorage.getItem(CONFIG_CACHE_KEY) - return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false } - } catch { return { config: null as AppConfig | null, fromCache: false } } - }) - .then(({ config, fromCache }) => { - if (config) { - setAppConfig(config) - if (!config.has_users) setMode('register') - // Skip auto-redirect when config is from cache — network is unreliable - // and auto-redirecting to the IdP could loop if the proxy changed. - if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { - // No switch to consult on this path: OIDC-only always asks for the - // remembered lifetime, matching the SSO button on the panel (#1927). - window.location.href = '/api/auth/oidc/login?remember=1' - } - } - }) + loadConfig(true) }, [navigate, t, noRedirect]) // Language detection chain (runs once on mount, only if user has no saved preference): @@ -333,6 +384,7 @@ export function useLogin() { showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, noRedirect, showRegisterOption, oidcOnly, + redirectScreen, configWait, idpSlow, idpName, handleDemoLogin, handleSubmit, handlePasskeyLogin, } } diff --git a/shared/src/i18n/ar/login.ts b/shared/src/i18n/ar/login.ts index 38655a091..e7d4e7871 100644 --- a/shared/src/i18n/ar/login.ts +++ b/shared/src/i18n/ar/login.ts @@ -27,6 +27,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'تسجيل الدخول عبر {name}', 'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.', 'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.', + 'login.oidcRedirecting': 'جارٍ تحويلك إلى {name}…', + 'login.oidcRedirectSlow': 'ما زلت هنا؟ تابع إلى {name}', 'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل', 'login.mfaTitle': 'المصادقة الثنائية', 'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.', diff --git a/shared/src/i18n/br/login.ts b/shared/src/i18n/br/login.ts index 194cac1ed..daca82698 100644 --- a/shared/src/i18n/br/login.ts +++ b/shared/src/i18n/br/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Entrar com {name}', 'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.', 'login.oidcLoggedOut': 'Você foi desconectado. Entre novamente usando o provedor SSO.', + 'login.oidcRedirecting': 'Redirecionando para {name}…', + 'login.oidcRedirectSlow': 'Ainda aqui? Continuar para {name}', 'login.demoHint': 'Experimente a demonstração — sem cadastro', 'login.mfaTitle': 'Autenticação em duas etapas', 'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.', diff --git a/shared/src/i18n/ca/login.ts b/shared/src/i18n/ca/login.ts index 78bda4729..2eab75d0a 100644 --- a/shared/src/i18n/ca/login.ts +++ b/shared/src/i18n/ca/login.ts @@ -67,6 +67,8 @@ const login: TranslationStrings = { 'login.oidcOnly': "L'autenticació per contrasenya està desactivada. Si us plau, inicia la sessió amb el teu proveïdor SSO.", 'login.oidcLoggedOut': 'Has tancat la sessió. Torna a iniciar-la amb el teu proveïdor SSO.', + 'login.oidcRedirecting': 'Redirigint a {name}…', + 'login.oidcRedirectSlow': 'Encara aquí? Continua a {name}', 'login.passkey.signIn': 'Inicia la sessió amb una passkey', 'login.passkey.failed': 'Error en iniciar la sessió amb la passkey. Torna-ho a provar.', diff --git a/shared/src/i18n/cs/login.ts b/shared/src/i18n/cs/login.ts index e7c8a5fa8..4463b818b 100644 --- a/shared/src/i18n/cs/login.ts +++ b/shared/src/i18n/cs/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Přihlásit se přes {name}', 'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.', 'login.oidcLoggedOut': 'Byl jste odhlášen. Přihlaste se znovu přes SSO poskytovatele.', + 'login.oidcRedirecting': 'Přesměrování na {name}…', + 'login.oidcRedirectSlow': 'Pořád tady? Pokračovat na {name}', 'login.demoHint': 'Vyzkoušejte demo – registrace není nutná', 'login.mfaTitle': 'Dvoufaktorové ověření', 'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.', diff --git a/shared/src/i18n/de/login.ts b/shared/src/i18n/de/login.ts index 1ca952f97..8dea1b28b 100644 --- a/shared/src/i18n/de/login.ts +++ b/shared/src/i18n/de/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Anmelden mit {name}', 'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.', 'login.oidcLoggedOut': 'Du wurdest abgemeldet. Melde dich erneut über deinen SSO-Anbieter an.', + 'login.oidcRedirecting': 'Du wirst zu {name} weitergeleitet…', + 'login.oidcRedirectSlow': 'Noch hier? Weiter zu {name}', 'login.demoHint': 'Demo ausprobieren — ohne Registrierung', 'login.mfaTitle': 'Zwei-Faktor-Authentifizierung', 'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.', diff --git a/shared/src/i18n/en/login.ts b/shared/src/i18n/en/login.ts index 926eb1eb8..2bcbfb6de 100644 --- a/shared/src/i18n/en/login.ts +++ b/shared/src/i18n/en/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Sign in with {name}', 'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.', 'login.oidcLoggedOut': 'You have been logged out. Sign in again using your SSO provider.', + 'login.oidcRedirecting': 'Taking you to {name}…', + 'login.oidcRedirectSlow': 'Still here? Continue to {name}', 'login.demoHint': 'Try the demo — no registration needed', 'login.mfaTitle': 'Two-factor authentication', 'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.', diff --git a/shared/src/i18n/es/login.ts b/shared/src/i18n/es/login.ts index de96659c6..46194227c 100644 --- a/shared/src/i18n/es/login.ts +++ b/shared/src/i18n/es/login.ts @@ -67,6 +67,8 @@ const login: TranslationStrings = { 'login.demoHint': 'Prueba la demo: no necesitas registrarte', 'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.', 'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.', + 'login.oidcRedirecting': 'Redirigiendo a {name}…', + 'login.oidcRedirectSlow': '¿Sigues aquí? Continuar a {name}', 'login.passkey.signIn': 'Iniciar sesión con una passkey', 'login.passkey.failed': 'Error al iniciar sesión con la passkey. Inténtalo de nuevo.', 'login.insecureCookie.title': "Login won't stick over HTTP", diff --git a/shared/src/i18n/fr/login.ts b/shared/src/i18n/fr/login.ts index 90f43a243..272852a21 100644 --- a/shared/src/i18n/fr/login.ts +++ b/shared/src/i18n/fr/login.ts @@ -68,6 +68,8 @@ const login: TranslationStrings = { 'login.oidcOnly': "L'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.", 'login.oidcLoggedOut': 'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.', + 'login.oidcRedirecting': 'Redirection vers {name}…', + 'login.oidcRedirectSlow': 'Toujours ici ? Continuer vers {name}', 'login.demoHint': 'Essayez la démo — aucune inscription nécessaire', 'login.passkey.signIn': 'Se connecter avec une passkey', 'login.passkey.failed': 'Échec de la connexion par passkey. Veuillez réessayer.', diff --git a/shared/src/i18n/gr/login.ts b/shared/src/i18n/gr/login.ts index 129f33be1..056671cb5 100644 --- a/shared/src/i18n/gr/login.ts +++ b/shared/src/i18n/gr/login.ts @@ -30,6 +30,8 @@ const login: TranslationStrings = { 'login.oidcOnly': 'Ο έλεγχος ταυτότητας με κωδικό είναι απενεργοποιημένος. Παρακαλώ συνδεθείτε χρησιμοποιώντας τον πάροχο SSO σας.', 'login.oidcLoggedOut': 'Έχετε αποσυνδεθεί. Συνδεθείτε ξανά χρησιμοποιώντας τον πάροχο SSO σας.', + 'login.oidcRedirecting': 'Ανακατεύθυνση στο {name}…', + 'login.oidcRedirectSlow': 'Ακόμα εδώ; Συνέχεια στο {name}', 'login.demoHint': 'Δοκιμάστε το demo — δεν απαιτείται εγγραφή', 'login.mfaTitle': 'Έλεγχος ταυτότητας δύο παραγόντων', 'login.mfaSubtitle': 'Εισάγετε τον 6ψήφιο κωδικό από την εφαρμογή authenticator σας.', diff --git a/shared/src/i18n/hu/login.ts b/shared/src/i18n/hu/login.ts index 1a69db77f..d90bbabc6 100644 --- a/shared/src/i18n/hu/login.ts +++ b/shared/src/i18n/hu/login.ts @@ -29,6 +29,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Bejelentkezés ezzel: {name}', 'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.', 'login.oidcLoggedOut': 'Kijelentkeztél. Jelentkezz be újra az SSO szolgáltatódon keresztül.', + 'login.oidcRedirecting': 'Átirányítás ide: {name}…', + 'login.oidcRedirectSlow': 'Még mindig itt? Tovább ide: {name}', 'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül', 'login.mfaTitle': 'Kétfaktoros hitelesítés', 'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.', diff --git a/shared/src/i18n/id/login.ts b/shared/src/i18n/id/login.ts index 86ca7ce42..eb4d56d78 100644 --- a/shared/src/i18n/id/login.ts +++ b/shared/src/i18n/id/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Masuk dengan {name}', 'login.oidcOnly': 'Autentikasi kata sandi dinonaktifkan. Masuk menggunakan penyedia SSO kamu.', 'login.oidcLoggedOut': 'Kamu telah keluar. Masuk kembali menggunakan penyedia SSO kamu.', + 'login.oidcRedirecting': 'Mengalihkan ke {name}…', + 'login.oidcRedirectSlow': 'Masih di sini? Lanjutkan ke {name}', 'login.demoHint': 'Coba demo — tidak perlu registrasi', 'login.mfaTitle': 'Autentikasi dua faktor', 'login.mfaSubtitle': 'Masukkan kode 6 digit dari aplikasi autentikator kamu.', diff --git a/shared/src/i18n/it/login.ts b/shared/src/i18n/it/login.ts index 1804993f9..cf046ee89 100644 --- a/shared/src/i18n/it/login.ts +++ b/shared/src/i18n/it/login.ts @@ -29,6 +29,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Accedi con {name}', 'login.oidcOnly': "L'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.", 'login.oidcLoggedOut': 'Sei stato disconnesso. Accedi nuovamente tramite il tuo provider SSO.', + 'login.oidcRedirecting': 'Reindirizzamento a {name}…', + 'login.oidcRedirectSlow': 'Ancora qui? Continua su {name}', 'login.demoHint': 'Prova la demo — nessuna registrazione necessaria', 'login.mfaTitle': 'Autenticazione a due fattori', 'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.', diff --git a/shared/src/i18n/ja/login.ts b/shared/src/i18n/ja/login.ts index 6db123a19..68b528b85 100644 --- a/shared/src/i18n/ja/login.ts +++ b/shared/src/i18n/ja/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': '{name}でサインイン', 'login.oidcOnly': 'パスワード認証は無効です。SSOプロバイダーでサインインしてください。', 'login.oidcLoggedOut': 'ログアウトしました。SSOプロバイダーで再度サインインしてください。', + 'login.oidcRedirecting': '{name} に移動しています…', + 'login.oidcRedirectSlow': 'まだこの画面ですか?{name} に進む', 'login.demoHint': 'デモを試す — 登録不要', 'login.mfaTitle': '二要素認証', 'login.mfaSubtitle': '認証アプリの6桁コードを入力してください。', diff --git a/shared/src/i18n/ko/login.ts b/shared/src/i18n/ko/login.ts index 45cf3685a..0a9cae602 100644 --- a/shared/src/i18n/ko/login.ts +++ b/shared/src/i18n/ko/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': '{name}으로 로그인', 'login.oidcOnly': '비밀번호 인증이 비활성화되었습니다. SSO 공급자로 로그인하세요.', 'login.oidcLoggedOut': '로그아웃되었습니다. SSO 공급자로 다시 로그인하세요.', + 'login.oidcRedirecting': '{name}(으)로 이동하는 중…', + 'login.oidcRedirectSlow': '아직 이 화면인가요? {name}(으)로 계속', 'login.demoHint': '데모 체험 — 회원가입 불필요', 'login.mfaTitle': '2단계 인증', 'login.mfaSubtitle': '인증 앱의 6자리 코드를 입력하세요.', diff --git a/shared/src/i18n/nl/login.ts b/shared/src/i18n/nl/login.ts index 083106fa0..d3656f4bc 100644 --- a/shared/src/i18n/nl/login.ts +++ b/shared/src/i18n/nl/login.ts @@ -65,6 +65,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Inloggen met {name}', 'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.', 'login.oidcLoggedOut': 'Je bent uitgelogd. Log opnieuw in via je SSO-provider.', + 'login.oidcRedirecting': 'Je wordt doorgestuurd naar {name}…', + 'login.oidcRedirectSlow': 'Nog steeds hier? Verder naar {name}', 'login.demoHint': 'Probeer de demo — geen registratie nodig', 'login.passkey.signIn': 'Inloggen met een passkey', 'login.passkey.failed': 'Inloggen met passkey mislukt. Probeer het opnieuw.', diff --git a/shared/src/i18n/pl/login.ts b/shared/src/i18n/pl/login.ts index a26d647ad..70bea165f 100644 --- a/shared/src/i18n/pl/login.ts +++ b/shared/src/i18n/pl/login.ts @@ -27,6 +27,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Zaloguj się z {name}', 'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.', 'login.oidcLoggedOut': 'Zostałeś wylogowany. Zaloguj się ponownie za pomocą swojego dostawcy SSO.', + 'login.oidcRedirecting': 'Przekierowanie do {name}…', + 'login.oidcRedirectSlow': 'Nadal tutaj? Przejdź do {name}', 'login.demoHint': 'Wypróbuj demo — nie wymaga rejestracji', 'login.mfaTitle': 'Uwierzytelnianie dwuskładnikowe', 'login.mfaSubtitle': 'Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.', diff --git a/shared/src/i18n/ru/login.ts b/shared/src/i18n/ru/login.ts index 48e7a1066..78ee3cf4a 100644 --- a/shared/src/i18n/ru/login.ts +++ b/shared/src/i18n/ru/login.ts @@ -66,6 +66,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Войти через {name}', 'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.', 'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.', + 'login.oidcRedirecting': 'Переходим к {name}…', + 'login.oidcRedirectSlow': 'Всё ещё здесь? Перейти к {name}', 'login.demoHint': 'Попробуйте демо — регистрация не требуется', 'login.passkey.signIn': 'Войти с помощью passkey', 'login.passkey.failed': 'Не удалось войти с помощью passkey. Попробуйте ещё раз.', diff --git a/shared/src/i18n/sv/login.ts b/shared/src/i18n/sv/login.ts index 05e0ec172..4f944a5ac 100644 --- a/shared/src/i18n/sv/login.ts +++ b/shared/src/i18n/sv/login.ts @@ -29,6 +29,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Logga in med {name}', 'login.oidcOnly': 'Lösenordsautentisering är inaktiverad. Logga in via din SSO-leverantör.', 'login.oidcLoggedOut': 'Du har loggats ut. Logga in igen via din SSO-leverantör.', + 'login.oidcRedirecting': 'Skickar dig till {name}…', + 'login.oidcRedirectSlow': 'Fortfarande här? Fortsätt till {name}', 'login.demoHint': 'Prova demoversionen – ingen registrering krävs', 'login.mfaTitle': 'Tvåfaktorsautentisering', 'login.mfaSubtitle': 'Ange den 6-siffriga koden från din autentiseringsapp.', diff --git a/shared/src/i18n/tr/login.ts b/shared/src/i18n/tr/login.ts index d851c51b9..d76e11595 100644 --- a/shared/src/i18n/tr/login.ts +++ b/shared/src/i18n/tr/login.ts @@ -29,6 +29,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': '{name} ile oturum açın', 'login.oidcOnly': 'Parola kimlik doğrulaması devre dışı. Lütfen SSO sağlayıcınızı kullanarak oturum açın.', 'login.oidcLoggedOut': 'Çıkış yaptınız. SSO sağlayıcınızı kullanarak tekrar oturum açın.', + 'login.oidcRedirecting': '{name} adresine yönlendiriliyorsunuz…', + 'login.oidcRedirectSlow': 'Hâlâ burada mısınız? {name} ile devam edin', 'login.demoHint': 'Demoyu deneyin; kayıt olmanıza gerek yok', 'login.mfaTitle': 'İki faktörlü kimlik doğrulama', 'login.mfaSubtitle': 'Kimlik doğrulayıcı uygulamanızdan 6 haneli kodu girin.', diff --git a/shared/src/i18n/uk/login.ts b/shared/src/i18n/uk/login.ts index ba502d03b..d38af02fd 100644 --- a/shared/src/i18n/uk/login.ts +++ b/shared/src/i18n/uk/login.ts @@ -65,6 +65,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Увійти через {name}', 'login.oidcOnly': 'Вхід за паролем вимкнено. Використайте вашого SSO-провайдера для входу.', 'login.oidcLoggedOut': 'Ви вийшли з системи. Увійдіть знову через вашого SSO-провайдера.', + 'login.oidcRedirecting': 'Переходимо до {name}…', + 'login.oidcRedirectSlow': 'Досі тут? Перейти до {name}', 'login.demoHint': 'Спробуйте демо — реєстрація не потрібна', 'login.passkey.signIn': 'Увійти за допомогою passkey', 'login.passkey.failed': 'Не вдалося увійти за допомогою passkey. Спробуйте ще раз.', diff --git a/shared/src/i18n/vi/login.ts b/shared/src/i18n/vi/login.ts index 495906834..a21e2f9f1 100644 --- a/shared/src/i18n/vi/login.ts +++ b/shared/src/i18n/vi/login.ts @@ -28,6 +28,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': 'Đăng nhập bằng {name}', 'login.oidcOnly': 'Xác thực mật khẩu bị vô hiệu hóa. Vui lòng đăng nhập bằng nhà cung cấp SSO của bạn.', 'login.oidcLoggedOut': 'Bạn đã đăng xuất. Đăng nhập lại bằng nhà cung cấp SSO của bạn.', + 'login.oidcRedirecting': 'Đang chuyển đến {name}…', + 'login.oidcRedirectSlow': 'Vẫn ở đây? Tiếp tục đến {name}', 'login.demoHint': 'Hãy thử bản demo - không cần đăng ký', 'login.mfaTitle': 'Xác thực hai yếu tố', 'login.mfaSubtitle': 'Nhập mã gồm 6 chữ số từ ứng dụng xác thực của bạn.', diff --git a/shared/src/i18n/zh-TW/login.ts b/shared/src/i18n/zh-TW/login.ts index 22451f2cf..bfb3d2e28 100644 --- a/shared/src/i18n/zh-TW/login.ts +++ b/shared/src/i18n/zh-TW/login.ts @@ -62,6 +62,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': '透過 {name} 登入', 'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。', 'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。', + 'login.oidcRedirecting': '正在跳轉到 {name}…', + 'login.oidcRedirectSlow': '還在這裡?繼續前往 {name}', 'login.demoHint': '試用演示——無需註冊', 'login.passkey.signIn': '使用 Passkey 登入', 'login.passkey.failed': 'Passkey 登入失敗,請重試。', diff --git a/shared/src/i18n/zh/login.ts b/shared/src/i18n/zh/login.ts index 02d7226ed..6622ca7be 100644 --- a/shared/src/i18n/zh/login.ts +++ b/shared/src/i18n/zh/login.ts @@ -62,6 +62,8 @@ const login: TranslationStrings = { 'login.oidcSignIn': '通过 {name} 登录', 'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。', 'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。', + 'login.oidcRedirecting': '正在跳转到 {name}…', + 'login.oidcRedirectSlow': '还在这里?继续前往 {name}', 'login.demoHint': '试用演示——无需注册', 'login.passkey.signIn': '使用通行密钥登录', 'login.passkey.failed': '通行密钥登录失败,请重试。', diff --git a/wiki/Login-and-Registration.md b/wiki/Login-and-Registration.md index c19585534..13bfea4e2 100644 --- a/wiki/Login-and-Registration.md +++ b/wiki/Login-and-Registration.md @@ -95,7 +95,7 @@ When the server is started with `DEMO_MODE=true`, a **"Try the demo — no regis If the admin has configured OpenID Connect, a **"Sign in with SSO"** button (labelled with the configured `OIDC_DISPLAY_NAME`, defaulting to `SSO`) appears below the login form. See [OIDC-SSO](OIDC-SSO) for details on setup and the sign-in flow. -When OIDC-only mode is active (password login disabled), visiting `/login` automatically redirects the browser to the identity provider. The email/password form is not shown. The automatic redirect is suppressed only when you have explicitly logged out, in which case the SSO button is shown instead so you can choose to sign back in. +When OIDC-only mode is active (password login disabled), visiting `/login` automatically redirects the browser to the identity provider. The page only says where it is going ("Taking you to Keycloak…", with the configured display name) and offers nothing to fill in; should the redirect not have happened after eight seconds, a link to continue appears. The email/password form is never shown, not even while the page is still asking the server which sign-in it offers: until the answer arrives it shows a short wait, and on a repeat visit it announces the redirect straight away. The automatic redirect is suppressed when you have explicitly logged out, after a failed sign-in at the identity provider, and when the server could not be reached; the SSO button is shown instead so you can choose to sign back in. ---