diff --git a/playwright.config.ts b/playwright.config.ts index 8074bc8..482749c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, }, diff --git a/prisma/migrations/20260808130804_invitation_revocation/migration.sql b/prisma/migrations/20260808130804_invitation_revocation/migration.sql new file mode 100644 index 0000000..75ee4fc --- /dev/null +++ b/prisma/migrations/20260808130804_invitation_revocation/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7bcaf00..6ad590d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } // ============================================================================ diff --git a/src/app/(app)/equipe/[id]/InvitationPanel.tsx b/src/app/(app)/equipe/[id]/InvitationPanel.tsx new file mode 100644 index 0000000..dde8603 --- /dev/null +++ b/src/app/(app)/equipe/[id]/InvitationPanel.tsx @@ -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 = { + 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 ( +
+ {invitation ? ( +

+ + {INVITATION_STATE_LABELS[invitation.state]} + + {invitation.email} + + {invitation.state === 'PENDING' + ? `valable jusqu’au ${dateFormat.format(invitation.expiresAt)}` + : `envoyée le ${dateFormat.format(invitation.createdAt)}`} + +

+ ) : null} + + {hasAccount ? ( +

+ Ce salarié a un accès actif. Réinviter n’est utile que pour rattacher + une nouvelle adresse. +

+ ) : ( +

+ Sans invitation, ce salarié reste planifiable et exportable, mais ne + peut ni consulter son planning ni demander une absence. +

+ )} + +
+ + + +
+ + {pending ? ( +
+ + +
+ ) : null} + + {inviteState.error ? ( +

+ {inviteState.error} +

+ ) : null} + {inviteState.message ? ( +

{inviteState.message}

+ ) : null} + + {inviteState.url ? ( +
+

+ Lien d’invitation +

+ {/* Affiché une seule fois. Recharger l'écran ne le remontrera pas : + la base n'en garde que l'empreinte. */} +

+ {inviteState.url} +

+

+ Visible une seule fois. Utile si le message n’arrive pas, ou tant + que le serveur d’envoi n’est pas configuré. +

+
+ ) : null} + {revokeState.error ? ( +

+ {revokeState.error} +

+ ) : null} + {revokeState.message ? ( +

{revokeState.message}

+ ) : null} +
+ ); +} diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx index aadfdde..ad22c5b 100644 --- a/src/app/(app)/equipe/[id]/page.tsx +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -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} + {employee.canInvite ? ( + + + + + ) : null} + +

{state.message}

+

+ + Se connecter + +

+ + ); + } + + return ( +
+ + +
+ + Votre adresse + + {/* Non modifiable : l'invitation vaut pour cette adresse, et pouvoir la + changer ici reviendrait à ouvrir l'accès à qui bon semble. */} +

{email}

+
+ + {hasUser ? ( +

+ Un compte existe déjà pour cette adresse. Il sera simplement rattaché à + votre nouvel employeur : votre mot de passe actuel reste valable. +

+ ) : ( + <> + + + + + )} + + + + {state.error ? ( +

+ {state.error} +

+ ) : null} +
+ ); +} diff --git a/src/app/invitation/[token]/page.tsx b/src/app/invitation/[token]/page.tsx new file mode 100644 index 0000000..45949d0 --- /dev/null +++ b/src/app/invitation/[token]/page.tsx @@ -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 ( +
+
+

+ PlanFlow +

+

+ {invitation?.state === 'PENDING' + ? `Bienvenue${invitation.firstName ? `, ${invitation.firstName}` : ''}` + : 'Invitation'} +

+
+ + {!invitation ? ( + + ) : invitation.state !== 'PENDING' ? ( + + ) : ( + <> +

+ {invitation.accountName} vous + ouvre un accès à PlanFlow, où vous consulterez votre planning, vos + compteurs et vos demandes d’absence. +

+ + + )} + +

+ + Aller à la connexion + +

+
+ ); +} + +function Refusal({ title, message }: { title?: string; message: string }) { + return ( +
+ {title ?

{title}

: null} +

{message}

+
+ ); +} diff --git a/src/domain/access/invitation.ts b/src/domain/access/invitation.ts new file mode 100644 index 0000000..8a680bf --- /dev/null +++ b/src/domain/access/invitation.ts @@ -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 = { + PENDING: 'Invitation en attente', + ACCEPTED: 'Invitation acceptée', + REVOKED: 'Invitation révoquée', + EXPIRED: 'Invitation expirée', +}; diff --git a/src/domain/access/permissions.ts b/src/domain/access/permissions.ts index b61189d..990681f 100644 --- a/src/domain/access/permissions.ts +++ b/src/domain/access/permissions.ts @@ -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', diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts index 3b5e629..e288026 100644 --- a/src/server/employees/queries.ts +++ b/src/server/employees/queries.ts @@ -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 { @@ -163,6 +175,21 @@ export async function getEmployee(id: string): Promise { }) : 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 { })), })), canSeeSalary, + canInvite: can(actor, 'members.invite'), + invitation: lastInvitation + ? { + state: invitationState(lastInvitation, new Date()), + email: lastInvitation.email, + expiresAt: lastInvitation.expiresAt, + createdAt: lastInvitation.createdAt, + } + : null, }; }); } diff --git a/src/server/invitations/actions.ts b/src/server/invitations/actions.ts new file mode 100644 index 0000000..f3da5fa --- /dev/null +++ b/src/server/invitations/actions.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/src/server/invitations/service.ts b/src/server/invitations/service.ts new file mode 100644 index 0000000..ef2f00d --- /dev/null +++ b/src/server/invitations/service.ts @@ -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 { + 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 { + 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 { + 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 = { + 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.', +}; diff --git a/tests/e2e/invitation.spec.ts b/tests/e2e/invitation.spec.ts new file mode 100644 index 0000000..b310e04 --- /dev/null +++ b/tests/e2e/invitation.spec.ts @@ -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(); +}); diff --git a/tests/unit/invitation.test.ts b/tests/unit/invitation.test.ts new file mode 100644 index 0000000..b2d7c46 --- /dev/null +++ b/tests/unit/invitation.test.ts @@ -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/); + }); +});