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:
parent
a704542a14
commit
06433195bb
4 changed files with 93 additions and 7 deletions
|
|
@ -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
39
frontend/src/App.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
16
frontend/src/pages/DocumentDetailPage.css
Normal file
16
frontend/src/pages/DocumentDetailPage.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue