Critère d'acceptation de WP-01 resté sans écran : « un rôle personnalisé créé par un client modifie effectivement l'accès, sans changement de code ». Le catalogue de capacités existait, l'autorisation s'y adossait déjà, mais seul le semis pouvait attribuer quoi que ce soit. Deux dangers opposés, et il faut se protéger des deux. L'escalade d'abord : on ne peut accorder qu'une capacité qu'on détient soi-même, sans quoi la première personne autorisée à éditer un rôle s'accorde l'accès aux rémunérations en trois clics et le catalogue ne sert plus à rien. Le retrait, lui, reste permis même sur une capacité qu'on n'a pas — réduire un droit n'a jamais élargi le sien, et l'interdire empêcherait de corriger un rôle trop large. Le verrouillage ensuite : au moins un rôle doit conserver la gestion des droits. La condition se juge sur l'ensemble des rôles après coup, non sur celui qu'on édite — ce qui compte n'est pas que ce rôle-ci garde la capacité, mais qu'un rôle la garde. Sans cela une organisation se fermerait dehors, et le seul recours serait une intervention en base. Un rôle naît sans aucune capacité : en hériter de celles de son créateur distribuerait des droits que personne n'a demandés. Le niveau propriétaire se délègue à part. Un rôle système ne se supprime pas, un rôle attribué non plus — ses membres se retrouveraient dehors sans que personne l'ait décidé pour eux. Le test va jusqu'au bout : créer le rôle, l'attribuer, se connecter, constater qu'un écran s'ouvre et qu'un autre reste fermé. S'arrêter à « la case est cochée » n'aurait rien dit du critère. Trois défauts d'isolation des tests corrigés au passage, tous du même genre — un état qui s'accumule d'une exécution à l'autre : - la file des congés désignait « la première ligne de ce salarié » pour son nettoyage, et tombait sur une ligne laissée par un passage antérieur ; - l'écran de conservation tronquait à quarante pièces triées par ancienneté, faisant disparaître les pièces échues derrière celles dont il n'y a rien à dire — corrigé dans le produit, pas seulement dans le test ; - le test de verrouillage de période consommait un mois par exécution sans jamais le rendre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
220 lines
6.9 KiB
TypeScript
220 lines
6.9 KiB
TypeScript
'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;
|
|
}
|