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>
173 lines
6.6 KiB
TypeScript
173 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|