feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (push) Successful in 2m26s
All checks were successful
CI / test (push) Successful in 2m26s
This commit is contained in:
commit
2b8d786cf9
14 changed files with 691 additions and 11 deletions
|
|
@ -0,0 +1,22 @@
|
|||
-- Sprint K : tokens d'invitation CE_MEMBER.
|
||||
-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit
|
||||
-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation.
|
||||
|
||||
CREATE TABLE "OrgInviteToken" (
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"createdByUserId" TEXT,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash")
|
||||
);
|
||||
|
||||
CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId");
|
||||
CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt");
|
||||
|
||||
ALTER TABLE "OrgInviteToken"
|
||||
ADD CONSTRAINT "OrgInviteToken_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -86,11 +86,30 @@ model Organization {
|
|||
members User[]
|
||||
carbetMemberships OrganizationCarbetMembership[]
|
||||
rentalProviders RentalProvider[]
|
||||
invites OrgInviteToken[]
|
||||
|
||||
@@index([name])
|
||||
@@index([approved])
|
||||
}
|
||||
|
||||
/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER.
|
||||
/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN.
|
||||
/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire.
|
||||
model OrgInviteToken {
|
||||
tokenHash String @id
|
||||
organizationId String
|
||||
email String?
|
||||
createdByUserId String?
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
|
||||
/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
|
||||
/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||
import { z } from "zod";
|
||||
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites";
|
||||
import { hashPassword } from "@/lib/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
|
|
@ -23,6 +24,7 @@ const schema = z.object({
|
|||
providerName: z.string().trim().min(2).max(200).optional(),
|
||||
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
|
||||
orgName: z.string().trim().min(2).max(200).optional(),
|
||||
inviteToken: z.string().trim().min(8).max(200).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
|
|
@ -55,6 +57,23 @@ export async function POST(req: Request) {
|
|||
return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER
|
||||
// et on rattache à l'org du token (org déjà validée — pas de bannière pending).
|
||||
let inviteOrgId: string | null = null;
|
||||
if (data.inviteToken) {
|
||||
const invite = await getOrgInviteByToken(data.inviteToken);
|
||||
if (!invite) {
|
||||
return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 });
|
||||
}
|
||||
if (invite.email && invite.email.toLowerCase() !== data.email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ce lien d'invitation est réservé à un autre email." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
inviteOrgId = invite.organizationId;
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
|
||||
|
|
@ -68,7 +87,24 @@ export async function POST(req: Request) {
|
|||
let createdOrgId: string | null = null;
|
||||
let user: { id: string; email: string; role: UserRole };
|
||||
|
||||
if (data.role === UserRole.CE_MANAGER) {
|
||||
if (inviteOrgId) {
|
||||
// Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role.
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
passwordHash,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone?.trim() || null,
|
||||
role: UserRole.CE_MEMBER,
|
||||
organizationId: inviteOrgId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
createdOrgId = inviteOrgId;
|
||||
await markOrgInviteConsumed(data.inviteToken!).catch(() => {});
|
||||
} else if (data.role === UserRole.CE_MANAGER) {
|
||||
const orgName = data.orgName!.trim();
|
||||
const baseSlug = slugify(orgName);
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
|||
? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}`
|
||||
: ` · Route directe (embarquement ${carbet.embarkPoint})`}
|
||||
</p>
|
||||
{carbet.organizations.length > 0 ? (
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Géré par le CE{" "}
|
||||
{carbet.organizations.map((o, i) => (
|
||||
<span key={o.id}>
|
||||
<strong className="text-zinc-700">{o.name}</strong>
|
||||
{i < carbet.organizations.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
) : null}
|
||||
{carbet.reviewStats.count > 0 &&
|
||||
carbet.reviewStats.averageRating !== null ? (
|
||||
<p className="mt-2 flex items-center gap-2 text-sm text-zinc-700">
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { useState, useTransition } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
type Props = { next: string };
|
||||
type InviteContext = { token: string; orgName: string; emailLock?: string | null };
|
||||
|
||||
export function SignupForm({ next }: Props) {
|
||||
type Props = { next: string; invite?: InviteContext | null };
|
||||
|
||||
export function SignupForm({ next, invite }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -14,6 +16,7 @@ export function SignupForm({ next }: Props) {
|
|||
const [providerName, setProviderName] = useState("");
|
||||
const [providerRivers, setProviderRivers] = useState("");
|
||||
const [orgName, setOrgName] = useState("");
|
||||
const isInvite = Boolean(invite);
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
|
|
@ -55,6 +58,13 @@ export function SignupForm({ next }: Props) {
|
|||
if (role === "CE_MANAGER") {
|
||||
body.orgName = orgName.trim();
|
||||
}
|
||||
if (isInvite && invite) {
|
||||
body.inviteToken = invite.token;
|
||||
// L'API force le rôle CE_MEMBER quand inviteToken est valide ;
|
||||
// on retire les champs inutiles pour ne pas créer de confusion.
|
||||
delete (body as { providerName?: unknown }).providerName;
|
||||
delete (body as { orgName?: unknown }).orgName;
|
||||
}
|
||||
const res = await fetch("/api/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -98,7 +108,17 @@ export function SignupForm({ next }: Props) {
|
|||
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Email</span>
|
||||
<input name="email" type="email" required maxLength={200} className={inputCls + " mt-0.5"} />
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
maxLength={200}
|
||||
defaultValue={invite?.emailLock ?? undefined}
|
||||
readOnly={Boolean(invite?.emailLock)}
|
||||
className={
|
||||
inputCls + " mt-0.5" + (invite?.emailLock ? " bg-zinc-50 text-zinc-700" : "")
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
|
|
@ -118,7 +138,14 @@ export function SignupForm({ next }: Props) {
|
|||
<input name="phone" type="tel" maxLength={40} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
|
||||
<fieldset className="space-y-1">
|
||||
{isInvite ? (
|
||||
<p className="rounded-md border border-emerald-200 bg-emerald-50/60 px-3 py-2 text-xs text-emerald-900">
|
||||
Vous rejoignez <strong>{invite!.orgName}</strong> comme membre CE — les autres
|
||||
types de compte sont masqués.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="space-y-1" hidden={isInvite}>
|
||||
<legend className="text-xs text-zinc-600">Type de compte</legend>
|
||||
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { redirect } from "next/navigation";
|
|||
import Link from "next/link";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getOrgInviteByToken } from "@/lib/ce-invites";
|
||||
|
||||
import { SignupForm } from "./_components/SignupForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ next?: string }>;
|
||||
searchParams: Promise<{ next?: string; invite?: string }>;
|
||||
};
|
||||
|
||||
export default async function SignupPage({ searchParams }: PageProps) {
|
||||
|
|
@ -16,17 +18,32 @@ export default async function SignupPage({ searchParams }: PageProps) {
|
|||
const next = sp.next && sp.next.startsWith("/") ? sp.next : "/";
|
||||
if (session?.user?.id) redirect(next);
|
||||
|
||||
// Si un token d'invitation valide est présent, on pré-remplit le contexte CE_MEMBER.
|
||||
let invite: { token: string; orgName: string; emailLock?: string | null } | null = null;
|
||||
if (sp.invite) {
|
||||
const found = await getOrgInviteByToken(sp.invite);
|
||||
if (found) {
|
||||
invite = {
|
||||
token: sp.invite,
|
||||
orgName: found.organization.name,
|
||||
emailLock: found.email,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
|
||||
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Créer un compte</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet.
|
||||
{invite
|
||||
? `Vous avez été invité à rejoindre « ${invite.orgName} » comme membre CE.`
|
||||
: "Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet."}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SignupForm next={next} />
|
||||
<SignupForm next={next} invite={invite} />
|
||||
|
||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||
Déjà un compte ?{" "}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
import { getContentPage } from "@/lib/content-pages";
|
||||
import { getLocale } from "@/lib/i18n/server";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -15,5 +17,28 @@ export default async function CEPage() {
|
|||
if (!(await isPluginEnabled("content-pages"))) notFound();
|
||||
const page = await getContentPage("pour-comites-entreprise", await getLocale());
|
||||
if (!page) notFound();
|
||||
return <ContentPageRenderer page={page} />;
|
||||
const ceEnabled = await isPluginEnabled("ce-management");
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentPageRenderer page={page} />
|
||||
{ceEnabled ? (
|
||||
<section className="mx-auto my-8 max-w-3xl rounded-lg border border-emerald-200 bg-emerald-50/60 px-6 py-6 text-center">
|
||||
<h2 className="text-lg font-semibold text-emerald-900">
|
||||
Vous êtes un Comité d'Entreprise ?
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-emerald-900">
|
||||
Créez votre espace CE sur Karbé pour proposer vos carbets à vos membres et au public
|
||||
touriste, et activer la location de matériel.
|
||||
</p>
|
||||
<Link
|
||||
href="/inscription"
|
||||
className="mt-3 inline-block rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Créer mon espace CE
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export type PublicCarbetDetail = {
|
|||
longitude: string;
|
||||
ownerId: string;
|
||||
ownerFirstName: string;
|
||||
/** Comités d'Entreprise qui co-gèrent ce carbet (vide si hôte individuel). */
|
||||
organizations: { id: string; name: string; slug: string }[];
|
||||
media: PublicCarbetMedia[];
|
||||
amenities: { key: string; label: string }[];
|
||||
reviewStats: CarbetReviewStats;
|
||||
|
|
@ -99,6 +101,12 @@ export const getPublicCarbet = cache(
|
|||
amenities: {
|
||||
select: { amenity: { select: { key: true, label: true } } },
|
||||
},
|
||||
organizations: {
|
||||
where: { organization: { approved: true } },
|
||||
select: {
|
||||
organization: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -146,6 +154,11 @@ export const getPublicCarbet = cache(
|
|||
longitude: carbet.longitude.toString(),
|
||||
ownerId: carbet.ownerId,
|
||||
ownerFirstName: carbet.owner.firstName,
|
||||
organizations: carbet.organizations.map((m) => ({
|
||||
id: m.organization.id,
|
||||
name: m.organization.name,
|
||||
slug: m.organization.slug,
|
||||
})),
|
||||
media: carbet.media.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
|
|
|
|||
68
src/lib/ce-invites.ts
Normal file
68
src/lib/ce-invites.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import "server-only";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 jours
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function createOrgInviteToken(opts: {
|
||||
organizationId: string;
|
||||
createdByUserId: string;
|
||||
email?: string | null;
|
||||
ttlMs?: number;
|
||||
}): Promise<string> {
|
||||
const token = crypto.randomBytes(24).toString("base64url");
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + (opts.ttlMs ?? INVITE_TTL_MS));
|
||||
await prisma.orgInviteToken.create({
|
||||
data: {
|
||||
tokenHash,
|
||||
organizationId: opts.organizationId,
|
||||
createdByUserId: opts.createdByUserId,
|
||||
email: opts.email ?? null,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function listOrgInviteTokens(organizationId: string) {
|
||||
return prisma.orgInviteToken.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
/** Renvoie l'invitation si elle existe, non expirée et non consommée. */
|
||||
export async function getOrgInviteByToken(plainToken: string) {
|
||||
const tokenHash = hashToken(plainToken);
|
||||
const row = await prisma.orgInviteToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: {
|
||||
organization: { select: { id: true, name: true, slug: true, approved: true } },
|
||||
},
|
||||
});
|
||||
if (!row) return null;
|
||||
if (row.usedAt) return null;
|
||||
if (row.expiresAt < new Date()) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Marque l'invitation comme consommée. À appeler dans la transaction de signup. */
|
||||
export async function markOrgInviteConsumed(plainToken: string): Promise<void> {
|
||||
const tokenHash = hashToken(plainToken);
|
||||
await prisma.orgInviteToken.update({
|
||||
where: { tokenHash },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeOrgInviteToken(tokenHash: string): Promise<void> {
|
||||
await prisma.orgInviteToken.delete({ where: { tokenHash } }).catch(() => {});
|
||||
}
|
||||
111
tests/lib/ce-access.test.ts
Normal file
111
tests/lib/ce-access.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue