Merge pull request #22 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Configurer les rôles et leurs capacités
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