diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
new file mode 100644
index 0000000..e6a7d0e
--- /dev/null
+++ b/app/admin/layout.tsx
@@ -0,0 +1,59 @@
+import Image from 'next/image';
+import Link from 'next/link';
+
+import { auth, signOut } from '@/lib/auth';
+
+/**
+ * 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 (
+
+ );
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 0549a5f..fae14d7 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -1,8 +1,17 @@
-export default function AdminHome() {
+import { auth } from '@/lib/auth';
+
+export const metadata = { title: 'Tableau de bord — ITA ITO' };
+
+export default async function AdminHome() {
+ const session = await auth();
+
return (
-
- ITA ITO — Horaires
- Tableau de bord à venir.
+
+ Tableau de bord
+
+ Connecté en tant que {session?.user?.email} (
+ {session?.user?.role === 'admin' ? 'administrateur' : 'lecture seule'}).
+
);
}
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..3df0029
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1 @@
+export { GET, POST } from '@/lib/auth';
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..f3e6fbf
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,71 @@
+import Image from 'next/image';
+import { redirect } from 'next/navigation';
+
+import { auth, signIn } from '@/lib/auth';
+
+export const metadata = { title: 'Connexion — ITA ITO' };
+
+const ERRORS: Record = {
+ AccessDenied: "Ce compte n'a pas accès à cette application.",
+ Configuration: "La configuration de la connexion est incomplète. Prévenez l'administrateur.",
+ Verification: 'Le lien de connexion a expiré. Réessayez.',
+};
+
+export default async function LoginPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ error?: string; from?: string }>;
+}) {
+ const { error, from } = await searchParams;
+
+ // Someone who is already signed in has no business on this page.
+ if (await auth()) {
+ redirect(from && from.startsWith('/admin') ? from : '/admin');
+ }
+
+ const providerName = process.env.AUTHENTIK_DISPLAY_NAME?.trim() || 'Loxi';
+
+ return (
+
+
+
+
+
Horaires de la boutique
+
Réservé à l’équipe ITA ITO.
+
+ {error ? (
+
+ {ERRORS[error] ?? 'La connexion a échoué. Réessayez.'}
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/lib/audit.test.ts b/lib/audit.test.ts
new file mode 100644
index 0000000..217f687
--- /dev/null
+++ b/lib/audit.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+
+import { diffOf } from './audit';
+
+describe('diffOf', () => {
+ it('reports nothing when nothing moved', () => {
+ // A log full of empty changes is a log nobody reads.
+ expect(diffOf({ a: 1 }, { a: 1 })).toBeNull();
+ expect(diffOf(null, null)).toBeNull();
+ expect(diffOf({}, {})).toBeNull();
+ });
+
+ it('reports a changed field', () => {
+ expect(diffOf({ isClosed: false }, { isClosed: true })).toEqual({
+ isClosed: { before: false, after: true },
+ });
+ });
+
+ it('reports only the fields that moved', () => {
+ expect(diffOf({ a: 1, b: 2 }, { a: 1, b: 3 })).toEqual({ b: { before: 2, after: 3 } });
+ });
+
+ it('reports an added and a removed field', () => {
+ expect(diffOf({ a: 1 }, { a: 1, b: 2 })).toEqual({ b: { before: null, after: 2 } });
+ expect(diffOf({ a: 1, b: 2 }, { a: 1 })).toEqual({ b: { before: 2, after: null } });
+ });
+
+ it('treats a missing field and an explicit null as the same absence', () => {
+ expect(diffOf({ note: null }, {})).toBeNull();
+ expect(diffOf({ note: undefined }, { note: null })).toBeNull();
+ });
+
+ it('compares slot arrays by value, not by identity', () => {
+ const before = { slots: [{ open: '10:00', close: '18:30' }] };
+ const after = { slots: [{ open: '10:00', close: '18:30' }] };
+ expect(diffOf(before, after)).toBeNull();
+
+ expect(diffOf(before, { slots: [{ open: '14:00', close: '18:30' }] })).toEqual({
+ slots: {
+ before: [{ open: '10:00', close: '18:30' }],
+ after: [{ open: '14:00', close: '18:30' }],
+ },
+ });
+ });
+
+ it('compares dates by their instant, not their object', () => {
+ const before = { startsAt: new Date('2026-09-20T00:00:00Z') };
+ expect(diffOf(before, { startsAt: new Date('2026-09-20T00:00:00Z') })).toBeNull();
+
+ const changed = diffOf(before, { startsAt: new Date('2026-09-21T00:00:00Z') });
+ expect(changed?.startsAt).toEqual({
+ before: '2026-09-20T00:00:00.000Z',
+ after: '2026-09-21T00:00:00.000Z',
+ });
+ });
+
+ it('records a creation and a deletion', () => {
+ expect(diffOf(null, { textFr: 'Bonjour' })).toEqual({
+ textFr: { before: null, after: 'Bonjour' },
+ });
+ expect(diffOf({ textFr: 'Bonjour' }, null)).toEqual({
+ textFr: { before: 'Bonjour', after: null },
+ });
+ });
+
+ it('distinguishes values that only look alike', () => {
+ expect(diffOf({ priority: 1 }, { priority: '1' })).not.toBeNull();
+ expect(diffOf({ isActive: false }, { isActive: null })).not.toBeNull();
+ });
+});
diff --git a/lib/audit.ts b/lib/audit.ts
new file mode 100644
index 0000000..7e8650b
--- /dev/null
+++ b/lib/audit.ts
@@ -0,0 +1,86 @@
+/**
+ * The audit trail: who changed what, and when.
+ *
+ * The diff is computed here rather than by each caller so every entry has the
+ * same shape, and so a field can never be recorded as "changed" when only its
+ * representation moved.
+ */
+
+import type { InputJsonValue } from '@/lib/generated/prisma/internal/prismaNamespace';
+
+import { prisma } from '@/lib/db';
+
+export type FieldChange = { before: unknown; after: unknown };
+export type Diff = Record;
+
+/**
+ * Field-by-field difference between two snapshots.
+ *
+ * Returns `null` when nothing moved, so a no-op save leaves no entry: a log
+ * full of empty changes is a log nobody reads.
+ *
+ * Values are compared by their JSON form, which is what makes `slots` arrays
+ * and dates compare sensibly rather than by identity.
+ */
+export function diffOf(
+ before: Record | null,
+ after: Record | null,
+): Diff | null {
+ const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
+ const diff: Diff = {};
+
+ for (const key of keys) {
+ const previous = before?.[key];
+ const next = after?.[key];
+ if (!sameValue(previous, next)) {
+ diff[key] = { before: normalise(previous), after: normalise(next) };
+ }
+ }
+
+ return Object.keys(diff).length > 0 ? diff : null;
+}
+
+function sameValue(a: unknown, b: unknown): boolean {
+ return JSON.stringify(normalise(a)) === JSON.stringify(normalise(b));
+}
+
+/** Dates are compared and stored in ISO form; undefined and null are the same absence. */
+function normalise(value: unknown): unknown {
+ if (value === undefined) {
+ return null;
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ return value;
+}
+
+export type AuditEntry = {
+ userEmail: string;
+ action: string;
+ entity: string;
+ entityId?: string | null;
+ diff?: Diff | null;
+};
+
+/**
+ * Records an entry. Never throws: losing the trail is bad, but refusing the
+ * user's change because the trail could not be written is worse.
+ */
+export async function recordAudit(entry: AuditEntry): Promise {
+ try {
+ await prisma.auditLog.create({
+ data: {
+ userEmail: entry.userEmail,
+ action: entry.action,
+ entity: entry.entity,
+ entityId: entry.entityId ?? null,
+ // Prisma's JSON input type does not accept an arbitrary record; the
+ // diff is plain JSON by construction, so the assertion is safe.
+ diff: entry.diff ? (entry.diff as unknown as InputJsonValue) : undefined,
+ },
+ });
+ } catch (error) {
+ console.error('Could not write the audit entry', entry.action, entry.entity, error);
+ }
+}
diff --git a/lib/auth/authorize.test.ts b/lib/auth/authorize.test.ts
new file mode 100644
index 0000000..08ceb12
--- /dev/null
+++ b/lib/auth/authorize.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+
+import { authorize } from './authorize';
+
+describe('authorize', () => {
+ it('refuses anyone without a session', () => {
+ expect(authorize(undefined, 'GET')).toEqual({
+ ok: false,
+ status: 401,
+ error: 'Non authentifié',
+ });
+ expect(authorize(undefined, 'POST')).toMatchObject({ status: 401 });
+ });
+
+ it('lets any signed-in account read', () => {
+ for (const method of ['GET', 'HEAD', 'OPTIONS', 'get', 'head']) {
+ expect(authorize('viewer', method)).toEqual({ ok: true });
+ expect(authorize('admin', method)).toEqual({ ok: true });
+ }
+ });
+
+ it('refuses a write from a read-only account', () => {
+ for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
+ expect(authorize('viewer', method)).toEqual({
+ ok: false,
+ status: 403,
+ error: 'Compte en lecture seule',
+ });
+ }
+ });
+
+ it('allows a write from an administrator', () => {
+ for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'post']) {
+ expect(authorize('admin', method)).toEqual({ ok: true });
+ }
+ });
+
+ it('treats an unknown method as a write', () => {
+ // New verbs should arrive locked, not open.
+ expect(authorize('viewer', 'PURGE')).toMatchObject({ status: 403 });
+ expect(authorize('admin', 'PURGE')).toEqual({ ok: true });
+ });
+
+ it('checks the session before the method', () => {
+ // A read-only method must not turn a missing session into a 403; the
+ // caller has to know they are signed out, not under-privileged.
+ expect(authorize(undefined, 'GET')).toMatchObject({ status: 401 });
+ });
+});
diff --git a/lib/auth/authorize.ts b/lib/auth/authorize.ts
new file mode 100644
index 0000000..00364a0
--- /dev/null
+++ b/lib/auth/authorize.ts
@@ -0,0 +1,32 @@
+/**
+ * The authorisation rule, on its own and free of any framework import.
+ *
+ * Splitting it out is what lets it be tested directly: pulling in the Auth.js
+ * instance would drag half of Next into a unit test for six lines of policy.
+ */
+
+import { canWrite, type Role } from './roles';
+
+export type Decision = { ok: true } | { ok: false; status: 401 | 403; error: string };
+
+const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
+
+/**
+ * Whether a caller may perform `method`.
+ *
+ * Anyone the identity provider vouches for may read; only an administrator may
+ * change anything. An unknown method counts as a write: new verbs should
+ * arrive locked, not open.
+ */
+export function authorize(role: Role | undefined, method: string): Decision {
+ if (!role) {
+ return { ok: false, status: 401, error: 'Non authentifié' };
+ }
+ if (READ_METHODS.has(method.toUpperCase())) {
+ return { ok: true };
+ }
+ if (!canWrite(role)) {
+ return { ok: false, status: 403, error: 'Compte en lecture seule' };
+ }
+ return { ok: true };
+}
diff --git a/lib/auth/guard.ts b/lib/auth/guard.ts
new file mode 100644
index 0000000..55ed6a4
--- /dev/null
+++ b/lib/auth/guard.ts
@@ -0,0 +1,40 @@
+/**
+ * Authorisation enforced inside the handlers.
+ *
+ * The middleware is the first line: it covers every /admin page and
+ * /api/admin route at the edge. This is the second — because a matcher is a
+ * string, strings get edited, and a route quietly falling outside it should
+ * not be the same thing as a route with no access control.
+ */
+
+import { authorize } from './authorize';
+import { auth } from './index';
+import type { Role } from './roles';
+
+export type Caller = { email: string; role: Role };
+
+/**
+ * Resolves the caller and applies the rule. Returns either the caller or the
+ * Response to send back, so a handler reads as:
+ *
+ * const gate = await guard(request);
+ * if ('response' in gate) return gate.response;
+ */
+export async function guard(
+ request: Request,
+): Promise<{ caller: Caller } | { response: Response }> {
+ const session = await auth();
+ const decision = authorize(session?.user?.role, request.method);
+
+ if (!decision.ok) {
+ return { response: Response.json({ error: decision.error }, { status: decision.status }) };
+ }
+
+ return {
+ caller: {
+ // The subject is the e-mail address, which is what the audit trail records.
+ email: session?.user?.email ?? 'inconnu',
+ role: session?.user?.role ?? 'viewer',
+ },
+ };
+}
diff --git a/lib/auth/index.ts b/lib/auth/index.ts
new file mode 100644
index 0000000..68b21e2
--- /dev/null
+++ b/lib/auth/index.ts
@@ -0,0 +1,63 @@
+import NextAuth from 'next-auth';
+import Authentik from 'next-auth/providers/authentik';
+
+import { groupsFromClaim, roleFromGroups, type Role } from './roles';
+
+/**
+ * Authentication against Authentik.
+ *
+ * Sessions are JWTs with no database adapter, which keeps this module usable
+ * from the edge middleware and means a sign-in costs no query.
+ *
+ * The trade-off is worth stating: the role is read from the token, so removing
+ * someone from the admin group does not end a session already in flight — it
+ * takes effect at the next sign-in, or when the eight-hour session expires.
+ * Immediate revocation would mean asking Authentik's API on every request,
+ * which is what api_llm_loxi does and what this application deliberately does
+ * not: it drives a shop window, not a fleet.
+ */
+
+const ADMIN_GROUP = process.env.AUTHENTIK_ADMIN_GROUP?.trim() || 'horaires-admins';
+
+declare module 'next-auth' {
+ interface Session {
+ user: {
+ email?: string | null;
+ name?: string | null;
+ image?: string | null;
+ role: Role;
+ };
+ }
+}
+
+declare module '@auth/core/jwt' {
+ interface JWT {
+ role?: Role;
+ }
+}
+
+const nextAuth = NextAuth({
+ providers: [Authentik({ name: process.env.AUTHENTIK_DISPLAY_NAME?.trim() || 'Loxi' })],
+ // The application sits behind a reverse proxy; the forwarded host is the
+ // real one.
+ trustHost: true,
+ session: { strategy: 'jwt', maxAge: 8 * 60 * 60 },
+ pages: { signIn: '/login', error: '/login' },
+ callbacks: {
+ jwt({ token, profile }) {
+ // `profile` is only present on the request that follows a sign-in, so
+ // the group membership is resolved once and carried in the token.
+ if (profile) {
+ token.role = roleFromGroups(groupsFromClaim(profile.groups), ADMIN_GROUP);
+ }
+ return token;
+ },
+ session({ session, token }) {
+ session.user.role = token.role ?? 'viewer';
+ return session;
+ },
+ },
+});
+
+export const { auth, signIn, signOut } = nextAuth;
+export const { GET, POST } = nextAuth.handlers;
diff --git a/lib/auth/roles.test.ts b/lib/auth/roles.test.ts
new file mode 100644
index 0000000..73d952f
--- /dev/null
+++ b/lib/auth/roles.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+
+import { canWrite, groupsFromClaim, roleFromGroups } from './roles';
+
+describe('roleFromGroups', () => {
+ it('promotes a member of the configured group', () => {
+ expect(roleFromGroups(['horaires-admins'], 'horaires-admins')).toBe('admin');
+ });
+
+ it('leaves everyone else read-only', () => {
+ expect(roleFromGroups(['autre-groupe'], 'horaires-admins')).toBe('viewer');
+ expect(roleFromGroups([], 'horaires-admins')).toBe('viewer');
+ });
+
+ it('finds the group among several', () => {
+ expect(roleFromGroups(['a', 'horaires-admins', 'b'], 'horaires-admins')).toBe('admin');
+ });
+
+ it('survives a capitalisation or spacing mismatch', () => {
+ expect(roleFromGroups(['Horaires-Admins'], 'horaires-admins')).toBe('admin');
+ expect(roleFromGroups([' horaires-admins '], 'horaires-admins')).toBe('admin');
+ expect(roleFromGroups(['horaires-admins'], ' HORAIRES-ADMINS ')).toBe('admin');
+ });
+
+ it('never promotes when no group is configured', () => {
+ // An empty variable must not turn every visitor into an administrator.
+ expect(roleFromGroups(['horaires-admins'], '')).toBe('viewer');
+ expect(roleFromGroups([''], ' ')).toBe('viewer');
+ });
+
+ it('does not match a partial group name', () => {
+ expect(roleFromGroups(['horaires-admins-readonly'], 'horaires-admins')).toBe('viewer');
+ expect(roleFromGroups(['admins'], 'horaires-admins')).toBe('viewer');
+ });
+});
+
+describe('groupsFromClaim', () => {
+ it('accepts a list of strings', () => {
+ expect(groupsFromClaim(['a', 'b'])).toEqual(['a', 'b']);
+ });
+
+ it('drops anything that is not a string', () => {
+ // The claim comes from an external system; it is data, not a promise.
+ expect(groupsFromClaim(['a', 1, null, { name: 'b' }, 'c'])).toEqual(['a', 'c']);
+ });
+
+ it('returns nothing for a missing or malformed claim', () => {
+ expect(groupsFromClaim(undefined)).toEqual([]);
+ expect(groupsFromClaim(null)).toEqual([]);
+ expect(groupsFromClaim('horaires-admins')).toEqual([]);
+ expect(groupsFromClaim({ groups: ['a'] })).toEqual([]);
+ });
+});
+
+describe('canWrite', () => {
+ it('is true only for an administrator', () => {
+ expect(canWrite('admin')).toBe(true);
+ expect(canWrite('viewer')).toBe(false);
+ expect(canWrite(undefined)).toBe(false);
+ });
+});
diff --git a/lib/auth/roles.ts b/lib/auth/roles.ts
new file mode 100644
index 0000000..698447f
--- /dev/null
+++ b/lib/auth/roles.ts
@@ -0,0 +1,38 @@
+/**
+ * Mapping an Authentik group membership to what someone may do here.
+ *
+ * Kept pure and separate from the Auth.js wiring so the rule can be tested
+ * exhaustively: this is the only thing standing between a read-only visitor
+ * and the shop's opening hours.
+ */
+
+export type Role = 'admin' | 'viewer';
+
+/**
+ * Anyone the identity provider vouches for may look; only members of the
+ * configured group may change anything.
+ *
+ * Comparison is trimmed and case-insensitive. Authentik group names are
+ * case-sensitive, but a capitalisation mismatch between the group and the
+ * environment variable is a silent lockout of the shop owner, which is the
+ * worse failure of the two.
+ */
+export function roleFromGroups(groups: readonly string[], adminGroup: string): Role {
+ const target = adminGroup.trim().toLowerCase();
+ if (!target) {
+ return 'viewer';
+ }
+ return groups.some((group) => group.trim().toLowerCase() === target) ? 'admin' : 'viewer';
+}
+
+/** Reads the `groups` claim defensively: it arrives from an external system. */
+export function groupsFromClaim(claim: unknown): string[] {
+ if (!Array.isArray(claim)) {
+ return [];
+ }
+ return claim.filter((value): value is string => typeof value === 'string');
+}
+
+export function canWrite(role: Role | undefined): boolean {
+ return role === 'admin';
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..564eca2
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,47 @@
+import { NextResponse } from 'next/server';
+
+import { auth } from '@/lib/auth';
+
+/**
+ * The single choke point for administrative access.
+ *
+ * Every /admin page and every /api/admin route passes through here, so no
+ * individual page can forget to check. Pages get a redirect to the sign-in
+ * screen; API routes get a status code, because a fetch that receives an HTML
+ * login page is a confusing way to learn you are signed out.
+ *
+ * Write access is enforced here too: a `viewer` may read anything and change
+ * nothing. Doing it at the edge means a read-only account cannot reach a
+ * handler that mutates, whatever that handler remembers to check.
+ *
+ * The device API (/api/setup, /api/display, /api/log) is deliberately outside
+ * this matcher: the panel cannot sign in, and carries its own bearer token.
+ */
+export default auth((request) => {
+ const { pathname } = request.nextUrl;
+ const isApi = pathname.startsWith('/api/admin');
+ const session = request.auth;
+
+ if (!session?.user) {
+ if (isApi) {
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
+ }
+ const target = new URL('/login', request.nextUrl.origin);
+ target.searchParams.set('from', pathname);
+ return NextResponse.redirect(target);
+ }
+
+ const isRead = request.method === 'GET' || request.method === 'HEAD';
+ if (!isRead && session.user.role !== 'admin') {
+ return NextResponse.json(
+ { error: 'Compte en lecture seule' },
+ { status: 403 },
+ );
+ }
+
+ return NextResponse.next();
+});
+
+export const config = {
+ matcher: ['/admin/:path*', '/api/admin/:path*'],
+};