feat(ce): Sprint I — CRUD carbets côté CE
All checks were successful
CI / test (pull_request) Successful in 2m36s
All checks were successful
CI / test (pull_request) Successful in 2m36s
Session étendue : - Ajout session.user.organizationId (typedef + auth callbacks JWT & session). Permet à canManageCarbet de check membership sans refetch DB. src/lib/carbet-access.ts : - MANAGER_ROLES inclut désormais CE_MANAGER → /espace-hote ET /espace-ce sont gardés par requireOwnerSession (CE_MANAGER passe, OWNER passe, ADMIN passe). - canManageCarbet(session, carbetOwnerId, linkedOrgIds=[]) : - ADMIN → toujours vrai - OWNER + session.user.id === carbetOwnerId → vrai - CE_MANAGER + session.user.organizationId ∈ linkedOrgIds → vrai - sinon faux. - Callers historiques (qui ne passent pas linkedOrgIds) restent sûrs : CE_MANAGER ne peut rien gérer par défaut. createCarbet étendu : si role=CE_MANAGER + organizationId présent, crée OrganizationCarbetMembership dans la même transaction. Redirige ensuite vers /espace-ce/carbets/[id] au lieu de /espace-hote/. Sweep des callers canManageCarbet (8 sites) : chargent désormais `Carbet.organizations` + passent linkedOrgIds. Includes : - updateCarbet, setCarbetStatus, deleteCarbet, reorderMedia, deleteMedia dans espace-hote/carbets/actions.ts - espace-hote/carbets/[carbetId]/page.tsx - API POST /api/carbets/[carbetId]/media Pages /espace-ce/carbets/* : - page.tsx : liste les carbets co-gérés via OrganizationCarbetMembership, forms Publier/Dépublier/Supprimer pointent vers les actions existantes de /espace-hote (réutilisation totale) - nouveau/page.tsx : requireApprovedOrg (redirect dashboard si pending), CarbetForm + createCarbet (même action que /espace-hote — détecte CE_MANAGER et crée membership) - [carbetId]/page.tsx : vérif que le carbet est lié à l'org du user + MediaUploader + CarbetForm (updateCarbet partagé) Dashboard /espace-ce/page.tsx : ActionCard « Mes carbets » devient active (le lien marche même en pending — l'org peut préparer des brouillons, c'est juste la publication qui est bloquée). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d77632ba0
commit
74ea280f28
10 changed files with 462 additions and 28 deletions
|
|
@ -26,7 +26,11 @@ export async function POST(
|
|||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||
}
|
||||
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
|
||||
if (
|
||||
session.user.role !== UserRole.OWNER &&
|
||||
session.user.role !== UserRole.ADMIN &&
|
||||
session.user.role !== UserRole.CE_MANAGER
|
||||
) {
|
||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||
}
|
||||
|
||||
|
|
@ -34,12 +38,15 @@ export async function POST(
|
|||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { ownerId: true },
|
||||
select: {
|
||||
ownerId: true,
|
||||
organizations: { select: { organizationId: true } },
|
||||
},
|
||||
});
|
||||
if (!carbet) {
|
||||
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
|
||||
}
|
||||
if (!canManageCarbet(session, carbet.ownerId)) {
|
||||
if (!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))) {
|
||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||
}
|
||||
|
||||
|
|
|
|||
126
src/app/espace-ce/carbets/[carbetId]/page.tsx
Normal file
126
src/app/espace-ce/carbets/[carbetId]/page.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { MediaUploader } from "@/components/MediaUploader";
|
||||
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
|
||||
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import { updateCarbet } from "../../../espace-hote/carbets/actions";
|
||||
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditCeCarbetPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ carbetId: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const session = await requireOwnerSession();
|
||||
const org = await getCurrentCeOrganization();
|
||||
if (!org) redirect("/admin/organizations");
|
||||
const { carbetId } = await params;
|
||||
const { publishError } = await searchParams;
|
||||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
river: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
embarkPoint: true,
|
||||
pirogueDurationMin: true,
|
||||
capacity: true,
|
||||
roadAccess: true,
|
||||
electricity: true,
|
||||
gsmAtCarbet: true,
|
||||
gsmExitDistanceKm: true,
|
||||
status: true,
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
},
|
||||
amenities: { select: { amenity: { select: { key: true } } } },
|
||||
organizations: { select: { organizationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!carbet ||
|
||||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Sécurité supplémentaire : assure que le carbet est bien lié à l'org du user.
|
||||
// (Un ADMIN peut éditer n'importe quel carbet via /admin, pas via /espace-ce.)
|
||||
const isLinked = carbet.organizations.some((o) => o.organizationId === org.id);
|
||||
if (!isLinked && session.user.role !== "ADMIN") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
title: carbet.title,
|
||||
description: carbet.description,
|
||||
river: carbet.river,
|
||||
latitude: carbet.latitude.toString(),
|
||||
longitude: carbet.longitude.toString(),
|
||||
embarkPoint: carbet.embarkPoint,
|
||||
pirogueDurationMin: String(carbet.pirogueDurationMin),
|
||||
capacity: String(carbet.capacity),
|
||||
roadAccess: carbet.roadAccess ?? "",
|
||||
electricity: carbet.electricity ?? "",
|
||||
gsmAtCarbet: carbet.gsmAtCarbet,
|
||||
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "",
|
||||
status: carbet.status,
|
||||
amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<Link
|
||||
href="/espace-ce/carbets"
|
||||
className="text-sm text-zinc-600 hover:text-zinc-900"
|
||||
>
|
||||
← Carbets de {org.name}
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
{carbet.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Co-géré par <strong>{org.name}</strong>
|
||||
</p>
|
||||
|
||||
{publishError ? (
|
||||
<p className="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Ajoutez au moins un média avant de publier ce carbet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-zinc-900">Médias</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-zinc-600">
|
||||
Déposez photos et vidéos courtes, réorganisez par glisser-déposer.
|
||||
Le premier média sert de cover sur le catalogue.
|
||||
</p>
|
||||
<MediaUploader carbetId={carbet.id} initialMedia={carbet.media} />
|
||||
</section>
|
||||
|
||||
<section className="mt-10 border-t border-zinc-200 pt-8">
|
||||
<CarbetForm
|
||||
action={updateCarbet}
|
||||
mode="edit"
|
||||
carbetId={carbet.id}
|
||||
defaults={defaults}
|
||||
submitLabel="Enregistrer les modifications"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
42
src/app/espace-ce/carbets/nouveau/page.tsx
Normal file
42
src/app/espace-ce/carbets/nouveau/page.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { requireApprovedOrg } from "@/lib/ce-access";
|
||||
|
||||
import { createCarbet } from "../../../espace-hote/carbets/actions";
|
||||
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewCeCarbetPage() {
|
||||
// Bloque la création si l'org n'est pas validée — redirect vers dashboard
|
||||
// avec bannière « En attente de validation ».
|
||||
const org = await requireApprovedOrg();
|
||||
if (!org) redirect("/espace-ce");
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<Link
|
||||
href="/espace-ce/carbets"
|
||||
className="text-sm text-zinc-600 hover:text-zinc-900"
|
||||
>
|
||||
← Carbets de {org.name}
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||
Nouveau carbet CE
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Ce carbet sera automatiquement lié à <strong>{org.name}</strong> et co-géré
|
||||
par tous ses CE_MANAGERs. Vous ajouterez les médias après la création.
|
||||
</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<CarbetForm
|
||||
action={createCarbet}
|
||||
mode="create"
|
||||
submitLabel="Créer le carbet"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
163
src/app/espace-ce/carbets/page.tsx
Normal file
163
src/app/espace-ce/carbets/page.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { CarbetStatus } from "@/generated/prisma/enums";
|
||||
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import {
|
||||
deleteCarbet,
|
||||
setCarbetStatus,
|
||||
} from "../../espace-hote/carbets/actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Mes carbets CE — Karbé" };
|
||||
|
||||
const STATUS_LABELS: Record<CarbetStatus, string> = {
|
||||
[CarbetStatus.DRAFT]: "Brouillon",
|
||||
[CarbetStatus.PUBLISHED]: "Publié",
|
||||
[CarbetStatus.ARCHIVED]: "Archivé",
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<CarbetStatus, string> = {
|
||||
[CarbetStatus.DRAFT]: "bg-zinc-100 text-zinc-700",
|
||||
[CarbetStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800",
|
||||
[CarbetStatus.ARCHIVED]: "bg-amber-100 text-amber-800",
|
||||
};
|
||||
|
||||
export default async function CeCarbetsListPage() {
|
||||
const org = await getCurrentCeOrganization();
|
||||
if (!org) redirect("/admin/organizations");
|
||||
|
||||
const memberships = await prisma.organizationCarbetMembership.findMany({
|
||||
where: { organizationId: org.id },
|
||||
orderBy: { addedAt: "desc" },
|
||||
select: {
|
||||
carbet: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
river: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
ownerId: true,
|
||||
owner: { select: { firstName: true, lastName: true } },
|
||||
_count: { select: { media: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const carbets = memberships.map((m) => m.carbet);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<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">
|
||||
Carbets co-gérés par {org.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Les carbets visibles ici peuvent être édités par tous les CE_MANAGERs de votre
|
||||
organisation. La propriété nominale reste sur leur créateur initial.
|
||||
</p>
|
||||
</div>
|
||||
{org.approved ? (
|
||||
<Link
|
||||
href="/espace-ce/carbets/nouveau"
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Nouveau carbet
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-zinc-100 px-3 py-2 text-xs text-zinc-500">
|
||||
Publication bloquée : organisation en attente de validation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{carbets.length === 0 ? (
|
||||
<p className="mt-10 rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
|
||||
Votre CE n'a pas encore de carbet.{" "}
|
||||
{org.approved ? "Créez votre premier carbet pour démarrer." : "Vous pourrez en publier dès que votre organisation sera validée."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-8 space-y-4">
|
||||
{carbets.map((carbet) => (
|
||||
<li
|
||||
key={carbet.id}
|
||||
className="flex flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/espace-ce/carbets/${carbet.id}`}
|
||||
className="truncate text-lg font-medium text-zinc-900 hover:text-emerald-700"
|
||||
>
|
||||
{carbet.title}
|
||||
</Link>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_STYLES[carbet.status]}`}
|
||||
>
|
||||
{STATUS_LABELS[carbet.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{carbet.river} · {carbet._count.media} média{carbet._count.media > 1 ? "s" : ""}
|
||||
{" · "}créé par {carbet.owner.firstName} {carbet.owner.lastName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<Link
|
||||
href={`/espace-ce/carbets/${carbet.id}`}
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
Éditer
|
||||
</Link>
|
||||
|
||||
{org.approved && carbet.status !== CarbetStatus.PUBLISHED ? (
|
||||
<form action={setCarbetStatus}>
|
||||
<input type="hidden" name="carbetId" value={carbet.id} />
|
||||
<input type="hidden" name="status" value={CarbetStatus.PUBLISHED} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Publier
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{carbet.status === CarbetStatus.PUBLISHED ? (
|
||||
<form action={setCarbetStatus}>
|
||||
<input type="hidden" name="carbetId" value={carbet.id} />
|
||||
<input type="hidden" name="status" value={CarbetStatus.DRAFT} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
Dépublier
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<form action={deleteCarbet}>
|
||||
<input type="hidden" name="carbetId" value={carbet.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,20 +59,18 @@ export default async function CeDashboardPage() {
|
|||
|
||||
<section className="grid gap-3 sm:grid-cols-2">
|
||||
<ActionCard
|
||||
href={org.approved ? "/espace-ce/carbets" : "/espace-ce"}
|
||||
href="/espace-ce/carbets"
|
||||
title="Mes carbets"
|
||||
description={
|
||||
kpis.carbetsCount > 0
|
||||
? `${kpis.carbetsCount} carbet${kpis.carbetsCount > 1 ? "s" : ""} co-géré${kpis.carbetsCount > 1 ? "s" : ""} par votre CE.`
|
||||
: org.approved
|
||||
? "Ajoutez votre premier carbet et ouvrez-le à vos membres + au public."
|
||||
: "Disponible après validation de votre organisation."
|
||||
: "Vous pouvez préparer vos carbets en brouillon, ils seront publiables après validation."
|
||||
}
|
||||
disabled={!org.approved}
|
||||
comingSoon
|
||||
/>
|
||||
<ActionCard
|
||||
href={org.approved ? "/espace-ce/materiel" : "/espace-ce"}
|
||||
href="/espace-ce/materiel"
|
||||
title="Matériel rental"
|
||||
description={
|
||||
kpis.rentalItemsCount > 0
|
||||
|
|
@ -87,8 +85,7 @@ export default async function CeDashboardPage() {
|
|||
</section>
|
||||
|
||||
<p className="text-xs text-zinc-500">
|
||||
Les liens « Mes carbets » et « Matériel rental » seront actifs au Sprint I et J du plan
|
||||
CE management.
|
||||
Le bouton « Matériel rental » sera actif au Sprint J du plan CE management.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,10 +42,14 @@ export default async function EditCarbetPage({
|
|||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
},
|
||||
amenities: { select: { amenity: { select: { key: true } } } },
|
||||
organizations: { select: { organizationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
|
||||
if (
|
||||
!carbet ||
|
||||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
|
|||
import { ensureUniqueCarbetSlug } from "@/lib/slug";
|
||||
import { deleteObject } from "@/lib/storage";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums";
|
||||
import { CarbetStatus, Electricity, RoadAccess, UserRole } from "@/generated/prisma/enums";
|
||||
|
||||
import type { CarbetFormState } from "./form-types";
|
||||
|
||||
|
|
@ -213,6 +213,10 @@ export async function createCarbet(
|
|||
|
||||
const slug = await ensureUniqueCarbetSlug(data.title);
|
||||
|
||||
// Si CE_MANAGER : on lie automatiquement le carbet à son org via OrganizationCarbetMembership.
|
||||
const isCeCreator =
|
||||
session.user.role === UserRole.CE_MANAGER && Boolean(session.user.organizationId);
|
||||
|
||||
const carbet = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.carbet.create({
|
||||
data: {
|
||||
|
|
@ -235,9 +239,22 @@ export async function createCarbet(
|
|||
select: { id: true },
|
||||
});
|
||||
await syncAmenities(tx, created.id, data.amenities);
|
||||
if (isCeCreator) {
|
||||
await tx.organizationCarbetMembership.create({
|
||||
data: {
|
||||
organizationId: session.user.organizationId!,
|
||||
carbetId: created.id,
|
||||
addedByUserId: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
if (isCeCreator) {
|
||||
revalidatePath("/espace-ce/carbets");
|
||||
redirect(`/espace-ce/carbets/${carbet.id}`);
|
||||
}
|
||||
revalidatePath("/espace-hote/carbets");
|
||||
redirect(`/espace-hote/carbets/${carbet.id}`);
|
||||
}
|
||||
|
|
@ -251,10 +268,17 @@ export async function updateCarbet(
|
|||
|
||||
const existing = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { ownerId: true, _count: { select: { media: true } } },
|
||||
select: {
|
||||
ownerId: true,
|
||||
organizations: { select: { organizationId: true } },
|
||||
_count: { select: { media: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing || !canManageCarbet(session, existing.ownerId)) {
|
||||
if (
|
||||
!existing ||
|
||||
!canManageCarbet(session, existing.ownerId, existing.organizations.map((o) => o.organizationId))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: { _global: "Carbet introuvable ou accès refusé." },
|
||||
|
|
@ -313,10 +337,17 @@ export async function setCarbetStatus(formData: FormData): Promise<void> {
|
|||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { ownerId: true, _count: { select: { media: true } } },
|
||||
select: {
|
||||
ownerId: true,
|
||||
organizations: { select: { organizationId: true } },
|
||||
_count: { select: { media: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
|
||||
if (
|
||||
!carbet ||
|
||||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
|
@ -340,10 +371,17 @@ export async function deleteCarbet(formData: FormData): Promise<void> {
|
|||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { ownerId: true, media: { select: { s3Key: true } } },
|
||||
select: {
|
||||
ownerId: true,
|
||||
organizations: { select: { organizationId: true } },
|
||||
media: { select: { s3Key: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
|
||||
if (
|
||||
!carbet ||
|
||||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
|
@ -366,10 +404,17 @@ export async function reorderMedia(
|
|||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { ownerId: true, media: { select: { id: true } } },
|
||||
select: {
|
||||
ownerId: true,
|
||||
organizations: { select: { organizationId: true } },
|
||||
media: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
|
||||
if (
|
||||
!carbet ||
|
||||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
|
||||
) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
|
|
@ -396,10 +441,26 @@ export async function deleteMedia(
|
|||
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id: mediaId },
|
||||
select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } },
|
||||
select: {
|
||||
s3Key: true,
|
||||
carbetId: true,
|
||||
carbet: {
|
||||
select: {
|
||||
ownerId: true,
|
||||
organizations: { select: { organizationId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!media || !canManageCarbet(session, media.carbet.ownerId)) {
|
||||
if (
|
||||
!media ||
|
||||
!canManageCarbet(
|
||||
session,
|
||||
media.carbet.ownerId,
|
||||
media.carbet.organizations.map((o) => o.organizationId),
|
||||
)
|
||||
) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
organizationId: true,
|
||||
isActive: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
|
|
@ -50,6 +51,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
email: user.email,
|
||||
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
|
@ -59,12 +61,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
if (user?.role) {
|
||||
token.role = user.role;
|
||||
}
|
||||
if (user && "organizationId" in user) {
|
||||
token.organizationId = (user as { organizationId?: string | null }).organizationId ?? null;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.sub ?? "";
|
||||
session.user.role = token.role;
|
||||
session.user.organizationId = token.organizationId ?? null;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,19 +3,44 @@ import type { Session } from "next-auth";
|
|||
import { requireRole } from "@/lib/authorization";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
|
||||
const MANAGER_ROLES: UserRole[] = [UserRole.OWNER, UserRole.ADMIN];
|
||||
/**
|
||||
* Espace hôte ET espace CE (les deux dashboards) : accessible à OWNER, CE_MANAGER, ADMIN.
|
||||
* Chacun ne voit que ses propres carbets (own ou via membership). La page liste filtre
|
||||
* par session.user.id / session.user.organizationId.
|
||||
*/
|
||||
const MANAGER_ROLES: UserRole[] = [
|
||||
UserRole.OWNER,
|
||||
UserRole.CE_MANAGER,
|
||||
UserRole.ADMIN,
|
||||
];
|
||||
|
||||
// Owner area (espace hôte) — accessible to carbet owners and admins.
|
||||
export async function requireOwnerSession(): Promise<Session> {
|
||||
return requireRole(MANAGER_ROLES);
|
||||
}
|
||||
|
||||
// A user can manage a given carbet if they own it, or if they are an admin.
|
||||
/**
|
||||
* Vrai si :
|
||||
* - ADMIN
|
||||
* - OWNER + session.user.id === carbetOwnerId
|
||||
* - CE_MANAGER + son organizationId est dans `linkedOrgIds`
|
||||
*
|
||||
* Les callers DOIVENT charger `Carbet.organizations.map(m => m.organizationId)` quand le rôle
|
||||
* peut être CE_MANAGER. Pour un caller historique qui n'a que l'ownerId, le CE_MANAGER ne
|
||||
* pourra pas gérer le carbet — comportement sûr par défaut.
|
||||
*/
|
||||
export function canManageCarbet(
|
||||
session: Session,
|
||||
carbetOwnerId: string,
|
||||
linkedOrgIds: string[] = [],
|
||||
): boolean {
|
||||
return (
|
||||
session.user.role === UserRole.ADMIN || session.user.id === carbetOwnerId
|
||||
);
|
||||
if (session.user.role === UserRole.ADMIN) return true;
|
||||
if (session.user.id === carbetOwnerId) return true;
|
||||
if (
|
||||
session.user.role === UserRole.CE_MANAGER &&
|
||||
session.user.organizationId &&
|
||||
linkedOrgIds.includes(session.user.organizationId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
3
src/types/next-auth.d.ts
vendored
3
src/types/next-auth.d.ts
vendored
|
|
@ -8,16 +8,19 @@ declare module "next-auth" {
|
|||
user: {
|
||||
id: string;
|
||||
role?: UserRole;
|
||||
organizationId?: string | null;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
role?: UserRole;
|
||||
organizationId?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT extends DefaultJWT {
|
||||
role?: UserRole;
|
||||
organizationId?: string | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue