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:
12 files changed
+361
-19
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user