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:
LogiFlow authored and GitHub committed 2026-08-09 10:08:09 +02:00
commit ba6482d4ff
12 files changed
+1097 -12

No files matched your search

+20 -1
View File
@@ -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
+104
View File
@@ -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>
);
}
+210
View File
@@ -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}
</>
);
}
+1 -1
View File
@@ -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' },
],
},
];
+110
View File
@@ -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);
}
+219
View File
@@ -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;
}
+14 -4
View File
@@ -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();
}
});
+5 -6
View File
@@ -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();
+12
View File
@@ -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 }) => {
+203
View File
@@ -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');
});
+28
View File
@@ -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}`);
}
}
+171
View File
@@ -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();
}
});
});