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
This commit is contained in:
Claude committed 2026-08-08 21:42:19 +00:00
1 parent 9ed7373839
commit 874c3fcd80
14 files changed
+1383 -1

No files matched your search

+4 -1
View File
@@ -66,7 +66,10 @@ export default defineConfig({
`node .next/standalone/server.js`,
].join(' && '),
url: baseURL,
env: { PORT: String(PORT), HOSTNAME: '127.0.0.1' },
// APP_URL fabrique les liens des messages — invitations comprises. Le
// laisser sur sa valeur par défaut ferait pointer les liens vers un port
// où rien n'écoute pendant les tests.
env: { PORT: String(PORT), HOSTNAME: '127.0.0.1', APP_URL: baseURL },
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Invitation" ADD COLUMN "revokedAt" TIMESTAMP(3),
ADD COLUMN "revokedBy" TEXT;
-- CreateIndex
CREATE INDEX "Invitation_membershipId_idx" ON "Invitation"("membershipId");
+8
View File
@@ -197,6 +197,8 @@ model Session {
@@index([expiresAt])
}
/// Invitation à créer un accès. Le jeton n'existe en clair que dans le message
/// envoyé : seule son empreinte est conservée, comme pour une session.
model Invitation {
id String @id @default(cuid())
accountId String
@@ -205,12 +207,18 @@ model Invitation {
email String
expiresAt DateTime
acceptedAt DateTime?
/// Révocation explicite. Distincte de l'expiration : « je retire cet accès »
/// et « personne n'a répondu » ne se lisent pas de la même façon dans un
/// dossier.
revokedAt DateTime?
revokedBy String?
createdBy String
createdAt DateTime @default(now())
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([membershipId])
}
// ============================================================================
@@ -0,0 +1,158 @@
'use client';
import { useActionState } from 'react';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import {
INVITATION_STATE_LABELS,
type InvitationState as State,
} from '@/domain/access/invitation';
import {
inviteMemberAction,
revokeInvitationAction,
type InvitationState,
} from '@/server/invitations/actions';
const empty: InvitationState = {};
const TONES: Record<State, Tone> = {
PENDING: 'info',
ACCEPTED: 'ok',
REVOKED: 'neutral',
EXPIRED: 'warn',
};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
export interface InvitationView {
state: State;
email: string;
expiresAt: Date;
createdAt: Date;
}
/**
* Accès applicatif d'un salarié.
*
* Le lien envoyé n'est **pas réaffiché** : seule son empreinte est conservée.
* « Renvoyer » émet donc un nouveau lien et invalide le précédent — ce qui est
* aussi la bonne réponse à « il a perdu le message ».
*/
export function InvitationPanel({
membershipId,
defaultEmail,
hasAccount,
invitation,
}: {
membershipId: string;
defaultEmail: string;
hasAccount: boolean;
invitation: InvitationView | null;
}) {
const [inviteState, invite, inviting] = useActionState(
inviteMemberAction,
empty,
);
const [revokeState, revoke, revoking] = useActionState(
revokeInvitationAction,
empty,
);
const pending = invitation?.state === 'PENDING';
return (
<div className="flex flex-col gap-3 p-4">
{invitation ? (
<p className="flex flex-wrap items-center gap-2 text-sm text-ink-2">
<Badge tone={TONES[invitation.state]}>
{INVITATION_STATE_LABELS[invitation.state]}
</Badge>
<span className="text-ink-1">{invitation.email}</span>
<span className="tnum text-micro text-ink-3">
{invitation.state === 'PENDING'
? `valable jusqu’au ${dateFormat.format(invitation.expiresAt)}`
: `envoyée le ${dateFormat.format(invitation.createdAt)}`}
</span>
</p>
) : null}
{hasAccount ? (
<p className="text-sm text-ink-2">
Ce salarié a un accès actif. Réinviter n’est utile que pour rattacher
une nouvelle adresse.
</p>
) : (
<p className="text-sm text-ink-2">
Sans invitation, ce salarié reste planifiable et exportable, mais ne
peut ni consulter son planning ni demander une absence.
</p>
)}
<form action={invite} className="flex flex-wrap items-end gap-3">
<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">
Adresse d’invitation
</span>
<input
name="email"
type="email"
defaultValue={defaultEmail}
required
className="h-8 w-72 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</label>
<Button type="submit" variant="primary" disabled={inviting}>
{pending ? 'Renvoyer l’invitation' : 'Inviter'}
</Button>
</form>
{pending ? (
<form action={revoke}>
<input type="hidden" name="membershipId" value={membershipId} />
<Button type="submit" disabled={revoking}>
Révoquer l’invitation
</Button>
</form>
) : null}
{inviteState.error ? (
<p role="alert" className="text-xs text-danger">
{inviteState.error}
</p>
) : null}
{inviteState.message ? (
<p className="text-xs text-ok-soft-ink">{inviteState.message}</p>
) : null}
{inviteState.url ? (
<div className="rounded-3 border border-line-2 bg-surface-2 p-3">
<p className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Lien d’invitation
</p>
{/* Affiché une seule fois. Recharger l'écran ne le remontrera pas :
la base n'en garde que l'empreinte. */}
<p
data-testid="invitation-link"
className="mt-1 break-all font-mono text-xs text-ink-1"
>
{inviteState.url}
</p>
<p className="mt-1 text-micro text-ink-3">
Visible une seule fois. Utile si le message n’arrive pas, ou tant
que le serveur d’envoi n’est pas configuré.
</p>
</div>
) : null}
{revokeState.error ? (
<p role="alert" className="text-xs text-danger">
{revokeState.error}
</p>
) : null}
{revokeState.message ? (
<p className="text-xs text-ok-soft-ink">{revokeState.message}</p>
) : null}
</div>
);
}
+15
View File
@@ -1,5 +1,6 @@
import { notFound } from 'next/navigation';
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';
@@ -62,6 +63,20 @@ export default async function FichePage({
) : null}
</div>
{employee.canInvite ? (
<Card>
<CardHeader title="Accès à l’application" />
<InvitationPanel
membershipId={employee.id}
defaultEmail={
employee.email ?? employee.profile?.personalEmail ?? ''
}
hasAccount={employee.hasAccount}
invitation={employee.invitation}
/>
</Card>
) : null}
<Card>
<CardHeader
title="Contrats et avenants"
@@ -0,0 +1,109 @@
'use client';
import Link from 'next/link';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
import { MIN_PASSWORD_LENGTH } from '@/domain/access/invitation';
import {
acceptInvitationAction,
type InvitationState,
} from '@/server/invitations/actions';
const empty: InvitationState = {};
export function AcceptInvitationForm({
token,
email,
hasUser,
}: {
token: string;
email: string;
hasUser: boolean;
}) {
const [state, formAction, pending] = useActionState(
acceptInvitationAction,
empty,
);
if (state.ok) {
return (
<div className="rounded-3 border border-ok bg-ok-soft p-4 text-sm text-ok-soft-ink">
<p>{state.message}</p>
<p className="mt-3">
<Link href="/connexion" className="font-medium underline">
Se connecter
</Link>
</p>
</div>
);
}
return (
<form
action={formAction}
className="flex flex-col gap-4 rounded-3 border border-line-1 bg-surface p-4"
>
<input type="hidden" name="token" value={token} />
<div className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Votre adresse
</span>
{/* Non modifiable : l'invitation vaut pour cette adresse, et pouvoir la
changer ici reviendrait à ouvrir l'accès à qui bon semble. */}
<p className="text-sm text-ink-1">{email}</p>
</div>
{hasUser ? (
<p className="text-sm text-ink-2">
Un compte existe déjà pour cette adresse. Il sera simplement rattaché à
votre nouvel employeur : votre mot de passe actuel reste valable.
</p>
) : (
<>
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Choisissez un mot de passe
</span>
<input
name="password"
type="password"
autoComplete="new-password"
minLength={MIN_PASSWORD_LENGTH}
required
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
<span className="text-micro text-ink-3">
{MIN_PASSWORD_LENGTH} caractères au minimum. Une phrase dont vous
vous souvenez vaut mieux qu’un assemblage de symboles.
</span>
</label>
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Confirmez
</span>
<input
name="confirmation"
type="password"
autoComplete="new-password"
required
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</label>
</>
)}
<Button type="submit" variant="primary" disabled={pending}>
{hasUser ? 'Rattacher mon compte' : 'Activer mon accès'}
</Button>
{state.error ? (
<p role="alert" className="text-xs text-danger">
{state.error}
</p>
) : null}
</form>
);
}
+82
View File
@@ -0,0 +1,82 @@
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>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Invitations — entrée dans l'application.
*
* Le lien d'invitation est le seul chemin par lequel un salarié obtient un
* accès. Il vaut donc autant qu'un mot de passe le temps de sa validité, ce qui
* commande trois règles : une durée courte, un usage unique, et une révocation
* possible sans attendre l'expiration.
*/
/**
* Sept jours. Assez pour couvrir une semaine de congés du destinataire, trop
* peu pour qu'un lien oublié dans une boîte reste exploitable des mois.
*/
export const INVITATION_TTL_DAYS = 7;
export type InvitationState = 'PENDING' | 'ACCEPTED' | 'REVOKED' | 'EXPIRED';
export interface InvitationTimestamps {
expiresAt: Date;
acceptedAt: Date | null;
revokedAt: Date | null;
}
/**
* État d'une invitation.
*
* L'ordre des tests compte : une invitation acceptée reste acceptée une fois
* sa date d'expiration passée — dire « expirée » d'un accès qui a servi
* induirait en erreur celui qui relit le dossier.
*/
export function invitationState(
invitation: InvitationTimestamps,
now: Date,
): InvitationState {
if (invitation.acceptedAt) return 'ACCEPTED';
if (invitation.revokedAt) return 'REVOKED';
if (invitation.expiresAt <= now) return 'EXPIRED';
return 'PENDING';
}
export function expiryFrom(now: Date): Date {
return new Date(now.getTime() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
}
/**
* Longueur minimale d'un mot de passe.
*
* Douze caractères sans règle de composition : les exigences de casse et de
* caractères spéciaux produisent des mots de passe plus courts et plus
* prévisibles, sans gain mesurable. C'est un choix produit, pas une obligation
* réglementaire.
*/
export const MIN_PASSWORD_LENGTH = 12;
export interface PersonalContext {
firstName?: string | null;
lastName?: string | null;
email?: string | null;
}
/**
* Motif de refus d'un mot de passe, ou `null` s'il convient.
*
* Le refus est **motivé** : « mot de passe invalide » sans explication conduit
* l'utilisateur à essayer des variantes tout aussi faibles.
*/
export function passwordProblem(
password: string,
personal: PersonalContext = {},
): string | null {
if (password.length < MIN_PASSWORD_LENGTH) {
return `Le mot de passe doit faire au moins ${MIN_PASSWORD_LENGTH} caractères.`;
}
// Une limite haute évite qu'une saisie de plusieurs mégaoctets fasse tourner
// argon2 indéfiniment ; elle ne bride aucun usage réel.
if (password.length > 200) {
return 'Le mot de passe ne peut pas dépasser 200 caractères.';
}
const needle = fold(password);
const forbidden = [
personal.firstName,
personal.lastName,
personal.email?.split('@')[0],
]
.filter((value): value is string => Boolean(value) && value!.length >= 3)
.map(fold);
if (forbidden.some((value) => needle.includes(value))) {
return 'Le mot de passe ne doit pas contenir votre nom ni votre adresse : ce sont les premières combinaisons essayées.';
}
if (/^(.)\1*$/.test(password)) {
return 'Le mot de passe ne peut pas être une répétition du même caractère.';
}
return null;
}
/**
* Composition du jeton de lien.
*
* Le compte est porté **par le lien**, préfixé au secret. La table des
* invitations est protégée par RLS, laquelle exige de connaître le compte avant
* de lire quoi que ce soit : sans ce préfixe, il faudrait ou bien ouvrir la
* politique aux requêtes sans compte — c'est-à-dire la vider de son sens — ou
* bien passer par une fonction privilégiée. Divulguer l'identifiant de compte
* ne coûte rien : c'est un identifiant opaque, et le destinataire du lien en
* est membre.
*/
export function composeInvitationToken(accountId: string, secret: string): string {
return `${accountId}.${secret}`;
}
export function splitInvitationToken(
value: string,
): { accountId: string; secret: string } | null {
const separator = value.indexOf('.');
if (separator <= 0 || separator === value.length - 1) return null;
return {
accountId: value.slice(0, separator),
secret: value.slice(separator + 1),
};
}
/**
* Minuscules sans accents.
*
* Comparer « Rivière » à « riviere2026 » sans replier les accents laisserait
* passer exactement la variante qu'un salarié écrira spontanément au clavier.
*/
function fold(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
export const INVITATION_STATE_LABELS: Record<InvitationState, string> = {
PENDING: 'Invitation en attente',
ACCEPTED: 'Invitation acceptée',
REVOKED: 'Invitation révoquée',
EXPIRED: 'Invitation expirée',
};
+4
View File
@@ -51,6 +51,9 @@ export const PERMISSIONS = [
['members.documents.manage', 'Personnel', 'Gérer les documents'],
['members.register.export', 'Personnel', 'Exporter le registre du personnel'],
['members.dpae.check', 'Personnel', 'Contrôler l’éligibilité DPAE'],
// Distincte de `members.edit` : ouvrir un accès à l'application n'est pas
// modifier un dossier, et tel client voudra confier l'un sans l'autre.
['members.invite', 'Personnel', 'Inviter un salarié à se connecter'],
// --- Heures --------------------------------------------------------------
['hours.view', 'Heures', 'Voir les heures'],
@@ -178,6 +181,7 @@ const DIRECTOR_PERMISSIONS: string[] = [
'members.documents.manage',
'members.register.export',
'members.dpae.check',
'members.invite',
'timeoff.delete',
'timeoff.bypass_notice',
'counters.adjust',
+36
View File
@@ -1,4 +1,8 @@
import { can } from '@/domain/access/authorize';
import {
invitationState,
type InvitationState,
} from '@/domain/access/invitation';
import { decryptOptional } from '@/server/crypto';
import { query } from '@/server/context';
@@ -129,6 +133,14 @@ export interface EmployeeDetail extends EmployeeListRow {
amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>;
}>;
canSeeSalary: boolean;
canInvite: boolean;
/** Dernière invitation émise, quel que soit son sort. */
invitation: {
state: InvitationState;
email: string;
expiresAt: Date;
createdAt: Date;
} | null;
}
export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
@@ -163,6 +175,21 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
})
: null;
// La plus récente, pas la seule en attente : « invitation expirée le 3 »
// est une information utile, et la masquer laisserait croire qu'aucune
// n'a jamais été envoyée.
const lastInvitation = await db.invitation.findFirst({
where: { membershipId: membership.id },
orderBy: { createdAt: 'desc' },
select: {
email: true,
expiresAt: true,
acceptedAt: true,
revokedAt: true,
createdAt: true,
},
});
return {
id: membership.id,
employeeNumber: membership.employeeNumber,
@@ -217,6 +244,15 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
})),
})),
canSeeSalary,
canInvite: can(actor, 'members.invite'),
invitation: lastInvitation
? {
state: invitationState(lastInvitation, new Date()),
email: lastInvitation.email,
expiresAt: lastInvitation.expiresAt,
createdAt: lastInvitation.createdAt,
}
: null,
};
});
}
+179
View File
@@ -0,0 +1,179 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { passwordProblem } from '@/domain/access/invitation';
import { isEmailAddress } from '@/domain/email/message';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import {
acceptInvitation,
issueInvitation,
resolveInvitation,
} from '@/server/invitations/service';
export interface InvitationState {
error?: string;
ok?: boolean;
message?: string;
/** Rendu une fois à l'émetteur, jamais rechargé ni journalisé. */
url?: string;
}
const inviteInput = z.object({
membershipId: z.string().trim().min(1),
email: z.string().trim().min(1, 'Adresse électronique requise').max(255),
});
/**
* Invite un salarié à se connecter.
*
* Sous `members.invite` et non `members.edit` : ouvrir un accès à l'application
* n'est pas modifier un dossier. Un client peut vouloir qu'un responsable
* corrige une fiche sans pouvoir distribuer des accès.
*/
export async function inviteMemberAction(
_previous: InvitationState,
formData: FormData,
): Promise<InvitationState> {
const parsed = inviteInput.safeParse({
membershipId: formData.get('membershipId'),
email: formData.get('email'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
if (!isEmailAddress(parsed.data.email)) {
return { error: 'Cette adresse électronique n’est pas valide.' };
}
let outcome: InvitationState;
try {
outcome = await mutate('members.invite', async (db, actor) => {
const result = await issueInvitation(db, actor.accountId, {
membershipId: parsed.data.membershipId,
email: parsed.data.email,
createdBy: actor.membershipId,
});
if (!result.ok) {
return { error: result.error ?? 'Invitation impossible.' };
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.invite',
entityType: 'Membership',
entityId: parsed.data.membershipId,
// L'adresse est la donnée utile en relecture ; le jeton, jamais.
after: { email: parsed.data.email, delivered: result.delivered },
});
// Créée mais non délivrée : le succès est réel côté base, l'échec l'est
// aussi côté salarié. L'écran doit dire les deux plutôt que d'en choisir
// un — annoncer « envoyée » ferait attendre un message qui n'arrivera pas.
const link = result.url ? { url: result.url } : {};
return result.delivered
? {
ok: true,
message: `Invitation envoyée à ${parsed.data.email}.`,
...link,
}
: { ok: true, error: result.error ?? 'Invitation non envoyée.', ...link };
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit d'inviter un salarié.");
}
revalidatePath(`/equipe/${parsed.data.membershipId}`);
revalidatePath('/equipe');
return outcome;
}
export async function revokeInvitationAction(
_previous: InvitationState,
formData: FormData,
): Promise<InvitationState> {
const membershipId = String(formData.get('membershipId') ?? '');
if (!membershipId) return { error: 'Salarié introuvable.' };
try {
await mutate('members.invite', async (db, actor) => {
const revoked = await db.invitation.updateMany({
where: { membershipId, acceptedAt: null, revokedAt: null },
data: { revokedAt: new Date(), revokedBy: actor.membershipId },
});
if (revoked.count === 0) return;
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.invite.revoke',
entityType: 'Membership',
entityId: membershipId,
after: { revoked: revoked.count },
});
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit de révoquer une invitation.");
}
revalidatePath(`/equipe/${membershipId}`);
return { ok: true, message: 'Invitation révoquée. Le lien ne fonctionne plus.' };
}
/**
* Acceptation, côté destinataire — **sans session**.
*
* Aucun `mutate` ici : il n'y a pas encore d'acteur. Le droit vient du lien
* lui-même, et c'est pourquoi sa durée est courte et son usage unique.
*/
export async function acceptInvitationAction(
_previous: InvitationState,
formData: FormData,
): Promise<InvitationState> {
const token = String(formData.get('token') ?? '');
const password = String(formData.get('password') ?? '');
const confirmation = String(formData.get('confirmation') ?? '');
const target = await resolveInvitation(token);
if (!target) return { error: 'Lien d’invitation invalide.' };
if (!target.hasUser) {
const problem = passwordProblem(password, {
firstName: target.firstName,
lastName: target.lastName,
email: target.email,
});
if (problem) return { error: problem };
if (password !== confirmation) {
return { error: 'Les deux mots de passe ne correspondent pas.' };
}
}
const result = await acceptInvitation(
token,
target.hasUser ? null : password,
);
if (!result.ok) return { error: result.error };
// Pas de connexion automatique : le salarié vient de choisir un mot de passe,
// s'en servir tout de suite le fixe en mémoire. Et si son compte existait
// déjà, l'ouvrir sans mot de passe transformerait le lien en dérobade
// d'authentification.
return {
ok: true,
message: 'Votre accès est actif. Connectez-vous avec votre adresse.',
};
}
function toState(error: unknown, denied: string): InvitationState {
if (error instanceof AuthorizationError) return { error: denied };
throw error;
}
+277
View File
@@ -0,0 +1,277 @@
import 'server-only';
import {
composeInvitationToken,
expiryFrom,
invitationState,
splitInvitationToken,
type InvitationState,
} from '@/domain/access/invitation';
import { invitationMessage } from '@/domain/email/message';
import { env } from '@/lib/env';
import { hashPassword } from '@/server/auth/session';
import { generateToken, hashToken } from '@/server/crypto';
import { sendEmail } from '@/server/email/mailer';
import type { ScopedClient } from '@/server/tenant';
import { withTenant } from '@/server/tenant';
/**
* Cycle de vie d'une invitation.
*
* Le secret n'existe en clair qu'entre sa génération et son départ dans le
* message : la base n'en garde que l'empreinte, comme pour un jeton de session.
* Personne — pas même un administrateur — ne peut relire un lien envoyé, ce qui
* est le comportement attendu et la raison pour laquelle « renvoyer » émet un
* nouveau lien plutôt que de rejouer l'ancien.
*/
export interface IssueInput {
membershipId: string;
email: string;
createdBy: string;
}
export interface IssueResult {
ok: boolean;
/** Motif d'échec destiné à l'écran. */
error?: string;
/** Vrai si le message est parti. Faux = invitation créée mais non délivrée. */
delivered: boolean;
/**
* Le lien, rendu **une seule fois** à celui qui vient de l'émettre.
*
* 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. C'est aussi la réponse à « le message n'est pas arrivé ».
* Il n'est ni conservé ni journalisé : rouvrir l'écran ne le remontre pas.
*/
url?: string;
}
/**
* Émet une invitation et l'envoie.
*
* Toute invitation encore en attente pour ce salarié est révoquée d'abord :
* laisser deux liens vivants pour un même accès multiplie les portes sans
* qu'aucune ne soit tracée comme celle qui a servi.
*/
export async function issueInvitation(
db: ScopedClient,
accountId: string,
input: IssueInput,
): Promise<IssueResult> {
const membership = await db.membership.findUnique({
where: { id: input.membershipId },
include: { profile: { select: { firstName: true } }, user: { select: { id: true } } },
});
if (!membership) {
return { ok: false, error: 'Salarié introuvable.', delivered: false };
}
if (membership.archivedAt) {
return {
ok: false,
error: 'Ce salarié est archivé : réactivez-le avant de lui ouvrir un accès.',
delivered: false,
};
}
const now = new Date();
const email = input.email.toLowerCase().trim();
await db.invitation.updateMany({
where: { membershipId: membership.id, acceptedAt: null, revokedAt: null },
data: { revokedAt: now, revokedBy: input.createdBy },
});
const secret = generateToken();
await db.invitation.create({
data: {
membershipId: membership.id,
tokenHash: hashToken(secret),
email,
expiresAt: expiryFrom(now),
createdBy: input.createdBy,
} as never,
});
await db.membership.update({
where: { id: membership.id },
data: { status: 'INVITED', invitedAt: now } as never,
});
const account = await db.account.findFirst({ select: { name: true } });
const url = `${env.APP_URL}/invitation/${composeInvitationToken(accountId, secret)}`;
const message = invitationMessage(email, {
firstName: membership.profile?.firstName ?? '',
accountName: account?.name ?? 'PlanFlow',
url,
expiresAt: expiryFrom(now),
});
const sent = await sendEmail(db, message, 'INVITATION');
// L'invitation existe même si le message n'est pas parti : elle est valide, et
// l'écran propose de renvoyer. Supprimer l'invitation sur échec d'envoi
// masquerait le vrai problème, qui est la configuration du serveur.
return sent.ok
? { ok: true, delivered: true, url }
: {
ok: true,
delivered: false,
url,
error: `Invitation créée mais non envoyée — ${sent.error ?? 'raison inconnue'}. Transmettez le lien ci-dessous, ou vérifiez les réglages d’envoi de courrier.`,
};
}
export interface InvitationTarget {
accountId: string;
invitationId: string;
membershipId: string;
state: InvitationState;
email: string;
firstName: string;
lastName: string;
accountName: string;
/** Un compte existe déjà pour cette adresse : il n'y a pas de mot de passe à choisir. */
hasUser: boolean;
}
/**
* Résout un lien d'invitation, sans session.
*
* Le compte vient du lien lui-même — la politique RLS exige de le connaître
* avant toute lecture. Un identifiant de compte inventé ne donne rien : la
* requête s'exécute dans un périmètre où l'empreinte cherchée n'existe pas.
*/
export async function resolveInvitation(
token: string,
): Promise<InvitationTarget | null> {
const parts = splitInvitationToken(token);
if (!parts) return null;
try {
return await withTenant(parts.accountId, async (db) => {
const invitation = await db.invitation.findFirst({
where: { tokenHash: hashToken(parts.secret) },
include: {
membership: {
include: {
profile: { select: { firstName: true, lastName: true } },
user: { select: { id: true } },
},
},
},
});
if (!invitation) return null;
const account = await db.account.findFirst({ select: { name: true } });
return {
accountId: parts.accountId,
invitationId: invitation.id,
membershipId: invitation.membershipId,
state: invitationState(invitation, new Date()),
email: invitation.email,
firstName: invitation.membership.profile?.firstName ?? '',
lastName: invitation.membership.profile?.lastName ?? '',
accountName: account?.name ?? 'PlanFlow',
hasUser: invitation.membership.userId !== null,
};
});
} catch {
// Un identifiant de compte malformé fait échouer la transaction avant toute
// lecture. Du point de vue de l'appelant c'est un lien invalide, pas une
// panne : le dire autrement inviterait à sonder les erreurs.
return null;
}
}
export type AcceptResult =
| { ok: true; email: string }
| { ok: false; error: string };
/**
* Accepte une invitation.
*
* Deux cas, et un seul demande un mot de passe :
*
* - **Aucun compte pour cette adresse** — on le crée avec le mot de passe
* choisi ici.
* - **Un compte existe déjà** — on rattache simplement le salarié, sans
* toucher au 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 le mot de
* passe d'un compte existant.
*/
export async function acceptInvitation(
token: string,
password: string | null,
): Promise<AcceptResult> {
const parts = splitInvitationToken(token);
if (!parts) return { ok: false, error: 'Lien d’invitation invalide.' };
const target = await resolveInvitation(token);
if (!target) return { ok: false, error: 'Lien d’invitation invalide.' };
if (target.state !== 'PENDING') {
return { ok: false, error: REFUSAL[target.state] };
}
// L'empreinte est calculée hors transaction : argon2 tient plusieurs
// centaines de millisecondes, et les tenir ouverte sur une transaction
// immobiliserait une connexion du pool pour rien.
const passwordHash = password ? await hashPassword(password) : null;
return withTenant(parts.accountId, async (db) => {
// Marquer d'abord, et sur la condition « pas encore acceptée » : deux
// ouvertures simultanées du même lien ne doivent pas créer deux comptes.
const claimed = await db.invitation.updateMany({
where: { id: target.invitationId, acceptedAt: null, revokedAt: null },
data: { acceptedAt: new Date() },
});
if (claimed.count === 0) {
return { ok: false as const, error: REFUSAL.ACCEPTED };
}
const existing = await db.user.findUnique({
where: { email: target.email },
select: { id: true },
});
let userId = existing?.id ?? null;
if (!userId) {
if (!passwordHash) {
throw new Error('Mot de passe requis pour créer un compte');
}
const created = await db.user.create({
data: {
email: target.email,
passwordHash,
firstName: target.firstName,
lastName: target.lastName,
},
});
userId = created.id;
}
await db.membership.update({
where: { id: target.membershipId },
data: { userId, status: 'ACTIVE' } as never,
});
return { ok: true as const, email: target.email };
});
}
const REFUSAL: Record<InvitationState, string> = {
PENDING: '',
ACCEPTED:
'Cette invitation a déjà été acceptée. Connectez-vous, ou demandez un nouveau lien.',
REVOKED:
'Cette invitation a été révoquée. Rapprochez-vous de votre responsable.',
EXPIRED:
'Cette invitation a expiré. Demandez à votre responsable de vous en envoyer une nouvelle.',
};
+204
View File
@@ -0,0 +1,204 @@
import { expect, test, type Page } from '@playwright/test';
/**
* Entrée d'un salarié dans l'application.
*
* C'est le seul chemin d'accès : sans lui, personne ne peut se connecter à un
* déploiement neuf. Le parcours complet est éprouvé — invitation, choix du mot
* de passe, puis connexion réelle avec ce mot de passe.
*
* Chaque test crée **son** salarié : l'état d'une invitation est porté par la
* personne, et deux tests qui se partageraient un salarié se marcheraient
* dessus au deuxième passage.
*/
const password = 'les mesanges du matin';
async function createEmployee(page: Page, tag: string) {
const suffix = `${Date.now()}-${tag}`;
const firstName = 'Sacha';
const lastName = `Test${suffix}`;
const email = `sacha.${suffix}@exemple.test`;
await page.goto('/equipe');
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.getByLabel('Prénom').fill(firstName);
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`E2E${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
await form.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
const row = page.getByRole('link', { name: new RegExp(lastName) });
await expect(row).toBeVisible();
await row.click();
await expect(
page.getByRole('heading', { name: `${firstName} ${lastName}` }),
).toBeVisible();
return { firstName, lastName, email, url: page.url() };
}
function panel(page: Page) {
return page.locator('form').filter({ hasText: 'Adresse d’invitation' });
}
/** Le libellé bascule de « Inviter » à « Renvoyer » : viser le rôle, pas le mot. */
function inviteButton(page: Page) {
return panel(page).getByRole('button');
}
/** Le lien n'est rendu qu'une fois, après l'émission. */
function shownLink(page: Page) {
return page.getByTestId('invitation-link');
}
test('un salarié invité choisit son mot de passe et se connecte', async ({
page,
browser,
}) => {
const employee = await createEmployee(page, 'complet');
await inviteButton(page).click();
// Le lien est rendu une fois à l'émetteur : sans serveur d'envoi configuré,
// c'est le seul moyen d'amorcer un déploiement neuf.
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
// Navigateur vierge. `storageState: undefined` est indispensable : sans lui
// le contexte hérite de la session du projet, et le test se déroulerait
// sous l'identité du responsable au lieu de celle du destinataire.
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(url);
await expect(guestPage.getByText(employee.email)).toBeVisible();
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();
// La preuve que l'accès existe n'est pas le message de confirmation, c'est
// une connexion réussie avec le mot de passe qui vient d'être choisi.
await guestPage.goto('/connexion');
await guestPage.getByLabel('Adresse électronique').fill(employee.email);
await guestPage.getByLabel('Mot de passe').fill(password);
await guestPage.getByRole('button', { name: 'Se connecter' }).click();
await expect(guestPage.getByRole('heading').first()).toBeVisible();
await guest.close();
});
test('un lien d’invitation ne sert qu’une fois', async ({ page, browser }) => {
const employee = await createEmployee(page, 'unique');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
const first = await browser.newContext({ storageState: undefined });
const firstPage = await first.newPage();
await firstPage.goto(url);
await firstPage.getByLabel('Choisissez un mot de passe').fill(password);
await firstPage.getByLabel('Confirmez').fill(password);
await firstPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(firstPage.getByText(/Votre accès est actif/)).toBeVisible();
await first.close();
// Rejouer le lien ne doit rien rouvrir : un message transféré ou une boîte
// compromise ne donnerait sinon un second accès au même compte.
const second = await browser.newContext({ storageState: undefined });
const secondPage = await second.newPage();
await secondPage.goto(url);
await expect(secondPage.getByText('Invitation acceptée')).toBeVisible();
await expect(
secondPage.getByLabel('Choisissez un mot de passe'),
).toHaveCount(0);
await second.close();
expect(employee.email).toContain('@');
});
test('renvoyer une invitation invalide la précédente', async ({
page,
browser,
}) => {
await createEmployee(page, 'renvoi');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const first = (await shownLink(page).textContent())!.trim();
await inviteButton(page).click();
// Le second lien remplace le premier à l'écran : attendre qu'il ait changé
// plutôt que de lire trop tôt.
await expect(shownLink(page)).not.toHaveText(first);
const second = (await shownLink(page).textContent())!.trim();
expect(second).not.toBe(first);
// Deux liens vivants pour un même accès, c'est deux portes dont une seule
// est tracée comme ayant servi.
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(first);
await expect(guestPage.getByText('Invitation révoquée')).toBeVisible();
await guest.close();
});
test('une invitation révoquée refuse le lien', async ({ page, browser }) => {
await createEmployee(page, 'revoc');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
await page.getByRole('button', { name: 'Révoquer l’invitation' }).click();
await expect(page.getByText(/Le lien ne fonctionne plus/)).toBeVisible();
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(url);
await expect(guestPage.getByText('Invitation révoquée')).toBeVisible();
await guest.close();
});
test('un lien fabriqué est refusé sans rien révéler', async ({ browser }) => {
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
// L'identifiant de compte est porté par le lien ; un identifiant inventé ne
// doit pas produire d'erreur serveur, seulement un refus.
await guestPage.goto('/invitation/cmxxxxxxxxxxxxxxxxxxxxxx.jeton-invente');
await expect(guestPage.getByText(/n’est pas valide/)).toBeVisible();
await guestPage.goto('/invitation/sans-separateur');
await expect(guestPage.getByText(/n’est pas valide/)).toBeVisible();
await guest.close();
});
test('un mot de passe contenant le nom du salarié est refusé', async ({
page,
browser,
}) => {
const employee = await createEmployee(page, 'motdepasse');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(url);
const weak = `${employee.lastName}-2026-abcdef`;
await guestPage.getByLabel('Choisissez un mot de passe').fill(weak);
await guestPage.getByLabel('Confirmez').fill(weak);
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(guestPage.getByText(/ne doit pas contenir votre nom/)).toBeVisible();
// Le refus ne consomme pas l'invitation : le formulaire reste utilisable.
await expect(guestPage.getByLabel('Choisissez un mot de passe')).toBeVisible();
await guest.close();
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import {
composeInvitationToken,
expiryFrom,
INVITATION_TTL_DAYS,
invitationState,
MIN_PASSWORD_LENGTH,
passwordProblem,
splitInvitationToken,
} from '@/domain/access/invitation';
const now = new Date('2026-03-10T09:00:00Z');
function invitation(overrides: Partial<{
expiresAt: Date;
acceptedAt: Date | null;
revokedAt: Date | null;
}> = {}) {
return {
expiresAt: new Date('2026-03-17T09:00:00Z'),
acceptedAt: null,
revokedAt: null,
...overrides,
};
}
describe('état d’une invitation', () => {
it('est en attente tant qu’elle n’a rien subi', () => {
expect(invitationState(invitation(), now)).toBe('PENDING');
});
it('expire à l’instant exact de son échéance', () => {
// La borne compte : une invitation « valable jusqu'au 17 » ne doit pas
// rester utilisable une seconde de plus.
const expiresAt = new Date('2026-03-10T09:00:00Z');
expect(invitationState(invitation({ expiresAt }), now)).toBe('EXPIRED');
expect(
invitationState(
invitation({ expiresAt: new Date('2026-03-10T09:00:01Z') }),
now,
),
).toBe('PENDING');
});
it('reste acceptée après son expiration', () => {
// Dire « expirée » d'un accès qui a servi tromperait celui qui relit le
// dossier : l'accès existe, la date d'échéance ne le concerne plus.
const state = invitationState(
invitation({
expiresAt: new Date('2026-01-01T00:00:00Z'),
acceptedAt: new Date('2025-12-30T00:00:00Z'),
}),
now,
);
expect(state).toBe('ACCEPTED');
});
it('distingue la révocation de l’expiration', () => {
expect(
invitationState(invitation({ revokedAt: new Date('2026-03-09T00:00:00Z') }), now),
).toBe('REVOKED');
});
it('donne la priorité à l’acceptation sur la révocation', () => {
// Révoquer après coup ne défait pas un accès déjà pris : c'est le
// membership qu'il faut archiver, et le dossier doit le montrer.
const state = invitationState(
invitation({
acceptedAt: new Date('2026-03-08T00:00:00Z'),
revokedAt: new Date('2026-03-09T00:00:00Z'),
}),
now,
);
expect(state).toBe('ACCEPTED');
});
});
describe('échéance', () => {
it('court sur la durée annoncée', () => {
const expiry = expiryFrom(now);
const days = (expiry.getTime() - now.getTime()) / 86_400_000;
expect(days).toBe(INVITATION_TTL_DAYS);
});
});
describe('jeton de lien', () => {
it('fait l’aller-retour', () => {
const token = composeInvitationToken('acc_123', 'sEcReT-value');
expect(splitInvitationToken(token)).toEqual({
accountId: 'acc_123',
secret: 'sEcReT-value',
});
});
it('coupe au premier point seulement', () => {
// Un secret contenant un point ne doit pas être tronqué : il partirait
// valide dans le message et serait refusé au retour.
expect(splitInvitationToken('acc.a.b.c')).toEqual({
accountId: 'acc',
secret: 'a.b.c',
});
});
it('refuse les formes dégénérées', () => {
expect(splitInvitationToken('sansPoint')).toBeNull();
expect(splitInvitationToken('.secret')).toBeNull();
expect(splitInvitationToken('compte.')).toBeNull();
expect(splitInvitationToken('')).toBeNull();
});
});
describe('mot de passe', () => {
const personal = {
firstName: 'Camille',
lastName: 'Rivière',
email: 'camille.riviere@example.fr',
};
it('accepte une phrase de passe', () => {
expect(passwordProblem('le vent se leve en mars', personal)).toBeNull();
});
it('refuse en dessous de la longueur minimale', () => {
const problem = passwordProblem('a'.repeat(MIN_PASSWORD_LENGTH - 1), {});
expect(problem).toContain(String(MIN_PASSWORD_LENGTH));
});
it('refuse un mot de passe contenant le nom', () => {
// Prénom et nom sont les premières combinaisons essayées, et ce sont
// précisément celles que l'invitation vient de divulguer.
expect(passwordProblem('Camille-2026-ok', personal)).toMatch(/nom/);
expect(passwordProblem('xx-riviere-xxxxxx', personal)).toMatch(/nom/);
});
it('refuse un mot de passe contenant l’adresse', () => {
expect(
passwordProblem('camille.riviere+1', personal),
).toMatch(/nom|adresse/);
});
it('ignore un nom trop court pour être discriminant', () => {
// Sans ce garde-fou, un salarié nommé « Li » ne pourrait employer aucun
// mot de passe contenant ces deux lettres.
expect(passwordProblem('lit de camp fleuri', { lastName: 'Li' })).toBeNull();
});
it('refuse une répétition du même caractère', () => {
expect(passwordProblem('aaaaaaaaaaaaaaaa', {})).toMatch(/répétition/);
});
it('borne la longueur haute', () => {
// Argon2 sur une saisie de plusieurs mégaoctets immobiliserait le serveur.
expect(passwordProblem('x'.repeat(201), {})).toMatch(/200/);
});
});