Files
planflow/src/app/invitation/[token]/page.tsx
T
Claude 874c3fcd80 Ouvrir un accès à un salarié par invitation
Jusqu'ici personne ne pouvait entrer dans l'application : le modèle Invitation
existait en base mais aucun code ne s'en servait, et le seul moyen d'obtenir un
compte était le jeu de données de démonstration.

Le lien vaut autant qu'un mot de passe le temps de sa validité, d'où trois
règles : sept jours, un seul usage, et une révocation possible sans attendre
l'expiration. Seule l'empreinte du jeton est conservée — renvoyer une
invitation émet donc un nouveau lien et invalide le précédent, ce qui est aussi
la bonne réponse à « il a perdu le message ». Deux liens vivants pour un même
accès, ce sont deux portes dont une seule est tracée comme ayant servi.

Le compte est porté par le lien lui-même, préfixé au secret. La table est
protégée par RLS, laquelle exige de connaître le compte avant toute lecture :
sans ce préfixe il aurait fallu ouvrir la politique aux requêtes sans compte —
c'est-à-dire la vider de son sens. Divulguer un identifiant opaque à qui est
membre du compte ne coûte rien.

Le lien est aussi rendu une fois, à l'écran de celui qui l'émet. Sans cela un
déploiement neuf ne peut inviter personne : configurer le serveur d'envoi
demande d'être connecté, et être connecté demande une invitation. Il n'est ni
conservé ni journalisé.

Deux cas à l'acceptation, un seul demande un mot de passe. Si aucun compte
n'existe pour l'adresse, il est créé ; s'il en existe un, le salarié est
rattaché sans qu'on touche à son mot de passe — détenir le lien prouve l'accès
à la boîte, ce qui suffit à rattacher un accès mais ne justifie pas de
réinitialiser l'authentification d'un compte existant. Pas de connexion
automatique non plus : un mot de passe qu'on vient de choisir se fixe en s'en
servant.

`members.invite` est une capacité distincte de `members.edit` : ouvrir un accès
n'est pas modifier un dossier, et tel client voudra confier l'un sans l'autre.

Deux défauts trouvés par les tests plutôt qu'en production : le contrôle qui
refuse un mot de passe contenant le nom du salarié laissait passer « riviere »
pour « Rivière » faute de replier les accents — soit exactement la variante
qu'on tape au clavier ; et les contextes « visiteur » des tests héritaient de
la session du responsable, si bien que le parcours anonyme n'était pas éprouvé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-08 21:42:19 +00:00

83 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from 'next/link';
import { AcceptInvitationForm } from '@/app/invitation/[token]/AcceptInvitationForm';
import { INVITATION_STATE_LABELS } from '@/domain/access/invitation';
import { resolveInvitation } from '@/server/invitations/service';
export const metadata = { title: 'Invitation · PlanFlow' };
export const dynamic = 'force-dynamic';
/**
* Acceptation d'une invitation — page publique.
*
* Hors du shell applicatif : le destinataire n'a pas encore de session, et
* afficher une navigation qu'il ne peut pas suivre serait déroutant.
*/
export default async function InvitationPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const invitation = await resolveInvitation(decodeURIComponent(token));
return (
<main className="mx-auto flex min-h-svh w-full max-w-md flex-col justify-center gap-6 p-6">
<header>
<p className="text-micro font-semibold tracking-[0.12em] text-ink-3 uppercase">
PlanFlow
</p>
<h1 className="mt-1 text-xl font-semibold text-ink-1">
{invitation?.state === 'PENDING'
? `Bienvenue${invitation.firstName ? `, ${invitation.firstName}` : ''}`
: 'Invitation'}
</h1>
</header>
{!invitation ? (
<Refusal message="Ce lien d’invitation n’est pas valide. Il a peut-être été tronqué par votre logiciel de messagerie — copiez-le entièrement, ou demandez-en un nouveau." />
) : invitation.state !== 'PENDING' ? (
<Refusal
title={INVITATION_STATE_LABELS[invitation.state]}
message={
invitation.state === 'ACCEPTED'
? 'Votre accès existe déjà. Connectez-vous avec votre adresse électronique.'
: 'Demandez à votre responsable de vous envoyer une nouvelle invitation.'
}
/>
) : (
<>
<p className="text-sm text-ink-2">
<strong className="text-ink-1">{invitation.accountName}</strong> vous
ouvre un accès à PlanFlow, où vous consulterez votre planning, vos
compteurs et vos demandes d’absence.
</p>
<AcceptInvitationForm
token={decodeURIComponent(token)}
email={invitation.email}
hasUser={invitation.hasUser}
/>
</>
)}
<p className="text-micro text-ink-3">
<Link href="/connexion" className="underline">
Aller à la connexion
</Link>
</p>
</main>
);
}
function Refusal({ title, message }: { title?: string; message: string }) {
return (
<div
role="alert"
className="rounded-3 border border-warn bg-warn-soft p-4 text-sm text-warn-soft-ink"
>
{title ? <p className="font-semibold">{title}</p> : null}
<p className={title ? 'mt-1' : undefined}>{message}</p>
</div>
);
}