É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
@@ -74,6 +74,19 @@ Les autres ont une valeur par défaut utilisable : `POSTGRES_PASSWORD`, `POSTGRE
|
||||
|
||||
**Après le premier déploiement, relevez la clé dans les journaux du conteneur `app` et conservez-la hors du serveur.**
|
||||
|
||||
### Première installation
|
||||
|
||||
Les migrations posent le schéma, rien de plus : une instance neuve n'a **aucun compte et aucun utilisateur**. Le jeu de données de démonstration (`pnpm db:seed`) n'y remédie pas et refuse de tourner en production, à raison — personne ne veut de « Maison Rivage » et de salariés fictifs dans son registre du personnel.
|
||||
|
||||
À la place, la première visite est redirigée vers `/installation`. L'écran demande le nom de l'entreprise, un premier établissement avec son fuseau horaire, et le compte qui administrera l'instance. Il crée le compte, le catalogue des capacités, les cinq rôles fournis, et vous connecte.
|
||||
|
||||
Deux choses à savoir :
|
||||
|
||||
- **L'écran ne se rouvre pas.** Il crée un propriétaire sans demander de session ; le laisser accessible ensuite reviendrait à offrir tous les droits au premier visiteur. Une ligne en base marque l'installation, et cette ligne ne se modifie ni ne s'efface depuis l'application. Remettre une instance à zéro est un geste d'exploitant, fait depuis la base.
|
||||
- **Un second facteur vous sera demandé aussitôt.** Le rôle propriétaire donne accès aux rémunérations et à la distribution des droits ; l'application l'exige avant d'ouvrir quoi que ce soit. Prévoyez une application d'authentification à portée de main, et conservez les codes de secours affichés à l'enrôlement — ils ne sont montrés qu'une fois.
|
||||
|
||||
Le fuseau horaire n'est pas cosmétique : il décide des durées travaillées, changement d'heure compris. Un établissement outre-mer se déclare avec le sien.
|
||||
|
||||
### En local
|
||||
|
||||
Nécessite Node 22, pnpm 10 et un PostgreSQL 16 accessible.
|
||||
|
||||
@@ -46,13 +46,14 @@ export default defineConfig({
|
||||
// compte de direction — sans lequel la connexion ne peut pas être jouée.
|
||||
{
|
||||
name: 'anonyme',
|
||||
testMatch: /(auth|reglages|acces)\.spec\.ts/,
|
||||
testMatch: /(auth|reglages|acces|installation)\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec|acces\.spec)\.ts/,
|
||||
testIgnore:
|
||||
/(auth\.setup|auth\.spec|reglages\.spec|acces\.spec|installation\.spec)\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Première installation — PLAN.md §5.
|
||||
--
|
||||
-- Après les migrations, une instance neuve a le schéma mais aucun compte et
|
||||
-- aucun utilisateur : personne ne peut se connecter, et le seed installe des
|
||||
-- données de démonstration qu'une production ne doit pas recevoir. L'écran
|
||||
-- d'installation comble ce trou ; cette table est ce qui l'empêche de se
|
||||
-- rouvrir une fois refermé.
|
||||
|
||||
CREATE TABLE "Installation" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"accountId" TEXT NOT NULL,
|
||||
"installedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Installation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Le doublon devient impossible, et non seulement improbable : deux
|
||||
-- installations concurrentes se disputent une clé primaire, et la perdante
|
||||
-- annule tout son travail au lieu de créer un second propriétaire.
|
||||
ALTER TABLE "Installation"
|
||||
ADD CONSTRAINT "Installation_singleton" CHECK ("id" = 'singleton');
|
||||
|
||||
CREATE UNIQUE INDEX "Installation_accountId_key" ON "Installation"("accountId");
|
||||
|
||||
ALTER TABLE "Installation"
|
||||
ADD CONSTRAINT "Installation_accountId_fkey"
|
||||
FOREIGN KEY ("accountId") REFERENCES "Account"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Volontairement **hors** row-level security.
|
||||
--
|
||||
-- C'est toute sa raison d'être : la politique de `Account` ne laisse voir que
|
||||
-- le compte courant, donc une instance installée paraît vierge à une requête
|
||||
-- sans session. Poser la question à `Account` rouvrirait l'écran
|
||||
-- d'installation — et donc la création d'un propriétaire — à n'importe quel
|
||||
-- visiteur. Cette table ne porte que l'existence d'une installation et un
|
||||
-- identifiant de compte, que le lien d'invitation transmet déjà en clair.
|
||||
|
||||
-- Ni UPDATE ni DELETE : une instance installée ne redevient pas vierge sur une
|
||||
-- requête de l'application. La contrepartie est assumée — la suppression en
|
||||
-- cascade d'un compte bute sur ce trigger, si bien que remettre une instance à
|
||||
-- zéro est un geste d'exploitant, fait depuis la base et non depuis un écran.
|
||||
CREATE TRIGGER installation_append_only
|
||||
BEFORE UPDATE OR DELETE ON "Installation"
|
||||
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
|
||||
@@ -35,6 +35,32 @@ model Account {
|
||||
labels Label[]
|
||||
absenceTypes AbsenceType[]
|
||||
legalConfig LegalConfigEntry[]
|
||||
installation Installation?
|
||||
}
|
||||
|
||||
/// Marqueur d'installation de l'instance — une ligne, jamais deux.
|
||||
///
|
||||
/// Il répond à « cette instance a-t-elle déjà un compte ? » **avant** qu'un
|
||||
/// compte soit connu. La question ne peut pas être posée à `Account` : sa
|
||||
/// politique RLS ne laisse voir que le compte courant, si bien qu'une instance
|
||||
/// déjà installée paraîtrait vierge à qui n'a pas de session — et l'écran
|
||||
/// d'installation, qui crée un propriétaire, se rouvrirait à tout venant.
|
||||
///
|
||||
/// Hors RLS à dessein : la seule chose qu'il divulgue est l'existence d'une
|
||||
/// installation et son identifiant de compte, que le lien d'invitation porte
|
||||
/// déjà en clair (voir `composeInvitationToken`).
|
||||
///
|
||||
/// Ni modifiable ni supprimable, imposé par un trigger : une instance installée
|
||||
/// ne redevient pas vierge sur une requête de l'application. Remettre une
|
||||
/// instance à zéro est un geste d'exploitant, depuis la base.
|
||||
model Installation {
|
||||
/// Constante, contrainte en base : c'est elle qui rend le doublon impossible
|
||||
/// plutôt que seulement improbable.
|
||||
id String @id @default("singleton")
|
||||
accountId String @unique
|
||||
installedAt DateTime @default(now())
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Location {
|
||||
|
||||
@@ -80,6 +80,16 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Le marqueur d'installation, sans quoi une base semée serait tenue pour
|
||||
// vierge et l'application renverrait vers l'écran de première installation
|
||||
// au lieu de la connexion. `upsert` parce que le seed se rejoue, et que la
|
||||
// ligne, elle, ne se supprime pas.
|
||||
await prisma.installation.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: {},
|
||||
create: { id: 'singleton', accountId: account.id },
|
||||
});
|
||||
|
||||
console.log('→ Rôles');
|
||||
const roleIds = new Map<string, string>();
|
||||
for (const role of SYSTEM_ROLES) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import { useId, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { Button, type ButtonProps } from '@/components/ui/Button';
|
||||
@@ -11,20 +11,37 @@ export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function Field({ label, hint, className, ...rest }: FieldProps) {
|
||||
export function Field({ label, hint, className, id, ...rest }: FieldProps) {
|
||||
// L'indication est rattachée par `aria-describedby` et non laissée dans le
|
||||
// `<label>`. Placée dedans, elle entre dans le **nom accessible** du champ :
|
||||
// une aide à la saisie devient alors une partie de son intitulé, annoncée
|
||||
// comme telle par un lecteur d'écran, et deux champs voisins cessent d'être
|
||||
// distinguables par leur nom.
|
||||
const generated = useId();
|
||||
const fieldId = id ?? generated;
|
||||
const hintId = `${fieldId}-hint`;
|
||||
|
||||
return (
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
{...rest}
|
||||
id={fieldId}
|
||||
{...(hint ? { 'aria-describedby': hintId } : {})}
|
||||
className={cx(
|
||||
'h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1',
|
||||
'outline-none placeholder:text-ink-3 focus-visible:border-focus',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{hint ? <span className="text-micro text-ink-3">{hint}</span> : null}
|
||||
</label>
|
||||
{hint ? (
|
||||
<span id={hintId} className="text-micro text-ink-3">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, type FormHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Un formulaire qui ne perd pas la saisie quand le serveur refuse.
|
||||
*
|
||||
* React 19 **vide les champs non contrôlés dès que l'action se termine**, sans
|
||||
* distinguer le succès de l'échec. Sur un refus, l'utilisateur retrouve donc un
|
||||
* formulaire vierge et doit tout ressaisir — sur l'écran de première
|
||||
* installation, huit champs pour un mot de passe trop court.
|
||||
*
|
||||
* Pire que l'agacement : les champs vidés portent `required`, si bien que le
|
||||
* clic suivant est arrêté par la validation du navigateur **avant** d'émettre
|
||||
* un `submit`. Le formulaire paraît alors mort — le bouton répond, et rien ne
|
||||
* part.
|
||||
*
|
||||
* Le remède : photographier la saisie à l'envoi, et la rétablir quand React
|
||||
* remet le formulaire à zéro. Les mots de passe sont exclus de la photo — les
|
||||
* réécrire dans le document les exposerait au cache de la page et à toute
|
||||
* extension qui la lit.
|
||||
*/
|
||||
|
||||
export interface PersistentFormProps
|
||||
extends Omit<FormHTMLAttributes<HTMLFormElement>, 'action'> {
|
||||
action: (formData: FormData) => void | Promise<void>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le rétablissement est **inconditionnel**, et ce n'est pas un raccourci.
|
||||
*
|
||||
* Le distinguer selon l'issue supposerait de lire l'état de l'action au moment
|
||||
* de la remise à zéro. Or React remet le formulaire à zéro pendant la
|
||||
* validation du rendu, tandis qu'un `useEffect` s'exécute après : le drapeau lu
|
||||
* serait celui de l'envoi précédent, et le premier refus perdrait la saisie —
|
||||
* exactement le défaut qu'on corrige. Mieux vaut ne pas dépendre de cet ordre.
|
||||
*
|
||||
* Un formulaire qui doit se vider après un envoi réussi le fait donc lui-même.
|
||||
* La règle par défaut est la bonne : ne jamais perdre ce que l'utilisateur a
|
||||
* tapé.
|
||||
*/
|
||||
export function PersistentForm({
|
||||
action,
|
||||
children,
|
||||
...rest
|
||||
}: PersistentFormProps) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const snapshot = useRef<Array<[string, string]> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const form = formRef.current;
|
||||
if (!form) return;
|
||||
|
||||
const restorable = (element: Element): element is HTMLInputElement =>
|
||||
element instanceof HTMLInputElement &&
|
||||
Boolean(element.name) &&
|
||||
// Un mot de passe ne se réécrit pas dans le document.
|
||||
element.type !== 'password' &&
|
||||
// Cases et boutons radio : leur état vit dans `checked`, pas dans
|
||||
// `value`, et le rétablir demanderait de photographier autre chose.
|
||||
element.type !== 'checkbox' &&
|
||||
element.type !== 'radio' &&
|
||||
// Les champs internes des actions serveur portent un nom en `$ACTION_`.
|
||||
// Les toucher défairait le câblage de l'action elle-même.
|
||||
!element.name.startsWith('$');
|
||||
|
||||
const capture = () => {
|
||||
snapshot.current = [...form.elements]
|
||||
.filter(restorable)
|
||||
.map((field) => [field.name, field.value]);
|
||||
};
|
||||
|
||||
const rewrite = () => {
|
||||
const captured = snapshot.current;
|
||||
if (!captured) return;
|
||||
|
||||
// Après la remise à zéro, pas pendant : l'événement `reset` précède
|
||||
// l'effacement, et écrire ici serait aussitôt défait.
|
||||
queueMicrotask(() => {
|
||||
for (const [name, value] of captured) {
|
||||
const field = form.elements.namedItem(name);
|
||||
if (field instanceof Element && restorable(field)) {
|
||||
field.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
form.addEventListener('submit', capture);
|
||||
form.addEventListener('reset', rewrite);
|
||||
return () => {
|
||||
form.removeEventListener('submit', capture);
|
||||
form.removeEventListener('reset', rewrite);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form ref={formRef} action={action} {...rest}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { passwordProblem } from '@/domain/access/invitation';
|
||||
|
||||
/**
|
||||
* Règles de la première installation — PLAN.md §5.
|
||||
*
|
||||
* L'écran qui s'appuie dessus est le seul de l'application à s'ouvrir sans
|
||||
* session : il crée le compte, le premier établissement et le propriétaire.
|
||||
* Ses refus sont donc énoncés ici, en fonctions pures, pour être éprouvés sans
|
||||
* base ni navigateur.
|
||||
*/
|
||||
|
||||
export interface InstallationForm {
|
||||
companyName: string;
|
||||
locationName: string;
|
||||
timezone: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
passwordConfirmation: string;
|
||||
}
|
||||
|
||||
/** Matricule du premier salarié. Le registre du personnel les veut ordonnés. */
|
||||
export const FIRST_EMPLOYEE_NUMBER = 'E0001';
|
||||
|
||||
/**
|
||||
* Fuseau par défaut. La convention collective et les durées légales portées par
|
||||
* l'application sont françaises ; proposer autre chose par défaut serait un
|
||||
* piège plutôt qu'une ouverture.
|
||||
*/
|
||||
export const DEFAULT_TIMEZONE = 'Europe/Paris';
|
||||
|
||||
const MAX_NAME_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* Un fuseau valide, jugé par la plateforme et non par une liste tenue à la
|
||||
* main : une liste se périme, et un fuseau inconnu ne se voit qu'au premier
|
||||
* calcul d'horaire — c'est-à-dire trop tard, sur une paie.
|
||||
*/
|
||||
export function isKnownTimezone(timezone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat('fr-FR', { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function nameProblem(value: string, subject: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return `${subject} est requis.`;
|
||||
if (trimmed.length > MAX_NAME_LENGTH) {
|
||||
return `${subject} ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse électronique plausible.
|
||||
*
|
||||
* Volontairement permissif : la seule validation qui prouve qu'une adresse
|
||||
* existe est l'envoi d'un message. Refuser ici sur une grammaire trop stricte
|
||||
* écarterait des adresses valides — et enfermerait l'exploitant dehors, sans
|
||||
* recours, puisque cet écran ne se rouvre pas.
|
||||
*/
|
||||
function emailProblem(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return 'L’adresse électronique est requise.';
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
||||
return 'L’adresse électronique n’a pas une forme valide.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Motif de refus du formulaire, ou `null` s'il convient.
|
||||
*
|
||||
* L'ordre compte : le premier motif rencontré est celui qui s'affiche, et on
|
||||
* veut qu'il désigne le champ le plus haut dans l'écran.
|
||||
*/
|
||||
export function installationProblem(form: InstallationForm): string | null {
|
||||
const problems = [
|
||||
nameProblem(form.companyName, 'Le nom de l’entreprise'),
|
||||
nameProblem(form.locationName, 'Le nom de l’établissement'),
|
||||
isKnownTimezone(form.timezone)
|
||||
? null
|
||||
: `Le fuseau horaire « ${form.timezone} » est inconnu.`,
|
||||
nameProblem(form.firstName, 'Le prénom'),
|
||||
nameProblem(form.lastName, 'Le nom'),
|
||||
emailProblem(form.email),
|
||||
];
|
||||
|
||||
const first = problems.find((problem) => problem !== null);
|
||||
if (first) return first;
|
||||
|
||||
// Le mot de passe est jugé en dernier, avec le nom et l'adresse déjà validés
|
||||
// pour de bon : c'est ce contexte qui permet de refuser « martin2026 » à
|
||||
// quelqu'un qui s'appelle Martin.
|
||||
const password = passwordProblem(form.password, {
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
});
|
||||
if (password) return password;
|
||||
|
||||
if (form.password !== form.passwordConfirmation) {
|
||||
return 'Les deux mots de passe ne correspondent pas.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Forme normalisée, telle qu'elle doit être écrite en base. */
|
||||
export function normaliseInstallation(form: InstallationForm) {
|
||||
return {
|
||||
companyName: form.companyName.trim(),
|
||||
locationName: form.locationName.trim(),
|
||||
timezone: form.timezone.trim(),
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
// L'adresse sert d'identifiant de connexion : la casse ne doit pas décider
|
||||
// si quelqu'un entre ou non.
|
||||
email: form.email.trim().toLowerCase(),
|
||||
password: form.password,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use server';
|
||||
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
installationProblem,
|
||||
normaliseInstallation,
|
||||
type InstallationForm,
|
||||
} from '@/domain/install/rules';
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
SESSION_COOKIE_OPTIONS,
|
||||
signIn,
|
||||
} from '@/server/auth/session';
|
||||
import {
|
||||
hashOwnerPassword,
|
||||
installAccount,
|
||||
INSTALLATION_ID,
|
||||
} from '@/server/install/install';
|
||||
import { isInstalled, markInstalled } from '@/server/install/state';
|
||||
import { unscoped } from '@/server/tenant';
|
||||
|
||||
export interface InstallState {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function read(formData: FormData, field: string): string {
|
||||
const value = formData.get(field);
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Première installation — PLAN.md §5.
|
||||
*
|
||||
* La seule action de l'application qui s'exécute sans session, et la seule qui
|
||||
* crée un propriétaire. Deux garde-fous, et non un :
|
||||
*
|
||||
* 1. Un refus explicite si l'instance est déjà installée, pour donner un
|
||||
* message lisible plutôt qu'une erreur de contrainte.
|
||||
* 2. L'écriture du marqueur **dans la même transaction** que la création du
|
||||
* compte. C'est celui-là qui protège vraiment : deux requêtes simultanées
|
||||
* sur une instance vierge passent toutes deux le premier contrôle, puis se
|
||||
* disputent une clé primaire — la perdante annule tout son travail au lieu
|
||||
* de poser un second propriétaire.
|
||||
*/
|
||||
export async function installAction(
|
||||
_previous: InstallState,
|
||||
formData: FormData,
|
||||
): Promise<InstallState> {
|
||||
if (await isInstalled()) {
|
||||
return {
|
||||
error:
|
||||
'Cette instance est déjà installée. Connectez-vous, ou faites-vous inviter par son administrateur.',
|
||||
};
|
||||
}
|
||||
|
||||
const form: InstallationForm = {
|
||||
companyName: read(formData, 'companyName'),
|
||||
locationName: read(formData, 'locationName'),
|
||||
timezone: read(formData, 'timezone'),
|
||||
firstName: read(formData, 'firstName'),
|
||||
lastName: read(formData, 'lastName'),
|
||||
email: read(formData, 'email'),
|
||||
password: read(formData, 'password'),
|
||||
passwordConfirmation: read(formData, 'passwordConfirmation'),
|
||||
};
|
||||
|
||||
const problem = installationProblem(form);
|
||||
if (problem) return { error: problem };
|
||||
|
||||
const data = normaliseInstallation(form);
|
||||
|
||||
// Avant la transaction : argon2 est délibérément lent, et le faire tourner
|
||||
// une connexion ouverte l'immobiliserait sans rien y gagner.
|
||||
const passwordHash = await hashOwnerPassword(data.password);
|
||||
|
||||
try {
|
||||
await unscoped().$transaction(async (tx) => {
|
||||
const instance = await installAccount(tx, form, passwordHash);
|
||||
await tx.installation.create({
|
||||
data: { id: INSTALLATION_ID, accountId: instance.accountId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
'Cette instance vient d’être installée par ailleurs. Rechargez la page pour vous connecter.',
|
||||
};
|
||||
}
|
||||
// Le détail part au journal du serveur, pas à l'écran : il peut contenir le
|
||||
// schéma de la base, et cet écran est ouvert sans authentification.
|
||||
console.error('[installation] échec', error);
|
||||
return {
|
||||
error:
|
||||
'L’installation a échoué. Le journal du serveur en donne la raison ; rien n’a été enregistré.',
|
||||
};
|
||||
}
|
||||
|
||||
markInstalled();
|
||||
|
||||
// Ouvrir la session par le chemin ordinaire plutôt que d'en fabriquer une :
|
||||
// le verrouillage après échecs, le second facteur et la révocation vivent
|
||||
// tous là, et une session posée à côté échapperait à ces règles.
|
||||
const requestHeaders = await headers();
|
||||
const session = await signIn({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
ip: requestHeaders.get('x-forwarded-for'),
|
||||
userAgent: requestHeaders.get('user-agent'),
|
||||
});
|
||||
|
||||
if (!session.ok) {
|
||||
// L'installation, elle, a réussi : renvoyer vers la connexion plutôt que
|
||||
// laisser croire le contraire.
|
||||
redirect('/connexion');
|
||||
}
|
||||
|
||||
(await cookies()).set(SESSION_COOKIE, session.token, {
|
||||
...SESSION_COOKIE_OPTIONS,
|
||||
expires: session.expiresAt,
|
||||
});
|
||||
|
||||
// Le rôle propriétaire donne accès aux rémunérations et à la distribution des
|
||||
// droits : le layout applicatif exigera aussitôt un second facteur. C'est
|
||||
// voulu — c'est le compte le plus exposé de l'instance.
|
||||
redirect('/');
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
DEFAULT_ROLE_PERMISSIONS,
|
||||
PERMISSION_DEFINITIONS,
|
||||
SYSTEM_ROLES,
|
||||
} from '@/domain/access/permissions';
|
||||
import {
|
||||
FIRST_EMPLOYEE_NUMBER,
|
||||
normaliseInstallation,
|
||||
type InstallationForm,
|
||||
} from '@/domain/install/rules';
|
||||
import { hashPassword } from '@/server/auth/session';
|
||||
|
||||
/**
|
||||
* Contenu d'une instance neuve — PLAN.md §5.
|
||||
*
|
||||
* Le seed, lui, installe une démonstration : deux établissements, des salariés
|
||||
* fictifs, quatre semaines de planning. Il refuse de tourner en production, et
|
||||
* c'est bien ainsi. Il restait donc un trou : après les migrations, une
|
||||
* instance de production a le schéma et rien d'autre — pas de compte, pas
|
||||
* d'utilisateur, personne pour se connecter.
|
||||
*
|
||||
* Ce module comble ce trou, et **rien de plus** : le catalogue des capacités,
|
||||
* les rôles fournis, un compte, un établissement, un propriétaire. Aucune
|
||||
* donnée d'exemple.
|
||||
*/
|
||||
|
||||
export interface InstalledInstance {
|
||||
accountId: string;
|
||||
membershipId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose le contenu minimal d'un compte dans une transaction déjà ouverte.
|
||||
*
|
||||
* Séparé de l'action pour deux raisons. La transaction appartient à l'appelant,
|
||||
* qui seul peut y écrire le marqueur d'installation dans le même souffle — un
|
||||
* compte à demi installé, sans marqueur, laisserait l'écran ouvert sur une base
|
||||
* déjà peuplée. Et cette forme se laisse éprouver contre une vraie base dans
|
||||
* une transaction que le test annule, ce qu'une fonction qui scelle
|
||||
* l'installation ne permettrait pas : le marqueur, lui, ne se supprime pas.
|
||||
*/
|
||||
export async function installAccount(
|
||||
tx: Prisma.TransactionClient,
|
||||
form: InstallationForm,
|
||||
passwordHash: string,
|
||||
): Promise<InstalledInstance> {
|
||||
const data = normaliseInstallation(form);
|
||||
|
||||
// L'identifiant est tiré ici, et non laissé à la base, parce qu'il faut
|
||||
// l'annoncer **avant** de créer le compte.
|
||||
//
|
||||
// La politique d'insertion d'`Account` accepte tout — il faut bien pouvoir
|
||||
// créer le premier. Mais sa politique de lecture, elle, n'ouvre que le compte
|
||||
// courant, et un `INSERT ... RETURNING` relit la ligne qu'il vient d'écrire :
|
||||
// sans compte courant, l'insertion réussit et la relecture est refusée.
|
||||
// Poser le compte d'abord est la même règle que partout ailleurs — une
|
||||
// transaction porte son compte — appliquée à celle qui le crée.
|
||||
const accountId = randomUUID();
|
||||
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
|
||||
|
||||
const account = await tx.account.create({
|
||||
data: { id: accountId, name: data.companyName },
|
||||
});
|
||||
|
||||
// Référentiel global, sans compte : les capacités sont définies par le
|
||||
// produit. L'upsert vaut pour une instance dont le catalogue a déjà été posé
|
||||
// par un seed ou une migration de données.
|
||||
for (const permission of PERMISSION_DEFINITIONS) {
|
||||
await tx.permission.upsert({
|
||||
where: { code: permission.code },
|
||||
update: { category: permission.category, label: permission.label },
|
||||
create: permission,
|
||||
});
|
||||
}
|
||||
|
||||
const permissionIds = new Map(
|
||||
(await tx.permission.findMany({ select: { id: true, code: true } })).map(
|
||||
(permission) => [permission.code, permission.id],
|
||||
),
|
||||
);
|
||||
|
||||
const roleIds = new Map<string, string>();
|
||||
for (const role of SYSTEM_ROLES) {
|
||||
const created = await tx.role.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
key: role.key,
|
||||
name: role.name,
|
||||
isSystem: true,
|
||||
},
|
||||
});
|
||||
roleIds.set(role.key, created.id);
|
||||
|
||||
await tx.rolePermission.createMany({
|
||||
data: DEFAULT_ROLE_PERMISSIONS[role.key]
|
||||
.map((code) => permissionIds.get(code))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map((permissionId) => ({ roleId: created.id, permissionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
await tx.location.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
name: data.locationName,
|
||||
timezone: data.timezone,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await tx.membership.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
userId: user.id,
|
||||
roleId: roleIds.get('owner') as string,
|
||||
employeeNumber: FIRST_EMPLOYEE_NUMBER,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
// Le registre du personnel exige le nom, et il le lit sur le dossier et non
|
||||
// sur le compte : la plupart des salariés n'ont pas de compte.
|
||||
await tx.employeeProfile.create({
|
||||
data: {
|
||||
membershipId: membership.id,
|
||||
accountId: account.id,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
},
|
||||
});
|
||||
|
||||
// Tous les établissements, y compris ceux qui n'existent pas encore : la
|
||||
// personne qui installe l'instance ne doit pas perdre la vue sur le deuxième
|
||||
// établissement le jour de son ouverture.
|
||||
await tx.membershipScope.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
membershipId: membership.id,
|
||||
allLocations: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Première entrée du journal, et elle compte : elle date la création du
|
||||
// compte propriétaire, seul accès à l'instance jusqu'à la première
|
||||
// invitation. Le journal est append-only.
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
actorMembershipId: membership.id,
|
||||
action: 'install.complete',
|
||||
entityType: 'Account',
|
||||
entityId: account.id,
|
||||
after: {
|
||||
companyName: data.companyName,
|
||||
locationName: data.locationName,
|
||||
timezone: data.timezone,
|
||||
ownerEmail: data.email,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accountId: account.id,
|
||||
membershipId: membership.id,
|
||||
userId: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Empreinte du mot de passe du propriétaire. */
|
||||
export function hashOwnerPassword(password: string): Promise<string> {
|
||||
// Hors transaction, à dessein : argon2 est délibérément lent, et le faire
|
||||
// tourner une connexion ouverte immobiliserait celle-ci pour rien.
|
||||
return hashPassword(password);
|
||||
}
|
||||
|
||||
/** Identifiant unique de la ligne d'installation, contraint en base. */
|
||||
export const INSTALLATION_ID = 'singleton';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { unscoped } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Cette instance a-t-elle déjà été installée ? — PLAN.md §5.
|
||||
*
|
||||
* La question se pose à chaque affichage de la connexion et de l'application,
|
||||
* c'est-à-dire à chaque requête. Elle ne peut pas être posée à `Account` : la
|
||||
* politique RLS n'y laisse voir que le compte courant, si bien qu'une instance
|
||||
* installée paraîtrait vierge à qui n'a pas de session — et l'écran
|
||||
* d'installation, qui crée un propriétaire, se rouvrirait à tout venant. D'où
|
||||
* la table `Installation`, délibérément hors RLS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mémorisation **du seul résultat « installée »**.
|
||||
*
|
||||
* L'asymétrie est le point : une instance installée ne redevient pas vierge —
|
||||
* le trigger `installation_append_only` l'interdit — donc ce résultat ne se
|
||||
* périme jamais et la requête peut être évitée. L'inverse ne tient pas : une
|
||||
* instance vierge le devient d'un instant à l'autre, et mémoriser cela ferait
|
||||
* boucler l'écran d'installation après son propre succès.
|
||||
*/
|
||||
let known = false;
|
||||
|
||||
export async function isInstalled(): Promise<boolean> {
|
||||
if (known) return true;
|
||||
|
||||
const row = await unscoped().installation.findFirst({
|
||||
select: { accountId: true },
|
||||
});
|
||||
known = row !== null;
|
||||
return known;
|
||||
}
|
||||
|
||||
/**
|
||||
* À appeler après une installation réussie.
|
||||
*
|
||||
* Sans cela, le premier affichage suivant repasserait par la base pour
|
||||
* apprendre ce que le processus vient lui-même d'écrire.
|
||||
*/
|
||||
export function markInstalled(): void {
|
||||
known = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remet la mémorisation à zéro. Réservé aux tests, qui installent et
|
||||
* désinstallent dans une transaction annulée.
|
||||
*/
|
||||
export function forgetInstallationState(): void {
|
||||
known = false;
|
||||
}
|
||||
@@ -35,6 +35,39 @@ test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) =>
|
||||
expect(knownAccount).toContain('Identifiants incorrects');
|
||||
});
|
||||
|
||||
test('un formulaire refusé se soumet une deuxième fois', async ({ page }) => {
|
||||
// Régression. 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 bouton répondait, et rien ne
|
||||
// partait. Tous les écrans de l'application étaient concernés, et la suite
|
||||
// l'avait manqué parce qu'aucun test ne soumettait deux fois de suite.
|
||||
//
|
||||
// Une adresse inconnue, à dessein : deux échecs sur un compte réel
|
||||
// rapprocheraient les autres tests du verrouillage.
|
||||
await page.goto('/connexion');
|
||||
const alert = page.locator('form').getByRole('alert');
|
||||
const email = page.getByLabel('Adresse électronique');
|
||||
|
||||
await email.fill('inconnu@example.test');
|
||||
await page.getByLabel('Mot de passe').fill('premier-essai-faux');
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
await expect(alert).toContainText('Identifiants incorrects');
|
||||
|
||||
// Ce que l'utilisateur avait tapé est toujours là — sauf le mot de passe,
|
||||
// qui n'est jamais réécrit dans le document.
|
||||
await expect(email).toHaveValue('inconnu@example.test');
|
||||
|
||||
// Le second envoi doit **aboutir**. Réaffirmer le même refus ne prouverait
|
||||
// rien : le message précédent reste à l'écran, et un formulaire mort le
|
||||
// laisserait tel quel. Une connexion réussie, elle, ne peut venir que du
|
||||
// serveur.
|
||||
await email.fill('salarie@example.test');
|
||||
await page.getByLabel('Mot de passe').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
await expect(page).not.toHaveURL(/\/connexion/);
|
||||
});
|
||||
|
||||
test('connexion, navigation, puis déconnexion', async ({ page }) => {
|
||||
const remembered = rememberedSecret();
|
||||
// Un code ne sert qu'une fois : attendre le pas suivant celui qu'a employé la
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Première installation — PLAN.md §5.
|
||||
*
|
||||
* La base de cette suite est semée, donc **installée**. Ce que ces tests
|
||||
* peuvent prouver est justement ce qui compte le plus : l'écran ne se rouvre
|
||||
* pas. Il crée un propriétaire sans demander de session ; laissé accessible
|
||||
* après coup, il donnerait à n'importe quel visiteur un compte disposant de
|
||||
* tous les droits sur l'instance.
|
||||
*
|
||||
* La création elle-même est éprouvée contre une base réelle dans
|
||||
* tests/integration/installation.test.ts, où la transaction est annulée — le
|
||||
* marqueur d'installation, lui, ne se supprime pas.
|
||||
*/
|
||||
|
||||
test('l’écran d’installation est refermé sur une instance installée', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/installation');
|
||||
|
||||
// Redirigé vers la connexion, et non pas seulement privé de son formulaire.
|
||||
await expect(page).toHaveURL(/\/connexion/);
|
||||
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
|
||||
|
||||
// Le formulaire ne doit pas non plus subsister dans le HTML : un champ caché
|
||||
// reste soumettable.
|
||||
expect(await page.content()).not.toContain('Première installation');
|
||||
});
|
||||
|
||||
test('la connexion reste la porte d’entrée', async ({ page }) => {
|
||||
// Le pendant du test précédent : sur une instance installée, la racine ne
|
||||
// doit pas dériver vers l'installation. Les deux redirections se répondent,
|
||||
// et une erreur de sens les ferait boucler l'une sur l'autre.
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/connexion/);
|
||||
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { adminPrisma } from './admin-db';
|
||||
|
||||
import { DEFAULT_TIMEZONE, type InstallationForm } from '@/domain/install/rules';
|
||||
import { PERMISSION_CODES } from '@/domain/access/permissions';
|
||||
import { installAccount, INSTALLATION_ID } from '@/server/install/install';
|
||||
import { unscoped } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Première installation, branchée sur la base — PLAN.md §5.
|
||||
*
|
||||
* Ce que les tests de règles ne peuvent pas montrer : que l'installation passe
|
||||
* **par la connexion de l'application**, soumise à la row-level security. C'est
|
||||
* le point délicat. Les politiques d'insertion exigent
|
||||
* `accountId = planflow_current_account()`, et le compte n'existe pas encore
|
||||
* quand la transaction s'ouvre. Si le réglage du compte courant venait à
|
||||
* manquer, chaque écriture serait refusée et l'instance resterait vide.
|
||||
*
|
||||
* Tout se joue dans une transaction **annulée** : le marqueur d'installation,
|
||||
* lui, ne se supprime pas — c'est sa raison d'être — donc un test qui le
|
||||
* poserait pour de bon condamnerait la base de développement.
|
||||
*/
|
||||
|
||||
const enabled = (process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '')
|
||||
.length > 0;
|
||||
const describeIfDb = enabled ? describe : describe.skip;
|
||||
|
||||
/** Sentinelle d'annulation : sortir par une erreur est ce qui défait tout. */
|
||||
class Rollback extends Error {}
|
||||
|
||||
function form(overrides: Partial<InstallationForm> = {}): InstallationForm {
|
||||
const unique = Date.now();
|
||||
return {
|
||||
companyName: `Entreprise ${unique}`,
|
||||
locationName: 'Siège',
|
||||
timezone: DEFAULT_TIMEZONE,
|
||||
firstName: 'Camille',
|
||||
lastName: 'Ferrand',
|
||||
email: `Install.${unique}@Exemple.Test`,
|
||||
password: 'quatre chevaux blancs',
|
||||
passwordConfirmation: 'quatre chevaux blancs',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Joue l'installation puis annule. `fn` reçoit la transaction : c'est le seul
|
||||
* moment où le travail est visible.
|
||||
*/
|
||||
async function installThenRollback(
|
||||
input: InstallationForm,
|
||||
fn: (
|
||||
tx: Prisma.TransactionClient,
|
||||
instance: Awaited<ReturnType<typeof installAccount>>,
|
||||
) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
unscoped().$transaction(
|
||||
async (tx) => {
|
||||
const instance = await installAccount(tx, input, 'empreinte-factice');
|
||||
await fn(tx, instance);
|
||||
throw new Rollback();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
).rejects.toBeInstanceOf(Rollback);
|
||||
}
|
||||
|
||||
describeIfDb('première installation', () => {
|
||||
it('pose un compte utilisable depuis la connexion de l’application', async () => {
|
||||
const input = form();
|
||||
|
||||
await installThenRollback(input, async (tx, instance) => {
|
||||
const account = await tx.account.findUniqueOrThrow({
|
||||
where: { id: instance.accountId },
|
||||
});
|
||||
expect(account.name).toBe(input.companyName);
|
||||
|
||||
// Cinq rôles fournis, et le propriétaire détient tout : c'est lui qui
|
||||
// délègue ensuite. Un propriétaire amputé d'une capacité ne pourrait
|
||||
// jamais l'accorder à personne.
|
||||
const roles = await tx.role.findMany({
|
||||
where: { accountId: instance.accountId },
|
||||
});
|
||||
expect(roles).toHaveLength(5);
|
||||
expect(roles.every((role) => role.isSystem)).toBe(true);
|
||||
|
||||
const owner = roles.find((role) => role.key === 'owner');
|
||||
if (!owner) throw new Error('Rôle propriétaire absent.');
|
||||
const ownerPermissions = await tx.rolePermission.count({
|
||||
where: { roleId: owner.id },
|
||||
});
|
||||
expect(ownerPermissions).toBe(PERMISSION_CODES.length);
|
||||
|
||||
// L'employé, lui, ne doit surtout pas tout recevoir.
|
||||
const employee = roles.find((role) => role.key === 'employee');
|
||||
if (!employee) throw new Error('Rôle employé absent.');
|
||||
const employeePermissions = await tx.rolePermission.count({
|
||||
where: { roleId: employee.id },
|
||||
});
|
||||
expect(employeePermissions).toBeGreaterThan(0);
|
||||
expect(employeePermissions).toBeLessThan(PERMISSION_CODES.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('crée l’établissement avec le fuseau demandé', async () => {
|
||||
// Le fuseau décide des durées travaillées, changement d'heure compris : le
|
||||
// perdre en route fausserait les compteurs sans rien signaler.
|
||||
await installThenRollback(
|
||||
form({ timezone: 'America/Martinique' }),
|
||||
async (tx, instance) => {
|
||||
const location = await tx.location.findFirstOrThrow({
|
||||
where: { accountId: instance.accountId },
|
||||
});
|
||||
expect(location.timezone).toBe('America/Martinique');
|
||||
expect(location.name).toBe('Siège');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rattache le propriétaire à tous les établissements', async () => {
|
||||
await installThenRollback(form(), async (tx, instance) => {
|
||||
const membership = await tx.membership.findUniqueOrThrow({
|
||||
where: { id: instance.membershipId },
|
||||
include: { scopes: true, role: true, profile: true },
|
||||
});
|
||||
|
||||
expect(membership.status).toBe('ACTIVE');
|
||||
expect(membership.role.key).toBe('owner');
|
||||
expect(membership.employeeNumber).toBe('E0001');
|
||||
expect(membership.profile?.lastName).toBe('Ferrand');
|
||||
|
||||
// `allLocations` plutôt qu'un rattachement nommé : sans cela, la personne
|
||||
// qui installe l'instance perdrait la vue le jour du deuxième
|
||||
// établissement.
|
||||
expect(membership.scopes).toHaveLength(1);
|
||||
expect(membership.scopes[0]?.allLocations).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('replie la casse de l’adresse de connexion', async () => {
|
||||
const input = form();
|
||||
|
||||
await installThenRollback(input, async (tx, instance) => {
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: instance.userId },
|
||||
});
|
||||
expect(user.email).toBe(input.email.toLowerCase());
|
||||
expect(user.passwordHash).toBe('empreinte-factice');
|
||||
});
|
||||
});
|
||||
|
||||
it('date l’installation dans le journal', async () => {
|
||||
await installThenRollback(form(), async (tx, instance) => {
|
||||
const entry = await tx.auditLog.findFirstOrThrow({
|
||||
where: { accountId: instance.accountId, action: 'install.complete' },
|
||||
});
|
||||
expect(entry.entityId).toBe(instance.accountId);
|
||||
expect(entry.actorMembershipId).toBe(instance.membershipId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeIfDb('marqueur d’installation', () => {
|
||||
it('rapporte une instance semée comme installée', async () => {
|
||||
// C'est la première ligne de `installAction` : sans ce constat, l'action
|
||||
// rouvrirait la création d'un propriétaire sur une instance en service.
|
||||
const { isInstalled, forgetInstallationState } = await import(
|
||||
'@/server/install/state'
|
||||
);
|
||||
forgetInstallationState();
|
||||
await expect(isInstalled()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('n’admet pas une seconde ligne', async () => {
|
||||
// Le garde-fou est en base, pas seulement dans l'action : deux requêtes
|
||||
// simultanées sur une instance vierge franchissent toutes deux le contrôle
|
||||
// applicatif, et c'est la clé primaire qui départage.
|
||||
const existing = await adminPrisma().installation.findFirst();
|
||||
expect(existing, 'la base de test doit être semée').not.toBeNull();
|
||||
|
||||
await expect(
|
||||
unscoped().$transaction(async (tx) => {
|
||||
// Même précaution que l'installation elle-même : la politique de
|
||||
// lecture d'`Account` n'ouvre que le compte courant, et `create`
|
||||
// relit la ligne qu'il écrit.
|
||||
const accountId = randomUUID();
|
||||
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
|
||||
await tx.account.create({
|
||||
data: { id: accountId, name: `Doublon ${Date.now()}` },
|
||||
});
|
||||
await tx.installation.create({
|
||||
data: { id: INSTALLATION_ID, accountId },
|
||||
});
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'P2002' });
|
||||
});
|
||||
|
||||
it('refuse toute modification et toute suppression', async () => {
|
||||
// Une instance installée ne redevient pas vierge sur une requête de
|
||||
// l'application : sans cela, effacer une ligne rouvrirait la création d'un
|
||||
// propriétaire à n'importe quel visiteur.
|
||||
const db = adminPrisma();
|
||||
|
||||
await expect(
|
||||
db.installation.update({
|
||||
where: { id: INSTALLATION_ID },
|
||||
data: { installedAt: new Date() },
|
||||
}),
|
||||
).rejects.toThrow(/append-only/);
|
||||
|
||||
await expect(
|
||||
db.installation.delete({ where: { id: INSTALLATION_ID } }),
|
||||
).rejects.toThrow(/append-only/);
|
||||
});
|
||||
});
|
||||
@@ -178,6 +178,19 @@ describeIfDb('immutabilité du journal d’audit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tables qui portent `accountId` sans être soumises à la RLS.
|
||||
*
|
||||
* Une seule, et elle doit le rester : `Installation` répond à « cette instance
|
||||
* a-t-elle déjà un compte ? » **avant** qu'un compte soit connu. La lui poser
|
||||
* sous RLS reviendrait à demander à la politique du compte courant de parler
|
||||
* d'un compte qu'on n'a pas — elle répondrait « rien », et l'écran de première
|
||||
* installation, qui crée un propriétaire, se rouvrirait à tout venant.
|
||||
*
|
||||
* Ce qui la protège à la place est vérifié par le test suivant.
|
||||
*/
|
||||
const EXEMPT = new Set(['Installation']);
|
||||
|
||||
describeIfDb('couverture des politiques', () => {
|
||||
let client: Client;
|
||||
|
||||
@@ -221,7 +234,8 @@ describeIfDb('couverture des politiques', () => {
|
||||
|
||||
const unprotected = rows.filter(
|
||||
(row) =>
|
||||
!row.relrowsecurity || !row.relforcerowsecurity || row.policies < 2,
|
||||
!EXEMPT.has(row.table_name) &&
|
||||
(!row.relrowsecurity || !row.relforcerowsecurity || row.policies < 2),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -229,4 +243,31 @@ describeIfDb('couverture des politiques', () => {
|
||||
'tables sans RLS forcée ou sans politique de lecture et d’écriture',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('la seule table exemptée l’est bien, et autrement protégée', async () => {
|
||||
// Une exemption qui n'est que l'absence d'une règle finit par en couvrir
|
||||
// d'autres. Celle-ci est donc affirmée dans les deux sens : `Installation`
|
||||
// est hors RLS **et** ce qui la protège à la place existe réellement.
|
||||
const { rows } = await client.query<{ relrowsecurity: boolean }>(
|
||||
`SELECT relrowsecurity FROM pg_class WHERE relname = 'Installation'`,
|
||||
);
|
||||
expect(rows[0]?.relrowsecurity, 'Installation est hors RLS à dessein').toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// Ce qui tient lieu de protection : la ligne ne se réécrit pas, ne
|
||||
// s'efface pas, et ne peut pas être doublée.
|
||||
const { rows: guards } = await client.query<{
|
||||
triggers: number;
|
||||
checks: number;
|
||||
}>(`
|
||||
SELECT
|
||||
(SELECT count(*)::int FROM pg_trigger
|
||||
WHERE tgrelid = '"Installation"'::regclass AND NOT tgisinternal) AS triggers,
|
||||
(SELECT count(*)::int FROM pg_constraint
|
||||
WHERE conrelid = '"Installation"'::regclass AND contype = 'c') AS checks
|
||||
`);
|
||||
expect(guards[0]?.triggers, 'trigger append-only').toBeGreaterThan(0);
|
||||
expect(guards[0]?.checks, 'contrainte de ligne unique').toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
installationProblem,
|
||||
isKnownTimezone,
|
||||
normaliseInstallation,
|
||||
type InstallationForm,
|
||||
} from '@/domain/install/rules';
|
||||
|
||||
/**
|
||||
* Règles de la première installation — PLAN.md §5.
|
||||
*
|
||||
* Cet écran est le seul à s'ouvrir sans session, et il ne se rouvre pas : un
|
||||
* refus mal placé enferme l'exploitant dehors sans recours, un refus manquant
|
||||
* pose un propriétaire avec un mot de passe faible. Les deux se vérifient ici,
|
||||
* sans base ni navigateur.
|
||||
*/
|
||||
|
||||
function form(overrides: Partial<InstallationForm> = {}): InstallationForm {
|
||||
return {
|
||||
companyName: 'Maison Rivage',
|
||||
locationName: 'Nantes Atlantis',
|
||||
timezone: DEFAULT_TIMEZONE,
|
||||
firstName: 'Camille',
|
||||
lastName: 'Ferrand',
|
||||
email: 'camille@exemple.test',
|
||||
password: 'quatre chevaux blancs',
|
||||
passwordConfirmation: 'quatre chevaux blancs',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('installationProblem', () => {
|
||||
it('accepte un formulaire complet', () => {
|
||||
expect(installationProblem(form())).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['companyName', 'Le nom de l’entreprise'],
|
||||
['locationName', 'Le nom de l’établissement'],
|
||||
['firstName', 'Le prénom'],
|
||||
['lastName', 'Le nom'],
|
||||
] as const)('exige %s', (field, expected) => {
|
||||
const problem = installationProblem(form({ [field]: ' ' }));
|
||||
expect(problem).toContain(expected);
|
||||
});
|
||||
|
||||
it('refuse un fuseau inconnu', () => {
|
||||
// Un fuseau faux ne se voit qu'au premier calcul d'horaire, c'est-à-dire
|
||||
// sur une paie.
|
||||
expect(installationProblem(form({ timezone: 'Europe/Atlantide' }))).toMatch(
|
||||
/fuseau horaire .* est inconnu/,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepte un fuseau hors de France', () => {
|
||||
// L'application est française, son fuseau par défaut aussi ; elle n'a pas
|
||||
// à interdire un établissement à Fort-de-France.
|
||||
expect(
|
||||
installationProblem(form({ timezone: 'America/Martinique' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('refuse une adresse sans arobase', () => {
|
||||
expect(installationProblem(form({ email: 'camille.exemple.test' }))).toMatch(
|
||||
/forme valide/,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepte une adresse à sous-adressage', () => {
|
||||
// `+` et points sont légitimes ; les refuser fermerait la porte à des
|
||||
// adresses réelles sur un écran qui ne se rouvre pas.
|
||||
expect(
|
||||
installationProblem(form({ email: 'camille+planflow@exemple.test' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('refuse un mot de passe trop court', () => {
|
||||
expect(
|
||||
installationProblem(
|
||||
form({ password: 'court', passwordConfirmation: 'court' }),
|
||||
),
|
||||
).toMatch(/12 caractères/);
|
||||
});
|
||||
|
||||
it('refuse un mot de passe contenant le nom, accent compris', () => {
|
||||
expect(
|
||||
installationProblem(
|
||||
form({
|
||||
lastName: 'Rivière',
|
||||
password: 'riviere2026!!',
|
||||
passwordConfirmation: 'riviere2026!!',
|
||||
}),
|
||||
),
|
||||
).toMatch(/ne doit pas contenir votre nom/);
|
||||
});
|
||||
|
||||
it('refuse une confirmation divergente', () => {
|
||||
expect(
|
||||
installationProblem(form({ passwordConfirmation: 'quatre chevaux noirs' })),
|
||||
).toMatch(/ne correspondent pas/);
|
||||
});
|
||||
|
||||
it('juge le mot de passe après les champs qui le contextualisent', () => {
|
||||
// Sans cet ordre, un nom vide ferait passer « martin2026 » : la règle
|
||||
// « ne contient pas votre nom » n'aurait rien à comparer.
|
||||
const problem = installationProblem(
|
||||
form({ lastName: '', password: 'court', passwordConfirmation: 'court' }),
|
||||
);
|
||||
expect(problem).toContain('Le nom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliseInstallation', () => {
|
||||
it('replie la casse de l’adresse et rogne les espaces', () => {
|
||||
// L'adresse sert d'identifiant de connexion : la casse ne doit pas décider
|
||||
// si quelqu'un entre ou non.
|
||||
const data = normaliseInstallation(
|
||||
form({ email: ' Camille@Exemple.Test ', companyName: ' Maison Rivage ' }),
|
||||
);
|
||||
expect(data.email).toBe('camille@exemple.test');
|
||||
expect(data.companyName).toBe('Maison Rivage');
|
||||
});
|
||||
|
||||
it('ne touche pas au mot de passe', () => {
|
||||
// Rogner un mot de passe changerait celui que l'utilisateur croit avoir
|
||||
// choisi, et le refus suivant serait incompréhensible.
|
||||
const data = normaliseInstallation(
|
||||
form({ password: ' quatre chevaux ', passwordConfirmation: ' quatre chevaux ' }),
|
||||
);
|
||||
expect(data.password).toBe(' quatre chevaux ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKnownTimezone', () => {
|
||||
it('reconnaît un fuseau IANA et rejette une invention', () => {
|
||||
expect(isKnownTimezone('Europe/Paris')).toBe(true);
|
||||
expect(isKnownTimezone('Pacific/Auckland')).toBe(true);
|
||||
expect(isKnownTimezone('Europe/Atlantide')).toBe(false);
|
||||
expect(isKnownTimezone('')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user