fix: a page you cannot open says so; a section row fits its card

Hitting an educators-only page redirected to the dashboard in silence,
which leaves you looking at a page you did not ask for with no idea
whether the link was broken, you mistyped, or it simply is not yours. It
says which, and offers the dashboard and settings — the two places you
are actually likely to want from there. Not being signed in stays a
redirect, because there is nothing to explain.

The section row gave its name whatever width was left after a button
group that would not wrap, so "NBME 7 STEP 3" broke to one word a line
while Extract, Create Cards and Delete ran off the right of the card. On
a narrow screen the name takes the row and the buttons take the next
one. The page had a stylesheet that nothing imported, which is why it
had no layout rules of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 01:21:20 +02:00
parent a704542a14
commit 06433195bb
4 changed files with 93 additions and 7 deletions

View file

@ -1,5 +1,5 @@
import { Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useParams } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
@ -75,12 +75,39 @@ function AppLayout() {
)
}
// Guard: redirect to /home if not logged in, or to / if not moderator
/**
* Somewhere this account cannot go.
*
* It used to be a silent redirect to the dashboard, which leaves you looking
* at a page you did not ask for with no idea why the link was dead, or you
* mistyped, or somebody sent you a page only an educator can open. It says
* which, and offers the two places you are actually likely to want.
*/
function Forbidden() {
return (
<div className="card" style={{ maxWidth: 520, margin: '48px auto', textAlign: 'center' }}>
<h1 style={{ margin: '0 0 10px', fontSize: '1.2rem' }}>Not yours to open</h1>
<p style={{ margin: '0 0 20px', color: 'var(--text-muted)', lineHeight: 1.6 }}>
This page is for educators and administrators. Your account is not one,
which is why the link went nowhere rather than because it is broken.
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<Link className="btn btn-secondary" to="/">Dashboard</Link>
<Link className="btn btn-primary" to="/settings">Settings</Link>
</div>
</div>
)
}
// Guard: sign-in is a redirect, because there is nothing to say you simply
// are not signed in. A role you do not have is worth saying out loud.
export function RequireAuthForTest(props) { return RequireAuth(props) }
function RequireAuth({ moderator = false }) {
const { user, loading } = useAuth()
if (loading) return <LoadingFallback />
if (!user) return <Navigate to="/home" replace />
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Forbidden />
return <Outlet />
}

39
frontend/src/App.test.jsx Normal file
View file

@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
let currentUser = { id: 1, role: 'user' }
vi.mock('./context/AuthContext', () => ({
useAuth: () => ({ user: currentUser, loading: false }),
AuthProvider: ({ children }) => children,
}))
const { RequireAuthForTest } = await import('./App')
const mount = (path = '/editorial') => render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route element={<RequireAuthForTest moderator />}>
<Route path="/editorial" element={<h1>Editorial</h1>} />
</Route>
<Route path="/" element={<h1>Dashboard</h1>} />
</Routes>
</MemoryRouter>)
describe('a page this account cannot open', () => {
it('says so, and offers the way out', () => {
currentUser = { id: 1, role: 'user' }
mount()
// It used to redirect in silence, which leaves you on a page you did not
// ask for with no idea whether the link was broken or simply not yours.
expect(screen.getByRole('heading', { name: 'Not yours to open' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings')
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/')
})
it('lets an educator through', () => {
currentUser = { id: 2, role: 'moderator' }
mount()
expect(screen.getByRole('heading', { name: 'Editorial' })).toBeInTheDocument()
})
})

View file

@ -0,0 +1,16 @@
/* A section and what can be done to it
The name took whatever width was left after a button group that would
not wrap, so "NBME 7 STEP 3" broke to one word a line while Extract,
Create Cards and Delete ran off the right of the card. On a narrow
screen the name takes the row and the buttons take the next one. */
.doc-section { flex-wrap: wrap; gap: 10px; }
.doc-section-name { flex: 1 1 220px; min-width: 0; }
.doc-section-name strong { overflow-wrap: anywhere; }
.doc-section-actions { display: flex; gap: 8px; flex-wrap: wrap; flex: none; }
@media (max-width: 640px) {
.doc-section-name { flex-basis: 100%; }
.doc-section-actions { width: 100%; }
.doc-section-actions > * { flex: 1; }
}

View file

@ -3,6 +3,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
import './DocumentDetailPage.css'
function ExtractionProgress({ jobId, onDone, onClose, label = 'Extracting Questions' }) {
const [steps, setSteps] = useState([])
@ -472,14 +473,17 @@ export default function DocumentDetailPage() {
)}
{doc.sections.map(section => (
<div className="section-item" key={section.id}>
<div>
<div className="section-item doc-section" key={section.id}>
<div className="doc-section-name">
<strong>{section.name}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Pages {section.start_page}{section.end_page}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{/* The row had a fixed name column and a non-wrapping button
group, so a long section title was squeezed to one word a
line while the buttons ran off the card. */}
<div className="doc-section-actions">
{isModerator && (
<>
<button