Déposer et consulter les pièces du dossier salarié
Rien ne pouvait être téléversé jusqu'ici : ni pièce d'identité, ni relevé d'identité bancaire, ni arrêt de travail. Le dossier RH n'existait qu'en champs de formulaire. Les fichiers vivent sur disque, chiffrés avec la clé qui protège déjà le NIR et l'IBAN. Le plan n'exige le chiffrement que des pièces jointes de santé ; les chiffrer toutes supprime une branche dont l'oubli serait silencieux et ne coûte rien de plus. Leur emplacement est tiré au sort, jamais dérivé du nom déposé : la traversée de chemin devient impossible par construction plutôt que par filtrage, et un filtre s'oublie. Le caractère sensible se déduit de la catégorie et n'est jamais saisi : laisser déclarer qu'un arrêt de travail n'est pas une donnée de santé reviendrait à laisser désactiver la journalisation de sa lecture. Cette lecture est inscrite au journal avant d'être servie, et l'écran l'annonce — celui qui ouvre la pièce doit savoir que sa consultation laisse une trace nominative. Les liens sont signés et durent deux minutes, comme l'exige le plan. La signature ne remplace pas le contrôle d'accès : la route revérifie session, capacité et périmètre. Elle s'y ajoute pour qu'un lien recopié dans un message cesse de fonctionner de lui-même, sans attendre qu'une session expire. La signature est éprouvée avant l'échéance, sans quoi répondre « expiré » à un lien fabriqué indiquerait qu'il aurait pu marcher. L'empreinte du clair est conservée et revérifiée à chaque lecture : servir un contenu qui ne correspond plus reviendrait à présenter comme authentique une pièce altérée. Retirer une pièce efface le contenu mais garde la ligne : le dossier doit conserver trace qu'elle a existé et qui l'a retirée. Aucune durée de conservation n'est appliquée par défaut — le plan l'interdit explicitement (§12.5). L'échéance reste nulle et l'écran le dit, plutôt que d'inventer « cinq ans partout ». Deux pièges d'outillage rencontrés et documentés dans les tests : le cookie de session étant marqué Secure, le client HTTP de Playwright ne l'émet pas sur http et faisait passer les refus pour de bonnes raisons sans rien prouver ; et la visionneuse PDF intégrée de Chromium ne restitue pas le corps d'une navigation, ce qui masquait la vérification d'intégrité du contenu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
16 files changed
+1275
No files matched your search
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
ACCEPTED_MIME_TYPES,
|
||||
CATEGORY_LABELS,
|
||||
DOCUMENT_CATEGORIES,
|
||||
formatBytes,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
type DocumentCategory,
|
||||
} from '@/domain/documents/rules';
|
||||
import {
|
||||
deleteDocumentAction,
|
||||
uploadDocumentAction,
|
||||
type DocumentState,
|
||||
} from '@/server/documents/actions';
|
||||
|
||||
const empty: DocumentState = {};
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||
|
||||
export interface DocumentView {
|
||||
id: string;
|
||||
name: string;
|
||||
category: DocumentCategory;
|
||||
sizeBytes: number;
|
||||
isSensitive: boolean;
|
||||
retentionUntil: Date | null;
|
||||
uploadedAt: Date;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pièces du dossier.
|
||||
*
|
||||
* Les liens sont signés et expirent en quelques minutes : recharger la page en
|
||||
* fabrique de nouveaux. C'est voulu — un lien recopié dans un message cesse de
|
||||
* fonctionner de lui-même, sans attendre qu'une session expire.
|
||||
*/
|
||||
export function DocumentsPanel({
|
||||
membershipId,
|
||||
documents,
|
||||
canManage,
|
||||
}: {
|
||||
membershipId: string;
|
||||
documents: DocumentView[];
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const [uploadState, upload, uploading] = useActionState(
|
||||
uploadDocumentAction,
|
||||
empty,
|
||||
);
|
||||
const [deleteState, remove] = useActionState(deleteDocumentAction, empty);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-sm text-ink-3">
|
||||
Aucune pièce. Un dossier complet porte au minimum une pièce d’identité
|
||||
et un relevé d’identité bancaire.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{documents.map((document) => (
|
||||
<li
|
||||
key={document.id}
|
||||
className="flex flex-wrap items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<a
|
||||
href={document.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-ink-1 underline"
|
||||
>
|
||||
{document.name}
|
||||
</a>
|
||||
<Badge tone="neutral">{CATEGORY_LABELS[document.category]}</Badge>
|
||||
{document.isSensitive ? (
|
||||
// Le dire à l'écran : celui qui ouvre la pièce doit savoir que
|
||||
// sa consultation laisse une trace nominative.
|
||||
<Badge tone="warn">Santé · lecture journalisée</Badge>
|
||||
) : null}
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
{formatBytes(document.sizeBytes)}
|
||||
</span>
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
déposée le {dateFormat.format(document.uploadedAt)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{document.retentionUntil ? (
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
conservée jusqu’au {dateFormat.format(document.retentionUntil)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-micro text-ink-3">
|
||||
échéance de conservation non fixée
|
||||
</span>
|
||||
)}
|
||||
{canManage ? (
|
||||
<form action={remove}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="documentId"
|
||||
value={document.id}
|
||||
/>
|
||||
<Button type="submit">Retirer</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<form
|
||||
action={upload}
|
||||
className="flex flex-wrap items-end gap-3 border-t border-line-1 pt-4"
|
||||
>
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Catégorie
|
||||
</span>
|
||||
<select
|
||||
name="category"
|
||||
defaultValue="IDENTITY"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{DOCUMENT_CATEGORIES.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Fichier
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
required
|
||||
accept={ACCEPTED_MIME_TYPES.join(',')}
|
||||
className="text-sm text-ink-2 file:mr-2 file:rounded-2 file:border file:border-line-2 file:bg-surface-2 file:px-2 file:py-1 file:text-sm"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">
|
||||
PDF ou image, {formatBytes(MAX_DOCUMENT_BYTES)} au plus. Chiffré au
|
||||
repos.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={uploading}>
|
||||
Déposer
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{uploadState.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{uploadState.error}
|
||||
</p>
|
||||
) : null}
|
||||
{uploadState.message ? (
|
||||
<p className="text-xs text-ok-soft-ink">{uploadState.message}</p>
|
||||
) : null}
|
||||
{deleteState.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{deleteState.error}
|
||||
</p>
|
||||
) : null}
|
||||
{deleteState.message ? (
|
||||
<p className="text-xs text-ok-soft-ink">{deleteState.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
|
||||
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listDocuments } from '@/server/documents/queries';
|
||||
import { getEmployee } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -19,6 +21,10 @@ export default async function FichePage({
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
// `members.documents.view` peut manquer là où `members.view` est accordée :
|
||||
// la section disparaît alors, plutôt que d'échouer sur toute la page.
|
||||
const documents = await listDocuments(id).catch(() => null);
|
||||
|
||||
const active = employee.contracts.find(
|
||||
(contract) => contract.status === 'ACTIVE',
|
||||
);
|
||||
@@ -63,6 +69,20 @@ export default async function FichePage({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { query } from '@/server/context';
|
||||
import { checkSignature } from '@/server/documents/links';
|
||||
import { readFileByKey } from '@/server/documents/storage';
|
||||
|
||||
/**
|
||||
* Téléchargement d'une pièce — PLAN.md §3.6.
|
||||
*
|
||||
* Trois conditions, toutes nécessaires : une signature valide et non expirée,
|
||||
* une session, et la capacité de lire les documents. La signature seule ne
|
||||
* suffit pas — un lien recopié dans un message ne doit rien ouvrir à qui n'a
|
||||
* pas le droit de lire le dossier.
|
||||
*
|
||||
* La lecture d'une pièce de santé est journalisée, comme l'exige le plan pour
|
||||
* toute donnée de catégorie particulière.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<NextResponse> {
|
||||
const { id } = await context.params;
|
||||
const parameters = request.nextUrl.searchParams;
|
||||
|
||||
const link = checkSignature(id, parameters.get('e'), parameters.get('s'));
|
||||
if (!link.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
link.reason === 'expired'
|
||||
? 'Ce lien a expiré. Rouvrez le dossier pour en obtenir un nouveau.'
|
||||
: 'Lien invalide.',
|
||||
},
|
||||
{ status: link.reason === 'expired' ? 410 : 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// `query` impose la session et la capacité avant d'ouvrir la transaction, et
|
||||
// le client scopé ne voit que les documents du compte courant.
|
||||
const found = await query('members.documents.view', async (db, actor) => {
|
||||
const document = await db.document.findUnique({ where: { id } });
|
||||
if (!document || document.deletedAt) return null;
|
||||
|
||||
if (document.isSensitive) {
|
||||
// Journalisée **avant** la lecture : une consultation qui échouerait
|
||||
// après coup a tout de même eu lieu du point de vue de l'accès.
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'document.sensitive.read',
|
||||
entityType: 'Document',
|
||||
entityId: document.id,
|
||||
after: {
|
||||
category: document.category,
|
||||
membershipId: document.membershipId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return NextResponse.json({ error: 'Document introuvable.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const file = await readFileByKey(found.fileKey, found.checksum);
|
||||
if (!file.intact) {
|
||||
// Servir un contenu qui ne correspond plus à son empreinte reviendrait à
|
||||
// présenter comme authentique une pièce altérée.
|
||||
return NextResponse.json(
|
||||
{ error: 'Le fichier stocké ne correspond plus à son empreinte.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const name = sanitiseFileName(found.name);
|
||||
return new NextResponse(new Uint8Array(file.content), {
|
||||
headers: {
|
||||
'content-type': found.mimeType,
|
||||
'content-length': String(file.content.byteLength),
|
||||
// `inline` laisse le navigateur afficher un PDF ou une image sans
|
||||
// téléchargement ; le nom accentué passe par `filename*`, le repli ASCII
|
||||
// sert aux clients qui ne lisent que `filename`.
|
||||
'content-disposition': `inline; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
// Une pièce de dossier RH n'a rien à faire dans un cache partagé.
|
||||
'cache-control': 'private, no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user