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>
);
}