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.
|
// Parcours d'authentification : doit partir d'un navigateur vierge.
|
||||||
{
|
{
|
||||||
name: 'anonyme',
|
name: 'anonyme',
|
||||||
testMatch: /auth\.spec\.ts/,
|
testMatch: /(auth|reglages)\.spec\.ts/,
|
||||||
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
|
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
testIgnore: /auth\.(setup|spec)\.ts/,
|
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec)\.ts/,
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup'],
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...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[]
|
auditLogs AuditLog[]
|
||||||
retention RetentionPolicy[]
|
retention RetentionPolicy[]
|
||||||
featureFlags FeatureFlag[]
|
featureFlags FeatureFlag[]
|
||||||
|
jobTitles JobTitle[]
|
||||||
|
labels Label[]
|
||||||
|
absenceTypes AbsenceType[]
|
||||||
|
legalConfig LegalConfigEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Location {
|
model Location {
|
||||||
@@ -286,3 +290,90 @@ model FeatureFlag {
|
|||||||
|
|
||||||
@@unique([accountId, key])
|
@@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',
|
id: 'reglages',
|
||||||
label: 'Réglages',
|
label: 'Réglages',
|
||||||
items: [
|
items: [
|
||||||
|
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
|
||||||
|
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
|
||||||
{ id: 'convention', label: 'Convention collective' },
|
{ id: 'convention', label: 'Convention collective' },
|
||||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||||
{ id: 'sites', label: 'Établissements' },
|
|
||||||
{ id: 'roles', label: 'Rôles et permissions' },
|
{ 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';
|
import { prisma } from '@/server/db';
|
||||||
|
|
||||||
@@ -17,18 +17,24 @@ import { prisma } from '@/server/db';
|
|||||||
* dire pourquoi ; la seconde seule tomberait avec le premier `$queryRaw`.
|
* dire pourquoi ; la seconde seule tomberait avec le premier `$queryRaw`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Tables portant une colonne `accountId`. */
|
/**
|
||||||
const SCOPED_MODELS = new Set([
|
* Modèles portant une colonne `accountId`, **dérivés du schéma**.
|
||||||
'Location',
|
*
|
||||||
'Team',
|
* Une liste tenue à la main se périme au premier modèle ajouté, et l'oubli est
|
||||||
'Membership',
|
* silencieux : le scoping ne s'applique plus, et selon les cas la requête
|
||||||
'MembershipScope',
|
* échoue avec un message obscur ou — bien pire — réussit sans filtre.
|
||||||
'Invitation',
|
* La dériver du DMMF supprime le mode de défaillance plutôt que de compter sur
|
||||||
'Role',
|
* la vigilance.
|
||||||
'AuditLog',
|
*/
|
||||||
'RetentionPolicy',
|
const SCOPED_MODELS = new Set(
|
||||||
'FeatureFlag',
|
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([
|
const READ_OPERATIONS = new Set([
|
||||||
'findFirst',
|
'findFirst',
|
||||||
@@ -135,4 +141,3 @@ export function unscoped(): PrismaClient {
|
|||||||
return prisma;
|
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);
|
).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