feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (pull_request) Successful in 2m45s
All checks were successful
CI / test (pull_request) Successful in 2m45s
Public badge sur fiche carbet :
- carbet-public.ts charge les OrganizationCarbetMembership (org
approuvée uniquement) + expose `organizations: {id,name,slug}[]`.
- /carbets/[slug] affiche « Géré par le CE <name> » sous le header
si au moins 1 org liée.
Invites CE_MEMBER :
- Migration 20260603300000_org_invite_token : OrgInviteToken
(tokenHash, organizationId, email?, createdByUserId, expiresAt,
usedAt). Cascade sur Organization. Index expiresAt + organizationId.
- src/lib/ce-invites.ts : createOrgInviteToken (TTL 14j),
listOrgInviteTokens, getOrgInviteByToken (validité + expiry),
markOrgInviteConsumed, revokeOrgInviteToken. Token = 24 bytes
base64url, hash sha256.
- /espace-ce/membres : liste membres (CE_MANAGER + CE_MEMBER actifs)
+ form de génération de lien (email optionnel = lock email côté
signup) + liste des invitations avec statut actif/consommé/expiré +
bouton révoquer.
- /espace-ce/membres/actions.ts : createInviteAction +
revokeInviteAction. Audit log scope=ce.invite.
- API /api/signup étendue : zod accepte inviteToken, branche dédiée
qui crée User CE_MEMBER + organizationId du token + marquage
usedAt. Vérif email match si email fourni dans le token.
- /inscription?invite=TOKEN : récupère l'invite, pré-affiche org name,
lock email si fourni, masque les fieldsets type de compte (forcé
CE_MEMBER).
CTA marketing :
- /pour-comites-entreprise : section CTA « Créer mon espace CE » sous
le rendu content-pages, conditionnée par plugin ce-management.
Tests vitest (tests/lib/ce-access.test.ts) :
- canManageCarbet : admin always, owner direct, CE_MANAGER via org
match, refus si autre org / pas d'org / TOURIST / pas de membership.
- 9 tests, mocks next-auth + @/auth + @/lib/authorization pour éviter
next/server (incompatible vitest sans setup).
- Total tests projet : 62/62 ✓.
Dashboard /espace-ce : lien vers /espace-ce/membres en bas.
Migration prod appliquée.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab1bbb5484
commit
ea0e606735
14 changed files with 691 additions and 11 deletions
84
src/app/espace-ce/membres/_components/InviteForm.tsx
Normal file
84
src/app/espace-ce/membres/_components/InviteForm.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import type { CreateInviteResult } from "../actions";
|
||||
|
||||
export function InviteForm({
|
||||
action,
|
||||
siteUrl,
|
||||
}: {
|
||||
action: (fd: FormData) => Promise<CreateInviteResult>;
|
||||
siteUrl: string;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
|
||||
function onSubmit(fd: FormData) {
|
||||
setError(null);
|
||||
setLink(null);
|
||||
startTransition(async () => {
|
||||
const res = await action(fd);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setLink(`${siteUrl}/inscription?invite=${res.token}`);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<form action={onSubmit} className="flex flex-wrap items-end gap-2">
|
||||
<label className="block grow">
|
||||
<span className="text-xs text-zinc-600">Email du futur CE_MEMBER (optionnel)</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="prenom.nom@entreprise.gf"
|
||||
maxLength={200}
|
||||
className="mt-0.5 block w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{pending ? "Génération…" : "Générer un lien"}
|
||||
</button>
|
||||
</form>
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{link ? (
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/60 p-3 text-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-800">
|
||||
✓ Lien d'invitation généré (valable 14 jours)
|
||||
</p>
|
||||
<code className="mt-1 block break-all rounded bg-white px-2 py-1.5 font-mono text-xs text-zinc-700">
|
||||
{link}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(link).catch(() => {});
|
||||
}
|
||||
}}
|
||||
className="mt-2 rounded border border-emerald-300 bg-white px-2 py-1 text-[11px] text-emerald-800 hover:bg-emerald-100"
|
||||
>
|
||||
Copier
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
Si vous indiquez un email, le lien sera bloqué pour tout autre adresse à la connexion.
|
||||
Sinon, n'importe qui ayant le lien peut rejoindre votre CE.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/app/espace-ce/membres/actions.ts
Normal file
70
src/app/espace-ce/membres/actions.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import {
|
||||
createOrgInviteToken,
|
||||
revokeOrgInviteToken,
|
||||
} from "@/lib/ce-invites";
|
||||
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type CreateInviteResult =
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function createInviteAction(fd: FormData): Promise<CreateInviteResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { ok: false, error: "Non authentifié." };
|
||||
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) {
|
||||
return { ok: false, error: "Réservé aux CE_MANAGER." };
|
||||
}
|
||||
const org = await getCurrentCeOrganization();
|
||||
if (!org) return { ok: false, error: "Aucune organisation détectée." };
|
||||
if (!org.approved) return { ok: false, error: "Votre organisation doit être validée." };
|
||||
|
||||
const email = ((fd.get("email") as string | null) ?? "").trim().toLowerCase() || null;
|
||||
if (email && !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
|
||||
return { ok: false, error: "Email invalide." };
|
||||
}
|
||||
|
||||
const token = await createOrgInviteToken({
|
||||
organizationId: org.id,
|
||||
createdByUserId: session.user.id,
|
||||
email,
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "ce.invite",
|
||||
event: "invite.create",
|
||||
target: org.id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { email },
|
||||
});
|
||||
revalidatePath("/espace-ce/membres");
|
||||
return { ok: true, token };
|
||||
}
|
||||
|
||||
export async function revokeInviteAction(tokenHash: string): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) return;
|
||||
const org = await getCurrentCeOrganization();
|
||||
if (!org) return;
|
||||
const invite = await prisma.orgInviteToken.findUnique({
|
||||
where: { tokenHash },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (!invite || invite.organizationId !== org.id) return;
|
||||
await revokeOrgInviteToken(tokenHash);
|
||||
await recordAudit({
|
||||
scope: "ce.invite",
|
||||
event: "invite.revoke",
|
||||
target: org.id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: {},
|
||||
});
|
||||
revalidatePath("/espace-ce/membres");
|
||||
}
|
||||
173
src/app/espace-ce/membres/page.tsx
Normal file
173
src/app/espace-ce/membres/page.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||
import { listOrgInviteTokens } from "@/lib/ce-invites";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import { createInviteAction, revokeInviteAction } from "./actions";
|
||||
import { InviteForm } from "./_components/InviteForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Membres CE — Karbé" };
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
CE_MANAGER: "Manager",
|
||||
CE_MEMBER: "Membre",
|
||||
};
|
||||
|
||||
export default async function CeMembresPage() {
|
||||
const org = await getCurrentCeOrganization();
|
||||
if (!org) redirect("/admin/organizations");
|
||||
|
||||
const [members, invites] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
role: { in: [UserRole.CE_MANAGER, UserRole.CE_MEMBER] },
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: [{ role: "asc" }, { lastName: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
listOrgInviteTokens(org.id),
|
||||
]);
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<header>
|
||||
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tableau de bord CE
|
||||
</Link>
|
||||
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
|
||||
Membres — {org.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
{members.length} membre{members.length > 1 ? "s" : ""} actif{members.length > 1 ? "s" : ""}.
|
||||
Générez un lien d'invitation pour qu'un nouveau CE_MEMBER s'inscrive et
|
||||
rejoigne automatiquement votre organisation.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Inviter un membre
|
||||
</h2>
|
||||
{!org.approved ? (
|
||||
<p className="mt-3 text-sm text-amber-900">
|
||||
🕒 La génération d'invitations est bloquée tant que votre organisation n'est
|
||||
pas validée.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<InviteForm action={createInviteAction} siteUrl={siteUrl} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Membres ({members.length})
|
||||
</h2>
|
||||
{members.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">Aucun membre actif pour l'instant.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100">
|
||||
{members.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{m.firstName} {m.lastName}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{m.email}</div>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
|
||||
(m.role === "CE_MANAGER"
|
||||
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
|
||||
: "bg-zinc-100 text-zinc-700 ring-1 ring-inset ring-zinc-300")
|
||||
}
|
||||
>
|
||||
{ROLE_LABEL[m.role] ?? m.role}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Invitations en cours ({invites.filter((i) => !i.usedAt && i.expiresAt > new Date()).length})
|
||||
</h2>
|
||||
{invites.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">Aucune invitation envoyée pour l'instant.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100">
|
||||
{invites.map((inv) => {
|
||||
const expired = inv.expiresAt < new Date();
|
||||
const used = inv.usedAt !== null;
|
||||
const status = used ? "consommé" : expired ? "expiré" : "actif";
|
||||
return (
|
||||
<li
|
||||
key={inv.tokenHash}
|
||||
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-mono text-xs text-zinc-700">
|
||||
{inv.email ?? "(lien partagé)"}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
Créé {dateFmt.format(inv.createdAt)} · Expire {dateFmt.format(inv.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
|
||||
(status === "actif"
|
||||
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
|
||||
: status === "consommé"
|
||||
? "bg-zinc-100 text-zinc-600 ring-1 ring-inset ring-zinc-300"
|
||||
: "bg-amber-100 text-amber-800 ring-1 ring-inset ring-amber-300")
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
{!used && !expired ? (
|
||||
<form action={revokeInviteAction.bind(null, inv.tokenHash)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-rose-200 bg-white px-2 py-0.5 text-[11px] text-rose-700 hover:bg-rose-50"
|
||||
>
|
||||
Révoquer
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,7 +85,11 @@ export default async function CeDashboardPage() {
|
|||
</section>
|
||||
|
||||
<p className="text-xs text-zinc-500">
|
||||
Le bouton « Matériel rental » sera actif au Sprint J du plan CE management.
|
||||
Gérez aussi vos{" "}
|
||||
<Link href="/espace-ce/membres" className="text-zinc-700 underline hover:text-zinc-900">
|
||||
membres et invitations CE
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue