diff --git a/README.md b/README.md index 15ee6dc..e91d15c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/playwright.config.ts b/playwright.config.ts index da63908..c33e201 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'], diff --git a/prisma/migrations/20260809180000_installation/migration.sql b/prisma/migrations/20260809180000_installation/migration.sql new file mode 100644 index 0000000..9d78535 --- /dev/null +++ b/prisma/migrations/20260809180000_installation/migration.sql @@ -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(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30ca82b..41d64fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/prisma/seed.ts b/prisma/seed.ts index 6ba6f5b..013784f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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(); for (const role of SYSTEM_ROLES) { diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 953ab8b..9d52192 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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'); diff --git a/src/app/(auth)/connexion/SignInForm.tsx b/src/app/(auth)/connexion/SignInForm.tsx index 633ae8e..1de7bcd 100644 --- a/src/app/(auth)/connexion/SignInForm.tsx +++ b/src/app/(auth)/connexion/SignInForm.tsx @@ -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 ( -
+
); } diff --git a/src/app/(auth)/connexion/page.tsx b/src/app/(auth)/connexion/page.tsx index 966fdea..6b57e30 100644 --- a/src/app/(auth)/connexion/page.tsx +++ b/src/app/(auth)/connexion/page.tsx @@ -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 ( diff --git a/src/app/installation/InstallForm.tsx b/src/app/installation/InstallForm.tsx new file mode 100644 index 0000000..88fdaf6 --- /dev/null +++ b/src/app/installation/InstallForm.tsx @@ -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 ( + + ); +} + +export function InstallForm() { + const [state, formAction] = useActionState( + installAction, + {}, + ); + + return ( + +
+ Votre entreprise + + + +
+ + +
+
+ +
+ + Votre compte administrateur + + +
+ + +
+ + + + + +
+ + {state.error} + + +
+ ); +} diff --git a/src/app/installation/page.tsx b/src/app/installation/page.tsx new file mode 100644 index 0000000..a44d9fa --- /dev/null +++ b/src/app/installation/page.tsx @@ -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 ( +
+
+
+ + + PlanFlow + +
+ +
+

Première installation

+

+ Cette instance est vierge. Créez votre entreprise et le compte qui + l’administrera — cet écran ne se rouvrira pas. +

+ + +
+ +

+ Le compte créé ici détient tous les droits. Un second facteur vous sera + demandé dès la première connexion. +

+
+
+ ); +} diff --git a/src/components/ui/Form.tsx b/src/components/ui/Form.tsx index c5c2dd3..a84ba51 100644 --- a/src/components/ui/Form.tsx +++ b/src/components/ui/Form.tsx @@ -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 { 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 + // `