Configurer les rôles et leurs capacités
Critère d'acceptation de WP-01 resté sans écran : « un rôle personnalisé créé par un client modifie effectivement l'accès, sans changement de code ». Le catalogue de capacités existait, l'autorisation s'y adossait déjà, mais seul le semis pouvait attribuer quoi que ce soit. Deux dangers opposés, et il faut se protéger des deux. L'escalade d'abord : on ne peut accorder qu'une capacité qu'on détient soi-même, sans quoi la première personne autorisée à éditer un rôle s'accorde l'accès aux rémunérations en trois clics et le catalogue ne sert plus à rien. Le retrait, lui, reste permis même sur une capacité qu'on n'a pas — réduire un droit n'a jamais élargi le sien, et l'interdire empêcherait de corriger un rôle trop large. Le verrouillage ensuite : au moins un rôle doit conserver la gestion des droits. La condition se juge sur l'ensemble des rôles après coup, non sur celui qu'on édite — ce qui compte n'est pas que ce rôle-ci garde la capacité, mais qu'un rôle la garde. Sans cela une organisation se fermerait dehors, et le seul recours serait une intervention en base. Un rôle naît sans aucune capacité : en hériter de celles de son créateur distribuerait des droits que personne n'a demandés. Le niveau propriétaire se délègue à part. Un rôle système ne se supprime pas, un rôle attribué non plus — ses membres se retrouveraient dehors sans que personne l'ait décidé pour eux. Le test va jusqu'au bout : créer le rôle, l'attribuer, se connecter, constater qu'un écran s'ouvre et qu'un autre reste fermé. S'arrêter à « la case est cochée » n'aurait rien dit du critère. Trois défauts d'isolation des tests corrigés au passage, tous du même genre — un état qui s'accumule d'une exécution à l'autre : - la file des congés désignait « la première ligne de ce salarié » pour son nettoyage, et tombait sur une ligne laissée par un passage antérieur ; - l'écran de conservation tronquait à quarante pièces triées par ancienneté, faisant disparaître les pièces échues derrière celles dont il n'y a rien à dire — corrigé dans le produit, pas seulement dans le test ; - le test de verrouillage de période consommait un mois par exécution sans jamais le rendre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
12 files changed
+1097
-12
No files matched your search
@@ -34,6 +34,18 @@ export default async function RetentionPage() {
|
||||
const due = candidates.filter((entry) => entry.verdict === 'DUE');
|
||||
const unpolicied = candidates.filter((entry) => entry.verdict === 'NO_POLICY');
|
||||
|
||||
// Ce qui appelle une décision d'abord. Trier par date de dépôt puis tronquer
|
||||
// ferait disparaître les pièces échues derrière les plus anciennes, qui sont
|
||||
// justement celles dont il n'y a rien à dire.
|
||||
const PRIORITY: Record<string, number> = { DUE: 0, HELD: 1, NOT_COMPUTABLE: 2 };
|
||||
const shown = [...candidates]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(PRIORITY[a.verdict] ?? 3) - (PRIORITY[b.verdict] ?? 3) ||
|
||||
b.uploadedAt.getTime() - a.uploadedAt.getTime(),
|
||||
)
|
||||
.slice(0, 40);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
@@ -130,7 +142,7 @@ export default async function RetentionPage() {
|
||||
<p className="text-sm text-ink-3">Aucune pièce déposée.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{candidates.slice(0, 40).map((candidate) => (
|
||||
{shown.map((candidate) => (
|
||||
<li key={candidate.id} className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
tone={
|
||||
@@ -155,6 +167,13 @@ export default async function RetentionPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{candidates.length > shown.length ? (
|
||||
<p className="text-micro text-ink-3">
|
||||
{candidates.length - shown.length} pièce(s) de plus, non affichées.
|
||||
Les pièces échues et suspendues figurent toujours en tête.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{unpolicied.length > 0 ? (
|
||||
<p className="text-micro text-ink-3">
|
||||
{unpolicied.length} pièce(s) sans politique déclarée : elles ne
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
CreateRoleForm,
|
||||
DeleteRoleForm,
|
||||
RolePermissionsForm,
|
||||
type CapabilityView,
|
||||
type RoleView,
|
||||
} from '@/components/settings/RoleEditor';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { query } from '@/server/context';
|
||||
|
||||
export const metadata = { title: 'Rôles et permissions · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Rôles et capacités.
|
||||
*
|
||||
* Critère d'acceptation de WP-01 : un rôle personnalisé créé par un client doit
|
||||
* modifier effectivement l'accès, sans changement de code. Le code ne teste
|
||||
* jamais un nom de rôle, seulement des capacités — c'est ce qui rend cet écran
|
||||
* possible.
|
||||
*/
|
||||
export default async function RolesPage() {
|
||||
const { roles, capabilities } = await query(
|
||||
'settings.roles.manage',
|
||||
async (db, actor) => {
|
||||
const [rows, permissions] = await Promise.all([
|
||||
db.role.findMany({
|
||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
_count: { select: { memberships: true } },
|
||||
},
|
||||
}),
|
||||
db.permission.findMany({ orderBy: [{ category: 'asc' }, { code: 'asc' }] }),
|
||||
]);
|
||||
|
||||
return {
|
||||
roles: rows.map(
|
||||
(role): RoleView => ({
|
||||
id: role.id,
|
||||
key: role.key,
|
||||
name: role.name,
|
||||
isSystem: role.isSystem,
|
||||
memberCount: role._count.memberships,
|
||||
granted: role.permissions.map((entry) => entry.permission.code),
|
||||
}),
|
||||
),
|
||||
capabilities: permissions.map(
|
||||
(permission): CapabilityView => ({
|
||||
code: permission.code,
|
||||
category: permission.category,
|
||||
label: permission.label,
|
||||
// Calculé côté serveur depuis l'acteur : le client n'a pas à
|
||||
// décider ce qu'il a le droit d'accorder.
|
||||
grantable: actor.permissions.has(permission.code),
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Rôles et permissions"
|
||||
subtitle="Le code teste des capacités, jamais un nom de rôle : renommer « Manager » ou en créer un cinquième ne casse rien."
|
||||
actions={<Badge tone="neutral">{roles.length} rôles</Badge>}
|
||||
/>
|
||||
|
||||
<section className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm text-ink-2">
|
||||
<p>
|
||||
Vous ne pouvez accorder que les capacités que vous détenez vous-même —
|
||||
sinon cet écran suffirait à s’octroyer l’accès aux rémunérations.
|
||||
Retirer reste possible : réduire un droit n’a jamais élargi le sien.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Au moins un rôle doit conserver « Gérer les rôles », faute de quoi
|
||||
l’organisation se fermerait dehors et le seul recours serait une
|
||||
intervention en base.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Créer un rôle" />
|
||||
<div className="p-4">
|
||||
<CreateRoleForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{roles.map((role) => (
|
||||
<Card key={role.id}>
|
||||
<CardHeader
|
||||
title={role.name}
|
||||
badge={<Badge tone="neutral">{role.granted.length}</Badge>}
|
||||
/>
|
||||
<RolePermissionsForm role={role} capabilities={capabilities} />
|
||||
<DeleteRoleForm role={role} />
|
||||
</Card>
|
||||
))}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user