Loading portal-gui/src/app/guards/protected-route.tsx +56 −3 Changes for portal-gui/src/app/guards/protected-route.tsx: 56 added lines, 3 removed lines. Original line number Diff line number Diff line import { Navigate, Outlet } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from '@/features/auth/auth-context'; import { LoadingState } from '@/components/common/loading-state'; import { ROUTES } from '../config/route'; import { AuthPending } from './auth-pending'; const LAST_REDIRECT_KEY = 'portal:login-redirect-at'; // Coming back signed out sooner than this means the login did not stick, e.g. the // session cookie is refused, and redirecting again would loop through Keycloak. const REDIRECT_COOLDOWN_MS = 10_000; function redirectedRecently(): boolean { try { const last = Number(sessionStorage.getItem(LAST_REDIRECT_KEY)); return Date.now() - last < REDIRECT_COOLDOWN_MS; } catch { return false; } } function markRedirect(at: number | null) { try { if (at === null) { sessionStorage.removeItem(LAST_REDIRECT_KEY); } else { sessionStorage.setItem(LAST_REDIRECT_KEY, String(at)); } } catch { // Without storage the guard is off; the callback's ?error still stops a loop. } } export function ProtectedRoute() { const { status, isAuthenticated } = useAuth(); const { status, isAuthenticated, login } = useAuth(); const location = useLocation(); // Read once, on arrival: a page load straight after our own redirect is the // browser coming back from Keycloak. const [justRedirected] = useState(redirectedRecently); // Set by the login callback when it failed; redirecting again would repeat it. const error = new URLSearchParams(location.search).get('error'); const signedOut = status === 'anonymous'; const stuck = signedOut && !error && justRedirected; const shouldRedirect = signedOut && !error && !stuck; const returnTo = location.pathname + location.search + location.hash; useEffect(() => { if (isAuthenticated) { // The login stuck, so a quick logout after it is not a loop. markRedirect(null); } else if (shouldRedirect) { markRedirect(Date.now()); login(returnTo); } }, [isAuthenticated, shouldRedirect, login, returnTo]); if (status === 'loading' || status === 'unavailable') { return <AuthPending status={status} />; } if (!isAuthenticated) { return <Navigate to={ROUTES.LOGIN} replace />; if (error || stuck) { const reason = new URLSearchParams({ error: error ?? 'failed' }); return <Navigate to={`${ROUTES.LOGIN}?${reason}`} replace />; } return <LoadingState message="Redirecting to sign in…" />; } return <Outlet />; Loading portal-gui/src/features/auth/auth-provider.tsx +10 −4 Changes for portal-gui/src/features/auth/auth-provider.tsx: 10 added lines, 4 removed lines. Original line number Diff line number Diff line import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { AuthContext } from './auth-context'; import type { AuthStatus, Loading Loading @@ -66,8 +66,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; }, []); const login = () => window.location.assign(`${AUTH_API}/login`); const register = () => window.location.assign(`${AUTH_API}/register`); // Stable, so a guard can call it from an effect without re-running it. const login = useCallback((returnTo?: string) => { const query = returnTo ? `?${new URLSearchParams({ return_to: returnTo })}` : ''; // replace, not assign: Back from Keycloak must not return to a page that // sends the browser straight back to Keycloak. window.location.replace(`${AUTH_API}/login${query}`); }, []); const logout = async () => { let logoutUrl: string | null = null; Loading @@ -88,7 +95,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { status, user, login, register, logout, isAuthenticated: status === 'authenticated', }} Loading portal-gui/src/features/auth/login.tsx +45 −16 Changes for portal-gui/src/features/auth/login.tsx: 45 added lines, 16 removed lines. Original line number Diff line number Diff line import { useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useAuth } from './auth-context'; type Message = { title: string; description: string }; const SIGNED_OUT: Message = { title: "You're signed out", description: 'Sign in to access the OpenOP.', }; const FAILED: Message = { title: "Couldn't sign you in", description: 'Try again. If this keeps happening, contact your administrator.', }; // The reasons GET /api/auth/callback sends back, and what the person can do about them. const LOGIN_ERRORS: Record<string, Message> = { cancelled: { title: 'Sign-in cancelled', description: 'You cancelled signing in.', }, expired: { title: 'Sign-in timed out', description: 'Signing in took too long. Please try again.', }, failed: FAILED, unavailable: { title: 'Sign-in unavailable', description: "The sign-in service isn't responding. Try again in a moment.", }, }; // Only reached when sending the browser to Keycloak straight away would be wrong: // after a failed sign-in, or a logout that could not end the Keycloak session. // It never redirects on its own, so a sign-in that keeps failing cannot loop. export function LoginPage() { const { login, register } = useAuth(); const { login } = useAuth(); const [searchParams] = useSearchParams(); const error = searchParams.get('error'); const { title, description } = error ? (LOGIN_ERRORS[error] ?? FAILED) : SIGNED_OUT; return ( <div className="space-y-6"> <CardHeader className="space-y-2 px-0"> <CardTitle className="text-2xl">Login</CardTitle> <CardDescription>Sign in to access the OpenOP.</CardDescription> <CardTitle className="text-2xl">{title}</CardTitle> <CardDescription>{description}</CardDescription> </CardHeader> <Button className="w-full" onClick={login}> Sign In <Button className="w-full" onClick={() => login()}> Sign in </Button> <div className="text-center text-sm text-muted-foreground"> Don't have an account?{' '} <button type="button" className="text-primary hover:underline" onClick={register} > Create account </button> </div> </div> ); } portal-gui/src/types/authentication.ts +1 −2 Changes for portal-gui/src/types/authentication.ts: 1 added line, 2 removed lines. Original line number Diff line number Diff line Loading @@ -30,8 +30,7 @@ export type AuthStatus = export type AuthContextType = { status: AuthStatus; user: CurrentUser | null; login: () => void; register: () => void; login: (returnTo?: string) => void; logout: () => Promise<void>; isAuthenticated: boolean; }; Loading portal-gui/src/types/ui.ts +0 −2 Changes for portal-gui/src/types/ui.ts: 0 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -9,5 +9,3 @@ export type ActionSettingsBtn = (typeof ActionSettingsBtn)[keyof typeof ActionSettingsBtn]; export type MyApplicationsTabKey = 'profile' | 'deployments'; export type LoginTabKey = 'login' | 'register'; Loading
portal-gui/src/app/guards/protected-route.tsx +56 −3 Changes for portal-gui/src/app/guards/protected-route.tsx: 56 added lines, 3 removed lines. Original line number Diff line number Diff line import { Navigate, Outlet } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from '@/features/auth/auth-context'; import { LoadingState } from '@/components/common/loading-state'; import { ROUTES } from '../config/route'; import { AuthPending } from './auth-pending'; const LAST_REDIRECT_KEY = 'portal:login-redirect-at'; // Coming back signed out sooner than this means the login did not stick, e.g. the // session cookie is refused, and redirecting again would loop through Keycloak. const REDIRECT_COOLDOWN_MS = 10_000; function redirectedRecently(): boolean { try { const last = Number(sessionStorage.getItem(LAST_REDIRECT_KEY)); return Date.now() - last < REDIRECT_COOLDOWN_MS; } catch { return false; } } function markRedirect(at: number | null) { try { if (at === null) { sessionStorage.removeItem(LAST_REDIRECT_KEY); } else { sessionStorage.setItem(LAST_REDIRECT_KEY, String(at)); } } catch { // Without storage the guard is off; the callback's ?error still stops a loop. } } export function ProtectedRoute() { const { status, isAuthenticated } = useAuth(); const { status, isAuthenticated, login } = useAuth(); const location = useLocation(); // Read once, on arrival: a page load straight after our own redirect is the // browser coming back from Keycloak. const [justRedirected] = useState(redirectedRecently); // Set by the login callback when it failed; redirecting again would repeat it. const error = new URLSearchParams(location.search).get('error'); const signedOut = status === 'anonymous'; const stuck = signedOut && !error && justRedirected; const shouldRedirect = signedOut && !error && !stuck; const returnTo = location.pathname + location.search + location.hash; useEffect(() => { if (isAuthenticated) { // The login stuck, so a quick logout after it is not a loop. markRedirect(null); } else if (shouldRedirect) { markRedirect(Date.now()); login(returnTo); } }, [isAuthenticated, shouldRedirect, login, returnTo]); if (status === 'loading' || status === 'unavailable') { return <AuthPending status={status} />; } if (!isAuthenticated) { return <Navigate to={ROUTES.LOGIN} replace />; if (error || stuck) { const reason = new URLSearchParams({ error: error ?? 'failed' }); return <Navigate to={`${ROUTES.LOGIN}?${reason}`} replace />; } return <LoadingState message="Redirecting to sign in…" />; } return <Outlet />; Loading
portal-gui/src/features/auth/auth-provider.tsx +10 −4 Changes for portal-gui/src/features/auth/auth-provider.tsx: 10 added lines, 4 removed lines. Original line number Diff line number Diff line import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { AuthContext } from './auth-context'; import type { AuthStatus, Loading Loading @@ -66,8 +66,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; }, []); const login = () => window.location.assign(`${AUTH_API}/login`); const register = () => window.location.assign(`${AUTH_API}/register`); // Stable, so a guard can call it from an effect without re-running it. const login = useCallback((returnTo?: string) => { const query = returnTo ? `?${new URLSearchParams({ return_to: returnTo })}` : ''; // replace, not assign: Back from Keycloak must not return to a page that // sends the browser straight back to Keycloak. window.location.replace(`${AUTH_API}/login${query}`); }, []); const logout = async () => { let logoutUrl: string | null = null; Loading @@ -88,7 +95,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { status, user, login, register, logout, isAuthenticated: status === 'authenticated', }} Loading
portal-gui/src/features/auth/login.tsx +45 −16 Changes for portal-gui/src/features/auth/login.tsx: 45 added lines, 16 removed lines. Original line number Diff line number Diff line import { useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useAuth } from './auth-context'; type Message = { title: string; description: string }; const SIGNED_OUT: Message = { title: "You're signed out", description: 'Sign in to access the OpenOP.', }; const FAILED: Message = { title: "Couldn't sign you in", description: 'Try again. If this keeps happening, contact your administrator.', }; // The reasons GET /api/auth/callback sends back, and what the person can do about them. const LOGIN_ERRORS: Record<string, Message> = { cancelled: { title: 'Sign-in cancelled', description: 'You cancelled signing in.', }, expired: { title: 'Sign-in timed out', description: 'Signing in took too long. Please try again.', }, failed: FAILED, unavailable: { title: 'Sign-in unavailable', description: "The sign-in service isn't responding. Try again in a moment.", }, }; // Only reached when sending the browser to Keycloak straight away would be wrong: // after a failed sign-in, or a logout that could not end the Keycloak session. // It never redirects on its own, so a sign-in that keeps failing cannot loop. export function LoginPage() { const { login, register } = useAuth(); const { login } = useAuth(); const [searchParams] = useSearchParams(); const error = searchParams.get('error'); const { title, description } = error ? (LOGIN_ERRORS[error] ?? FAILED) : SIGNED_OUT; return ( <div className="space-y-6"> <CardHeader className="space-y-2 px-0"> <CardTitle className="text-2xl">Login</CardTitle> <CardDescription>Sign in to access the OpenOP.</CardDescription> <CardTitle className="text-2xl">{title}</CardTitle> <CardDescription>{description}</CardDescription> </CardHeader> <Button className="w-full" onClick={login}> Sign In <Button className="w-full" onClick={() => login()}> Sign in </Button> <div className="text-center text-sm text-muted-foreground"> Don't have an account?{' '} <button type="button" className="text-primary hover:underline" onClick={register} > Create account </button> </div> </div> ); }
portal-gui/src/types/authentication.ts +1 −2 Changes for portal-gui/src/types/authentication.ts: 1 added line, 2 removed lines. Original line number Diff line number Diff line Loading @@ -30,8 +30,7 @@ export type AuthStatus = export type AuthContextType = { status: AuthStatus; user: CurrentUser | null; login: () => void; register: () => void; login: (returnTo?: string) => void; logout: () => Promise<void>; isAuthenticated: boolean; }; Loading
portal-gui/src/types/ui.ts +0 −2 Changes for portal-gui/src/types/ui.ts: 0 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -9,5 +9,3 @@ export type ActionSettingsBtn = (typeof ActionSettingsBtn)[keyof typeof ActionSettingsBtn]; export type MyApplicationsTabKey = 'profile' | 'deployments'; export type LoginTabKey = 'login' | 'register';