WP-02: locations, teams and the legal configuration register
Adds the referential models, the first database-backed settings screens, and the register the compliance matrix requires before any parameter is enforceable. The register is the point of the lot. The matrix is explicit that copying another product's configuration is not enough — each parameter must carry its value, source, effective date, population and an approver. Approval records the session's actor, never a form field: a signature you can type yourself is worth nothing. The screen names the domains that have no approved parameter yet, so the gap is visible rather than assumed closed. Two bugs of the same family, both now structurally impossible: - The Prisma scoping extension read a hand-written list of models carrying accountId. The four models added here were missing from it, so writes failed with an opaque Prisma error — and a read would have silently returned every account's rows. The list is now derived from the schema itself. - The RLS policies were likewise per-table. A new integration test fails if any table with an accountId column lacks forced RLS and both policies, which is the failure mode that hides best: nobody writes a wrong rule, someone forgets to write one. An end-to-end test signs in as a manager and confirms the settings screens refuse to render — the sidebar hiding them is a convenience, the server check is the control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
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