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:
14 files changed
+1383
-1
No files matched your search
@@ -66,7 +66,10 @@ export default defineConfig({
|
||||
`node .next/standalone/server.js`,
|
||||
].join(' && '),
|
||||
url: baseURL,
|
||||
env: { PORT: String(PORT), HOSTNAME: '127.0.0.1' },
|
||||
// APP_URL fabrique les liens des messages — invitations comprises. Le
|
||||
// laisser sur sa valeur par défaut ferait pointer les liens vers un port
|
||||
// où rien n'écoute pendant les tests.
|
||||
env: { PORT: String(PORT), HOSTNAME: '127.0.0.1', APP_URL: baseURL },
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invitation" ADD COLUMN "revokedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "revokedBy" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Invitation_membershipId_idx" ON "Invitation"("membershipId");
|
||||
@@ -197,6 +197,8 @@ model Session {
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Invitation à créer un accès. Le jeton n'existe en clair que dans le message
|
||||
/// envoyé : seule son empreinte est conservée, comme pour une session.
|
||||
model Invitation {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
@@ -205,12 +207,18 @@ model Invitation {
|
||||
email String
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
/// Révocation explicite. Distincte de l'expiration : « je retire cet accès »
|
||||
/// et « personne n'a répondu » ne se lisent pas de la même façon dans un
|
||||
/// dossier.
|
||||
revokedAt DateTime?
|
||||
revokedBy String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
@@index([membershipId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
INVITATION_STATE_LABELS,
|
||||
type InvitationState as State,
|
||||
} from '@/domain/access/invitation';
|
||||
import {
|
||||
inviteMemberAction,
|
||||
revokeInvitationAction,
|
||||
type InvitationState,
|
||||
} from '@/server/invitations/actions';
|
||||
|
||||
const empty: InvitationState = {};
|
||||
|
||||
const TONES: Record<State, Tone> = {
|
||||
PENDING: 'info',
|
||||
ACCEPTED: 'ok',
|
||||
REVOKED: 'neutral',
|
||||
EXPIRED: 'warn',
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||
|
||||
export interface InvitationView {
|
||||
state: State;
|
||||
email: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accès applicatif d'un salarié.
|
||||
*
|
||||
* Le lien envoyé n'est **pas réaffiché** : seule son empreinte est conservée.
|
||||
* « Renvoyer » émet donc un nouveau lien et invalide le précédent — ce qui est
|
||||
* aussi la bonne réponse à « il a perdu le message ».
|
||||
*/
|
||||
export function InvitationPanel({
|
||||
membershipId,
|
||||
defaultEmail,
|
||||
hasAccount,
|
||||
invitation,
|
||||
}: {
|
||||
membershipId: string;
|
||||
defaultEmail: string;
|
||||
hasAccount: boolean;
|
||||
invitation: InvitationView | null;
|
||||
}) {
|
||||
const [inviteState, invite, inviting] = useActionState(
|
||||
inviteMemberAction,
|
||||
empty,
|
||||
);
|
||||
const [revokeState, revoke, revoking] = useActionState(
|
||||
revokeInvitationAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
const pending = invitation?.state === 'PENDING';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{invitation ? (
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-ink-2">
|
||||
<Badge tone={TONES[invitation.state]}>
|
||||
{INVITATION_STATE_LABELS[invitation.state]}
|
||||
</Badge>
|
||||
<span className="text-ink-1">{invitation.email}</span>
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
{invitation.state === 'PENDING'
|
||||
? `valable jusqu’au ${dateFormat.format(invitation.expiresAt)}`
|
||||
: `envoyée le ${dateFormat.format(invitation.createdAt)}`}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasAccount ? (
|
||||
<p className="text-sm text-ink-2">
|
||||
Ce salarié a un accès actif. Réinviter n’est utile que pour rattacher
|
||||
une nouvelle adresse.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-ink-2">
|
||||
Sans invitation, ce salarié reste planifiable et exportable, mais ne
|
||||
peut ni consulter son planning ni demander une absence.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={invite} className="flex flex-wrap items-end gap-3">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Adresse d’invitation
|
||||
</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={defaultEmail}
|
||||
required
|
||||
className="h-8 w-72 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="primary" disabled={inviting}>
|
||||
{pending ? 'Renvoyer l’invitation' : 'Inviter'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{pending ? (
|
||||
<form action={revoke}>
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
<Button type="submit" disabled={revoking}>
|
||||
Révoquer l’invitation
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{inviteState.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{inviteState.error}
|
||||
</p>
|
||||
) : null}
|
||||
{inviteState.message ? (
|
||||
<p className="text-xs text-ok-soft-ink">{inviteState.message}</p>
|
||||
) : null}
|
||||
|
||||
{inviteState.url ? (
|
||||
<div className="rounded-3 border border-line-2 bg-surface-2 p-3">
|
||||
<p className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Lien d’invitation
|
||||
</p>
|
||||
{/* Affiché une seule fois. Recharger l'écran ne le remontrera pas :
|
||||
la base n'en garde que l'empreinte. */}
|
||||
<p
|
||||
data-testid="invitation-link"
|
||||
className="mt-1 break-all font-mono text-xs text-ink-1"
|
||||
>
|
||||
{inviteState.url}
|
||||
</p>
|
||||
<p className="mt-1 text-micro text-ink-3">
|
||||
Visible une seule fois. Utile si le message n’arrive pas, ou tant
|
||||
que le serveur d’envoi n’est pas configuré.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{revokeState.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{revokeState.error}
|
||||
</p>
|
||||
) : null}
|
||||
{revokeState.message ? (
|
||||
<p className="text-xs text-ok-soft-ink">{revokeState.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
@@ -62,6 +63,20 @@ export default async function FichePage({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={
|
||||
employee.email ?? employee.profile?.personalEmail ?? ''
|
||||
}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Contrats et avenants"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MIN_PASSWORD_LENGTH } from '@/domain/access/invitation';
|
||||
import {
|
||||
acceptInvitationAction,
|
||||
type InvitationState,
|
||||
} from '@/server/invitations/actions';
|
||||
|
||||
const empty: InvitationState = {};
|
||||
|
||||
export function AcceptInvitationForm({
|
||||
token,
|
||||
email,
|
||||
hasUser,
|
||||
}: {
|
||||
token: string;
|
||||
email: string;
|
||||
hasUser: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
acceptInvitationAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
if (state.ok) {
|
||||
return (
|
||||
<div className="rounded-3 border border-ok bg-ok-soft p-4 text-sm text-ok-soft-ink">
|
||||
<p>{state.message}</p>
|
||||
<p className="mt-3">
|
||||
<Link href="/connexion" className="font-medium underline">
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col gap-4 rounded-3 border border-line-1 bg-surface p-4"
|
||||
>
|
||||
<input type="hidden" name="token" value={token} />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Votre adresse
|
||||
</span>
|
||||
{/* Non modifiable : l'invitation vaut pour cette adresse, et pouvoir la
|
||||
changer ici reviendrait à ouvrir l'accès à qui bon semble. */}
|
||||
<p className="text-sm text-ink-1">{email}</p>
|
||||
</div>
|
||||
|
||||
{hasUser ? (
|
||||
<p className="text-sm text-ink-2">
|
||||
Un compte existe déjà pour cette adresse. Il sera simplement rattaché à
|
||||
votre nouvel employeur : votre mot de passe actuel reste valable.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Choisissez un mot de passe
|
||||
</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={MIN_PASSWORD_LENGTH}
|
||||
required
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">
|
||||
{MIN_PASSWORD_LENGTH} caractères au minimum. Une phrase dont vous
|
||||
vous souvenez vaut mieux qu’un assemblage de symboles.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Confirmez
|
||||
</span>
|
||||
<input
|
||||
name="confirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{hasUser ? 'Rattacher mon compte' : 'Activer mon accès'}
|
||||
</Button>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user