M2-T07: build auth UI (login, session bootstrap, forced password change, logout)

- real Mantine login form -> POST /api/auth/login; 401 inline error; redirect when already authed
- ProtectedRoute: loading state, preserves intended destination, gates force_password_change
- ChangePasswordPage forced-change gate -> POST /api/auth/password
- logout control in AppLayout nav -> POST /api/auth/logout
- typed client only; vitest tests for the login flow
This commit is contained in:
2026-06-13 15:20:50 +02:00
parent 8975acc48b
commit b2e26f0b17
8 changed files with 892 additions and 31 deletions
+24 -7
View File
@@ -1,12 +1,18 @@
/**
* ProtectedRoute — renders children when authenticated; redirects to /login otherwise.
*
* Shows nothing while the session is still loading to avoid flash-of-login.
* T07 can replace the loading placeholder with a proper spinner/skeleton.
* Additional gate (M2-T07):
* - If the authenticated user has force_password_change === true, redirect to
* /change-password instead of rendering children. This prevents access to any
* protected page until the password is changed.
* - Shows a loading spinner while the session is still resolving to avoid flash-of-login.
* - On unauthenticated access, preserves the intended destination in location.state.from
* so LoginPage can redirect back after login.
*/
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { Navigate, useLocation } from 'react-router-dom'
import { Center, Loader } from '@mantine/core'
import { useSession } from './SessionProvider'
interface ProtectedRouteProps {
@@ -14,15 +20,26 @@ interface ProtectedRouteProps {
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { status } = useSession()
const { status, user } = useSession()
const location = useLocation()
if (status === 'loading') {
// Render nothing while we check the session — avoids a flash to /login.
return null
// Render a centred spinner while we check the session — avoids a flash to /login.
return (
<Center mih="100vh">
<Loader />
</Center>
)
}
if (status === 'unauthenticated') {
return <Navigate to="/login" replace />
// Preserve the intended destination so LoginPage can redirect back after login.
return <Navigate to="/login" state={{ from: location }} replace />
}
// Authenticated but forced to change password — gate all protected pages.
if (user?.force_password_change && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />
}
return <>{children}</>