mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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={{
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user