diff --git a/playwright.config.ts b/playwright.config.ts index 8cc6123..d1e549e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -40,12 +40,12 @@ export default defineConfig({ // Parcours d'authentification : doit partir d'un navigateur vierge. { name: 'anonyme', - testMatch: /auth\.spec\.ts/, + testMatch: /(auth|reglages)\.spec\.ts/, use: { ...devices['Desktop Chrome'], ...chromiumOverride }, }, { name: 'chromium', - testIgnore: /auth\.(setup|spec)\.ts/, + testIgnore: /(auth\.setup|auth\.spec|reglages\.spec)\.ts/, dependencies: ['setup'], use: { ...devices['Desktop Chrome'], diff --git a/prisma/migrations/20260807225239_referentials_and_legal_register/migration.sql b/prisma/migrations/20260807225239_referentials_and_legal_register/migration.sql new file mode 100644 index 0000000..44b4ba9 --- /dev/null +++ b/prisma/migrations/20260807225239_referentials_and_legal_register/migration.sql @@ -0,0 +1,95 @@ +-- CreateTable +CREATE TABLE "JobTitle" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "JobTitle_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Label" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "paletteKey" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "Label_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AbsenceType" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "colorKey" TEXT NOT NULL, + "isPaid" BOOLEAN NOT NULL DEFAULT true, + "countsAsWorkTime" BOOLEAN NOT NULL DEFAULT false, + "affectsPaidLeaveAccrual" BOOLEAN NOT NULL DEFAULT true, + "isSocialSecurity" BOOLEAN NOT NULL DEFAULT false, + "requiresJustification" BOOLEAN NOT NULL DEFAULT false, + "minNoticeDays" INTEGER, + "silaeCode" TEXT, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "AbsenceType_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LegalConfigEntry" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "domain" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "source" TEXT NOT NULL, + "effectiveFrom" DATE NOT NULL, + "population" TEXT NOT NULL, + "approvedBy" TEXT, + "approvedAt" TIMESTAMP(3), + "attachmentRef" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LegalConfigEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "JobTitle_accountId_idx" ON "JobTitle"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobTitle_accountId_name_key" ON "JobTitle"("accountId", "name"); + +-- CreateIndex +CREATE INDEX "Label_accountId_idx" ON "Label"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Label_accountId_code_key" ON "Label"("accountId", "code"); + +-- CreateIndex +CREATE INDEX "AbsenceType_accountId_idx" ON "AbsenceType"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AbsenceType_accountId_code_key" ON "AbsenceType"("accountId", "code"); + +-- CreateIndex +CREATE INDEX "LegalConfigEntry_accountId_idx" ON "LegalConfigEntry"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LegalConfigEntry_accountId_domain_key_effectiveFrom_key" ON "LegalConfigEntry"("accountId", "domain", "key", "effectiveFrom"); + +-- AddForeignKey +ALTER TABLE "JobTitle" ADD CONSTRAINT "JobTitle_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Label" ADD CONSTRAINT "Label_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AbsenceType" ADD CONSTRAINT "AbsenceType_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalConfigEntry" ADD CONSTRAINT "LegalConfigEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260807230500_rls_referentials/migration.sql b/prisma/migrations/20260807230500_rls_referentials/migration.sql new file mode 100644 index 0000000..fade46e --- /dev/null +++ b/prisma/migrations/20260807230500_rls_referentials/migration.sql @@ -0,0 +1,25 @@ +-- Étend l'isolation aux tables ajoutées par WP-02. +-- +-- Une table portant `accountId` sans politique associée est un trou : elle +-- répond à tout le monde. C'est le mode de défaillance le plus discret de la +-- RLS — on n'ajoute pas une règle, on oublie d'en ajouter une. + +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY['JobTitle', 'Label', 'AbsenceType', 'LegalConfigEntry'] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', + t + ); + EXECUTE format( + 'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', + t + ); + END LOOP; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 61a918c..c93333f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,10 @@ model Account { auditLogs AuditLog[] retention RetentionPolicy[] featureFlags FeatureFlag[] + jobTitles JobTitle[] + labels Label[] + absenceTypes AbsenceType[] + legalConfig LegalConfigEntry[] } model Location { @@ -286,3 +290,90 @@ model FeatureFlag { @@unique([accountId, key]) } + +// ============================================================================ +// Référentiels — PLAN.md §4.3 et WP-02 +// ============================================================================ + +/// Intitulé d'emploi. Distinct du poste de planning : l'emploi qualifie le +/// contrat, le poste qualifie une occupation dans la journée. +model JobTitle { + id String @id @default(cuid()) + accountId String + name String + archivedAt DateTime? + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@unique([accountId, name]) + @@index([accountId]) +} + +/// Étiquette de planning — le « poste » coloré de la grille. +model Label { + id String @id @default(cuid()) + accountId String + code String + name String + /// Code de la palette catégorielle (voir src/lib/design/postes.ts). + paletteKey String + position Int @default(0) + archivedAt DateTime? + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@unique([accountId, code]) + @@index([accountId]) +} + +/// Type d'absence. `isSocialSecurity` isole maladie, maternité et AT : le +/// journal des absences les filtre séparément, et ce sont des données de santé. +model AbsenceType { + id String @id @default(cuid()) + accountId String + code String + name String + colorKey String + isPaid Boolean @default(true) + countsAsWorkTime Boolean @default(false) + affectsPaidLeaveAccrual Boolean @default(true) + isSocialSecurity Boolean @default(false) + requiresJustification Boolean @default(false) + minNoticeDays Int? + /// Partie de AB- à l'export Silae. Null tant qu'elle n'a pas + /// été fournie par le dossier du client (PLAN.md §8.2). + silaeCode String? + archivedAt DateTime? + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@unique([accountId, code]) + @@index([accountId]) +} + +/// Registre de paramétrage juridique — PLAN.md §12.7. +/// +/// La matrice impose de faire **signer** chaque paramètre avant migration, avec +/// sa valeur, sa source, sa date d'effet, sa population et son approbateur. Un +/// paramètre sans cette traçabilité n'est pas opposable : c'est ce registre qui +/// distingue une configuration justifiée d'une valeur recopiée d'un autre +/// logiciel. +model LegalConfigEntry { + id String @id @default(cuid()) + accountId String + domain String + key String + value String + source String + effectiveFrom DateTime @db.Date + population String + approvedBy String? + approvedAt DateTime? + attachmentRef String? + createdAt DateTime @default(now()) + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@unique([accountId, domain, key, effectiveFrom]) + @@index([accountId]) +} diff --git a/src/app/(app)/reglages/etablissements/AddLocationForm.tsx b/src/app/(app)/reglages/etablissements/AddLocationForm.tsx new file mode 100644 index 0000000..85f9ef6 --- /dev/null +++ b/src/app/(app)/reglages/etablissements/AddLocationForm.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { + createLocationAction, + type ActionState, +} from '@/server/settings/locations'; + +export function AddLocationForm() { + const [state, formAction] = useActionState( + createLocationAction, + {}, + ); + + return ( +
+
+ + +
+
+ + +
+ + {state.error} + +
+ Créer l’établissement + {state.ok ? ( + Établissement créé. + ) : null} +
+
+ ); +} diff --git a/src/app/(app)/reglages/etablissements/AddTeamForm.tsx b/src/app/(app)/reglages/etablissements/AddTeamForm.tsx new file mode 100644 index 0000000..f55d572 --- /dev/null +++ b/src/app/(app)/reglages/etablissements/AddTeamForm.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { + createTeamAction, + type ActionState, +} from '@/server/settings/locations'; + +export function AddTeamForm({ locationId }: { locationId: string }) { + const [state, formAction] = useActionState( + createTeamAction, + {}, + ); + + return ( +
+ + + Ajouter +
+ {state.error} +
+ + ); +} diff --git a/src/app/(app)/reglages/etablissements/page.tsx b/src/app/(app)/reglages/etablissements/page.tsx new file mode 100644 index 0000000..64147ae --- /dev/null +++ b/src/app/(app)/reglages/etablissements/page.tsx @@ -0,0 +1,98 @@ +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { AddLocationForm } from '@/app/(app)/reglages/etablissements/AddLocationForm'; +import { AddTeamForm } from '@/app/(app)/reglages/etablissements/AddTeamForm'; +import { archiveLocationAction, listLocations } from '@/server/settings/locations'; + +export const metadata = { title: 'Établissements · PlanFlow' }; +export const dynamic = 'force-dynamic'; + +export default async function EtablissementsPage() { + const locations = await listLocations(); + + return ( + + 1 ? 's' : ''} actif${locations.length > 1 ? 's' : ''}`} + /> + + {locations.length === 0 ? ( + + + + ) : null} + +
+ {locations.map((location) => ( + + + {location.teams.length} équipe + {location.teams.length > 1 ? 's' : ''} + + } + action={ +
+ + +
+ } + /> + +
+
+
SIRET
+
{location.siret ?? '—'}
+
+
+
Fuseau horaire
+
{location.timezone}
+
+
+
+ Cotisations patronales +
+
{location.employerContributionRate} %
+
+
+ +
+

+ Équipes +

+
    + {location.teams.length === 0 ? ( +
  • Aucune équipe
  • + ) : ( + location.teams.map((team) => ( +
  • + {team.name} +
  • + )) + )} +
+ +
+
+ ))} +
+ + + +
+ +
+
+
+ ); +} diff --git a/src/app/(app)/reglages/registre/AddEntryForm.tsx b/src/app/(app)/reglages/registre/AddEntryForm.tsx new file mode 100644 index 0000000..a4fdc76 --- /dev/null +++ b/src/app/(app)/reglages/registre/AddEntryForm.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { LEGAL_DOMAINS } from '@/domain/legal/domains'; +import { + addLegalEntryAction, + type ActionState, +} from '@/server/settings/legal-register'; + +export function AddEntryForm() { + const [state, formAction] = useActionState( + addLegalEntryAction, + {}, + ); + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {state.error} + +
+ Consigner + {state.ok ? ( + + Paramètre consigné — il reste à approuver. + + ) : null} +
+
+ ); +} diff --git a/src/app/(app)/reglages/registre/page.tsx b/src/app/(app)/reglages/registre/page.tsx new file mode 100644 index 0000000..080f828 --- /dev/null +++ b/src/app/(app)/reglages/registre/page.tsx @@ -0,0 +1,137 @@ +import { AddEntryForm } from '@/app/(app)/reglages/registre/AddEntryForm'; +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { LEGAL_DOMAINS } from '@/domain/legal/domains'; +import { + approveLegalEntryAction, + readLegalRegister, +} from '@/server/settings/legal-register'; + +export const metadata = { title: 'Registre de paramétrage · PlanFlow' }; +export const dynamic = 'force-dynamic'; + +const DOMAIN_LABELS = new Map( + LEGAL_DOMAINS.map((domain) => [domain.key, domain.label]), +); + +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }); + +export default async function RegistrePage() { + const register = await readLegalRegister(); + + return ( + + 1 ? 's' : ''} approuvé${register.approvedCount > 1 ? 's' : ''} · ${register.pendingCount} en attente`} + /> + + +
+

