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 ( +
+
+
+ + ITA ITO + + +
+ {!isAdmin ? ( + + Lecture seule + + ) : null} + {email} +
{ + 'use server'; + await signOut({ redirectTo: '/login' }); + }} + > + +
+
+
+
+ + {children} +
+ ); +} 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 ( +
+
+ ITA ITO + +

Horaires de la boutique

+

Réservé à l’équipe ITA ITO.

+ + {error ? ( +

+ {ERRORS[error] ?? 'La connexion a échoué. Réessayez.'} +

+ ) : null} + +
{ + 'use server'; + await signIn('authentik', { + redirectTo: from && from.startsWith('/admin') ? from : '/admin', + }); + }} + > + +
+
+
+ ); +} 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*'], +};