Merge pull request #18 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Ouvrir un accès à un salarié par invitation
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-08 23:42:44 +02:00
commit 8701fe8105
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`, `node .next/standalone/server.js`,
].join(' && '), ].join(' && '),
url: baseURL, 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, reuseExistingServer: !process.env.CI,
timeout: 180_000, 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]) @@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 { model Invitation {
id String @id @default(cuid()) id String @id @default(cuid())
accountId String accountId String
@@ -205,12 +207,18 @@ model Invitation {
email String email String
expiresAt DateTime expiresAt DateTime
acceptedAt 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 createdBy String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@index([accountId]) @@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 { notFound } from 'next/navigation';
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
import { PageBody, PageHeader } from '@/components/shell/PageHeader'; import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
@@ -62,6 +63,20 @@ export default async function FichePage({
) : null} ) : null}
</div> </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> <Card>
<CardHeader <CardHeader
title="Contrats et avenants" 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.documents.manage', 'Personnel', 'Gérer les documents'],
['members.register.export', 'Personnel', 'Exporter le registre du personnel'], ['members.register.export', 'Personnel', 'Exporter le registre du personnel'],
['members.dpae.check', 'Personnel', 'Contrôler l’éligibilité DPAE'], ['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 -------------------------------------------------------------- // --- Heures --------------------------------------------------------------
['hours.view', 'Heures', 'Voir les heures'], ['hours.view', 'Heures', 'Voir les heures'],
@@ -178,6 +181,7 @@ const DIRECTOR_PERMISSIONS: string[] = [
'members.documents.manage', 'members.documents.manage',
'members.register.export', 'members.register.export',
'members.dpae.check', 'members.dpae.check',
'members.invite',
'timeoff.delete', 'timeoff.delete',
'timeoff.bypass_notice', 'timeoff.bypass_notice',
'counters.adjust', 'counters.adjust',
+36
View File
@@ -1,4 +1,8 @@
import { can } from '@/domain/access/authorize'; import { can } from '@/domain/access/authorize';
import {
invitationState,
type InvitationState,
} from '@/domain/access/invitation';
import { decryptOptional } from '@/server/crypto'; import { decryptOptional } from '@/server/crypto';
import { query } from '@/server/context'; import { query } from '@/server/context';
@@ -129,6 +133,14 @@ export interface EmployeeDetail extends EmployeeListRow {
amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>; amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>;
}>; }>;
canSeeSalary: boolean; 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> { export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
@@ -163,6 +175,21 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
}) })
: 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 { return {
id: membership.id, id: membership.id,
employeeNumber: membership.employeeNumber, employeeNumber: membership.employeeNumber,
@@ -217,6 +244,15 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
})), })),
})), })),
canSeeSalary, 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/);
});
});