Ouvrir un accès à un salarié par invitation
Jusqu'ici personne ne pouvait entrer dans l'application : le modèle Invitation existait en base mais aucun code ne s'en servait, et le seul moyen d'obtenir un compte était le jeu de données de démonstration. Le lien vaut autant qu'un mot de passe le temps de sa validité, d'où trois règles : sept jours, un seul usage, et une révocation possible sans attendre l'expiration. Seule l'empreinte du jeton est conservée — renvoyer une invitation émet donc un nouveau lien et invalide le précédent, ce qui est aussi la bonne réponse à « il a perdu le message ». Deux liens vivants pour un même accès, ce sont deux portes dont une seule est tracée comme ayant servi. Le compte est porté par le lien lui-même, préfixé au secret. La table est protégée par RLS, laquelle exige de connaître le compte avant toute lecture : sans ce préfixe il aurait fallu ouvrir la politique aux requêtes sans compte — c'est-à-dire la vider de son sens. Divulguer un identifiant opaque à qui est membre du compte ne coûte rien. Le lien est aussi rendu une fois, à l'écran de celui qui l'émet. Sans cela un déploiement neuf ne peut inviter personne : configurer le serveur d'envoi demande d'être connecté, et être connecté demande une invitation. Il n'est ni conservé ni journalisé. Deux cas à l'acceptation, un seul demande un mot de passe. Si aucun compte n'existe pour l'adresse, il est créé ; s'il en existe un, le salarié est rattaché sans qu'on touche à son mot de passe — détenir le lien prouve l'accès à la boîte, ce qui suffit à rattacher un accès mais ne justifie pas de réinitialiser l'authentification d'un compte existant. Pas de connexion automatique non plus : un mot de passe qu'on vient de choisir se fixe en s'en servant. `members.invite` est une capacité distincte de `members.edit` : ouvrir un accès n'est pas modifier un dossier, et tel client voudra confier l'un sans l'autre. Deux défauts trouvés par les tests plutôt qu'en production : le contrôle qui refuse un mot de passe contenant le nom du salarié laissait passer « riviere » pour « Rivière » faute de replier les accents — soit exactement la variante qu'on tape au clavier ; et les contextes « visiteur » des tests héritaient de la session du responsable, si bien que le parcours anonyme n'était pas éprouvé. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
14 files changed
+1383
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user