diff --git a/src/app/(app)/reglages/conservation/page.tsx b/src/app/(app)/reglages/conservation/page.tsx index f9a2de1..5a03545 100644 --- a/src/app/(app)/reglages/conservation/page.tsx +++ b/src/app/(app)/reglages/conservation/page.tsx @@ -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 = { 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 ( Aucune pièce déposée.

) : (
    - {candidates.slice(0, 40).map((candidate) => ( + {shown.map((candidate) => (
  • )} + {candidates.length > shown.length ? ( +

    + {candidates.length - shown.length} pièce(s) de plus, non affichées. + Les pièces échues et suspendues figurent toujours en tête. +

    + ) : null} + {unpolicied.length > 0 ? (

    {unpolicied.length} pièce(s) sans politique déclarée : elles ne diff --git a/src/app/(app)/reglages/roles/page.tsx b/src/app/(app)/reglages/roles/page.tsx new file mode 100644 index 0000000..708db7a --- /dev/null +++ b/src/app/(app)/reglages/roles/page.tsx @@ -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 ( + + {roles.length} rôles} + /> + +

    +

    + 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. +

    +

    + 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. +

    +
    + + + +
    + +
    +
    + + {roles.map((role) => ( + + {role.granted.length}
    } + /> + + + + ))} + + ); +} diff --git a/src/components/settings/RoleEditor.tsx b/src/components/settings/RoleEditor.tsx new file mode 100644 index 0000000..3b769a7 --- /dev/null +++ b/src/components/settings/RoleEditor.tsx @@ -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 ( +
    + + + + + ); +} + +/** + * 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 ( +
    + + +
    + {role.name} + {role.key} + {role.isSystem ? Rôle système : null} + + {role.memberCount} membre{role.memberCount > 1 ? 's' : ''} ·{' '} + {role.granted.length} capacité{role.granted.length > 1 ? 's' : ''} + +
    + +
    + {categories.map((category) => ( +
    + + {category} + + {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 ( + + ); + })} +
    + ))} +
    + +
    + + +
    +
    + ); +} + +export function DeleteRoleForm({ role }: { role: RoleView }) { + const [state, action, pending] = useActionState(deleteRoleAction, empty); + + if (role.isSystem) return null; + + return ( +
    + + + {role.memberCount > 0 ? ( + + Réaffectez ses {role.memberCount} membre(s) d’abord. + + ) : null} + + + ); +} + +function Messages({ state }: { state: RoleState }) { + return ( + <> + {state.error ? ( + + {state.error} + + ) : null} + {state.message ? ( + {state.message} + ) : null} + + ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index 5951607..40dc5d1 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -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' }, ], }, ]; diff --git a/src/domain/access/role-editing.ts b/src/domain/access/role-editing.ts new file mode 100644 index 0000000..e275cb7 --- /dev/null +++ b/src/domain/access/role-editing.ts @@ -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 = { + 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; + /** Capacités qu'il portait avant. */ + previous: ReadonlySet; +} + +/** + * 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 }>; +} + +/** + * 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); +} diff --git a/src/server/roles/actions.ts b/src/server/roles/actions.ts new file mode 100644 index 0000000..c0a6985 --- /dev/null +++ b/src/server/roles/actions.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/tests/e2e/conges.spec.ts b/tests/e2e/conges.spec.ts index abda7ac..cd1b0de 100644 --- a/tests/e2e/conges.spec.ts +++ b/tests/e2e/conges.spec.ts @@ -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(); } }); diff --git a/tests/e2e/conservation.spec.ts b/tests/e2e/conservation.spec.ts index 1321a46..66a3b38 100644 --- a/tests/e2e/conservation.spec.ts +++ b/tests/e2e/conservation.spec.ts @@ -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(); diff --git a/tests/e2e/heures.spec.ts b/tests/e2e/heures.spec.ts index e2beb40..f233be3 100644 --- a/tests/e2e/heures.spec.ts +++ b/tests/e2e/heures.spec.ts @@ -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 }) => { diff --git a/tests/e2e/roles.spec.ts b/tests/e2e/roles.spec.ts new file mode 100644 index 0000000..93d2352 --- /dev/null +++ b/tests/e2e/roles.spec.ts @@ -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'); +}); diff --git a/tests/e2e/support/db.ts b/tests/e2e/support/db.ts index ae8f110..dc126f0 100644 --- a/tests/e2e/support/db.ts +++ b/tests/e2e/support/db.ts @@ -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 { + const rows = await db().$queryRaw>` + 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}`); + } +} diff --git a/tests/unit/role-editing.test.ts b/tests/unit/role-editing.test.ts new file mode 100644 index 0000000..f3f07c4 --- /dev/null +++ b/tests/unit/role-editing.test.ts @@ -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(); + } + }); +});