É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:
Claude committed 2026-08-09 19:10:25 +00:00
1 parent 759dd97c8e
commit 727c05407a
21 files changed
+1367 -11

No files matched your search

@@ -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();
+26
View File
@@ -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 {
+10
View File
@@ -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) {