É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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user