Wire authentication into the application

Adds the sign-in screen, sign-out, and a server-side guard on every
application route. The guard lives in the layout rather than the proxy
because the proxy cannot query the database to check whether a session
was revoked — and revocation is the reason sessions are stored there.

Sign-in returns one message for an unknown account and for a wrong
password, and verifies a dummy hash when the account does not exist, so
neither the wording nor the timing enumerates staff addresses. An
end-to-end test compares the two messages rather than trusting the
code to keep them aligned.

The shell now shows the signed-in person and their role from the
database instead of hardcoded initials.

Playwright signs in once in a setup project and shares the cookie;
argon2 is deliberately slow, and logging in per test would also drive
the shared failed-attempt counter toward a lockout. The seed resets
that counter so repeated local runs cannot lock the demo account.

Two test locators had to be scoped to the form: Next's route announcer
carries role="alert" and an empty string, which silently satisfied the
assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 22:45:24 +00:00
1 parent 11763142d5
commit 2c0e9e8dd4
12 files changed
+361 -19

No files matched your search

+24 -2
View File
@@ -1,7 +1,29 @@
import { redirect } from 'next/navigation';
import type { ReactNode } from 'react';
import { AppShell } from '@/components/shell/AppShell';
import { currentSession } from '@/server/auth/session';
export default function AppLayout({ children }: { children: ReactNode }) {
return <AppShell>{children}</AppShell>;
/**
* Toutes les routes applicatives passent par ici.
*
* Le contrôle est fait côté serveur, dans le layout, et non dans le proxy : le
* proxy ne peut pas interroger la base pour vérifier qu'une session n'a pas été
* révoquée, et c'est précisément la révocation qui justifie de tenir les
* sessions en base (matrice n° 23).
*/
export default async function AppLayout({ children }: { children: ReactNode }) {
const session = await currentSession();
if (!session) redirect('/connexion');
return (
<AppShell
initials={session.user.initials}
fullName={`${session.user.firstName} ${session.user.lastName}`}
roleName={session.roleName}
accountName={session.accountName}
>
{children}
</AppShell>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/Button';
import { signInAction, type SignInState } from '@/server/auth/actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button
type="submit"
variant="primary"
className="w-full"
disabled={pending}
>
{pending ? 'Connexion…' : 'Se connecter'}
</Button>
);
}
export function SignInForm() {
const [state, formAction] = useActionState<SignInState, FormData>(
signInAction,
{},
);
return (
<form action={formAction} className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Adresse électronique</span>
<input
name="email"
type="email"
autoComplete="username"
required
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Mot de passe</span>
<input
name="password"
type="password"
autoComplete="current-password"
required
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
/>
</label>
{state.error ? (
// `role="alert"` : l'échec doit être annoncé, pas seulement coloré.
<p
role="alert"
className="rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs text-danger-soft-ink"
>
{state.error}
</p>
) : null}
<SubmitButton />
</form>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { SignInForm } from '@/app/(auth)/connexion/SignInForm';
import { currentSession } from '@/server/auth/session';
export const metadata = { title: 'Connexion · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ConnexionPage() {
if (await currentSession()) redirect('/');
return (
<main className="flex min-h-dvh items-center justify-center bg-canvas p-6">
<div className="w-full max-w-sm">
<div className="mb-6 flex items-center gap-2.5">
<span aria-hidden className="size-6 rounded-2 bg-accent" />
<span className="text-xl font-semibold tracking-[-0.015em]">
PlanFlow
</span>
</div>
<div className="rounded-3 border border-line-1 bg-surface p-6 shadow-e1">
<h1 className="text-lg font-semibold">Connexion</h1>
<p className="mt-1 mb-5 text-sm text-ink-2">
Accédez à vos plannings et à votre équipe.
</p>
<SignInForm />
</div>
<p className="mt-4 text-center text-micro text-ink-3">
Instance auto-hébergée · Maison Rivage
</p>
</div>
</main>
);
}
+21 -4
View File
@@ -8,6 +8,7 @@ import { ThemeToggle } from '@/components/shell/ThemeToggle';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cx } from '@/lib/cx';
import { signOutAction } from '@/server/auth/actions';
import {
isActive,
NAVIGATION,
@@ -19,9 +20,19 @@ import {
export interface AppShellProps {
children: ReactNode;
initials: string;
fullName: string;
roleName: string;
accountName: string;
}
export function AppShell({ children }: AppShellProps) {
export function AppShell({
children,
initials,
fullName,
roleName,
accountName,
}: AppShellProps) {
const pathname = usePathname();
const section = sectionForPath(pathname);
@@ -67,11 +78,17 @@ export function AppShell({ children }: AppShellProps) {
</Button>
<ThemeToggle />
<span
aria-hidden
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
title={`${fullName} · ${roleName}`}
>
MR
<span aria-hidden>{initials}</span>
<span className="sr-only">{`${fullName}, ${roleName}`}</span>
</span>
<form action={signOutAction}>
<Button size="sm" type="submit">
Déconnexion
</Button>
</form>
</header>
<div className="flex min-h-0 flex-1">
@@ -86,7 +103,7 @@ export function AppShell({ children }: AppShellProps) {
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
Instance auto-hébergée
<br />
Maison Rivage · 34 établissements
{accountName}
</p>
</aside>
+69
View File
@@ -0,0 +1,69 @@
'use server';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import {
SESSION_COOKIE,
SESSION_COOKIE_OPTIONS,
signIn,
signOut,
} from '@/server/auth/session';
const credentials = z.object({
email: z.email('Adresse électronique invalide'),
password: z.string().min(1, 'Mot de passe requis'),
});
export interface SignInState {
error?: string;
}
export async function signInAction(
_previous: SignInState,
formData: FormData,
): Promise<SignInState> {
const parsed = credentials.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const requestHeaders = await headers();
const result = await signIn({
email: parsed.data.email,
password: parsed.data.password,
ip: requestHeaders.get('x-forwarded-for'),
userAgent: requestHeaders.get('user-agent'),
});
if (!result.ok) {
// Un seul message pour « compte inconnu » et « mot de passe faux » : les
// distinguer permettrait d'énumérer les adresses du personnel.
return {
error:
result.reason === 'locked'
? 'Compte temporairement verrouillé après plusieurs échecs. Réessayez dans quelques minutes.'
: 'Identifiants incorrects.',
};
}
(await cookies()).set(SESSION_COOKIE, result.token, {
...SESSION_COOKIE_OPTIONS,
expires: result.expiresAt,
});
redirect('/');
}
export async function signOutAction(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) await signOut(token);
store.delete(SESSION_COOKIE);
redirect('/connexion');
}
+25 -1
View File
@@ -138,9 +138,19 @@ export async function revokeAllSessions(
return result.count;
}
export interface SessionUser {
firstName: string;
lastName: string;
email: string;
initials: string;
}
export interface SessionContext {
actor: Actor;
sessionId: string;
user: SessionUser;
accountName: string;
roleName: string;
}
/**
@@ -162,6 +172,7 @@ export async function resolveSession(
memberships: {
where: { status: 'ACTIVE', archivedAt: null },
include: {
account: { select: { name: true } },
role: { include: { permissions: { include: { permission: true } } } },
scopes: true,
},
@@ -199,7 +210,20 @@ export async function resolveSession(
scope,
};
return { actor, sessionId: session.id };
const { firstName, lastName, email } = session.user;
return {
actor,
sessionId: session.id,
user: {
firstName,
lastName,
email,
initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(),
},
accountName: membership.account.name,
roleName: membership.role.name,
};
}
/** Session courante depuis le cookie, ou `null`. */