Shop identity, the cantonal code that drives the holiday calendar, the two wake intervals and the image format, plus the device list and a translation self-test. The audit trail sits at the bottom, rendered field by field in French rather than as raw JSON. Regenerating a device token shows it once and stores only its digest, so the panel has to be re-paired through the captive portal afterwards. That is the point rather than a drawback: this is the control you reach for when a token may have leaked, and a version that let you read the old one back would not be one. The server URL to type into the captive portal is reconstructed from the incoming request, so the value shown is the one the device would actually have to reach — not one assembled from configuration that may not match what the proxy is serving. Wake intervals are bounded by the same constants the device API enforces, so a value the settings page accepts cannot be one the panel is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
83 lines
2.9 KiB
TypeScript
83 lines
2.9 KiB
TypeScript
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
|
|
import { auth, signOut } from '@/lib/auth';
|
|
|
|
/** Extended as each page lands, so nothing in the bar leads nowhere. */
|
|
const NAV = [
|
|
{ href: '/admin', label: 'Tableau de bord' },
|
|
{ href: '/admin/horaires', label: 'Horaires' },
|
|
{ href: '/admin/exceptions', label: 'Exceptions' },
|
|
{ href: '/admin/vacances', label: 'Vacances' },
|
|
{ href: '/admin/messages', label: 'Messages' },
|
|
{ href: '/admin/feries', label: 'Fériés' },
|
|
{ href: '/admin/parametres', label: 'Paramètres' },
|
|
];
|
|
|
|
/**
|
|
* The admin shell.
|
|
*
|
|
* The middleware has already established that there is a session by the time
|
|
* this renders; the call here is only to know who it is. Navigation links are
|
|
* added as their pages land, so nothing in the bar leads nowhere.
|
|
*/
|
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
const session = await auth();
|
|
const email = session?.user?.email ?? '';
|
|
const isAdmin = session?.user?.role === 'admin';
|
|
|
|
return (
|
|
<div className="min-h-dvh">
|
|
<header className="border-b border-[var(--line)] bg-[var(--surface)]">
|
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-3 px-4 py-3">
|
|
<Link href="/admin" className="flex items-center" aria-label="Tableau de bord">
|
|
<Image
|
|
src="/brand/logo.png"
|
|
alt="ITA ITO"
|
|
width={406}
|
|
height={194}
|
|
className="h-auto w-24"
|
|
/>
|
|
</Link>
|
|
|
|
<nav aria-label="Sections" className="flex flex-wrap items-center gap-x-5 gap-y-1">
|
|
{NAV.map((item) => (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className="text-sm text-[var(--ink-muted)] underline-offset-4 transition-colors hover:text-[var(--ink)] hover:underline"
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="ml-auto flex items-center gap-3 text-sm">
|
|
{!isAdmin ? (
|
|
<span className="rounded-[var(--radius-sm)] bg-[var(--surface-muted)] px-2 py-1 text-xs text-[var(--ink-muted)]">
|
|
Lecture seule
|
|
</span>
|
|
) : null}
|
|
<span className="hidden text-[var(--ink-muted)] sm:inline">{email}</span>
|
|
<form
|
|
action={async () => {
|
|
'use server';
|
|
await signOut({ redirectTo: '/login' });
|
|
}}
|
|
>
|
|
<button
|
|
type="submit"
|
|
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-3 py-1.5 text-sm transition-colors hover:bg-[var(--surface-muted)]"
|
|
>
|
|
Se déconnecter
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|