feat(frontend): AuthBoundary, RoleGuard, UserMenu + Layout integration
This commit is contained in:
25
packages/frontend/src/components/AuthBoundary.tsx
Normal file
25
packages/frontend/src/components/AuthBoundary.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../stores/authStore.js';
|
||||||
|
|
||||||
|
export function AuthBoundary() {
|
||||||
|
const { user, ready, hydrate } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) hydrate();
|
||||||
|
}, [ready, hydrate]);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="h-2 w-32 animate-shimmer rounded-full bg-gradient-to-r from-brand-100 via-brand-200 to-brand-100 bg-[length:1000px_100%]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
const next = encodeURIComponent(location.pathname + location.search);
|
||||||
|
return <Navigate to={`/login?next=${next}`} replace />;
|
||||||
|
}
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { useSettings } from '../stores/settingsStore.js';
|
import { useSettings } from '../stores/settingsStore.js';
|
||||||
|
import { useAuth } from '../stores/authStore.js';
|
||||||
|
import { UserMenu } from './UserMenu.js';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Dashboard', end: true },
|
{ to: '/', label: 'Dashboard', end: true },
|
||||||
{ to: '/admin', label: 'Admin' },
|
{ to: '/admin', label: 'Lessen' },
|
||||||
{ to: '/stats', label: 'Stats' },
|
{ to: '/stats', label: 'Stats' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { theme, toggleTheme } = useSettings();
|
const { theme, toggleTheme } = useSettings();
|
||||||
|
const user = useAuth((s) => s.user);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header className="sticky top-0 z-20 border-b border-white/40 bg-white/70 backdrop-blur-xl dark:border-slate-800/60 dark:bg-slate-950/70">
|
<header className="sticky top-0 z-20 border-b border-white/40 bg-white/70 backdrop-blur-xl dark:border-slate-800/60 dark:bg-slate-950/70">
|
||||||
@@ -17,37 +20,27 @@ export function Layout() {
|
|||||||
<span className="grid h-8 w-8 place-items-center rounded-xl bg-brand-gradient text-white shadow-glow">⚡</span>
|
<span className="grid h-8 w-8 place-items-center rounded-xl bg-brand-gradient text-white shadow-glow">⚡</span>
|
||||||
<span className="bg-brand-gradient bg-clip-text text-transparent">Flashcards</span>
|
<span className="bg-brand-gradient bg-clip-text text-transparent">Flashcards</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<nav className="ml-4 hidden gap-1 sm:flex">
|
{user && (
|
||||||
{navItems.map((item) => (
|
<nav className="ml-4 hidden gap-1 sm:flex">
|
||||||
<NavLink
|
{navItems.map((item) => (
|
||||||
key={item.to}
|
<NavLink
|
||||||
to={item.to}
|
key={item.to}
|
||||||
end={item.end}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
end={item.end}
|
||||||
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
|
className={({ isActive }) =>
|
||||||
isActive
|
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
|
||||||
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
|
isActive
|
||||||
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60 dark:hover:text-white'
|
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
|
||||||
}`
|
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60'
|
||||||
}
|
}`
|
||||||
>
|
}
|
||||||
{item.label}
|
>
|
||||||
</NavLink>
|
{item.label}
|
||||||
))}
|
</NavLink>
|
||||||
</nav>
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<NavLink
|
|
||||||
to="/settings"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
|
|
||||||
isActive
|
|
||||||
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
|
|
||||||
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Instellingen
|
|
||||||
</NavLink>
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="grid h-9 w-9 place-items-center rounded-xl border border-white/60 bg-white/70 text-base shadow-sm transition hover:scale-105 dark:border-slate-800 dark:bg-slate-900/70"
|
className="grid h-9 w-9 place-items-center rounded-xl border border-white/60 bg-white/70 text-base shadow-sm transition hover:scale-105 dark:border-slate-800 dark:bg-slate-900/70"
|
||||||
@@ -55,24 +48,27 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
{theme === 'dark' ? '☀️' : '🌙'}
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
</button>
|
</button>
|
||||||
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-1 overflow-x-auto px-4 pb-2 sm:hidden">
|
{user && (
|
||||||
{navItems.map((item) => (
|
<nav className="flex gap-1 overflow-x-auto px-4 pb-2 sm:hidden">
|
||||||
<NavLink
|
{navItems.map((item) => (
|
||||||
key={item.to}
|
<NavLink
|
||||||
to={item.to}
|
key={item.to}
|
||||||
end={item.end}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
end={item.end}
|
||||||
`whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition ${
|
className={({ isActive }) =>
|
||||||
isActive ? 'bg-brand-600 text-white' : 'bg-white/60 text-slate-700 dark:bg-slate-900/60 dark:text-slate-300'
|
`whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition ${
|
||||||
}`
|
isActive ? 'bg-brand-600 text-white' : 'bg-white/60 text-slate-700 dark:bg-slate-900/60 dark:text-slate-300'
|
||||||
}
|
}`
|
||||||
>
|
}
|
||||||
{item.label}
|
>
|
||||||
</NavLink>
|
{item.label}
|
||||||
))}
|
</NavLink>
|
||||||
</nav>
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||||||
|
|||||||
17
packages/frontend/src/components/RoleGuard.tsx
Normal file
17
packages/frontend/src/components/RoleGuard.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import type { Role } from '@flashcard/shared';
|
||||||
|
import { useAuth } from '../stores/authStore.js';
|
||||||
|
|
||||||
|
export function RoleGuard({ role }: { role: Role }) {
|
||||||
|
const user = useAuth((s) => s.user);
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (user.role !== role) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md p-12 text-center">
|
||||||
|
<h1 className="font-display text-2xl font-bold">Geen toegang</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">Deze pagina is alleen voor beheerders.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
55
packages/frontend/src/components/UserMenu.tsx
Normal file
55
packages/frontend/src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../stores/authStore.js';
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const initials = user.displayName.trim().split(/\s+/).map((p) => p[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="grid h-9 w-9 place-items-center rounded-full bg-brand-gradient text-sm font-bold text-white shadow-glow hover:brightness-110"
|
||||||
|
aria-label="Account menu"
|
||||||
|
>
|
||||||
|
{initials || '?'}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-11 z-30 w-56 rounded-2xl border border-white/60 bg-white/95 p-2 shadow-soft backdrop-blur dark:border-slate-800 dark:bg-slate-900/95">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<div className="truncate text-sm font-semibold">{user.displayName}</div>
|
||||||
|
<div className="truncate text-xs text-slate-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<hr className="my-1 border-brand-100 dark:border-slate-800" />
|
||||||
|
<Link to="/profile" className="block rounded-xl px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-slate-800" onClick={() => setOpen(false)}>Profiel</Link>
|
||||||
|
{user.role === 'sysadmin' && (
|
||||||
|
<Link to="/admin/users" className="block rounded-xl px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-slate-800" onClick={() => setOpen(false)}>👑 Systeembeheer</Link>
|
||||||
|
)}
|
||||||
|
<button onClick={handleLogout} className="block w-full rounded-xl px-3 py-2 text-left text-sm text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10">
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,16 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { router } from './router.js';
|
import { router } from './router.js';
|
||||||
import { useSettings } from './stores/settingsStore.js';
|
import { useSettings } from './stores/settingsStore.js';
|
||||||
|
import { useAuth } from './stores/authStore.js';
|
||||||
|
import { onUnauthorized } from './api/client.js';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
useSettings.getState().hydrate();
|
useSettings.getState().hydrate();
|
||||||
|
|
||||||
|
onUnauthorized(() => {
|
||||||
|
useAuth.setState({ user: null, ready: true });
|
||||||
|
});
|
||||||
|
|
||||||
const root = createRoot(document.getElementById('root')!);
|
const root = createRoot(document.getElementById('root')!);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
Reference in New Issue
Block a user