Configurer les rôles et leurs capacités
Critère d'acceptation de WP-01 resté sans écran : « un rôle personnalisé créé par un client modifie effectivement l'accès, sans changement de code ». Le catalogue de capacités existait, l'autorisation s'y adossait déjà, mais seul le semis pouvait attribuer quoi que ce soit. Deux dangers opposés, et il faut se protéger des deux. L'escalade d'abord : on ne peut accorder qu'une capacité qu'on détient soi-même, sans quoi la première personne autorisée à éditer un rôle s'accorde l'accès aux rémunérations en trois clics et le catalogue ne sert plus à rien. Le retrait, lui, reste permis même sur une capacité qu'on n'a pas — réduire un droit n'a jamais élargi le sien, et l'interdire empêcherait de corriger un rôle trop large. Le verrouillage ensuite : au moins un rôle doit conserver la gestion des droits. La condition se juge sur l'ensemble des rôles après coup, non sur celui qu'on édite — ce qui compte n'est pas que ce rôle-ci garde la capacité, mais qu'un rôle la garde. Sans cela une organisation se fermerait dehors, et le seul recours serait une intervention en base. Un rôle naît sans aucune capacité : en hériter de celles de son créateur distribuerait des droits que personne n'a demandés. Le niveau propriétaire se délègue à part. Un rôle système ne se supprime pas, un rôle attribué non plus — ses membres se retrouveraient dehors sans que personne l'ait décidé pour eux. Le test va jusqu'au bout : créer le rôle, l'attribuer, se connecter, constater qu'un écran s'ouvre et qu'un autre reste fermé. S'arrêter à « la case est cochée » n'aurait rien dit du critère. Trois défauts d'isolation des tests corrigés au passage, tous du même genre — un état qui s'accumule d'une exécution à l'autre : - la file des congés désignait « la première ligne de ce salarié » pour son nettoyage, et tombait sur une ligne laissée par un passage antérieur ; - l'écran de conservation tronquait à quarante pièces triées par ancienneté, faisant disparaître les pièces échues derrière celles dont il n'y a rien à dire — corrigé dans le produit, pas seulement dans le test ; - le test de verrouillage de période consommait un mois par exécution sans jamais le rendre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
12 files changed
+1097
-12
No files matched your search
@@ -34,6 +34,18 @@ export default async function RetentionPage() {
|
||||
const due = candidates.filter((entry) => entry.verdict === 'DUE');
|
||||
const unpolicied = candidates.filter((entry) => entry.verdict === 'NO_POLICY');
|
||||
|
||||
// Ce qui appelle une décision d'abord. Trier par date de dépôt puis tronquer
|
||||
// ferait disparaître les pièces échues derrière les plus anciennes, qui sont
|
||||
// justement celles dont il n'y a rien à dire.
|
||||
const PRIORITY: Record<string, number> = { DUE: 0, HELD: 1, NOT_COMPUTABLE: 2 };
|
||||
const shown = [...candidates]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(PRIORITY[a.verdict] ?? 3) - (PRIORITY[b.verdict] ?? 3) ||
|
||||
b.uploadedAt.getTime() - a.uploadedAt.getTime(),
|
||||
)
|
||||
.slice(0, 40);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
@@ -130,7 +142,7 @@ export default async function RetentionPage() {
|
||||
<p className="text-sm text-ink-3">Aucune pièce déposée.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{candidates.slice(0, 40).map((candidate) => (
|
||||
{shown.map((candidate) => (
|
||||
<li key={candidate.id} className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
tone={
|
||||
@@ -155,6 +167,13 @@ export default async function RetentionPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{candidates.length > shown.length ? (
|
||||
<p className="text-micro text-ink-3">
|
||||
{candidates.length - shown.length} pièce(s) de plus, non affichées.
|
||||
Les pièces échues et suspendues figurent toujours en tête.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{unpolicied.length > 0 ? (
|
||||
<p className="text-micro text-ink-3">
|
||||
{unpolicied.length} pièce(s) sans politique déclarée : elles ne
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
CreateRoleForm,
|
||||
DeleteRoleForm,
|
||||
RolePermissionsForm,
|
||||
type CapabilityView,
|
||||
type RoleView,
|
||||
} from '@/components/settings/RoleEditor';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { query } from '@/server/context';
|
||||
|
||||
export const metadata = { title: 'Rôles et permissions · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Rôles et capacités.
|
||||
*
|
||||
* Critère d'acceptation de WP-01 : un rôle personnalisé créé par un client doit
|
||||
* modifier effectivement l'accès, sans changement de code. Le code ne teste
|
||||
* jamais un nom de rôle, seulement des capacités — c'est ce qui rend cet écran
|
||||
* possible.
|
||||
*/
|
||||
export default async function RolesPage() {
|
||||
const { roles, capabilities } = await query(
|
||||
'settings.roles.manage',
|
||||
async (db, actor) => {
|
||||
const [rows, permissions] = await Promise.all([
|
||||
db.role.findMany({
|
||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
_count: { select: { memberships: true } },
|
||||
},
|
||||
}),
|
||||
db.permission.findMany({ orderBy: [{ category: 'asc' }, { code: 'asc' }] }),
|
||||
]);
|
||||
|
||||
return {
|
||||
roles: rows.map(
|
||||
(role): RoleView => ({
|
||||
id: role.id,
|
||||
key: role.key,
|
||||
name: role.name,
|
||||
isSystem: role.isSystem,
|
||||
memberCount: role._count.memberships,
|
||||
granted: role.permissions.map((entry) => entry.permission.code),
|
||||
}),
|
||||
),
|
||||
capabilities: permissions.map(
|
||||
(permission): CapabilityView => ({
|
||||
code: permission.code,
|
||||
category: permission.category,
|
||||
label: permission.label,
|
||||
// Calculé côté serveur depuis l'acteur : le client n'a pas à
|
||||
// décider ce qu'il a le droit d'accorder.
|
||||
grantable: actor.permissions.has(permission.code),
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Rôles et permissions"
|
||||
subtitle="Le code teste des capacités, jamais un nom de rôle : renommer « Manager » ou en créer un cinquième ne casse rien."
|
||||
actions={<Badge tone="neutral">{roles.length} rôles</Badge>}
|
||||
/>
|
||||
|
||||
<section className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm text-ink-2">
|
||||
<p>
|
||||
Vous ne pouvez accorder que les capacités que vous détenez vous-même —
|
||||
sinon cet écran suffirait à s’octroyer l’accès aux rémunérations.
|
||||
Retirer reste possible : réduire un droit n’a jamais élargi le sien.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Au moins un rôle doit conserver « Gérer les rôles », faute de quoi
|
||||
l’organisation se fermerait dehors et le seul recours serait une
|
||||
intervention en base.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Créer un rôle" />
|
||||
<div className="p-4">
|
||||
<CreateRoleForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{roles.map((role) => (
|
||||
<Card key={role.id}>
|
||||
<CardHeader
|
||||
title={role.name}
|
||||
badge={<Badge tone="neutral">{role.granted.length}</Badge>}
|
||||
/>
|
||||
<RolePermissionsForm role={role} capabilities={capabilities} />
|
||||
<DeleteRoleForm role={role} />
|
||||
</Card>
|
||||
))}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
OWNER_LEVEL_CAPABILITY,
|
||||
ROLE_ADMIN_CAPABILITY,
|
||||
} from '@/domain/access/role-editing';
|
||||
import {
|
||||
createRoleAction,
|
||||
deleteRoleAction,
|
||||
saveRolePermissionsAction,
|
||||
type RoleState,
|
||||
} from '@/server/roles/actions';
|
||||
|
||||
const empty: RoleState = {};
|
||||
|
||||
export interface CapabilityView {
|
||||
code: string;
|
||||
category: string;
|
||||
label: string;
|
||||
/** L'acteur courant la détient : il peut donc l'accorder. */
|
||||
grantable: boolean;
|
||||
}
|
||||
|
||||
export interface RoleView {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
isSystem: boolean;
|
||||
memberCount: number;
|
||||
granted: string[];
|
||||
}
|
||||
|
||||
export function CreateRoleForm() {
|
||||
const [state, action, pending] = useActionState(createRoleAction, empty);
|
||||
|
||||
return (
|
||||
<form action={action} className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Nom du rôle
|
||||
</span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
placeholder="Responsable de secteur"
|
||||
className="h-8 w-64 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">
|
||||
Créé sans aucune capacité : un rôle neuf qui hériterait des vôtres
|
||||
distribuerait des droits que personne n’a demandés.
|
||||
</span>
|
||||
</label>
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
Créer
|
||||
</Button>
|
||||
<Messages state={state} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capacités d'un rôle.
|
||||
*
|
||||
* Les cases qu'on ne détient pas soi-même sont désactivées et le disent : on ne
|
||||
* peut accorder que ce qu'on a. Retirer reste possible — réduire un droit n'a
|
||||
* jamais élargi le sien.
|
||||
*/
|
||||
export function RolePermissionsForm({
|
||||
role,
|
||||
capabilities,
|
||||
}: {
|
||||
role: RoleView;
|
||||
capabilities: CapabilityView[];
|
||||
}) {
|
||||
const [state, action, pending] = useActionState(
|
||||
saveRolePermissionsAction,
|
||||
empty,
|
||||
);
|
||||
const granted = new Set(role.granted);
|
||||
|
||||
const categories = [...new Set(capabilities.map((c) => c.category))];
|
||||
|
||||
return (
|
||||
<form
|
||||
action={action}
|
||||
// Repère stable : les libellés de capacités contiennent les mots des
|
||||
// rôles (« niveau propriétaire »), et une recherche par texte désigne
|
||||
// alors plusieurs cartes à la fois.
|
||||
data-testid={`role-${role.key}`}
|
||||
className="flex flex-col gap-4 p-4"
|
||||
>
|
||||
<input type="hidden" name="roleId" value={role.id} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium text-ink-1">{role.name}</span>
|
||||
<Badge tone="neutral">{role.key}</Badge>
|
||||
{role.isSystem ? <Badge tone="info">Rôle système</Badge> : null}
|
||||
<span className="text-micro text-ink-3">
|
||||
{role.memberCount} membre{role.memberCount > 1 ? 's' : ''} ·{' '}
|
||||
{role.granted.length} capacité{role.granted.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
|
||||
{categories.map((category) => (
|
||||
<fieldset key={category} className="flex flex-col gap-1">
|
||||
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
{category}
|
||||
</legend>
|
||||
{capabilities
|
||||
.filter((capability) => capability.category === category)
|
||||
.map((capability) => {
|
||||
const isGranted = granted.has(capability.code);
|
||||
// Interdire de décocher ce qu'on ne peut pas re-cocher éviterait
|
||||
// un retrait qu'on ne saurait pas défaire — mais empêcherait
|
||||
// aussi de réduire un rôle trop large. Le retrait reste permis.
|
||||
const locked = !capability.grantable && !isGranted;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={capability.code}
|
||||
className="flex items-start gap-2 text-sm text-ink-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permission"
|
||||
value={capability.code}
|
||||
defaultChecked={isGranted}
|
||||
disabled={locked}
|
||||
className="mt-0.5 size-4"
|
||||
/>
|
||||
<span className={locked ? 'text-ink-3' : undefined}>
|
||||
{capability.label}
|
||||
{capability.code === ROLE_ADMIN_CAPABILITY ? (
|
||||
<span className="block text-micro text-ink-3">
|
||||
Au moins un rôle doit la conserver.
|
||||
</span>
|
||||
) : null}
|
||||
{capability.code === OWNER_LEVEL_CAPABILITY ? (
|
||||
<span className="block text-micro text-ink-3">
|
||||
Ne se délègue que par un propriétaire.
|
||||
</span>
|
||||
) : null}
|
||||
{locked ? (
|
||||
<span className="block text-micro text-ink-3">
|
||||
Vous ne la détenez pas.
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Messages state={state} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteRoleForm({ role }: { role: RoleView }) {
|
||||
const [state, action, pending] = useActionState(deleteRoleAction, empty);
|
||||
|
||||
if (role.isSystem) return null;
|
||||
|
||||
return (
|
||||
<form
|
||||
action={action}
|
||||
data-testid={`role-delete-${role.key}`}
|
||||
className="flex items-center gap-3 px-4 pb-4"
|
||||
>
|
||||
<input type="hidden" name="roleId" value={role.id} />
|
||||
<Button type="submit" disabled={pending || role.memberCount > 0}>
|
||||
Supprimer ce rôle
|
||||
</Button>
|
||||
{role.memberCount > 0 ? (
|
||||
<span className="text-micro text-ink-3">
|
||||
Réaffectez ses {role.memberCount} membre(s) d’abord.
|
||||
</span>
|
||||
) : null}
|
||||
<Messages state={state} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Messages({ state }: { state: RoleState }) {
|
||||
return (
|
||||
<>
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
{state.message ? (
|
||||
<span className="text-xs text-ok-soft-ink">{state.message}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export const NAVIGATION: NavSection[] = [
|
||||
},
|
||||
{ id: 'convention', label: 'Convention collective' },
|
||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||
{ id: 'roles', label: 'Rôles et permissions' },
|
||||
{ id: 'roles', label: 'Rôles et permissions', href: '/reglages/roles' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Actor } from '@/domain/access/authorize';
|
||||
|
||||
/**
|
||||
* Garde-fous de l'édition des rôles — PLAN.md §5.
|
||||
*
|
||||
* Un écran qui distribue les droits est celui par lequel on prend le contrôle
|
||||
* d'une application. Deux dangers, opposés, et il faut se protéger des deux :
|
||||
*
|
||||
* - **l'escalade** — s'accorder une capacité qu'on n'a pas ;
|
||||
* - **le verrouillage** — retirer la dernière capacité qui permettait encore de
|
||||
* revenir en arrière, et fermer les réglages à tout le monde.
|
||||
*/
|
||||
|
||||
/** Capacité sans laquelle plus personne ne peut redistribuer les droits. */
|
||||
export const ROLE_ADMIN_CAPABILITY = 'settings.roles.manage';
|
||||
|
||||
/** Capacité qui confère le niveau propriétaire, et se délègue à part. */
|
||||
export const OWNER_LEVEL_CAPABILITY = 'role_config.assign_owner_level';
|
||||
|
||||
export type EditRefusal =
|
||||
| 'ESCALATION'
|
||||
| 'OWNER_LEVEL'
|
||||
| 'LAST_ADMIN'
|
||||
| 'SYSTEM_ROLE'
|
||||
| 'DUPLICATE_KEY'
|
||||
| 'ROLE_IN_USE'
|
||||
| 'NOT_FOUND';
|
||||
|
||||
export const REFUSAL_MESSAGES: Record<EditRefusal, string> = {
|
||||
ESCALATION:
|
||||
'Vous ne pouvez pas accorder une capacité que vous ne détenez pas vous-même.',
|
||||
OWNER_LEVEL:
|
||||
'Le niveau propriétaire ne se délègue que par un propriétaire.',
|
||||
LAST_ADMIN:
|
||||
'Ce retrait fermerait la gestion des droits à tout le monde : au moins un rôle doit conserver « Gérer les rôles ».',
|
||||
SYSTEM_ROLE:
|
||||
'Un rôle système ne se supprime pas : le code et le semis y font référence par sa clé.',
|
||||
DUPLICATE_KEY: 'Un rôle porte déjà ce nom.',
|
||||
ROLE_IN_USE:
|
||||
'Ce rôle est encore attribué. Réaffectez ses membres avant de le supprimer.',
|
||||
NOT_FOUND: 'Rôle introuvable.',
|
||||
};
|
||||
|
||||
export interface GrantContext {
|
||||
actor: Actor;
|
||||
/** Capacités que le rôle portera après enregistrement. */
|
||||
next: ReadonlySet<string>;
|
||||
/** Capacités qu'il portait avant. */
|
||||
previous: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refus d'escalade.
|
||||
*
|
||||
* On ne peut accorder que ce qu'on détient. Sans cette règle, la première
|
||||
* personne autorisée à éditer un rôle s'accorderait l'accès aux rémunérations
|
||||
* en trois clics — et le catalogue de capacités ne servirait plus à rien.
|
||||
*
|
||||
* Retirer reste permis même sur une capacité qu'on n'a pas : réduire un droit
|
||||
* n'a jamais élargi le sien.
|
||||
*/
|
||||
export function escalationRefusal({
|
||||
actor,
|
||||
next,
|
||||
previous,
|
||||
}: GrantContext): EditRefusal | null {
|
||||
const added = [...next].filter((code) => !previous.has(code));
|
||||
|
||||
for (const code of added) {
|
||||
if (code === OWNER_LEVEL_CAPABILITY && !actor.permissions.has(code)) {
|
||||
return 'OWNER_LEVEL';
|
||||
}
|
||||
if (!actor.permissions.has(code)) return 'ESCALATION';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface LockoutContext {
|
||||
/** Rôles du compte avec leurs capacités, **après** l'enregistrement envisagé. */
|
||||
rolesAfter: ReadonlyArray<{ id: string; permissions: ReadonlySet<string> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refus de verrouillage.
|
||||
*
|
||||
* Compté après coup, sur l'ensemble des rôles : ce qui compte n'est pas que
|
||||
* *ce* rôle garde la capacité, mais qu'au moins un la conserve. Sans cela, une
|
||||
* organisation pourrait s'enfermer dehors, et le seul recours serait une
|
||||
* intervention en base.
|
||||
*/
|
||||
export function lockoutRefusal({
|
||||
rolesAfter,
|
||||
}: LockoutContext): EditRefusal | null {
|
||||
const remaining = rolesAfter.filter((role) =>
|
||||
role.permissions.has(ROLE_ADMIN_CAPABILITY),
|
||||
);
|
||||
return remaining.length === 0 ? 'LAST_ADMIN' : null;
|
||||
}
|
||||
|
||||
/** Clé technique d'un rôle créé par le client. */
|
||||
export function slugifyRoleKey(name: string): string {
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { PERMISSION_CODES } from '@/domain/access/permissions';
|
||||
import {
|
||||
escalationRefusal,
|
||||
lockoutRefusal,
|
||||
REFUSAL_MESSAGES,
|
||||
slugifyRoleKey,
|
||||
type EditRefusal,
|
||||
} from '@/domain/access/role-editing';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Édition des rôles — PLAN.md §5.
|
||||
*
|
||||
* Critère d'acceptation de WP-01 : « un rôle personnalisé créé par un client
|
||||
* modifie effectivement l'accès, sans changement de code ». C'est ce que cet
|
||||
* écran rend possible — et c'est aussi celui par lequel on prendrait le
|
||||
* contrôle de l'application, d'où les garde-fous.
|
||||
*/
|
||||
|
||||
export interface RoleState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class Refused extends Error {
|
||||
constructor(readonly refusal: EditRefusal) {
|
||||
super(REFUSAL_MESSAGES[refusal]);
|
||||
}
|
||||
}
|
||||
|
||||
const createInput = z.object({
|
||||
name: z.string().trim().min(2, 'Nom du rôle requis').max(60),
|
||||
});
|
||||
|
||||
export async function createRoleAction(
|
||||
_previous: RoleState,
|
||||
formData: FormData,
|
||||
): Promise<RoleState> {
|
||||
const parsed = createInput.safeParse({ name: formData.get('name') });
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const key = slugifyRoleKey(parsed.data.name);
|
||||
if (!key) {
|
||||
return { error: 'Ce nom ne produit aucune clé utilisable. Employez des lettres.' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.roles.manage', async (db, actor) => {
|
||||
const existing = await db.role.findFirst({ where: { key } });
|
||||
if (existing) throw new Refused('DUPLICATE_KEY');
|
||||
|
||||
// Créé **sans aucune capacité**. Un rôle neuf qui hériterait de celles de
|
||||
// son créateur distribuerait des droits que personne n'a demandés.
|
||||
const role = await db.role.create({
|
||||
data: { key, name: parsed.data.name, isSystem: false } as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'role.create',
|
||||
entityType: 'Role',
|
||||
entityId: role.id,
|
||||
after: { key, name: parsed.data.name },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de gérer les rôles.");
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/roles');
|
||||
return { ok: true, message: `Rôle « ${parsed.data.name} » créé, sans aucune capacité.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre les capacités d'un rôle.
|
||||
*
|
||||
* Les cases envoyées **remplacent** la liste : le formulaire porte l'état
|
||||
* complet, ce qui évite d'avoir à distinguer ajout et retrait côté client — et
|
||||
* ce qui rend les deux contrôles, escalade et verrouillage, calculables d'un
|
||||
* seul coup.
|
||||
*/
|
||||
export async function saveRolePermissionsAction(
|
||||
_previous: RoleState,
|
||||
formData: FormData,
|
||||
): Promise<RoleState> {
|
||||
const roleId = String(formData.get('roleId') ?? '');
|
||||
if (!roleId) return { error: 'Rôle introuvable.' };
|
||||
|
||||
const requested = new Set(
|
||||
formData
|
||||
.getAll('permission')
|
||||
.map(String)
|
||||
.filter((code) => (PERMISSION_CODES as readonly string[]).includes(code)),
|
||||
);
|
||||
|
||||
try {
|
||||
await mutate('settings.roles.manage', async (db, actor) => {
|
||||
const role = await db.role.findUnique({
|
||||
where: { id: roleId },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
if (!role) throw new Refused('NOT_FOUND');
|
||||
|
||||
const previous = new Set(
|
||||
role.permissions.map((entry) => entry.permission.code),
|
||||
);
|
||||
|
||||
const escalation = escalationRefusal({
|
||||
actor,
|
||||
next: requested,
|
||||
previous,
|
||||
});
|
||||
if (escalation) throw new Refused(escalation);
|
||||
|
||||
// Le verrouillage se juge sur l'ensemble des rôles après coup : ce qui
|
||||
// compte n'est pas que *ce* rôle garde la capacité, mais qu'au moins un
|
||||
// la conserve.
|
||||
const allRoles = await db.role.findMany({
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
const rolesAfter = allRoles.map((other) => ({
|
||||
id: other.id,
|
||||
permissions:
|
||||
other.id === roleId
|
||||
? requested
|
||||
: new Set(other.permissions.map((entry) => entry.permission.code)),
|
||||
}));
|
||||
|
||||
const lockout = lockoutRefusal({ rolesAfter });
|
||||
if (lockout) throw new Refused(lockout);
|
||||
|
||||
const permissions = await db.permission.findMany({
|
||||
where: { code: { in: [...requested] } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await db.rolePermission.deleteMany({ where: { roleId } });
|
||||
if (permissions.length > 0) {
|
||||
await db.rolePermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
roleId,
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'role.permissions.update',
|
||||
entityType: 'Role',
|
||||
entityId: roleId,
|
||||
before: { count: previous.size, codes: [...previous].sort() },
|
||||
after: { count: requested.size, codes: [...requested].sort() },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de gérer les rôles.");
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/roles');
|
||||
return { ok: true, message: 'Capacités enregistrées.' };
|
||||
}
|
||||
|
||||
export async function deleteRoleAction(
|
||||
_previous: RoleState,
|
||||
formData: FormData,
|
||||
): Promise<RoleState> {
|
||||
const roleId = String(formData.get('roleId') ?? '');
|
||||
if (!roleId) return { error: 'Rôle introuvable.' };
|
||||
|
||||
try {
|
||||
await mutate('settings.roles.manage', async (db, actor) => {
|
||||
const role = await db.role.findUnique({
|
||||
where: { id: roleId },
|
||||
include: { memberships: { select: { id: true }, take: 1 } },
|
||||
});
|
||||
if (!role) throw new Refused('NOT_FOUND');
|
||||
|
||||
// Un rôle système est référencé par le code et par le semis : le
|
||||
// supprimer casserait la création d'un salarié.
|
||||
if (role.isSystem) throw new Refused('SYSTEM_ROLE');
|
||||
|
||||
// Supprimer un rôle attribué laisserait des salariés sans capacités, donc
|
||||
// dehors, sans que personne ne l'ait décidé pour eux.
|
||||
if (role.memberships.length > 0) throw new Refused('ROLE_IN_USE');
|
||||
|
||||
await db.role.delete({ where: { id: roleId } });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'role.delete',
|
||||
entityType: 'Role',
|
||||
entityId: roleId,
|
||||
before: { key: role.key, name: role.name },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de gérer les rôles.");
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/roles');
|
||||
return { ok: true, message: 'Rôle supprimé.' };
|
||||
}
|
||||
|
||||
function toState(error: unknown, denied: string): RoleState {
|
||||
if (error instanceof Refused) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -31,6 +31,12 @@ function isoDate(offsetDays: number): string {
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** Format affiché sur la ligne d'absence. */
|
||||
function frenchDate(iso: string): string {
|
||||
const [year, month, day] = iso.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
/** Prochain jour de la semaine demandé (0 = dimanche) dans la fenêtre du run. */
|
||||
function nextWeekday(weekday: number): string {
|
||||
const cursor = new Date(Date.now() + (RUN_OFFSET + 17) * 86_400_000);
|
||||
@@ -150,15 +156,19 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
|
||||
|
||||
// Puis on libère les dates : annuler contre-passe la prise sans rien
|
||||
// effacer, ce qui est exactement le comportement voulu en production.
|
||||
//
|
||||
// La ligne est visée par **ses** dates, pas par « la première de ce
|
||||
// salarié » : les passages précédents en laissent d'autres dans le même
|
||||
// mois, et certaines n'offrent plus de bouton d'annulation.
|
||||
await page.goto(`/conges?mois=${from.slice(0, 7)}`);
|
||||
const accepted = page
|
||||
.locator('section')
|
||||
.filter({ hasText: 'Absences du mois' })
|
||||
.locator('li')
|
||||
.filter({ hasText: WHO.queue })
|
||||
.first();
|
||||
if (await accepted.isVisible()) {
|
||||
await accepted.getByRole('button', { name: 'Annuler' }).click();
|
||||
.filter({ hasText: `du ${frenchDate(from)} au ${frenchDate(to)}` });
|
||||
|
||||
if ((await accepted.count()) > 0) {
|
||||
await accepted.first().getByRole('button', { name: 'Annuler' }).click();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -180,15 +180,14 @@ test('une pièce échue est effectivement effacée', async ({ page }) => {
|
||||
await upload.getByRole('button', { name: 'Déposer' }).click();
|
||||
await expect(page.getByText(`« ${fileName} » déposé.`)).toBeVisible();
|
||||
|
||||
// Fraîchement déposée, elle n'est pas échue : la purge ne doit pas l'emporter.
|
||||
await page.goto('/reglages/conservation');
|
||||
await expect(
|
||||
page.locator('li').filter({ hasText: fileName }).getByText('Échéance non atteinte'),
|
||||
).toBeVisible();
|
||||
// Fraîchement déposée, elle n'est pas échue. Constaté sur le dossier, qui ne
|
||||
// liste que les pièces de ce salarié : l'écran de conservation tronque, et la
|
||||
// pièce d'un test neuf n'y figure pas tant qu'elle n'appelle aucune décision.
|
||||
await expect(page.getByText(/conservée jusqu’au/)).toBeVisible();
|
||||
|
||||
// Seule l'échéance déclenche l'effacement, et elle se compte en mois.
|
||||
await backdateDocument(fileName, 3);
|
||||
await page.reload();
|
||||
await page.goto('/reglages/conservation');
|
||||
await expect(
|
||||
page.locator('li').filter({ hasText: fileName }).getByText('À purger'),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -86,6 +86,18 @@ test('verrouiller une période ferme le mois aux modifications', async ({
|
||||
.fill('Correction demandée par le cabinet');
|
||||
await unlock.getByRole('button', { name: 'Déverrouiller' }).click();
|
||||
await expect(card.getByText('Ouverte')).toBeVisible();
|
||||
|
||||
// Rendre le mois : une période est unique par bornes, et en consommer une par
|
||||
// exécution finit par épuiser la plage — le test échouerait alors sur une
|
||||
// création refusée, pour une raison sans rapport avec ce qu'il vérifie.
|
||||
const remove = card.locator('form').filter({ hasText: 'Supprimer' });
|
||||
await remove.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(remove.getByText(/saisissez le libellé exact/i)).toBeVisible();
|
||||
await remove.getByPlaceholder(`Saisir « ${label} »`).fill(label);
|
||||
await remove.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('section').filter({ hasText: label })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('supprimer une période exige de retaper son libellé', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import { slugifyRoleKey } from '../../src/domain/access/role-editing';
|
||||
|
||||
/**
|
||||
* Rôles et permissions — critère d'acceptation de WP-01.
|
||||
*
|
||||
* « Un rôle personnalisé créé par un client modifie effectivement l'accès, sans
|
||||
* changement de code. » Le vérifier suppose d'aller jusqu'au bout : créer le
|
||||
* rôle, l'attribuer, et constater qu'un écran s'ouvre ou se ferme en
|
||||
* conséquence. Un test qui s'arrêterait à « la case est cochée » ne dirait rien.
|
||||
*/
|
||||
|
||||
const OWNER_PASSWORD = 'planflow-demo-2026';
|
||||
|
||||
function roleName(tag: string): string {
|
||||
return `Rôle ${tag} ${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte d'un rôle, par sa **clé**.
|
||||
*
|
||||
* Ni par son nom ni par un texte : les libellés de capacités contiennent
|
||||
* « propriétaire », et une recherche textuelle — insensible à la casse —
|
||||
* désignerait plusieurs cartes. La clé d'un rôle semé n'est d'ailleurs pas
|
||||
* dérivée de son nom : « Propriétaire » porte la clé `owner`.
|
||||
*/
|
||||
function roleCard(page: Page, key: string) {
|
||||
return page.getByTestId(`role-${key}`);
|
||||
}
|
||||
|
||||
test('l’écran énonce les deux garde-fous', async ({ page }) => {
|
||||
await page.goto('/reglages/roles');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Rôles et permissions' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/accorder que les capacités que vous détenez/),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Au moins un rôle doit conserver/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('un rôle naît sans aucune capacité', async ({ page }) => {
|
||||
const name = roleName('vierge');
|
||||
await page.goto('/reglages/roles');
|
||||
|
||||
await page.getByLabel('Nom du rôle').fill(name);
|
||||
await page.getByRole('button', { name: 'Créer' }).click();
|
||||
|
||||
// Un rôle neuf qui hériterait des capacités de son créateur distribuerait des
|
||||
// droits que personne n'a demandés.
|
||||
await expect(page.getByText(/sans aucune capacité/)).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
const card = roleCard(page, slugifyRoleKey(name));
|
||||
await expect(card.getByText('0 capacité')).toBeVisible();
|
||||
});
|
||||
|
||||
test('un rôle système ne s’efface pas', async ({ page }) => {
|
||||
await page.goto('/reglages/roles');
|
||||
|
||||
const card = roleCard(page, 'owner');
|
||||
await expect(card.getByText('Rôle système')).toBeVisible();
|
||||
// Le code et le semis y font référence par sa clé.
|
||||
await expect(page.getByTestId('role-delete-owner')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('retirer la dernière gestion des droits est refusé', async ({ page }) => {
|
||||
await page.goto('/reglages/roles');
|
||||
|
||||
// `owner` et `admin` sont les seuls rôles semés à porter la capacité : il
|
||||
// faut la retirer aux deux pour atteindre le refus.
|
||||
const admin = roleCard(page, 'admin');
|
||||
await admin.getByRole('checkbox', { name: /Gérer les rôles/ }).uncheck();
|
||||
await admin.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(admin.getByText('Capacités enregistrées.')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
const owner = roleCard(page, 'owner');
|
||||
await owner.getByRole('checkbox', { name: /Gérer les rôles/ }).uncheck();
|
||||
await owner.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
|
||||
// Le second retrait est celui qui fermerait la porte.
|
||||
await expect(
|
||||
page.getByText(/fermerait la gestion des droits à tout le monde/),
|
||||
).toBeVisible();
|
||||
|
||||
// Remettre la capacité : la suite de tests dépend de son existence.
|
||||
await page.reload();
|
||||
const restored = roleCard(page, 'admin');
|
||||
await restored.getByRole('checkbox', { name: /Gérer les rôles/ }).check();
|
||||
await restored.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(restored.getByText('Capacités enregistrées.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('un rôle client change effectivement l’accès', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const name = roleName('acces');
|
||||
|
||||
await page.goto('/reglages/roles');
|
||||
await page.getByLabel('Nom du rôle').fill(name);
|
||||
await page.getByRole('button', { name: 'Créer' }).click();
|
||||
await expect(page.getByText(/sans aucune capacité/)).toBeVisible();
|
||||
await page.reload();
|
||||
|
||||
// Une seule capacité : voir l'annuaire. Rien d'autre.
|
||||
const card = roleCard(page, slugifyRoleKey(name));
|
||||
await card.getByRole('checkbox', { name: 'Voir l’annuaire' }).check();
|
||||
await card.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(card.getByText('Capacités enregistrées.')).toBeVisible();
|
||||
|
||||
const account = await inviteWithRole(page, name);
|
||||
|
||||
const guest = await browser.newContext({ storageState: undefined });
|
||||
const guestPage = await guest.newPage();
|
||||
await guestPage.goto('/connexion');
|
||||
await guestPage.getByLabel('Adresse électronique').fill(account.email);
|
||||
await guestPage.getByLabel('Mot de passe').fill(account.password);
|
||||
await guestPage.getByRole('button', { name: 'Se connecter' }).click();
|
||||
await guestPage.waitForURL((url) => !url.pathname.endsWith('/connexion'));
|
||||
|
||||
// Ce que le rôle accorde s'ouvre.
|
||||
await guestPage.goto('/equipe');
|
||||
await expect(guestPage.getByRole('heading', { name: 'Équipe' })).toBeVisible();
|
||||
|
||||
// Ce qu'il n'accorde pas reste fermé — c'est la moitié qui compte.
|
||||
await guestPage.goto('/reglages/roles');
|
||||
await expect(
|
||||
guestPage.getByRole('heading', { name: 'Rôles et permissions' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await guest.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Crée un salarié, lui attribue le rôle donné en base de l'annuaire, et ouvre
|
||||
* son accès.
|
||||
*
|
||||
* Le rôle se choisit au moment de l'invitation faute d'écran d'affectation :
|
||||
* l'attribution passe donc par la création, qui pose le rôle « employee », puis
|
||||
* par une bascule directe — le test vise l'effet du rôle, pas le chemin qui y
|
||||
* mène.
|
||||
*/
|
||||
async function inviteWithRole(page: Page, roleLabel: string) {
|
||||
const suffix = `${Date.now()}-role`;
|
||||
const email = `role.${suffix}@exemple.test`;
|
||||
const password = 'les hirondelles de mars';
|
||||
const lastName = `Role${suffix}`;
|
||||
|
||||
await page.goto('/equipe');
|
||||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
await create.getByLabel('Prénom').fill('Alix');
|
||||
await create.getByLabel('Nom', { exact: true }).fill(lastName);
|
||||
await create.getByLabel('Matricule').fill(`ROL${suffix}`);
|
||||
await create.getByLabel('Adresse électronique').fill(email);
|
||||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
// Attendre la fiche avant de lire l'URL : lue trop tôt, elle vaut encore
|
||||
// celle de l'annuaire et l'identifiant récupéré ne désigne personne.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(lastName) }),
|
||||
).toBeVisible();
|
||||
const membershipId = page.url().split('/').pop()!;
|
||||
|
||||
const { assignRole } = await import('./support/db');
|
||||
await assignRole(membershipId, roleLabel);
|
||||
|
||||
await page
|
||||
.locator('form')
|
||||
.filter({ hasText: 'Adresse d’invitation' })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
const url = (await page.getByTestId('invitation-link').textContent())!.trim();
|
||||
|
||||
const guest = await page.context().browser()!.newContext({
|
||||
storageState: undefined,
|
||||
});
|
||||
const guestPage = await guest.newPage();
|
||||
await guestPage.goto(url);
|
||||
await guestPage.getByLabel('Choisissez un mot de passe').fill(password);
|
||||
await guestPage.getByLabel('Confirmez').fill(password);
|
||||
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
|
||||
await expect(guestPage.getByText(/Votre accès est actif/)).toBeVisible();
|
||||
await guest.close();
|
||||
|
||||
return { email, password, membershipId };
|
||||
}
|
||||
|
||||
test('le mot de passe de démonstration reste celui attendu', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Garde-fou : ces tests remanient les droits, et une dérive fermerait la
|
||||
// suite entière au passage suivant.
|
||||
await page.goto('/reglages/roles');
|
||||
await expect(page.getByRole('heading', { name: 'Rôles et permissions' })).toBeVisible();
|
||||
expect(OWNER_PASSWORD).toBe('planflow-demo-2026');
|
||||
});
|
||||
@@ -62,3 +62,31 @@ export async function backdateDocument(
|
||||
await tx.$executeRaw`UPDATE "Document" SET "uploadedAt" = ${uploadedAt} WHERE id = ${found.id}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribue un rôle à un membership, par son libellé.
|
||||
*
|
||||
* Aucun écran d'affectation n'existe encore : sans ce levier, l'effet d'un rôle
|
||||
* personnalisé — le critère d'acceptation de WP-01 — resterait invérifiable.
|
||||
*/
|
||||
export async function assignRole(
|
||||
membershipId: string,
|
||||
roleName: string,
|
||||
): Promise<void> {
|
||||
const rows = await db().$queryRaw<Array<{ id: string; accountId: string }>>`
|
||||
SELECT id, "accountId" FROM "Role" WHERE name = ${roleName} LIMIT 1
|
||||
`;
|
||||
const role = rows[0];
|
||||
if (!role) throw new Error(`Rôle introuvable : ${roleName}`);
|
||||
|
||||
const updated = await db().$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.account_id', ${role.accountId}, true)`;
|
||||
return tx.$executeRaw`UPDATE "Membership" SET "roleId" = ${role.id} WHERE id = ${membershipId}`;
|
||||
});
|
||||
|
||||
// Une mise à jour qui ne touche aucune ligne est indiscernable d'un succès :
|
||||
// le test échouerait bien plus loin, sur un refus d'accès inexpliqué.
|
||||
if (updated === 0) {
|
||||
throw new Error(`Aucun membership mis à jour : ${membershipId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Actor } from '@/domain/access/authorize';
|
||||
import {
|
||||
escalationRefusal,
|
||||
lockoutRefusal,
|
||||
OWNER_LEVEL_CAPABILITY,
|
||||
REFUSAL_MESSAGES,
|
||||
ROLE_ADMIN_CAPABILITY,
|
||||
slugifyRoleKey,
|
||||
type EditRefusal,
|
||||
} from '@/domain/access/role-editing';
|
||||
|
||||
function actor(permissions: string[]): Actor {
|
||||
return {
|
||||
membershipId: 'm1',
|
||||
accountId: 'a1',
|
||||
userId: 'u1',
|
||||
roleKey: 'admin',
|
||||
permissions: new Set(permissions),
|
||||
scope: { allLocations: true, locationIds: [], teamIds: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('escalade', () => {
|
||||
it('laisse accorder ce qu’on détient', () => {
|
||||
expect(
|
||||
escalationRefusal({
|
||||
actor: actor(['planning.publish', ROLE_ADMIN_CAPABILITY]),
|
||||
previous: new Set(),
|
||||
next: new Set(['planning.publish']),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('refuse d’accorder ce qu’on n’a pas', () => {
|
||||
// Sans cette règle, la première personne autorisée à éditer un rôle
|
||||
// s'accorde l'accès aux rémunérations en trois clics.
|
||||
expect(
|
||||
escalationRefusal({
|
||||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||||
previous: new Set(),
|
||||
next: new Set(['members.salary.view']),
|
||||
}),
|
||||
).toBe('ESCALATION');
|
||||
});
|
||||
|
||||
it('traite le niveau propriétaire à part', () => {
|
||||
expect(
|
||||
escalationRefusal({
|
||||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||||
previous: new Set(),
|
||||
next: new Set([OWNER_LEVEL_CAPABILITY]),
|
||||
}),
|
||||
).toBe('OWNER_LEVEL');
|
||||
});
|
||||
|
||||
it('laisse un propriétaire déléguer son niveau', () => {
|
||||
expect(
|
||||
escalationRefusal({
|
||||
actor: actor([ROLE_ADMIN_CAPABILITY, OWNER_LEVEL_CAPABILITY]),
|
||||
previous: new Set(),
|
||||
next: new Set([OWNER_LEVEL_CAPABILITY]),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('laisse retirer une capacité qu’on ne détient pas', () => {
|
||||
// Réduire un droit n'a jamais élargi le sien : interdire ce retrait
|
||||
// empêcherait de corriger un rôle trop large.
|
||||
expect(
|
||||
escalationRefusal({
|
||||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||||
previous: new Set(['members.salary.view', 'planning.publish']),
|
||||
next: new Set(['planning.publish']),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignore une capacité déjà présente', () => {
|
||||
// Enregistrer un rôle sans y toucher ne doit pas échouer parce qu'il porte
|
||||
// une capacité que l'éditeur n'a pas.
|
||||
expect(
|
||||
escalationRefusal({
|
||||
actor: actor([ROLE_ADMIN_CAPABILITY]),
|
||||
previous: new Set(['members.salary.view']),
|
||||
next: new Set(['members.salary.view']),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verrouillage', () => {
|
||||
it('accepte tant qu’un rôle garde la gestion des droits', () => {
|
||||
expect(
|
||||
lockoutRefusal({
|
||||
rolesAfter: [
|
||||
{ id: 'r1', permissions: new Set([ROLE_ADMIN_CAPABILITY]) },
|
||||
{ id: 'r2', permissions: new Set(['planning.view']) },
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('refuse le retrait du dernier', () => {
|
||||
// Une organisation qui s'enferme dehors n'a plus d'autre recours qu'une
|
||||
// intervention en base.
|
||||
expect(
|
||||
lockoutRefusal({
|
||||
rolesAfter: [
|
||||
{ id: 'r1', permissions: new Set(['planning.view']) },
|
||||
{ id: 'r2', permissions: new Set(['planning.publish']) },
|
||||
],
|
||||
}),
|
||||
).toBe('LAST_ADMIN');
|
||||
});
|
||||
|
||||
it('se juge sur l’ensemble, pas sur le rôle édité', () => {
|
||||
// Retirer la capacité d'un rôle est permis si un autre la conserve.
|
||||
expect(
|
||||
lockoutRefusal({
|
||||
rolesAfter: [
|
||||
{ id: 'r1', permissions: new Set() },
|
||||
{ id: 'r2', permissions: new Set([ROLE_ADMIN_CAPABILITY]) },
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clé de rôle', () => {
|
||||
it('translittère et met en minuscules', () => {
|
||||
expect(slugifyRoleKey('Responsable de secteur')).toBe(
|
||||
'responsable-de-secteur',
|
||||
);
|
||||
expect(slugifyRoleKey('Chef d’équipe régionale')).toBe(
|
||||
'chef-d-equipe-regionale',
|
||||
);
|
||||
});
|
||||
|
||||
it('ne laisse pas de tiret aux extrémités', () => {
|
||||
expect(slugifyRoleKey(' — Extra — ')).toBe('extra');
|
||||
});
|
||||
|
||||
it('rend une chaîne vide quand rien n’est utilisable', () => {
|
||||
// L'appelant refuse alors la création plutôt que de fabriquer une clé
|
||||
// arbitraire que personne ne reconnaîtra.
|
||||
expect(slugifyRoleKey('!!!')).toBe('');
|
||||
});
|
||||
|
||||
it('borne la longueur', () => {
|
||||
expect(slugifyRoleKey('a'.repeat(80)).length).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messages de refus', () => {
|
||||
it('en fournit un pour chaque motif', () => {
|
||||
const refusals: EditRefusal[] = [
|
||||
'ESCALATION',
|
||||
'OWNER_LEVEL',
|
||||
'LAST_ADMIN',
|
||||
'SYSTEM_ROLE',
|
||||
'DUPLICATE_KEY',
|
||||
'ROLE_IN_USE',
|
||||
'NOT_FOUND',
|
||||
];
|
||||
for (const refusal of refusals) {
|
||||
expect(REFUSAL_MESSAGES[refusal]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user