+ Chaque paramètre appliqué par PlanFlow doit porter sa{' '} + valeur, sa{' '} + source, sa{' '} + date d’effet, + la population{' '} + concernée et un{' '} + approbateur. +

+

+ Recopier la configuration d’un autre logiciel ne suffit pas : sans + justification conservée, un paramètre n’est pas opposable en cas de + contrôle. Les valeurs de la convention IDCC 1517 déjà chargées dans + le moteur restent à recouper avec le texte consolidé et à faire + valider par le gestionnaire de paie. +

+
+
+ + {register.missingDomains.length > 0 ? ( + + {register.missingDomains.length}} + /> +
    + {register.missingDomains.map((domain) => ( +
  • + {domain} +
  • + ))} +
+
+ ) : null} + + + + {register.entries.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {register.entries.map((entry) => ( + + + + + + + + + + ))} + +
DomaineParamètreValeurSourceEffetPopulationApprobation
+ {DOMAIN_LABELS.get(entry.domain) ?? entry.domain} + {entry.key}{entry.value}{entry.source} + {dateFormat.format(entry.effectiveFrom)} + + {entry.population} + + {entry.approvedAt ? ( + + Approuvé le {dateFormat.format(entry.approvedAt)} + + ) : ( +
+ + +
+ )} +
+
+ )} +
+ + + +
+ +
+
+
+ ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index 979123a..acf14ad 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -75,9 +75,10 @@ export const NAVIGATION: NavSection[] = [ id: 'reglages', label: 'Réglages', items: [ + { id: 'sites', label: 'Établissements', href: '/reglages/etablissements' }, + { id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' }, { id: 'convention', label: 'Convention collective' }, { id: 'postes', label: 'Postes et étiquettes' }, - { id: 'sites', label: 'Établissements' }, { id: 'roles', label: 'Rôles et permissions' }, ], }, diff --git a/src/components/ui/Form.tsx b/src/components/ui/Form.tsx new file mode 100644 index 0000000..c5c2dd3 --- /dev/null +++ b/src/components/ui/Form.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { InputHTMLAttributes, ReactNode } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { Button, type ButtonProps } from '@/components/ui/Button'; +import { cx } from '@/lib/cx'; + +export interface FieldProps extends InputHTMLAttributes { + label: string; + hint?: string; +} + +export function Field({ label, hint, className, ...rest }: FieldProps) { + return ( + + ); +} + +/** Bouton de soumission qui se désactive pendant l'envoi, pour éviter le double clic. */ +export function SubmitButton({ children, ...rest }: ButtonProps) { + const { pending } = useFormStatus(); + return ( + + ); +} + +export function FormError({ children }: { children: ReactNode }) { + if (!children) return null; + return ( +

+ {children} +

+ ); +} diff --git a/src/domain/legal/domains.ts b/src/domain/legal/domains.ts new file mode 100644 index 0000000..fbe447b --- /dev/null +++ b/src/domain/legal/domains.ts @@ -0,0 +1,23 @@ +/** + * Domaines du registre de paramétrage juridique — matrice, section + * « Paramétrage juridique minimal à faire signer avant migration ». + * + * Dans son propre module car un fichier « use server » ne peut exporter que des + * fonctions asynchrones, et ces constantes sont aussi lues côté client. + */ +export const LEGAL_DOMAINS = [ + { key: 'identite', label: 'Identité juridique' }, + { key: 'populations', label: 'Populations' }, + { key: 'temps', label: 'Temps' }, + { key: 'remuneration', label: 'Rémunération' }, + { key: 'absences', label: 'Absences' }, + { key: 'paie', label: 'Paie et déclarations' }, + { key: 'vie-privee', label: 'Vie privée' }, + { key: 'securite', label: 'Sécurité' }, +] as const; + +export type LegalDomainKey = (typeof LEGAL_DOMAINS)[number]['key']; + +export const LEGAL_DOMAIN_KEYS: readonly string[] = LEGAL_DOMAINS.map( + (domain) => domain.key, +); diff --git a/src/server/context.ts b/src/server/context.ts new file mode 100644 index 0000000..da0c86c --- /dev/null +++ b/src/server/context.ts @@ -0,0 +1,55 @@ +import { redirect } from 'next/navigation'; + +import { + authorize, + type Actor, + type ResourceRef, +} from '@/domain/access/authorize'; +import type { PermissionCode } from '@/domain/access/permissions'; +import { currentSession, type SessionContext } from '@/server/auth/session'; +import { withTenant, type ScopedClient } from '@/server/tenant'; + +/** + * Contexte d'exécution d'une requête authentifiée. + * + * Toute lecture et toute écriture métier passent par ici. Le compte vient de la + * session serveur, jamais d'un paramètre : c'est ce qui empêche un client de + * désigner lui-même le périmètre qu'il veut lire. + */ + +export async function requireSession(): Promise { + const session = await currentSession(); + if (!session) redirect('/connexion'); + return session; +} + +/** + * Exécute une lecture dans le périmètre de la session. + * + * `permission` est vérifiée **avant** d'ouvrir la transaction : un refus ne + * doit pas laisser de trace d'accès en base. + */ +export async function query( + permission: PermissionCode, + fn: (db: ScopedClient, actor: Actor) => Promise, + resource?: ResourceRef, +): Promise { + const session = await requireSession(); + authorize(session.actor, permission, resource); + return withTenant(session.actor.accountId, (db) => fn(db, session.actor)); +} + +/** + * Exécute une mutation dans le périmètre de la session. + * + * Identique à `query` par construction, mais nommée distinctement : une revue + * de code doit pouvoir repérer d'un coup d'œil les points d'écriture, et + * l'oubli d'un `authorize` s'y voit. + */ +export async function mutate( + permission: PermissionCode, + fn: (db: ScopedClient, actor: Actor) => Promise, + resource?: ResourceRef, +): Promise { + return query(permission, fn, resource); +} diff --git a/src/server/settings/legal-register.ts b/src/server/settings/legal-register.ts new file mode 100644 index 0000000..ec058bd --- /dev/null +++ b/src/server/settings/legal-register.ts @@ -0,0 +1,173 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import { AuthorizationError } from '@/domain/access/authorize'; +import { recordAudit } from '@/server/audit'; +import { LEGAL_DOMAINS, LEGAL_DOMAIN_KEYS } from '@/domain/legal/domains'; +import { mutate, query } from '@/server/context'; + +/** + * Registre de paramétrage juridique — PLAN.md §12.7, matrice n° 1. + * + * La matrice est explicite : « Il ne suffit pas de copier la configuration d'un + * autre logiciel : il faut conserver la justification de chaque paramètre. » + * + * Une valeur sans source, sans date d'effet et sans approbateur n'est pas + * opposable. Ce registre est donc la contrepartie du jeu de paramètres IDCC + * 1517 chargé en §6.3 : les valeurs existent, ce sont les preuves qui manquent. + */ + +export interface LegalEntryRow { + id: string; + domain: string; + key: string; + value: string; + source: string; + effectiveFrom: Date; + population: string; + approvedBy: string | null; + approvedAt: Date | null; +} + +export interface LegalRegisterView { + entries: LegalEntryRow[]; + /** Domaines sans aucune entrée approuvée. */ + missingDomains: string[]; + approvedCount: number; + pendingCount: number; +} + +export async function readLegalRegister(): Promise { + return query('settings.access', async (db) => { + const entries = await db.legalConfigEntry.findMany({ + orderBy: [{ domain: 'asc' }, { key: 'asc' }], + }); + + const approved = entries.filter((entry) => entry.approvedAt !== null); + const domainsWithApproval = new Set(approved.map((entry) => entry.domain)); + + return { + entries: entries.map((entry) => ({ + id: entry.id, + domain: entry.domain, + key: entry.key, + value: entry.value, + source: entry.source, + effectiveFrom: entry.effectiveFrom, + population: entry.population, + approvedBy: entry.approvedBy, + approvedAt: entry.approvedAt, + })), + missingDomains: LEGAL_DOMAINS.filter( + (domain) => !domainsWithApproval.has(domain.key), + ).map((domain) => domain.label), + approvedCount: approved.length, + pendingCount: entries.length - approved.length, + }; + }); +} + +const entryInput = z.object({ + domain: z.enum(LEGAL_DOMAIN_KEYS as unknown as [string, ...string[]]), + key: z.string().trim().min(1, 'Paramètre requis').max(120), + value: z.string().trim().min(1, 'Valeur requise').max(500), + source: z + .string() + .trim() + .min(1, 'Source requise — texte, article ou référence de l’accord') + .max(500), + effectiveFrom: z.coerce.date(), + population: z.string().trim().min(1, 'Population requise').max(200), +}); + +export interface ActionState { + error?: string; + ok?: boolean; +} + +export async function addLegalEntryAction( + _previous: ActionState, + formData: FormData, +): Promise { + const parsed = entryInput.safeParse({ + domain: formData.get('domain'), + key: formData.get('key'), + value: formData.get('value'), + source: formData.get('source'), + effectiveFrom: formData.get('effectiveFrom'), + population: formData.get('population'), + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + try { + await mutate('settings.agreement.manage', async (db, actor) => { + const created = await db.legalConfigEntry.create({ + data: { + domain: parsed.data.domain, + key: parsed.data.key, + value: parsed.data.value, + source: parsed.data.source, + effectiveFrom: parsed.data.effectiveFrom, + population: parsed.data.population, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'legal_config.create', + entityType: 'LegalConfigEntry', + entityId: created.id, + after: { + domain: created.domain, + key: created.key, + value: created.value, + source: created.source, + }, + }); + }); + } catch (error) { + if (error instanceof AuthorizationError) { + return { error: "Vous n'avez pas le droit de gérer la convention." }; + } + throw error; + } + + revalidatePath('/reglages/registre'); + return { ok: true }; +} + +export async function approveLegalEntryAction( + formData: FormData, +): Promise { + const id = String(formData.get('id') ?? ''); + if (!id) return; + + await mutate('settings.agreement.manage', async (db, actor) => { + const before = await db.legalConfigEntry.findUnique({ where: { id } }); + if (!before || before.approvedAt) return; + + // L'approbateur est l'acteur de la session, jamais un champ du formulaire : + // une signature qu'on peut saisir soi-même ne vaut rien. + await db.legalConfigEntry.update({ + where: { id }, + data: { approvedBy: actor.membershipId, approvedAt: new Date() }, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'legal_config.approve', + entityType: 'LegalConfigEntry', + entityId: id, + before: { approvedAt: null }, + after: { approvedBy: actor.membershipId }, + reason: `Approbation du paramètre ${before.domain}.${before.key}`, + }); + }); + + revalidatePath('/reglages/registre'); +} diff --git a/src/server/settings/locations.ts b/src/server/settings/locations.ts new file mode 100644 index 0000000..e2c8ae8 --- /dev/null +++ b/src/server/settings/locations.ts @@ -0,0 +1,219 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import { AuthorizationError } from '@/domain/access/authorize'; +import { recordAudit } from '@/server/audit'; +import { mutate, query } from '@/server/context'; + +/** + * Établissements et équipes. + * + * Chaque mutation est autorisée puis journalisée **dans la même transaction** + * que l'écriture : une modification sans trace, ou une trace sans modification, + * seraient toutes deux des mensonges pour le contrôle. + */ + +export interface LocationRow { + id: string; + name: string; + siret: string | null; + timezone: string; + employerContributionRate: string; + archivedAt: Date | null; + teams: Array<{ id: string; name: string; archivedAt: Date | null }>; +} + +export async function listLocations( + includeArchived = false, +): Promise { + return query('settings.access', async (db) => { + const locations = await db.location.findMany({ + where: includeArchived ? {} : { archivedAt: null }, + orderBy: { name: 'asc' }, + include: { + teams: { + where: includeArchived ? {} : { archivedAt: null }, + orderBy: { position: 'asc' }, + }, + }, + }); + + return locations.map((location) => ({ + id: location.id, + name: location.name, + siret: location.siret, + timezone: location.timezone, + employerContributionRate: location.employerContributionRate.toString(), + archivedAt: location.archivedAt, + teams: location.teams.map((team) => ({ + id: team.id, + name: team.name, + archivedAt: team.archivedAt, + })), + })); + }); +} + +const locationInput = z.object({ + name: z.string().trim().min(1, 'Nom requis').max(120), + siret: z + .string() + .trim() + .regex(/^\d{14}$/, 'Le SIRET compte 14 chiffres') + .or(z.literal('')), + timezone: z.string().trim().min(1), + employerContributionRate: z.coerce + .number() + .min(0, 'Taux négatif impossible') + .max(100, 'Un taux de cotisations dépasse rarement 100 %'), +}); + +export interface ActionState { + error?: string; + ok?: boolean; +} + +export async function createLocationAction( + _previous: ActionState, + formData: FormData, +): Promise { + const parsed = locationInput.safeParse({ + name: formData.get('name'), + siret: formData.get('siret') ?? '', + timezone: formData.get('timezone') || 'Europe/Paris', + employerContributionRate: formData.get('employerContributionRate') ?? 0, + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + try { + await mutate('settings.locations.manage', async (db, actor) => { + const created = await db.location.create({ + data: { + name: parsed.data.name, + siret: parsed.data.siret || null, + timezone: parsed.data.timezone, + employerContributionRate: parsed.data.employerContributionRate, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'location.create', + entityType: 'Location', + entityId: created.id, + after: { + name: created.name, + siret: created.siret, + timezone: created.timezone, + }, + }); + }); + } catch (error) { + if (error instanceof AuthorizationError) { + return { error: "Vous n'avez pas le droit de gérer les établissements." }; + } + throw error; + } + + revalidatePath('/reglages/etablissements'); + return { ok: true }; +} + +export async function archiveLocationAction(formData: FormData): Promise { + const id = String(formData.get('id') ?? ''); + if (!id) return; + + await mutate('settings.locations.manage', async (db, actor) => { + const before = await db.location.findUnique({ where: { id } }); + if (!before) return; + + // Archiver plutôt que supprimer : un établissement fermé garde des + // plannings, des contrats et des variables de paie dont la conservation + // court encore (PLAN.md §12.5). + await db.location.update({ + where: { id }, + data: { archivedAt: new Date() }, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'location.archive', + entityType: 'Location', + entityId: id, + before: { archivedAt: before.archivedAt }, + after: { archivedAt: new Date().toISOString() }, + }); + }); + + revalidatePath('/reglages/etablissements'); +} + +const teamInput = z.object({ + locationId: z.string().min(1), + name: z.string().trim().min(1, "Nom d'équipe requis").max(120), +}); + +export async function createTeamAction( + _previous: ActionState, + formData: FormData, +): Promise { + const parsed = teamInput.safeParse({ + locationId: formData.get('locationId'), + name: formData.get('name'), + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + try { + await mutate( + 'settings.teams.manage', + async (db, actor) => { + // Le périmètre est revérifié en base : l'établissement doit exister + // *dans ce compte*. Sans cette lecture, un identifiant soumis depuis le + // formulaire pourrait désigner celui d'un autre client. + const location = await db.location.findUnique({ + where: { id: parsed.data.locationId }, + }); + if (!location) { + throw new AuthorizationError('settings.teams.manage'); + } + + const count = await db.team.count({ + where: { locationId: location.id }, + }); + + const created = await db.team.create({ + data: { + locationId: location.id, + name: parsed.data.name, + position: count, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'team.create', + entityType: 'Team', + entityId: created.id, + after: { name: created.name, locationId: location.id }, + }); + }, + { locationId: parsed.data.locationId }, + ); + } catch (error) { + if (error instanceof AuthorizationError) { + return { error: "Vous n'avez pas le droit de gérer les équipes." }; + } + throw error; + } + + revalidatePath('/reglages/etablissements'); + return { ok: true }; +} diff --git a/src/server/tenant.ts b/src/server/tenant.ts index b2c7b98..ac17671 100644 --- a/src/server/tenant.ts +++ b/src/server/tenant.ts @@ -1,4 +1,4 @@ -import type { Prisma, PrismaClient } from '@prisma/client'; +import { Prisma, type PrismaClient } from '@prisma/client'; import { prisma } from '@/server/db'; @@ -17,18 +17,24 @@ import { prisma } from '@/server/db'; * dire pourquoi ; la seconde seule tomberait avec le premier `$queryRaw`. */ -/** Tables portant une colonne `accountId`. */ -const SCOPED_MODELS = new Set([ - 'Location', - 'Team', - 'Membership', - 'MembershipScope', - 'Invitation', - 'Role', - 'AuditLog', - 'RetentionPolicy', - 'FeatureFlag', -]); +/** + * Modèles portant une colonne `accountId`, **dérivés du schéma**. + * + * Une liste tenue à la main se périme au premier modèle ajouté, et l'oubli est + * silencieux : le scoping ne s'applique plus, et selon les cas la requête + * échoue avec un message obscur ou — bien pire — réussit sans filtre. + * La dériver du DMMF supprime le mode de défaillance plutôt que de compter sur + * la vigilance. + */ +const SCOPED_MODELS = new Set( + Prisma.dmmf.datamodel.models + .filter((model) => + model.fields.some( + (field) => field.name === 'accountId' && field.kind === 'scalar', + ), + ) + .map((model) => model.name), +); const READ_OPERATIONS = new Set([ 'findFirst', @@ -135,4 +141,3 @@ export function unscoped(): PrismaClient { return prisma; } -export type { Prisma }; diff --git a/tests/e2e/reglages.spec.ts b/tests/e2e/reglages.spec.ts new file mode 100644 index 0000000..3817dd6 --- /dev/null +++ b/tests/e2e/reglages.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +/** + * Ce test se connecte en manager : il ne peut donc pas réutiliser la session + * partagée de la direction, d'où le projet « anonyme ». + */ +async function signIn(page: import('@playwright/test').Page, email: string) { + await page.goto('/connexion'); + await page.getByLabel('Adresse électronique').fill(email); + await page.getByLabel('Mot de passe').fill('planflow-demo-2026'); + await page.getByRole('button', { name: 'Se connecter' }).click(); + await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible(); +} + +test('la direction lit et alimente le registre de paramétrage', async ({ + page, +}) => { + await signIn(page, 'direction@example.test'); + await page.goto('/reglages/registre'); + + await expect( + page.getByRole('heading', { name: 'Registre de paramétrage juridique' }), + ).toBeVisible(); + + const parameter = `Durée quotidienne maximale ${Date.now()}`; + // Ciblage par attribut `name` : les libellés portent un texte d'aide, et + // celui de « Source » contient lui-même le mot « valeur », ce qui rend la + // correspondance par libellé ambiguë. + const form = page.locator('form').filter({ hasText: 'Consigner' }); + await form.locator('input[name="key"]').fill(parameter); + await form.locator('input[name="value"]').fill('10 h'); + await form + .locator('input[name="source"]') + .fill('IDCC 1517 — texte consolidé Legifrance'); + await form.locator('input[name="population"]').fill('Tous les salariés'); + await page.getByRole('button', { name: 'Consigner' }).click(); + + const row = page.getByRole('row', { name: new RegExp(parameter) }); + await expect(row).toBeVisible(); + + // Consigné n'est pas approuvé : la matrice exige un approbateur nommé. + await row.getByRole('button', { name: 'Approuver' }).click(); + await expect(row.getByText(/Approuvé le/)).toBeVisible(); +}); + +test('un manager ne peut ni voir ni modifier les établissements', async ({ + page, +}) => { + await signIn(page, 'manager.nantes@example.test'); + + // La barre latérale ne propose pas la section, mais c'est un confort : + // le contrôle qui compte est celui du serveur, testé en accédant à l'URL. + await page.goto('/reglages/etablissements'); + + // Le refus se manifeste par une erreur serveur, pas par une page qui + // s'affiche à moitié : la lecture elle-même est refusée. + await expect( + page.getByRole('heading', { name: 'Établissements' }), + ).toBeHidden(); +}); diff --git a/tests/integration/rls.test.ts b/tests/integration/rls.test.ts index a1cc120..c50f70c 100644 --- a/tests/integration/rls.test.ts +++ b/tests/integration/rls.test.ts @@ -173,3 +173,56 @@ describeIfDb('immutabilité du journal d’audit', () => { ).rejects.toThrow(/append-only/i); }); }); + +describeIfDb('couverture des politiques', () => { + let client: Client; + + beforeAll(async () => { + client = new Client({ connectionString: adminUrl }); + await client.connect(); + }, 30_000); + + afterAll(async () => { + await client?.end().catch(() => undefined); + }, 30_000); + + it('toute table portant accountId est protégée', async () => { + // Le mode de défaillance de la RLS n'est pas d'écrire une mauvaise règle, + // c'est d'oublier d'en écrire une : la table répond alors à tout le monde, + // en silence. Ce test échoue quand une table est ajoutée sans politique. + const { rows } = await client.query<{ + table_name: string; + relrowsecurity: boolean; + relforcerowsecurity: boolean; + policies: number; + }>(` + SELECT c.relname AS table_name, + c.relrowsecurity, + c.relforcerowsecurity, + (SELECT count(*)::int FROM pg_policy p WHERE p.polrelid = c.oid) AS policies + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'r' + AND EXISTS ( + SELECT 1 FROM information_schema.columns col + WHERE col.table_schema = 'public' + AND col.table_name = c.relname + AND col.column_name = 'accountId' + ) + ORDER BY c.relname + `); + + expect(rows.length).toBeGreaterThan(0); + + const unprotected = rows.filter( + (row) => + !row.relrowsecurity || !row.relforcerowsecurity || row.policies < 2, + ); + + expect( + unprotected.map((row) => row.table_name), + 'tables sans RLS forcée ou sans politique de lecture et d’écriture', + ).toEqual([]); + }); +}); diff --git a/tests/unit/authorize.test.ts b/tests/unit/authorize.test.ts new file mode 100644 index 0000000..33ad836 --- /dev/null +++ b/tests/unit/authorize.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import { + authorize, + AuthorizationError, + can, + canForMember, + inScope, + type Actor, +} from '@/domain/access/authorize'; +import { + DEFAULT_ROLE_PERMISSIONS, + PERMISSION_CODES, + SYSTEM_ROLES, +} from '@/domain/access/permissions'; + +function actor(overrides: Partial = {}): Actor { + return { + membershipId: 'm1', + accountId: 'acc1', + userId: 'u1', + roleKey: 'manager', + permissions: new Set(['planning.view']), + scope: { allLocations: false, locationIds: ['loc1'], teamIds: [] }, + ...overrides, + }; +} + +describe('can', () => { + it('refuse une capacité absente', () => { + expect(can(actor(), 'planning.publish')).toBe(false); + }); + + it('accorde une capacité présente', () => { + expect(can(actor(), 'planning.view')).toBe(true); + }); + + it('refuse hors du compte, même avec la capacité', () => { + // Le cas qui compte : détenir le droit ne dit rien du périmètre. + expect( + can(actor(), 'planning.view', { accountId: 'autre-compte' }), + ).toBe(false); + }); + + it('refuse un établissement hors périmètre', () => { + expect(can(actor(), 'planning.view', { locationId: 'loc2' })).toBe(false); + expect(can(actor(), 'planning.view', { locationId: 'loc1' })).toBe(true); + }); + + it('accorde tous les établissements au périmètre global', () => { + const director = actor({ + scope: { allLocations: true, locationIds: [], teamIds: [] }, + }); + expect(can(director, 'planning.view', { locationId: 'loc99' })).toBe(true); + }); +}); + +describe('authorize', () => { + it('lève quand la capacité manque', () => { + expect(() => authorize(actor(), 'planning.publish')).toThrow( + AuthorizationError, + ); + }); + + it('lève quand le périmètre ne couvre pas la ressource', () => { + expect(() => + authorize(actor(), 'planning.view', { locationId: 'loc2' }), + ).toThrow(/périmètre/); + }); + + it('ne lève pas quand tout est réuni', () => { + expect(() => + authorize(actor(), 'planning.view', { locationId: 'loc1' }), + ).not.toThrow(); + }); +}); + +describe('canForMember', () => { + const employee = actor({ + permissions: new Set(['counters.view_own']), + }); + + it('permet de voir ses propres compteurs', () => { + expect( + canForMember(employee, 'counters.view_own', 'counters.view_others', 'm1'), + ).toBe(true); + }); + + it('refuse ceux des autres sans la capacité dédiée', () => { + // L'audit relève ces deux droits explicitement séparés : les confondre + // ouvrirait les compteurs de toute l'équipe à chaque salarié. + expect( + canForMember(employee, 'counters.view_own', 'counters.view_others', 'm2'), + ).toBe(false); + }); +}); + +describe('inScope', () => { + it('accepte une ressource sans périmètre précisé', () => { + expect(inScope(actor())).toBe(true); + }); +}); + +describe('catalogue de capacités', () => { + it('n’attribue que des capacités existantes', () => { + // Une faute de frappe dans une attribution donnerait un rôle qui ne peut + // rien faire, sans erreur au démarrage. + const known = new Set(PERMISSION_CODES); + for (const role of SYSTEM_ROLES) { + for (const code of DEFAULT_ROLE_PERMISSIONS[role.key]) { + expect(known.has(code), `${role.key} : capacité inconnue ${code}`).toBe( + true, + ); + } + } + }); + + it('réserve la délégation du niveau propriétaire', () => { + for (const role of SYSTEM_ROLES) { + const has = DEFAULT_ROLE_PERMISSIONS[role.key].includes( + 'role_config.assign_owner_level', + ); + expect(has, `${role.key}`).toBe(role.key === 'owner'); + } + }); + + it('ne donne pas les réglages au manager', () => { + const manager = DEFAULT_ROLE_PERMISSIONS.manager; + for (const code of [ + 'settings.access', + 'settings.locations.manage', + 'settings.agreement.manage', + 'members.salary.view', + 'payroll.access', + ]) { + expect(manager, `manager ne doit pas détenir ${code}`).not.toContain(code); + } + }); + + it('donne à l’employé le strict nécessaire', () => { + const employee = DEFAULT_ROLE_PERMISSIONS.employee; + expect(employee).toContain('timeoff.request'); + expect(employee).toContain('counters.view_own'); + expect(employee).not.toContain('counters.view_others'); + expect(employee).not.toContain('timeoff.decide'); + expect(employee).not.toContain('planning.publish'); + }); + + it('utilise des codes stables de la forme ressource.action', () => { + for (const code of PERMISSION_CODES) { + expect(code, `${code} doit être en minuscules avec des points`).toMatch( + /^[a-z_]+(\.[a-z_]+)+$/, + ); + } + }); +}); diff --git a/tests/unit/tenant-scope.test.ts b/tests/unit/tenant-scope.test.ts new file mode 100644 index 0000000..48c78ba --- /dev/null +++ b/tests/unit/tenant-scope.test.ts @@ -0,0 +1,49 @@ +import { Prisma } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +/** + * Le scoping multi-tenant se dérive du schéma Prisma. + * + * Ce test protège la dérivation elle-même : le mode de défaillance n'est pas + * d'écrire une mauvaise règle, c'est d'ajouter un modèle et d'oublier de le + * déclarer quelque part. Il a déjà été rencontré une fois — quatre modèles + * ajoutés au WP-02 échappaient au filtre. + */ +describe('modèles scopés', () => { + const scoped = Prisma.dmmf.datamodel.models.filter((model) => + model.fields.some( + (field) => field.name === 'accountId' && field.kind === 'scalar', + ), + ); + + it('détecte tous les modèles portant accountId', () => { + expect(scoped.length).toBeGreaterThanOrEqual(13); + }); + + it('couvre les modèles connus du périmètre', () => { + const names = new Set(scoped.map((model) => model.name)); + for (const model of [ + 'Location', + 'Team', + 'Membership', + 'Role', + 'AuditLog', + 'JobTitle', + 'Label', + 'AbsenceType', + 'LegalConfigEntry', + 'RetentionPolicy', + ]) { + expect(names.has(model), `${model} doit être scopé`).toBe(true); + } + }); + + it('n’inclut pas les modèles volontairement globaux', () => { + const names = new Set(scoped.map((model) => model.name)); + // Permission est un référentiel produit ; User et Session vivent avant + // qu'un compte soit connu, au moment de l'authentification. + for (const model of ['Permission', 'User', 'Session', 'Account']) { + expect(names.has(model), `${model} ne doit pas être scopé`).toBe(false); + } + }); +});