Écran de première installation
Les migrations posent le schéma et rien d'autre : une instance neuve n'a aucun compte et aucun utilisateur, donc personne ne peut se connecter. Le seed n'y remédie pas — il installe une démonstration et refuse de tourner en production, à raison. La première visite est désormais redirigée vers `/installation` : nom de l'entreprise, premier établissement avec son fuseau, et compte administrateur. L'écran crée le compte, le catalogue des capacités, les cinq rôles fournis, le propriétaire et son périmètre, puis ouvre la session par le chemin ordinaire — le rôle propriétaire exige aussitôt un second facteur, comme il se doit. Il ne se rouvre pas. Une table `Installation` d'une seule ligne, contrainte en base et protégée par un trigger append-only, marque l'instance. Elle est délibérément hors RLS, et c'est sa raison d'être : la politique d'`Account` ne laisse voir que le compte courant, si bien qu'une instance installée paraîtrait vierge à qui n'a pas de session — et la création d'un propriétaire se rouvrirait à tout venant. Le recensement des politiques porte l'exception, affirmée dans les deux sens. Deux défauts trouvés en éprouvant l'écran sur une base réellement vierge : `INSERT ... RETURNING` sur `Account` était refusé. L'insertion est permise, mais la relecture de la ligne écrite passe par la politique de lecture, qui exige un compte courant. L'identifiant est donc tiré côté application et annoncé avant la création — la règle de partout, appliquée à la transaction qui crée le compte. Et un défaut qui dépassait cet écran : React 19 vide les champs non contrôlés dès qu'une action se termine, refus compris. Les champs vidés portant `required`, le clic suivant était arrêté par la validation du navigateur avant d'émettre un `submit` — le formulaire paraissait mort. Le formulaire de connexion en souffrait aussi ; la suite l'avait manqué parce qu'aucun test ne soumettait deux fois de suite. `PersistentForm` photographie la saisie à l'envoi et la rétablit, mots de passe exclus. Au passage, `Field` rattache son indication par `aria-describedby` : placée dans le `<label>`, elle entrait dans le nom accessible du champ. Éprouvé sur une base vierge, image de production, rôle NOSUPERUSER NOBYPASSRLS : redirection, trois refus motivés, installation, second facteur exigé, écran refermé pour le propriétaire comme pour un visiteur. 450 tests unitaires et d'intégration, 77 tests de bout en bout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
21 files changed
+1367
-11
No files matched your search
@@ -7,6 +7,7 @@ import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { mfaRequired } from '@/domain/access/mfa-policy';
|
||||
import { currentSession } from '@/server/auth/session';
|
||||
import { isInstalled } from '@/server/install/state';
|
||||
|
||||
/**
|
||||
* Toutes les routes applicatives passent par ici.
|
||||
@@ -17,6 +18,11 @@ import { currentSession } from '@/server/auth/session';
|
||||
* sessions en base (matrice n° 23).
|
||||
*/
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
// Avant même la session : sur une instance vierge, il n'y a pas de compte à
|
||||
// qui appartenir, et renvoyer vers la connexion ferait rebondir d'un écran à
|
||||
// l'autre sans jamais dire ce qui manque.
|
||||
if (!(await isInstalled())) redirect('/installation');
|
||||
|
||||
const session = await currentSession();
|
||||
if (!session) redirect('/connexion');
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActionState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import { signInAction, type SignInState } from '@/server/auth/actions';
|
||||
|
||||
function SubmitButton() {
|
||||
@@ -27,7 +28,10 @@ export function SignInForm() {
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<PersistentForm
|
||||
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
|
||||
@@ -61,6 +65,6 @@ export function SignInForm() {
|
||||
) : null}
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { SignInForm } from '@/app/(auth)/connexion/SignInForm';
|
||||
import { currentSession } from '@/server/auth/session';
|
||||
import { isInstalled } from '@/server/install/state';
|
||||
|
||||
export const metadata = { title: 'Connexion · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ConnexionPage() {
|
||||
// Une instance vierge n'a aucun compte : proposer de se connecter y serait
|
||||
// une impasse, et l'échec ne dirait pas qu'il manque une installation.
|
||||
if (!(await isInstalled())) redirect('/installation');
|
||||
|
||||
if (await currentSession()) redirect('/');
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import { MIN_PASSWORD_LENGTH } from '@/domain/access/invitation';
|
||||
import { DEFAULT_TIMEZONE } from '@/domain/install/rules';
|
||||
import { installAction, type InstallState } from '@/server/install/actions';
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={pending}>
|
||||
{pending ? 'Installation…' : 'Installer PlanFlow'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstallForm() {
|
||||
const [state, formAction] = useActionState<InstallState, FormData>(
|
||||
installAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={formAction}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<fieldset className="flex flex-col gap-4 border-0 p-0">
|
||||
<legend className="mb-1 text-sm font-semibold">Votre entreprise</legend>
|
||||
|
||||
<Field
|
||||
label="Nom de l’entreprise"
|
||||
name="companyName"
|
||||
required
|
||||
autoComplete="organization"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Field
|
||||
label="Premier établissement"
|
||||
name="locationName"
|
||||
required
|
||||
hint="Vous pourrez en ajouter d’autres ensuite."
|
||||
/>
|
||||
<Field
|
||||
label="Fuseau horaire"
|
||||
name="timezone"
|
||||
required
|
||||
defaultValue={DEFAULT_TIMEZONE}
|
||||
hint="Il décide des durées travaillées, changement d’heure compris."
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-4 border-0 p-0">
|
||||
<legend className="mb-1 text-sm font-semibold">
|
||||
Votre compte administrateur
|
||||
</legend>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Field label="Prénom" name="firstName" required autoComplete="given-name" />
|
||||
<Field label="Nom" name="lastName" required autoComplete="family-name" />
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Adresse électronique"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Mot de passe"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
hint={`${MIN_PASSWORD_LENGTH} caractères au moins, sans votre nom ni votre adresse.`}
|
||||
/>
|
||||
<Field
|
||||
label="Confirmez le mot de passe"
|
||||
name="passwordConfirmation"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<SubmitButton />
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { InstallForm } from '@/app/installation/InstallForm';
|
||||
import { isInstalled } from '@/server/install/state';
|
||||
|
||||
export const metadata = { title: 'Installation · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Première installation — PLAN.md §5.
|
||||
*
|
||||
* Après `prisma migrate deploy`, une instance neuve a le schéma et rien
|
||||
* d'autre : aucun compte, aucun utilisateur, personne pour se connecter. Le
|
||||
* seed ne comble pas ce trou — il installe une démonstration et refuse de
|
||||
* tourner en production, à raison.
|
||||
*
|
||||
* Cet écran est donc le seul de l'application à s'ouvrir sans session. Il se
|
||||
* referme définitivement dès qu'il a servi.
|
||||
*/
|
||||
export default async function InstallationPage() {
|
||||
if (await isInstalled()) redirect('/connexion');
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-canvas p-6">
|
||||
<div className="w-full max-w-xl">
|
||||
<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">Première installation</h1>
|
||||
<p className="mt-1 mb-5 text-sm text-ink-2">
|
||||
Cette instance est vierge. Créez votre entreprise et le compte qui
|
||||
l’administrera — cet écran ne se rouvrira pas.
|
||||
</p>
|
||||
|
||||
<InstallForm />
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-micro text-ink-3">
|
||||
Le compte créé ici détient tous les droits. Un second facteur vous sera
|
||||
demandé dès la première connexion.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user