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`,
|
`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");
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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