feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
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:
Ubuntu 2026-06-03 00:03:03 +00:00
parent ab1bbb5484
commit ea0e606735
14 changed files with 691 additions and 11 deletions

111
tests/lib/ce-access.test.ts Normal file
View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi } from "vitest";
// L'enum est aussi un type ; on l'importe de manière statique pour TS.
import { UserRole } from "@/generated/prisma/enums";
// next-auth tire next/server qui n'est pas résolu dans le tunnel vitest.
// On stubbe les modules nécessaires avant d'importer carbet-access (qui
// importe Session de next-auth uniquement en type-only, mais authorization.ts
// dépend de auth() — d'où le mock).
vi.mock("next-auth", () => ({ default: () => ({}) }));
vi.mock("@/auth", () => ({ auth: () => Promise.resolve(null) }));
vi.mock("@/lib/authorization", () => ({
requireRole: () => Promise.resolve({}),
}));
const { canManageCarbet } = await import("@/lib/carbet-access");
// Pure-data shape qui satisfait la signature de canManageCarbet sans tirer
// next-auth/server (incompatible vitest sans setup).
type MinimalSession = {
user: {
id: string;
role: UserRole;
organizationId?: string | null;
email?: string | null;
};
};
function makeSession(opts: {
userId: string;
role: UserRole;
organizationId?: string | null;
}): MinimalSession {
return {
user: {
id: opts.userId,
role: opts.role,
organizationId: opts.organizationId ?? null,
email: "test@example.com",
},
};
}
describe("canManageCarbet", () => {
it("admin can always manage", () => {
const session = makeSession({ userId: "u-admin", role: UserRole.ADMIN });
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-other", [])).toBe(true);
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-other", ["org-x"])).toBe(true);
});
it("owner can manage their own carbet", () => {
const session = makeSession({ userId: "u1", role: UserRole.OWNER });
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u1", [])).toBe(true);
});
it("owner cannot manage someone else's carbet", () => {
const session = makeSession({ userId: "u1", role: UserRole.OWNER });
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u2", [])).toBe(false);
});
it("CE_MANAGER can manage carbet linked to their org via membership", () => {
const session = makeSession({
userId: "u-ce",
role: UserRole.CE_MANAGER,
organizationId: "org-1",
});
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-creator", ["org-1"])).toBe(true);
});
it("CE_MANAGER cannot manage carbet of another org", () => {
const session = makeSession({
userId: "u-ce",
role: UserRole.CE_MANAGER,
organizationId: "org-1",
});
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-creator", ["org-2"])).toBe(false);
});
it("CE_MANAGER cannot manage when carbet has no memberships", () => {
const session = makeSession({
userId: "u-ce",
role: UserRole.CE_MANAGER,
organizationId: "org-1",
});
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-creator", [])).toBe(false);
});
it("CE_MANAGER without organizationId cannot manage anything via membership", () => {
const session = makeSession({
userId: "u-ce",
role: UserRole.CE_MANAGER,
organizationId: null,
});
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-creator", ["org-1"])).toBe(false);
});
it("TOURIST cannot manage", () => {
const session = makeSession({ userId: "u1", role: UserRole.TOURIST });
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-other", ["org-1"])).toBe(false);
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u1", ["org-1"])).toBe(true); // matches as owner
});
it("CE_MANAGER can also manage as direct owner (rare but possible)", () => {
const session = makeSession({
userId: "u-ce",
role: UserRole.CE_MANAGER,
organizationId: "org-1",
});
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[0], "u-ce", [])).toBe(true);
});
});