feat(login): an OIDC-only instance shows the way to the IdP, not a form

On a slow line an OIDC-only login page drew the email and password form while it
asked the server which sign-in it offers, then the SSO button once it knew, and only
then left for the identity provider. Both looked like something to fill in or click.

Until the config answers, the page now shows a short wait instead of the form, and
on an OIDC-only instance it becomes a screen that only says where it is going,
"Taking you to Keycloak…", with nothing to fill in. A repeat visit announces that
redirect at once from the cached config. Should the redirect not have left the
page after eight seconds, a quiet link continues by hand.

The redirect still never runs on a cached config, right after a sign-out or after a
failed sign-in at the provider. That last case now loads the config as well, so an
OIDC-only instance shows its own screen with the error rather than a password form
nobody can use.
This commit is contained in:
Maurice
2026-09-24 15:54:08 +02:00
parent 20e912d6c4
commit 79cd3095e0
28 changed files with 300 additions and 28 deletions
@@ -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(<LoginPage />);
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<void>((resolve) => { release = resolve; });
server.use(
http.get('/api/auth/app-config', async () => {
await gate;
return HttpResponse.json(buildAppConfig());
}),
);
render(<LoginPage />);
expect(screen.queryByPlaceholderText('your@email.com')).toBeNull();
expect(screen.getByRole('status', { name: 'Loading...' })).toBeInTheDocument();
release();
expect(await screen.findByPlaceholderText('your@email.com')).toBeInTheDocument();
});
});
});
+36 -2
View File
@@ -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.
<div
role="status"
aria-live="polite"
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, padding: '16px 0', textAlign: 'center' }}
>
<Loader2 size={24} className="animate-spin" style={{ color: '#6b7280' }} aria-hidden="true" />
<p style={{ margin: 0, fontSize: 'calc(15px * var(--fs-scale-body, 1))', fontWeight: 600, color: '#111827' }}>
{t('login.oidcRedirecting', { name: idpName })}
</p>
{idpSlow && (
<a
href={IDP_LOGIN_URL}
onClick={clearSignedOut}
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#6b7280' }}
>
{t('login.oidcRedirectSlow', { name: idpName })}
</a>
)}
</div>
) : configWait ? (
// Which sign-in this instance offers is not known yet: no form it
// might not accept, only that something is on its way.
<div role="status" aria-label={t('common.loading')} style={{ display: 'flex', justifyContent: 'center', padding: '40px 0' }}>
<Loader2 size={24} className="animate-spin" style={{ color: '#9ca3af' }} aria-hidden="true" />
</div>
) : oidcOnly ? (
<>
<h2
style={{
+99
View File
@@ -1054,3 +1054,102 @@ describe('OIDC-only auto-redirect suppression', () => {
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<typeof buildAppConfig> | 'error'): () => void {
let release!: () => void;
const gate = new Promise<void>((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');
});
});
+77 -25
View File
@@ -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<AppConfig | null>(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<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
@@ -71,6 +102,17 @@ export function useLogin() {
// ProtectedRoute's stateless <Navigate replace>, 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,
}
}
+2
View File
@@ -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 أرقام من تطبيق المصادقة.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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",
+2
View File
@@ -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.',
+2
View File
@@ -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 σας.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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桁コードを入力してください。',
+2
View File
@@ -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자리 코드를 입력하세요.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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. Попробуйте ещё раз.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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. Спробуйте ще раз.',
+2
View File
@@ -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.',
+2
View File
@@ -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 登入失敗,請重試。',
+2
View File
@@ -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': '通行密钥登录失败,请重试。',
+1 -1
View File
@@ -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.
---