diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index a19a3d3..b841022 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -23,18 +23,43 @@ export function LoginPage() { const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [oidc, setOidc] = useState(null) + // While the OIDC config loads we may auto-bounce to the IdP. Hide + // the form until we know we're staying so the user doesn't see a + // flash of password fields right before the redirect kicks in. + const [autoRedirecting, setAutoRedirecting] = useState(true) - // Ask the backend which login methods to show. Failure is silent — - // worst case the SSO button just doesn't appear and the user falls - // back to username/password. + // Ask the backend which login methods to show. If OIDC is enabled + // and the user already has an SSO session at the IdP, the natural + // flow is for them to land here, get bounced through Authentik, and + // come straight back signed in — without ever clicking a button. + // Two escape hatches: `?password=1` in the URL for explicit password + // login, and a `skipAutoSso` sessionStorage flag set by logout and + // by the OIDC callback's error branch so users don't get trapped in + // a redirect loop. useEffect(() => { let cancelled = false ;(async () => { try { const res = await api.get('/auth/config') - if (!cancelled) setOidc(res.data.oidc) + if (cancelled) return + const cfg = res.data.oidc + setOidc(cfg) + if (!cfg?.enabled) { + setAutoRedirecting(false) + return + } + const params = new URLSearchParams(window.location.search) + if ( + params.has('password') || + sessionStorage.getItem('skipAutoSso') === '1' + ) { + sessionStorage.removeItem('skipAutoSso') + setAutoRedirecting(false) + return + } + window.location.href = cfg.login_url } catch { - /* ignore — SSO button stays hidden */ + if (!cancelled) setAutoRedirecting(false) } })() return () => { @@ -57,6 +82,25 @@ export function LoginPage() { } } + if (autoRedirecting) { + return ( +
+
+
+ Signing in with {oidc?.label ?? 'identity provider'}… +
+ setAutoRedirecting(false)} + > + Use password instead + +
+
+ ) + } + return (
diff --git a/frontend/src/components/auth/OidcCallback.tsx b/frontend/src/components/auth/OidcCallback.tsx index e352d50..172beb2 100644 --- a/frontend/src/components/auth/OidcCallback.tsx +++ b/frontend/src/components/auth/OidcCallback.tsx @@ -38,12 +38,17 @@ export function OidcCallback() { const clean = () => window.history.replaceState({}, '', '/') if (errCode) { + // Don't auto-bounce back to Authentik on the next LoginPage + // mount — show the error and let the user fall back to password + // or retry deliberately. + sessionStorage.setItem('skipAutoSso', '1') setError(ERROR_MESSAGES[errCode] || 'Sign-in failed. Please try again.') clean() return } if (!accessToken || !refreshToken) { + sessionStorage.setItem('skipAutoSso', '1') setError('The identity provider did not return the expected tokens.') clean() return diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 74daa1d..82c9521 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -127,6 +127,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { ) const logout = useCallback(() => { + // Tell LoginPage to skip its OIDC auto-redirect on the next mount — + // otherwise the user clicks Logout and gets bounced straight back + // through Authentik (whose session is still valid), which feels + // like the logout did nothing. + sessionStorage.setItem('skipAutoSso', '1') clearTokens() setUser(null) }, [])