Show a login notice when a session ends due to idle timeout.
Return a distinct session_idle error from the API and reload to /login with a clear signed-out message.
This commit is contained in:
@@ -1065,6 +1065,12 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.wizard-notice {
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.wizard-rules {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
resize: vertical;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LoginPage } from './LoginPage'
|
||||
|
||||
describe('LoginPage', () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/login')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows an idle signed-out notice from the query string', () => {
|
||||
window.history.replaceState(null, '', '/login?reason=idle')
|
||||
|
||||
render(<LoginPage onLoggedIn={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/You have been signed out because your idle time limit was reached/i,
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(window.location.search).toBe('')
|
||||
})
|
||||
|
||||
it('does not show an idle notice without the query reason', () => {
|
||||
window.history.replaceState(null, '', '/login')
|
||||
|
||||
render(<LoginPage onLoggedIn={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,34 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { login } from './api/client'
|
||||
|
||||
const IDLE_SIGNED_OUT_MESSAGE =
|
||||
'You have been signed out because your idle time limit was reached.'
|
||||
|
||||
type LoginPageProps = {
|
||||
onLoggedIn: (username: string) => void
|
||||
}
|
||||
|
||||
function readIdleSignedOutMessage(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('reason') !== 'idle') {
|
||||
return null
|
||||
}
|
||||
params.delete('reason')
|
||||
const nextSearch = params.toString()
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
return IDLE_SIGNED_OUT_MESSAGE
|
||||
}
|
||||
|
||||
export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('Admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [idleMessage] = useState<string | null>(() => readIdleSignedOutMessage())
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
@@ -35,6 +54,12 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
Use the administrator account created during setup.
|
||||
</p>
|
||||
|
||||
{idleMessage ? (
|
||||
<p className="wizard-notice" role="status">
|
||||
{idleMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="wizard-error" role="alert">
|
||||
{errorMessage}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
getApiBaseUrl,
|
||||
login,
|
||||
patchAction,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
@@ -716,3 +717,71 @@ describe('actions API', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session idle redirect', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reloads to login when an authenticated call returns session_idle', async () => {
|
||||
const assign = vi.fn()
|
||||
vi.stubGlobal('location', {
|
||||
pathname: '/configuration/security',
|
||||
assign,
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
clone: () => ({
|
||||
json: async () => ({ error: 'session_idle' }),
|
||||
}),
|
||||
json: async () => ({ error: 'session_idle' }),
|
||||
})
|
||||
|
||||
await expect(fetchStatus(fetchMock)).rejects.toThrow()
|
||||
expect(assign).toHaveBeenCalledWith('/login?reason=idle')
|
||||
})
|
||||
|
||||
it('does not redirect on login 401', async () => {
|
||||
const assign = vi.fn()
|
||||
vi.stubGlobal('location', {
|
||||
pathname: '/login',
|
||||
assign,
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
clone: () => ({
|
||||
json: async () => ({ error: 'invalid credentials' }),
|
||||
}),
|
||||
json: async () => ({ error: 'invalid credentials' }),
|
||||
})
|
||||
|
||||
await expect(login('Admin', 'wrong', '', fetchMock)).rejects.toThrow(
|
||||
'invalid credentials',
|
||||
)
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect for generic authentication required', async () => {
|
||||
const assign = vi.fn()
|
||||
vi.stubGlobal('location', {
|
||||
pathname: '/',
|
||||
assign,
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
clone: () => ({
|
||||
json: async () => ({ error: 'authentication required' }),
|
||||
}),
|
||||
json: async () => ({ error: 'authentication required' }),
|
||||
})
|
||||
|
||||
await expect(fetchStatus(fetchMock)).rejects.toThrow()
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+33
-1
@@ -154,6 +154,36 @@ async function readErrorMessage(response: Response): Promise<string> {
|
||||
return `request failed: ${response.status}`
|
||||
}
|
||||
|
||||
const SESSION_IDLE_ERROR = 'session_idle'
|
||||
const IDLE_LOGIN_PATH = '/login?reason=idle'
|
||||
|
||||
function shouldRedirectOnSessionIdle(path: string): boolean {
|
||||
if (path === '/api/v1/auth/login') {
|
||||
return false
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return window.location.pathname !== '/login'
|
||||
}
|
||||
|
||||
async function redirectIfSessionIdle(
|
||||
response: Response,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
if (response.status !== 401 || !shouldRedirectOnSessionIdle(path)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = (await response.clone().json()) as { error?: string }
|
||||
if (payload.error === SESSION_IDLE_ERROR) {
|
||||
window.location.assign(IDLE_LOGIN_PATH)
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON bodies
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
@@ -163,11 +193,13 @@ async function apiFetch(
|
||||
if (init.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
return fetchImpl(`${getApiBaseUrl()}${path}`, {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
await redirectIfSessionIdle(response, path)
|
||||
return response
|
||||
}
|
||||
|
||||
export async function fetchHealth(
|
||||
|
||||
Reference in New Issue
Block a user