Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/app/api/cards/[cardId]/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}

Expand Down
72 changes: 72 additions & 0 deletions src/app/api/teams/[teamId]/members/[memberId]/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 21 additions & 5 deletions src/app/api/teams/[teamId]/phrases/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,6 +13,21 @@ import { parseJsonBody } from "@/lib/schemas/parse";

type RouteContext = { params: Promise<{ teamId: string }> };

type ReadyAuth = Extract<Awaited<ReturnType<typeof requireApiAccountReady>>, { 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 7 additions & 6 deletions src/app/api/teams/[teamId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 }> };
Expand Down Expand Up @@ -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 });
}

Expand Down
20 changes: 17 additions & 3 deletions src/app/dashboard/teams/[teamId]/create/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <CreateCardClient teamId={teamId} initialDefaults={defaults} initialCustom={custom} />;
return (
<CreateCardClient
teamId={teamId}
initialDefaults={defaults}
initialCustom={custom}
canManagePhrases={canEditPhrases}
/>
);
}
3 changes: 1 addition & 2 deletions src/app/dashboard/teams/[teamId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ export default async function TeamPage({ params }: Props) {
<TeamClient
team={team}
initialCards={cards}
userId={session.user.id}
isAdmin={profile.isAdmin}
actor={{ userId: session.user.id, role: membership.role, isSiteAdmin: profile.isAdmin }}
/>
);
}
9 changes: 2 additions & 7 deletions src/components/admin/admin-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string> = {
OWNER: "Propriétaire",
ADMIN: "Admin",
MEMBER: "Membre",
};

return (
<div>
<div className="mb-8">
Expand Down Expand Up @@ -161,7 +156,7 @@ export function AdminClient({ initialUsers, initialInvites }: Props) {
key={team.id}
className="chip"
>
<span className="text-accent">{roleLabels[role] ?? role}</span>
<span className="text-accent">{isTeamRole(role) ? ROLE_LABELS[role] : role}</span>
{team.name}
</span>
))}
Expand Down
13 changes: 10 additions & 3 deletions src/components/create-card/create-card-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,21 @@ type Props = {
teamId: string;
initialDefaults: TeamPhrase[];
initialCustom: TeamPhrase[];
/** ADMIN de l'équipe ou plus : modifier / supprimer les phrases personnalisées. */
canManagePhrases?: boolean;
};

function defaultCardLabel() {
return `Réunion du ${new Date().toLocaleDateString("fr-FR", { day: "numeric", month: "long" })}`;
}

/** 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();
Expand Down Expand Up @@ -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}
/>
))}
</AnimatePresence>
Expand Down
13 changes: 10 additions & 3 deletions src/components/dashboard/dashboard-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -26,8 +27,14 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) {
const [deletingId, setDeletingId] = useState<string | null>(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<DashboardTeam[]>("/api/teams");
Expand Down
Loading
Loading