Feature/m2 frontend v2 #8
+49
-5
@@ -5,17 +5,18 @@
|
|||||||
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
|
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
|
||||||
*
|
*
|
||||||
* Route tree:
|
* Route tree:
|
||||||
* /login → LoginPage (public, T07 will build the real form)
|
* /login → LoginPage (public)
|
||||||
|
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
|
||||||
* / → ProtectedRoute → AppLayout → HomePage (T09)
|
* / → ProtectedRoute → AppLayout → HomePage (T09)
|
||||||
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
|
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
|
||||||
*
|
*
|
||||||
* AppLayout renders a minimal shell with a gear-icon nav entry for /config (§5#10).
|
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
|
||||||
* T07–T10 slot their real pages in without touching the provider/router setup.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MantineProvider } from '@mantine/core'
|
import { MantineProvider } from '@mantine/core'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
|
||||||
|
import { Button, Group } from '@mantine/core'
|
||||||
|
|
||||||
// Mantine requires its CSS to be imported once.
|
// Mantine requires its CSS to be imported once.
|
||||||
import '@mantine/core/styles.css'
|
import '@mantine/core/styles.css'
|
||||||
@@ -25,6 +26,9 @@ import { ProtectedRoute } from './auth/ProtectedRoute'
|
|||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { ConfigPage } from './pages/ConfigPage'
|
import { ConfigPage } from './pages/ConfigPage'
|
||||||
|
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
||||||
|
import apiClient from './api/client'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
||||||
@@ -45,6 +49,32 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logout button component (needs navigate + queryClient hooks, so it's a component)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LogoutButton() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await apiClient.POST('/api/auth/logout')
|
||||||
|
} catch {
|
||||||
|
// Ignore errors on logout — we clear the session regardless.
|
||||||
|
}
|
||||||
|
// Invalidate session so SessionProvider becomes unauthenticated.
|
||||||
|
await qc.invalidateQueries({ queryKey: ['session'] })
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="subtle" size="xs" onClick={handleLogout} data-testid="logout-button">
|
||||||
|
Log out
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App shell layout (used by all protected pages)
|
// App shell layout (used by all protected pages)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -52,7 +82,7 @@ const queryClient = new QueryClient({
|
|||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* Minimal top nav — T07–T10 can enhance this with Mantine AppShell */}
|
{/* Top nav */}
|
||||||
<nav
|
<nav
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -65,6 +95,8 @@ function AppLayout() {
|
|||||||
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
||||||
Home Automation
|
Home Automation
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
{/* Gear icon nav slot — links to config page (§5#10) */}
|
{/* Gear icon nav slot — links to config page (§5#10) */}
|
||||||
<Link
|
<Link
|
||||||
to="/config"
|
to="/config"
|
||||||
@@ -74,6 +106,8 @@ function AppLayout() {
|
|||||||
>
|
>
|
||||||
⚙
|
⚙
|
||||||
</Link>
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
|
</Group>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
@@ -98,6 +132,16 @@ export default function App() {
|
|||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
|
||||||
|
<Route
|
||||||
|
path="/change-password"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ChangePasswordPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Protected routes — all nested under AppLayout */}
|
{/* Protected routes — all nested under AppLayout */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* ProtectedRoute — renders children when authenticated; redirects to /login otherwise.
|
* ProtectedRoute — renders children when authenticated; redirects to /login otherwise.
|
||||||
*
|
*
|
||||||
* Shows nothing while the session is still loading to avoid flash-of-login.
|
* Additional gate (M2-T07):
|
||||||
* T07 can replace the loading placeholder with a proper spinner/skeleton.
|
* - 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 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'
|
import { useSession } from './SessionProvider'
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
@@ -14,15 +20,26 @@ interface ProtectedRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
const { status } = useSession()
|
const { status, user } = useSession()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
// Render nothing while we check the session — avoids a flash to /login.
|
// Render a centred spinner while we check the session — avoids a flash to /login.
|
||||||
return null
|
return (
|
||||||
|
<Center mih="100vh">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'unauthenticated') {
|
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}</>
|
return <>{children}</>
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ChangePasswordPage (M2-T07 rework-1).
|
||||||
|
*
|
||||||
|
* Strategy: vi.mock the apiClient and useSession modules so we can control
|
||||||
|
* POST /api/auth/password responses and session state without a real server.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders the change-password form when user has force_password_change=true.
|
||||||
|
* 2. Successful password change → navigates to '/' (proceeds into the app).
|
||||||
|
* 3. Client-side mismatch → shows error, does NOT call the API.
|
||||||
|
* 4. API 400 error → shows generic error, stays on form.
|
||||||
|
* 5. Guard: non-forced user visiting /change-password → redirected to '/'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { ChangePasswordPage } from './ChangePasswordPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
GET: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock useSession — default: forced-change user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUseSession = vi.fn(() => ({
|
||||||
|
status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated',
|
||||||
|
user: { username: 'admin', force_password_change: true } as
|
||||||
|
| null
|
||||||
|
| { username: string; force_password_change: boolean },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../auth/SessionProvider', () => ({
|
||||||
|
useSession: () => mockUseSession(),
|
||||||
|
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderChangePw(initialPath = '/change-password') {
|
||||||
|
return renderWithProviders(<ChangePasswordPage />, {
|
||||||
|
initialPath,
|
||||||
|
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) {
|
||||||
|
fireEvent.change(screen.getByTestId('current-password-input'), {
|
||||||
|
target: { value: currentPw },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('new-password-input'), {
|
||||||
|
target: { value: newPw },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('confirm-password-input'), {
|
||||||
|
target: { value: confirmPw },
|
||||||
|
})
|
||||||
|
fireEvent.submit(screen.getByTestId('change-password-form'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ChangePasswordPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Default: authenticated user with force_password_change=true
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
status: 'authenticated',
|
||||||
|
user: { username: 'admin', force_password_change: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the change-password form for a forced-change user', () => {
|
||||||
|
renderChangePw()
|
||||||
|
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('current-password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('new-password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('change-password-submit')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to "/" after a successful password change', async () => {
|
||||||
|
// Simulate successful POST /api/auth/password
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('old-password', 'new-password', 'new-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /api/auth/password with the correct body', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('current123', 'newpass456', 'newpass456')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/auth/password', {
|
||||||
|
body: {
|
||||||
|
current_password: 'current123',
|
||||||
|
new_password: 'newpass456',
|
||||||
|
confirm_password: 'newpass456',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error and does NOT call the API when new passwords do not match', async () => {
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
|
||||||
|
/do not match/i,
|
||||||
|
)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
// Should remain on the form
|
||||||
|
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows generic error on API 400 and stays on form', async () => {
|
||||||
|
// Simulate 400 via ApiError throw (as the client middleware does)
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' }))
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('wrong-current', 'newpass', 'newpass')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
|
||||||
|
/password change failed/i,
|
||||||
|
)
|
||||||
|
// Should NOT have navigated away
|
||||||
|
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects a non-forced user away from /change-password to "/"', async () => {
|
||||||
|
// A user who has already changed their password
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
status: 'authenticated',
|
||||||
|
user: { username: 'admin', force_password_change: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The change-password form must NOT be shown
|
||||||
|
expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* ChangePasswordPage — forced password change gate (M2-T07).
|
||||||
|
*
|
||||||
|
* Shown when the authenticated user has force_password_change === true.
|
||||||
|
* Blocks access to all other pages until the password is changed.
|
||||||
|
*
|
||||||
|
* Behaviours:
|
||||||
|
* - If the current user does NOT have force_password_change, redirect to '/'
|
||||||
|
* (mirrors LoginPage's already-authenticated guard).
|
||||||
|
* - POST /api/auth/password with { current_password, new_password, confirm_password }.
|
||||||
|
* - On ApiError 400 → show a generic failure message (do not leak details).
|
||||||
|
* - On success → invalidate ['session'] so SessionProvider re-fetches with
|
||||||
|
* force_password_change=false, then navigate to '/' to enter the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
PasswordInput,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Center,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useSession } from '../auth/SessionProvider'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: { pathname: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ChangePasswordPage() {
|
||||||
|
const { user } = useSession()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// Guard: if the user is authenticated but NOT in forced-change state, redirect
|
||||||
|
// to the app. This prevents a non-forced user from sitting on /change-password.
|
||||||
|
// (Mirrors LoginPage's already-authenticated redirect.)
|
||||||
|
if (user && !user.force_password_change) {
|
||||||
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||||
|
return <Navigate to={from} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Client-side validation: confirm passwords match before hitting the server.
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.POST('/api/auth/password', {
|
||||||
|
body: {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
confirm_password: confirmPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Success: refresh session so force_password_change becomes false,
|
||||||
|
// then navigate into the app — the guard above (and ProtectedRoute) will
|
||||||
|
// no longer block access once the session is updated.
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 400) {
|
||||||
|
// Generic failure message — do not leak backend detail.
|
||||||
|
setError('Password change failed. Please check your current password and try again.')
|
||||||
|
} else {
|
||||||
|
setError('An unexpected error occurred. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center mih="100vh">
|
||||||
|
<Container size="xs" w="100%">
|
||||||
|
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||||
|
<Title order={2} mb="xs" ta="center">
|
||||||
|
Change Password
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="sm" mb="lg" ta="center">
|
||||||
|
You must change your password before continuing.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" mb="md" role="alert" data-testid="change-password-error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} data-testid="change-password-form">
|
||||||
|
<Stack gap="md">
|
||||||
|
<PasswordInput
|
||||||
|
label="Current Password"
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
data-testid="current-password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="New Password"
|
||||||
|
placeholder="Enter your new password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-testid="new-password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Confirm New Password"
|
||||||
|
placeholder="Confirm your new password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-testid="confirm-password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
mt="sm"
|
||||||
|
data-testid="change-password-submit"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Tests for LoginPage (M2-T07).
|
||||||
|
*
|
||||||
|
* Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
|
||||||
|
* responses without a real server. We also mock useSession so tests can control
|
||||||
|
* the authentication state.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders the login form.
|
||||||
|
* 2. Successful login → invalidates session query + navigates.
|
||||||
|
* 3. 401 bad credentials → shows inline error, does not navigate.
|
||||||
|
* 4. Already-authenticated users visiting /login → redirected to '/'.
|
||||||
|
* 5. Unexpected error → shows generic error message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { LoginPage } from './LoginPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// We mock the entire api/client module. Each test can override POST as needed.
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
GET: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock useSession — default: unauthenticated
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Typed as returning the wider union so mockReturnValue accepts all status variants.
|
||||||
|
const mockUseSession = vi.fn(() => ({
|
||||||
|
status: 'unauthenticated' as 'loading' | 'authenticated' | 'unauthenticated',
|
||||||
|
user: null as null | { username: string; force_password_change: boolean },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../auth/SessionProvider', () => ({
|
||||||
|
useSession: () => mockUseSession(),
|
||||||
|
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderLogin(initialPath = '/login') {
|
||||||
|
return renderWithProviders(<LoginPage />, {
|
||||||
|
initialPath,
|
||||||
|
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAndSubmit(username: string, password: string) {
|
||||||
|
fireEvent.change(screen.getByTestId('username-input'), { target: { value: username } })
|
||||||
|
fireEvent.change(screen.getByTestId('password-input'), { target: { value: password } })
|
||||||
|
fireEvent.submit(screen.getByTestId('login-form'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Reset to unauthenticated by default
|
||||||
|
mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the login form with username and password fields', () => {
|
||||||
|
renderLogin()
|
||||||
|
expect(screen.getByTestId('login-form')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('username-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('login-submit')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Sign In heading', () => {
|
||||||
|
renderLogin()
|
||||||
|
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to "/" on successful login', async () => {
|
||||||
|
// Simulate a successful POST /api/auth/login response
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'correct-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /api/auth/login with the correct body', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('myuser', 'mypassword')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
|
||||||
|
body: { username: 'myuser', password: 'mypassword' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows inline error on 401 and does NOT navigate', async () => {
|
||||||
|
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: undefined,
|
||||||
|
response: { status: 401, ok: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'wrong-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('login-error')).toHaveTextContent(
|
||||||
|
/incorrect username or password/i,
|
||||||
|
)
|
||||||
|
// Should still be on the login form, not navigated away
|
||||||
|
expect(screen.getByTestId('login-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include the password in the error message', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: undefined,
|
||||||
|
response: { status: 401, ok: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'super-secret-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows generic error on unexpected network failure', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects already-authenticated users to "/"', async () => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
status: 'authenticated',
|
||||||
|
user: { username: 'admin', force_password_change: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,19 +1,147 @@
|
|||||||
/**
|
/**
|
||||||
* LoginPage — placeholder for M2-T07.
|
* LoginPage — real login form (M2-T07).
|
||||||
*
|
*
|
||||||
* T07 replaces this with the real login form (username/password → POST /api/auth/login,
|
* Behaviours:
|
||||||
* force-password-change flow, redirect to home on success).
|
* - Renders a Mantine form with username + password fields.
|
||||||
|
* - On submit → POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
|
||||||
|
* - On success → invalidate ['session'] so SessionProvider re-fetches, then navigate to the
|
||||||
|
* originally-requested route (from location.state.from) or fall back to '/'.
|
||||||
|
* - On 401 (bad credentials) → show an inline error without leaking the password.
|
||||||
|
* - Already-authenticated users visiting /login → redirect to '/'.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Container, Title, Text } from '@mantine/core'
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Title,
|
||||||
|
TextInput,
|
||||||
|
PasswordInput,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Center,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useSession } from '../auth/SessionProvider'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { setCsrfToken } from '../api/csrf'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: { pathname: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
const { status } = useSession()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// Already authenticated → redirect to intended destination or home.
|
||||||
|
if (status === 'authenticated') {
|
||||||
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||||
|
return <Navigate to={from} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.POST('/api/auth/login', {
|
||||||
|
body: { username, password },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.response.status === 401 || !res.data) {
|
||||||
|
// Bad credentials — do not leak the password in the message.
|
||||||
|
setError('Incorrect username or password.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: store the CSRF token returned by login (same shape as session response).
|
||||||
|
if (res.data.csrf_token) {
|
||||||
|
setCsrfToken(res.data.csrf_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh session state: invalidate the ['session'] query so SessionProvider
|
||||||
|
// picks up the new authenticated state (which may include force_password_change).
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||||
|
|
||||||
|
// Navigate to the originally-requested route or home.
|
||||||
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
} catch {
|
||||||
|
// Any unexpected error (network, 5xx, etc.)
|
||||||
|
setError('Login failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xs" pt="xl">
|
<Center mih="100vh">
|
||||||
<Title order={2}>Login</Title>
|
<Container size="xs" w="100%">
|
||||||
<Text c="dimmed" mt="sm">
|
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||||
Login form — implemented in M2-T07.
|
<Title order={2} mb="lg" ta="center">
|
||||||
</Text>
|
Sign In
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" mb="md" role="alert" data-testid="login-error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} data-testid="login-form">
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
data-testid="username-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
data-testid="password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
mt="sm"
|
||||||
|
data-testid="login-submit"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
|
</Center>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* Vitest global setup file.
|
* Vitest global setup file.
|
||||||
* Imports @testing-library/jest-dom to extend vitest matchers with DOM assertions.
|
* Imports @testing-library/jest-dom to extend vitest matchers with DOM assertions.
|
||||||
|
*
|
||||||
|
* Also polyfills browser APIs that jsdom does not implement but Mantine needs:
|
||||||
|
* - window.matchMedia (Mantine uses it for color-scheme detection)
|
||||||
|
* - ResizeObserver (Mantine uses it for responsive components)
|
||||||
*/
|
*/
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// window.matchMedia polyfill (jsdom does not implement this)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ResizeObserver polyfill (jsdom does not implement this)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
;(globalThis as any).ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Shared test utilities — wraps components in the providers they need.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { renderWithProviders } from '../test-utils'
|
||||||
|
* renderWithProviders(<LoginPage />, { initialPath: '/login' })
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { MantineProvider } from '@mantine/core'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RenderOptions {
|
||||||
|
/** Initial URL path (default: '/'). */
|
||||||
|
initialPath?: string
|
||||||
|
/**
|
||||||
|
* Extra routes to register alongside the component under test.
|
||||||
|
* Useful for asserting navigation (e.g. render a /home sentinel and check
|
||||||
|
* that the component navigates there after login).
|
||||||
|
*/
|
||||||
|
routes?: Array<{ path: string; element: ReactNode }>
|
||||||
|
/**
|
||||||
|
* React-router initial entries (overrides initialPath when provided).
|
||||||
|
* Use when you need to seed location.state (e.g. from-path for redirect-after-login).
|
||||||
|
*/
|
||||||
|
initialEntries?: Array<string | { pathname: string; state?: unknown }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render `ui` inside MantineProvider + a fresh QueryClientProvider + MemoryRouter.
|
||||||
|
* SessionProvider is NOT included — tests that need session state should mock
|
||||||
|
* `GET /api/session` via vi.fn() on the apiClient or use MSW.
|
||||||
|
*/
|
||||||
|
export function renderWithProviders(ui: ReactNode, options: RenderOptions = {}) {
|
||||||
|
const { initialPath = '/', routes = [], initialEntries } = options
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const entries = initialEntries ?? [initialPath]
|
||||||
|
|
||||||
|
function Wrapper() {
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={entries}>
|
||||||
|
<Routes>
|
||||||
|
<Route path={initialPath} element={ui} />
|
||||||
|
{routes.map(({ path, element }) => (
|
||||||
|
<Route key={path} path={path} element={element} />
|
||||||
|
))}
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(<Wrapper />)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a minimal SessionProvider-less wrapper that just supplies the
|
||||||
|
* query and router context. Returns the queryClient so tests can prime it.
|
||||||
|
*/
|
||||||
|
export function createTestQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user