diff --git a/src/app/api/cards/[cardId]/route.ts b/src/app/api/cards/[cardId]/route.ts index 7d968f0..3a25ac3 100644 --- a/src/app/api/cards/[cardId]/route.ts +++ b/src/app/api/cards/[cardId]/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; -import { canDeleteOwnedResource, getCardIfMember } from "@/lib/authz"; +import { getCardIfMember, getTeamActor } from "@/lib/authz"; +import { canDeleteCard } from "@/lib/authz-rules"; import { getPlayCard } from "@/lib/data"; import { updateCardSchema } from "@/lib/schemas/card"; import { parseJsonBody } from "@/lib/schemas/parse"; @@ -55,11 +56,14 @@ export async function DELETE(_req: Request, { params }: RouteContext) { const card = await prisma.bingoCard.findUnique({ where: { id: cardId }, - select: { createdById: true }, + select: { teamId: true, createdById: true }, }); if (!card) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); - if (!canDeleteOwnedResource(auth.session.user.id, card.createdById, auth.profile.isAdmin)) { + const actor = await getTeamActor(auth.session.user.id, card.teamId, auth.profile.isAdmin); + if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + + if (!canDeleteCard(actor, card.createdById)) { return NextResponse.json({ error: "Suppression non autorisée" }, { status: 403 }); } diff --git a/src/app/api/teams/[teamId]/members/[memberId]/route.ts b/src/app/api/teams/[teamId]/members/[memberId]/route.ts new file mode 100644 index 0000000..dde8d8e --- /dev/null +++ b/src/app/api/teams/[teamId]/members/[memberId]/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { requireApiAccountReady } from "@/lib/api-auth"; +import { prisma } from "@/lib/prisma"; +import { getTeamActor } from "@/lib/authz"; +import { canChangeRole, hasTeamRole, type TeamActor } from "@/lib/authz-rules"; +import type { TeamRole } from "@/lib/team-roles"; +import { updateMemberRoleSchema } from "@/lib/schemas/team"; +import { parseJsonBody } from "@/lib/schemas/parse"; + +type RouteContext = { params: Promise<{ teamId: string; memberId: string }> }; + +/** Change le rôle d'un membre (OWNER de l'équipe ou admin du site). */ +export async function PATCH(req: Request, { params }: RouteContext) { + const auth = await requireApiAccountReady(); + if (!auth.ok) return auth.response; + + const { teamId, memberId } = await params; + + const actor = await getTeamActor(auth.session.user.id, teamId, auth.profile.isAdmin); + if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!hasTeamRole(actor, "OWNER")) { + return NextResponse.json( + { error: "Seul le propriétaire de l'équipe peut changer les rôles" }, + { status: 403 } + ); + } + + const parsed = await parseJsonBody(req, updateMemberRoleSchema); + if (!parsed.ok) return parsed.response; + const { role } = parsed.data; + + try { + return await updateRole(actor, teamId, memberId, role); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2034") { + return NextResponse.json({ error: "Modification concurrente, réessaie" }, { status: 409 }); + } + throw err; + } +} + +async function updateRole(actor: TeamActor, teamId: string, memberId: string, role: TeamRole) { + // Sérialisable : deux OWNER qui se rétrogradent en même temps ne vident pas l'équipe + const result = await prisma.$transaction( + async (tx) => { + const target = await tx.teamMember.findFirst({ + where: { id: memberId, teamId }, + select: { id: true, role: true }, + }); + if (!target) return { status: 404, error: "Membre introuvable" } as const; + + const ownerCount = await tx.teamMember.count({ where: { teamId, role: "OWNER" } }); + if (!canChangeRole(actor, target, role, ownerCount)) { + return { status: 409, error: "L'équipe doit garder au moins un propriétaire" } as const; + } + + const member = await tx.teamMember.update({ + where: { id: target.id }, + data: { role }, + select: { id: true, role: true }, + }); + return { status: 200, member } as const; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + + if (result.status !== 200) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json(result.member); +} diff --git a/src/app/api/teams/[teamId]/phrases/route.ts b/src/app/api/teams/[teamId]/phrases/route.ts index 6a67db5..2615270 100644 --- a/src/app/api/teams/[teamId]/phrases/route.ts +++ b/src/app/api/teams/[teamId]/phrases/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; -import { getTeamMembership } from "@/lib/authz"; +import { getTeamActor, getTeamMembership } from "@/lib/authz"; +import { canManagePhrases } from "@/lib/authz-rules"; import { getTeamPhrases } from "@/lib/data"; import { createPhraseSchema, @@ -12,6 +13,21 @@ import { parseJsonBody } from "@/lib/schemas/parse"; type RouteContext = { params: Promise<{ teamId: string }> }; +type ReadyAuth = Extract>, { ok: true }>; + +/** 404 hors de l'équipe, 403 sous le rôle ADMIN ; null si autorisé. */ +async function requirePhraseManager(auth: ReadyAuth, teamId: string) { + const actor = await getTeamActor(auth.session.user.id, teamId, auth.profile.isAdmin); + if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!canManagePhrases(actor)) { + return NextResponse.json( + { error: "Seuls les admins de l'équipe peuvent modifier les phrases" }, + { status: 403 } + ); + } + return null; +} + export async function GET(_req: Request, { params }: RouteContext) { const auth = await requireApiAccountReady(); if (!auth.ok) return auth.response; @@ -50,8 +66,8 @@ export async function PATCH(req: Request, { params }: RouteContext) { const { teamId } = await params; - const membership = await getTeamMembership(auth.session.user.id, teamId); - if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const actorError = await requirePhraseManager(auth, teamId); + if (actorError) return actorError; const parsed = await parseJsonBody(req, updatePhraseSchema); if (!parsed.ok) return parsed.response; @@ -80,8 +96,8 @@ export async function DELETE(req: Request, { params }: RouteContext) { const { teamId } = await params; - const membership = await getTeamMembership(auth.session.user.id, teamId); - if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const actorError = await requirePhraseManager(auth, teamId); + if (actorError) return actorError; const parsed = await parseJsonBody(req, deletePhraseSchema); if (!parsed.ok) return parsed.response; diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts index da0799d..b06b736 100644 --- a/src/app/api/teams/[teamId]/route.ts +++ b/src/app/api/teams/[teamId]/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; -import { canDeleteOwnedResource, getTeamMembership } from "@/lib/authz"; +import { getTeamActor, getTeamMembership } from "@/lib/authz"; +import { canDeleteTeam } from "@/lib/authz-rules"; import { teamMembersInclude } from "@/lib/data"; type RouteContext = { params: Promise<{ teamId: string }> }; @@ -35,13 +36,13 @@ export async function DELETE(_req: Request, { params }: RouteContext) { const { teamId } = await params; - const team = await prisma.team.findUnique({ - where: { id: teamId }, - select: { createdById: true }, - }); + const team = await prisma.team.findUnique({ where: { id: teamId }, select: { id: true } }); if (!team) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); - if (!canDeleteOwnedResource(auth.session.user.id, team.createdById, auth.profile.isAdmin)) { + const actor = await getTeamActor(auth.session.user.id, teamId, auth.profile.isAdmin); + if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + + if (!canDeleteTeam(actor)) { return NextResponse.json({ error: "Suppression non autorisée" }, { status: 403 }); } diff --git a/src/app/dashboard/teams/[teamId]/create/page.tsx b/src/app/dashboard/teams/[teamId]/create/page.tsx index cfb96e3..9f248c7 100644 --- a/src/app/dashboard/teams/[teamId]/create/page.tsx +++ b/src/app/dashboard/teams/[teamId]/create/page.tsx @@ -1,17 +1,31 @@ import { notFound } from "next/navigation"; import { requireAccountReady } from "@/lib/account-guard"; import { getTeamMembership } from "@/lib/authz"; +import { canManagePhrases } from "@/lib/authz-rules"; import { getTeamPhrases } from "@/lib/data"; import { CreateCardClient } from "@/components/create-card/create-card-client"; type Props = { params: Promise<{ teamId: string }> }; export default async function CreateCardPage({ params }: Props) { - const [{ teamId }, { session }] = await Promise.all([params, requireAccountReady()]); + const [{ teamId }, { session, profile }] = await Promise.all([params, requireAccountReady()]); - if (!(await getTeamMembership(session.user.id, teamId))) notFound(); + const membership = await getTeamMembership(session.user.id, teamId); + if (!membership) notFound(); const { defaults, custom } = await getTeamPhrases(teamId); + const canEditPhrases = canManagePhrases({ + userId: session.user.id, + role: membership.role, + isSiteAdmin: profile.isAdmin, + }); - return ; + return ( + + ); } diff --git a/src/app/dashboard/teams/[teamId]/page.tsx b/src/app/dashboard/teams/[teamId]/page.tsx index 20bf988..5de3d44 100644 --- a/src/app/dashboard/teams/[teamId]/page.tsx +++ b/src/app/dashboard/teams/[teamId]/page.tsx @@ -19,8 +19,7 @@ export default async function TeamPage({ params }: Props) { ); } diff --git a/src/components/admin/admin-client.tsx b/src/components/admin/admin-client.tsx index 6a9b1ce..9c02600 100644 --- a/src/components/admin/admin-client.tsx +++ b/src/components/admin/admin-client.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { apiRequest } from "@/lib/api-client"; +import { isTeamRole, ROLE_LABELS } from "@/lib/team-roles"; import type { AdminInvite, AdminUser } from "@/types"; type Tab = "users" | "invites"; @@ -82,12 +83,6 @@ export function AdminClient({ initialUsers, initialInvites }: Props) { const usedInvites = invites.filter((i) => i.usedAt); const expiredInvites = invites.filter((i) => !i.usedAt && isExpired(i)); - const roleLabels: Record = { - OWNER: "Propriétaire", - ADMIN: "Admin", - MEMBER: "Membre", - }; - return (
@@ -161,7 +156,7 @@ export function AdminClient({ initialUsers, initialInvites }: Props) { key={team.id} className="chip" > - {roleLabels[role] ?? role} + {isTeamRole(role) ? ROLE_LABELS[role] : role} {team.name} ))} diff --git a/src/components/create-card/create-card-client.tsx b/src/components/create-card/create-card-client.tsx index 68d7e4c..4bba922 100644 --- a/src/components/create-card/create-card-client.tsx +++ b/src/components/create-card/create-card-client.tsx @@ -15,6 +15,8 @@ type Props = { teamId: string; initialDefaults: TeamPhrase[]; initialCustom: TeamPhrase[]; + /** ADMIN de l'équipe ou plus : modifier / supprimer les phrases personnalisées. */ + canManagePhrases?: boolean; }; function defaultCardLabel() { @@ -22,7 +24,12 @@ function defaultCardLabel() { } /** Configuration d'une nouvelle grille et gestion de la banque de phrases de l'équipe. */ -export function CreateCardClient({ teamId, initialDefaults, initialCustom }: Props) { +export function CreateCardClient({ + teamId, + initialDefaults, + initialCustom, + canManagePhrases = false, +}: Props) { const router = useRouter(); const labelId = useId(); const phraseInputId = useId(); @@ -343,8 +350,8 @@ export function CreateCardClient({ teamId, initialDefaults, initialCustom }: Pro selected={selectedIds.has(phrase.id)} editing={editingPhraseId === phrase.id} onClick={() => togglePhrase(phrase.id)} - onEdit={() => startEditPhrase(phrase)} - onDelete={() => void deletePhrase(phrase)} + onEdit={canManagePhrases ? () => startEditPhrase(phrase) : undefined} + onDelete={canManagePhrases ? () => void deletePhrase(phrase) : undefined} /> ))} diff --git a/src/components/dashboard/dashboard-client.tsx b/src/components/dashboard/dashboard-client.tsx index 8d8927d..2a1991c 100644 --- a/src/components/dashboard/dashboard-client.tsx +++ b/src/components/dashboard/dashboard-client.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import { useSession } from "next-auth/react"; import { motion, AnimatePresence } from "framer-motion"; import { apiRequest } from "@/lib/api-client"; -import { canDeleteOwnedResource } from "@/lib/authz-rules"; +import { canDeleteTeam as canDeleteTeamRule } from "@/lib/authz-rules"; +import { isTeamRole } from "@/lib/team-roles"; import type { DashboardTeam } from "@/types"; type Props = { @@ -26,8 +27,14 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(""); - const canDeleteTeam = (team: DashboardTeam) => - canDeleteOwnedResource(userId, team.createdById, isAdmin); + const canDeleteTeam = (team: DashboardTeam) => { + const role = team.members.find((m) => m.user.id === userId)?.role; + return canDeleteTeamRule({ + userId: userId ?? "", + role: isTeamRole(role) ? role : null, + isSiteAdmin: isAdmin, + }); + }; async function fetchTeams() { const result = await apiRequest("/api/teams"); diff --git a/src/components/team/__tests__/team-client.test.tsx b/src/components/team/__tests__/team-client.test.tsx index 8c81244..3711b24 100644 --- a/src/components/team/__tests__/team-client.test.tsx +++ b/src/components/team/__tests__/team-client.test.tsx @@ -1,7 +1,9 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { TeamActor } from "@/lib/authz-rules"; import type { TeamCardSummary, TeamOverview } from "@/lib/data"; -import { jsonResponse, mockFetch, routerMock } from "@/test-utils/mocks"; +import type { TeamRole } from "@/lib/team-roles"; +import { jsonResponse, mockFetch, routerMock, sentJson } from "@/test-utils/mocks"; import { TeamClient } from "../team-client"; jest.mock("next/navigation", () => ({ @@ -14,6 +16,11 @@ const team: TeamOverview = { inviteCode: "INVITE", createdById: "owner", _count: { members: 3 }, + members: [ + { id: "m-owner", role: "OWNER", user: { id: "owner", name: "Olga" } }, + { id: "m-me", role: "MEMBER", user: { id: "me", name: "Moi" } }, + { id: "m-other", role: "MEMBER", user: { id: "someone-else", name: "Sam" } }, + ], }; function makeCard(id: string, createdById: string | null, checked = 0): TeamCardSummary { @@ -32,6 +39,14 @@ function makeCard(id: string, createdById: string | null, checked = 0): TeamCard const cards = [makeCard("c1", "me", 2), makeCard("c2", "someone-else")]; +function actor(userId: string, role: TeamRole | null, isSiteAdmin = false): TeamActor { + return { userId, role, isSiteAdmin }; +} + +const member = actor("me", "MEMBER"); +const owner = actor("owner", "OWNER"); +const siteAdmin = actor("me", "MEMBER", true); + function cardItem(label: string) { return screen.getByRole("link", { name: new RegExp(label) }).closest("li") as HTMLElement; } @@ -45,7 +60,7 @@ describe("TeamClient", () => { }); it("affiche l'équipe et la progression des grilles", () => { - render(); + render(); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Produit"); expect(screen.getByText(/3 membres · 2 grilles/)).toBeInTheDocument(); @@ -53,24 +68,33 @@ describe("TeamClient", () => { expect(screen.getByRole("link", { name: /Grille c1/ })).toHaveAttribute("href", "/play/c1"); }); - it("ne propose la suppression qu'au créateur", () => { - render(); + it("ne propose à un MEMBER que la suppression de ses propres grilles", () => { + render(); expect(screen.queryByRole("button", { name: "Supprimer l'équipe" })).not.toBeInTheDocument(); expect(within(cardItem("Grille c1")).getByRole("button", { name: "Supprimer" })).toBeInTheDocument(); expect(within(cardItem("Grille c2")).queryByRole("button", { name: "Supprimer" })).not.toBeInTheDocument(); }); - it("propose toutes les suppressions à un admin", () => { - render(); + it("propose à un ADMIN toutes les grilles, mais pas l'équipe", () => { + render(); - expect(screen.getByRole("button", { name: "Supprimer l'équipe" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Supprimer l'équipe" })).not.toBeInTheDocument(); expect(screen.getAllByRole("button", { name: "Supprimer" })).toHaveLength(2); }); + it("propose toutes les suppressions à l'OWNER et à l'admin du site", () => { + for (const who of [owner, siteAdmin]) { + const { unmount } = render(); + expect(screen.getByRole("button", { name: "Supprimer l'équipe" })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Supprimer" })).toHaveLength(2); + unmount(); + } + }); + it("supprime une grille après confirmation", async () => { fetchMock.mockResolvedValue(jsonResponse({ ok: true })); - render(); + render(); await userEvent.click(within(cardItem("Grille c1")).getByRole("button", { name: "Supprimer" })); @@ -80,7 +104,7 @@ describe("TeamClient", () => { it("garde la grille et affiche l'erreur si la suppression échoue", async () => { fetchMock.mockResolvedValue(jsonResponse({ error: "Suppression non autorisée" }, 403)); - render(); + render(); await userEvent.click(within(cardItem("Grille c1")).getByRole("button", { name: "Supprimer" })); @@ -90,7 +114,7 @@ describe("TeamClient", () => { it("n'appelle pas l'API si l'utilisateur annule", async () => { jest.spyOn(window, "confirm").mockReturnValue(false); - render(); + render(); await userEvent.click(screen.getByRole("button", { name: "Supprimer l'équipe" })); @@ -98,3 +122,54 @@ describe("TeamClient", () => { expect(routerMock.push).not.toHaveBeenCalled(); }); }); + +describe("TeamClient — membres", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = mockFetch(); + }); + + it("affiche les rôles sans sélecteur pour un MEMBER", () => { + render(); + + const list = screen.getByRole("region", { name: "Membres" }); + expect(within(list).getByText("Propriétaire")).toBeInTheDocument(); + expect(within(list).queryByRole("combobox")).not.toBeInTheDocument(); + }); + + it("donne un sélecteur de rôle à l'OWNER et protège le dernier OWNER", () => { + render(); + + expect(screen.getAllByRole("combobox")).toHaveLength(3); + const ownerSelect = screen.getByRole("combobox", { name: "Rôle de Olga" }); + expect(within(ownerSelect).getByRole("option", { name: "Membre" })).toBeDisabled(); + expect(within(ownerSelect).getByRole("option", { name: "Propriétaire" })).toBeEnabled(); + }); + + it("change le rôle d'un membre", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "m-other", role: "ADMIN" })); + render(); + + const select = screen.getByRole("combobox", { name: "Rôle de Sam" }); + await userEvent.selectOptions(select, "ADMIN"); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/teams/t1/members/m-other", + expect.objectContaining({ method: "PATCH" }) + ); + expect(sentJson(fetchMock)).toEqual({ role: "ADMIN" }); + expect(select).toHaveValue("ADMIN"); + }); + + it("rétablit le rôle et affiche l'erreur si l'API refuse", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "Refusé" }, 403)); + render(); + + const select = screen.getByRole("combobox", { name: "Rôle de Sam" }); + await userEvent.selectOptions(select, "ADMIN"); + + expect(await screen.findByRole("alert")).toHaveTextContent("Refusé"); + expect(select).toHaveValue("MEMBER"); + }); +}); diff --git a/src/components/team/team-client.tsx b/src/components/team/team-client.tsx index ff9b66b..a55b4e7 100644 --- a/src/components/team/team-client.tsx +++ b/src/components/team/team-client.tsx @@ -5,15 +5,15 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { motion } from "framer-motion"; import { apiRequest } from "@/lib/api-client"; -import { canDeleteOwnedResource } from "@/lib/authz-rules"; +import { canDeleteCard, canDeleteTeam, type TeamActor } from "@/lib/authz-rules"; import { formatDistanceToNow } from "@/app/dashboard/utils/date"; import type { TeamCardSummary, TeamOverview } from "@/lib/data"; +import { TeamMembers } from "./team-members"; type Props = { team: TeamOverview; initialCards: TeamCardSummary[]; - userId: string; - isAdmin: boolean; + actor: TeamActor; }; function getCheckedCount(card: TeamCardSummary): number { @@ -25,8 +25,8 @@ function getProgress(card: TeamCardSummary): number { return Math.round((getCheckedCount(card) / card.cells.length) * 100); } -/** Vue d'une équipe : code d'invitation, liste des grilles, suppressions. */ -export function TeamClient({ team, initialCards, userId, isAdmin }: Props) { +/** Vue d'une équipe : code d'invitation, membres, liste des grilles, suppressions. */ +export function TeamClient({ team, initialCards, actor }: Props) { const router = useRouter(); const [cards, setCards] = useState(initialCards); const [copied, setCopied] = useState(false); @@ -34,9 +34,6 @@ export function TeamClient({ team, initialCards, userId, isAdmin }: Props) { const [deletingCardId, setDeletingCardId] = useState(null); const [error, setError] = useState(""); - const canDelete = (createdById: string | null) => - canDeleteOwnedResource(userId, createdById, isAdmin); - async function copyInviteCode() { try { await navigator.clipboard.writeText(team.inviteCode); @@ -121,7 +118,7 @@ export function TeamClient({ team, initialCards, userId, isAdmin }: Props) { + Nouvelle grille - {canDelete(team.createdById) && ( + {canDeleteTeam(actor) && (