fix: redirect expired sessions to login
Invalidate authentication on protected 401 responses and JWT expiration, redirect to the login route, and explain that the session is no longer active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c050815900
commit
0d8a25e73f
6 changed files with 147 additions and 8 deletions
|
|
@ -164,6 +164,52 @@ describe('Go Guess frontend', () => {
|
|||
expect(window.location.pathname).toBe('/jobs')
|
||||
})
|
||||
|
||||
it('returns to login when the active session expires', async () => {
|
||||
authenticate()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: 'session expired' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
renderApp('/jobs')
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Sign in to your workspace' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Your session is no longer active')
|
||||
expect(sessionStorage.getItem('go-guess-token')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an already expired stored token without waiting for an API response', async () => {
|
||||
const payload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 60 })).replace(
|
||||
/=+$/,
|
||||
'',
|
||||
)
|
||||
sessionStorage.setItem('go-guess-token', `header.${payload}.signature`)
|
||||
sessionStorage.setItem(
|
||||
'go-guess-user',
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
role: 'admin',
|
||||
}),
|
||||
)
|
||||
|
||||
renderApp('/calendar')
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Sign in to your workspace' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Your session is no longer active')
|
||||
expect(sessionStorage.getItem('go-guess-token')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters jobs by title', async () => {
|
||||
authenticate()
|
||||
const user = userEvent.setup()
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import {
|
|||
useNavigate,
|
||||
useParams,
|
||||
} from 'react-router-dom'
|
||||
import { api, ApiError, authStorage } from '../api/client'
|
||||
import { api, ApiError, authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../api/client'
|
||||
import {
|
||||
CalendarPage,
|
||||
InboxPage,
|
||||
|
|
@ -168,7 +168,7 @@ function Logo() {
|
|||
)
|
||||
}
|
||||
|
||||
function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
function LoginPage({ onLogin, sessionExpired }: { onLogin: () => void; sessionExpired: boolean }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
|
|
@ -210,6 +210,11 @@ function LoginPage({ onLogin }: { onLogin: () => void }) {
|
|||
<div className="login-card">
|
||||
<span className="eyebrow">Welcome back</span>
|
||||
<h2 id="login-title">Sign in to your workspace</h2>
|
||||
{sessionExpired && (
|
||||
<div className="session-notice" role="status">
|
||||
Your session is no longer active. Sign in again to continue.
|
||||
</div>
|
||||
)}
|
||||
<p className="muted">Use your work account to continue.</p>
|
||||
<form onSubmit={submit} className="stack-form">
|
||||
<FormField label="Email address" htmlFor="email">
|
||||
|
|
@ -2391,17 +2396,43 @@ function NotFound() {
|
|||
|
||||
export default function App() {
|
||||
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
||||
const [sessionExpired, setSessionExpired] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
function sessionExpired() {
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(true)
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, sessionExpired)
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, sessionExpired)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) return
|
||||
const expiresAt = authStorage.expiresAt()
|
||||
if (expiresAt === null) return
|
||||
const remaining = expiresAt - Date.now()
|
||||
if (remaining <= 0) {
|
||||
invalidateSession()
|
||||
return
|
||||
}
|
||||
const timeout = window.setTimeout(invalidateSession, remaining)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [authenticated])
|
||||
|
||||
function completeLogin() {
|
||||
setAuthenticated(true)
|
||||
setSessionExpired(false)
|
||||
navigate('/jobs', { replace: true })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStorage.clear()
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(false)
|
||||
}
|
||||
if (location.pathname.startsWith('/participant/')) {
|
||||
return (
|
||||
|
|
@ -2412,7 +2443,9 @@ export default function App() {
|
|||
</Routes>
|
||||
)
|
||||
}
|
||||
if (!authenticated) return <LoginPage onLogin={completeLogin} />
|
||||
if (!authenticated) {
|
||||
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired} />
|
||||
}
|
||||
return (
|
||||
<DataProvider>
|
||||
<AppLayout onLogout={logout} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue