113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
import { NavLink, Outlet } from 'react-router'
|
|
import { useEffect } from 'react'
|
|
import { currentUser, getAccessToken } from '../auth/keycloak'
|
|
import { LEARNER_NAV_ITEMS, hasAnyRole, hasLearnerPortalAccess } from '../learner-portal'
|
|
|
|
export function LearnerLayout() {
|
|
const user = currentUser()
|
|
const roles = user.roles ?? []
|
|
|
|
useEffect(() => {
|
|
// Re-enter bootstrap auth flow when the token is missing.
|
|
if (!getAccessToken()) {
|
|
window.location.reload()
|
|
}
|
|
}, [])
|
|
|
|
if (!hasLearnerPortalAccess(roles)) {
|
|
return (
|
|
<main className="mx-auto max-w-3xl px-4 py-14">
|
|
<h1 className="text-3xl font-semibold text-stone-900">Learner Portal Access Restricted</h1>
|
|
<p className="mt-2 text-sm text-stone-600">
|
|
Your account is authenticated but does not currently have learner portal access.
|
|
</p>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main data-testid="learner-layout" className="min-h-screen bg-gradient-to-b from-white via-slate-50 to-sky-50 text-slate-900">
|
|
<div className="mx-auto grid max-w-7xl gap-6 px-4 py-8 md:grid-cols-[260px_1fr] md:px-6 md:py-10">
|
|
<aside className="h-fit rounded-[24px] border border-stone-200/80 bg-white/90 p-4 shadow-[0_16px_40px_rgba(65,48,31,0.07)] backdrop-blur">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">Learner Area</p>
|
|
<p className="mt-1 text-sm text-stone-700">{user.username || user.email || 'Authenticated user'}</p>
|
|
|
|
<nav data-testid="learner-nav-mobile" className="mt-4 flex gap-2 overflow-x-auto pb-1 md:hidden" aria-label="Learner mobile navigation">
|
|
{LEARNER_NAV_ITEMS.map(item => {
|
|
const allowed = !item.minAnyRoles || hasAnyRole(roles, item.minAnyRoles)
|
|
if (!allowed) {
|
|
return (
|
|
<span
|
|
key={`mobile-${item.path}`}
|
|
className="inline-flex shrink-0 rounded-lg border border-slate-200 bg-stone-50 px-3 py-2 text-xs text-stone-400"
|
|
aria-disabled="true"
|
|
>
|
|
{item.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<NavLink
|
|
key={`mobile-${item.path}`}
|
|
to={item.path}
|
|
end={item.path === '/learn'}
|
|
data-testid={`learner-nav-mobile-${item.path.replace(/^\//, '').replace(/\//g, '-') || 'home'}`}
|
|
className={({ isActive }) =>
|
|
`inline-flex shrink-0 rounded-lg px-3 py-2 text-xs font-medium transition ${
|
|
isActive
|
|
? 'bg-indigo-50 text-indigo-800'
|
|
: 'text-slate-700 hover:bg-stone-100 hover:text-stone-900'
|
|
}`
|
|
}
|
|
>
|
|
{item.label}
|
|
</NavLink>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
<nav data-testid="learner-nav-desktop" className="mt-4 hidden space-y-1 md:block" aria-label="Learner desktop navigation">
|
|
{LEARNER_NAV_ITEMS.map(item => {
|
|
const allowed = !item.minAnyRoles || hasAnyRole(roles, item.minAnyRoles)
|
|
if (!allowed) {
|
|
return (
|
|
<span
|
|
key={item.path}
|
|
className="block rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-400"
|
|
aria-disabled="true"
|
|
>
|
|
{item.label} (restricted)
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<NavLink
|
|
key={item.path}
|
|
to={item.path}
|
|
end={item.path === '/learn'}
|
|
data-testid={`learner-nav-desktop-${item.path.replace(/^\//, '').replace(/\//g, '-') || 'home'}`}
|
|
className={({ isActive }) =>
|
|
`block rounded-lg px-3 py-2 text-sm font-medium transition ${
|
|
isActive
|
|
? 'bg-sky-100 text-sky-800'
|
|
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
|
}`
|
|
}
|
|
>
|
|
{item.label}
|
|
</NavLink>
|
|
)
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
|
|
<section data-testid="learner-layout-content" className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm md:p-6">
|
|
<Outlet />
|
|
</section>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|