Merge pull request #9 — WP-02 référentiels et registre de paramétrage
WP-02 — établissements, référentiels et registre de paramétrage juridique
This commit is contained in:
20 files changed
+1475
-17
No files matched your search
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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 <code> de AB-<code> à 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])
|
||||
}
|
||||
@@ -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<ActionState, FormData>(
|
||||
createLocationAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Nom" name="name" required placeholder="Nantes Atlantis" />
|
||||
<Field
|
||||
label="SIRET"
|
||||
name="siret"
|
||||
inputMode="numeric"
|
||||
placeholder="14 chiffres"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Fuseau horaire"
|
||||
name="timezone"
|
||||
defaultValue="Europe/Paris"
|
||||
hint="Les durées se calculent depuis les instants ; le fuseau ne sert qu'à l'affichage et au regroupement par jour."
|
||||
/>
|
||||
<Field
|
||||
label="Cotisations patronales"
|
||||
name="employerContributionRate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue="42"
|
||||
hint="Taux moyen, utilisé pour le coût prévisionnel du planning."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Créer l’établissement</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Établissement créé.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<ActionState, FormData>(
|
||||
createTeamAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-wrap items-end gap-2">
|
||||
<input type="hidden" name="locationId" value={locationId} />
|
||||
<Field label="Nouvelle équipe" name="name" placeholder="Caisse" required />
|
||||
<SubmitButton size="md">Ajouter</SubmitButton>
|
||||
<div className="w-full">
|
||||
<FormError>{state.error}</FormError>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Établissements"
|
||||
subtitle={`${locations.length} établissement${locations.length > 1 ? 's' : ''} actif${locations.length > 1 ? 's' : ''}`}
|
||||
/>
|
||||
|
||||
{locations.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Aucun établissement"
|
||||
description="Créez le premier établissement pour commencer à planifier."
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{locations.map((location) => (
|
||||
<Card key={location.id}>
|
||||
<CardHeader
|
||||
title={location.name}
|
||||
badge={
|
||||
<Badge tone="neutral">
|
||||
{location.teams.length} équipe
|
||||
{location.teams.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<form action={archiveLocationAction}>
|
||||
<input type="hidden" name="id" value={location.id} />
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
Archiver
|
||||
</Button>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<dl className="grid gap-x-8 gap-y-2 px-4 py-3 text-sm [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">SIRET</dt>
|
||||
<dd className="tnum">{location.siret ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Fuseau horaire</dt>
|
||||
<dd>{location.timezone}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">
|
||||
Cotisations patronales
|
||||
</dt>
|
||||
<dd className="tnum">{location.employerContributionRate} %</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="border-t border-line-1 px-4 py-3">
|
||||
<p className="mb-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Équipes
|
||||
</p>
|
||||
<ul className="mb-3 flex flex-wrap gap-1.5">
|
||||
{location.teams.length === 0 ? (
|
||||
<li className="text-sm text-ink-3">Aucune équipe</li>
|
||||
) : (
|
||||
location.teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<Badge tone="neutral">{team.name}</Badge>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<AddTeamForm locationId={location.id} />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouvel établissement" />
|
||||
<div className="p-4">
|
||||
<AddLocationForm />
|
||||
</div>
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -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<ActionState, FormData>(
|
||||
addLegalEntryAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Domaine</span>
|
||||
<select
|
||||
name="domain"
|
||||
required
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
>
|
||||
{LEGAL_DOMAINS.map((domain) => (
|
||||
<option key={domain.key} value={domain.key}>
|
||||
{domain.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Field
|
||||
label="Paramètre"
|
||||
name="key"
|
||||
required
|
||||
placeholder="Durée quotidienne maximale"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Valeur" name="value" required placeholder="10 h" />
|
||||
<Field
|
||||
label="Source"
|
||||
name="source"
|
||||
required
|
||||
placeholder="IDCC 1517, texte consolidé Legifrance du…"
|
||||
hint="Texte, article, ou référence de l’accord. Une valeur sans source n’est pas opposable."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Date d’effet"
|
||||
name="effectiveFrom"
|
||||
type="date"
|
||||
required
|
||||
defaultValue="2026-01-01"
|
||||
/>
|
||||
<Field
|
||||
label="Population"
|
||||
name="population"
|
||||
required
|
||||
defaultValue="Tous les salariés"
|
||||
placeholder="Cadres autonomes niveaux VII à IX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Consigner</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">
|
||||
Paramètre consigné — il reste à approuver.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>(
|
||||
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 (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Registre de paramétrage juridique"
|
||||
subtitle={`${register.approvedCount} paramètre${register.approvedCount > 1 ? 's' : ''} approuvé${register.approvedCount > 1 ? 's' : ''} · ${register.pendingCount} en attente`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
|
||||
<p>
|
||||
Chaque paramètre appliqué par PlanFlow doit porter sa{' '}
|
||||
<strong className="font-semibold text-ink-1">valeur</strong>, sa{' '}
|
||||
<strong className="font-semibold text-ink-1">source</strong>, sa{' '}
|
||||
<strong className="font-semibold text-ink-1">date d’effet</strong>,
|
||||
la <strong className="font-semibold text-ink-1">population</strong>{' '}
|
||||
concernée et un{' '}
|
||||
<strong className="font-semibold text-ink-1">approbateur</strong>.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{register.missingDomains.length > 0 ? (
|
||||
<Card className="border-warn">
|
||||
<CardHeader
|
||||
title="Domaines sans paramètre approuvé"
|
||||
badge={<Badge tone="warn">{register.missingDomains.length}</Badge>}
|
||||
/>
|
||||
<ul className="flex flex-wrap gap-1.5 p-4">
|
||||
{register.missingDomains.map((domain) => (
|
||||
<li key={domain}>
|
||||
<Badge tone="warn">{domain}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Paramètres enregistrés" />
|
||||
{register.entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Registre vide"
|
||||
description="Aucun paramètre n’a encore été consigné. Tant que le registre est vide, aucune valeur appliquée par l’application n’est justifiée."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
<th className="px-4 py-2.5">Domaine</th>
|
||||
<th className="px-4 py-2.5">Paramètre</th>
|
||||
<th className="px-4 py-2.5">Valeur</th>
|
||||
<th className="px-4 py-2.5">Source</th>
|
||||
<th className="px-4 py-2.5">Effet</th>
|
||||
<th className="px-4 py-2.5">Population</th>
|
||||
<th className="px-4 py-2.5">Approbation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{register.entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{DOMAIN_LABELS.get(entry.domain) ?? entry.domain}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium">{entry.key}</td>
|
||||
<td className="tnum px-4 py-2.5">{entry.value}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{entry.source}</td>
|
||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
||||
{dateFormat.format(entry.effectiveFrom)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{entry.population}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{entry.approvedAt ? (
|
||||
<Badge tone="ok">
|
||||
Approuvé le {dateFormat.format(entry.approvedAt)}
|
||||
</Badge>
|
||||
) : (
|
||||
<form action={approveLegalEntryAction}>
|
||||
<input type="hidden" name="id" value={entry.id} />
|
||||
<Button size="sm" type="submit">
|
||||
Approuver
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Consigner un paramètre" />
|
||||
<div className="p-4">
|
||||
<AddEntryForm />
|
||||
</div>
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<HTMLInputElement> {
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function Field({ label, hint, className, ...rest }: FieldProps) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<input
|
||||
{...rest}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Button type="submit" variant="primary" disabled={pending} {...rest}>
|
||||
{pending ? 'Enregistrement…' : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormError({ children }: { children: ReactNode }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs text-danger-soft-ink"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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<SessionContext> {
|
||||
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<T>(
|
||||
permission: PermissionCode,
|
||||
fn: (db: ScopedClient, actor: Actor) => Promise<T>,
|
||||
resource?: ResourceRef,
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
permission: PermissionCode,
|
||||
fn: (db: ScopedClient, actor: Actor) => Promise<T>,
|
||||
resource?: ResourceRef,
|
||||
): Promise<T> {
|
||||
return query(permission, fn, resource);
|
||||
}
|
||||
@@ -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<LegalRegisterView> {
|
||||
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<ActionState> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
@@ -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<LocationRow[]> {
|
||||
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<ActionState> {
|
||||
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<void> {
|
||||
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<ActionState> {
|
||||
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 };
|
||||
}
|
||||
+19
-14
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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_]+)+$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user