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:
14 files changed
+1383
-1
No files matched your search
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.',
|
||||
};
|
||||
Reference in new issue
Block a user