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
45 changes: 45 additions & 0 deletions src/app/api/teams/[teamId]/invite-code/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { nanoid } from "nanoid";
import { Prisma } from "@prisma/client";
import { apiError } from "@/lib/api-errors";
import { requireApiAccountReady } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
import { getTeamActor } from "@/lib/authz";
import { canRegenerateInviteCode } from "@/lib/authz-rules";

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

const MAX_INVITE_ATTEMPTS = 5;

/** Régénère le code d'invitation (OWNER de l'équipe ou admin du site). */
export async function POST(_req: Request, { params }: RouteContext) {
const auth = await requireApiAccountReady();
if (!auth.ok) return auth.response;

const { teamId } = await params;

const actor = await getTeamActor(auth.session.user.id, teamId, auth.profile.isAdmin);
if (!actor) return apiError("notFound", 404);
if (!canRegenerateInviteCode(actor)) {
return apiError("inviteCodeForbidden", 403);
}

for (let attempt = 0; attempt < MAX_INVITE_ATTEMPTS; attempt++) {
const inviteCode = nanoid();
try {
const team = await prisma.team.update({
where: { id: teamId },
data: { inviteCode },
select: { inviteCode: true },
});
return NextResponse.json(team);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
continue;
}
throw err;
}
}

return apiError("serverError", 500);
}
58 changes: 57 additions & 1 deletion src/app/api/teams/[teamId]/members/[memberId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { apiError } from "@/lib/api-errors";
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 {
canChangeRole,
canRemoveMember,
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";
Expand Down Expand Up @@ -38,6 +43,29 @@ export async function PATCH(req: Request, { params }: RouteContext) {
}
}

/**
* Retire un membre (ADMIN+) ou quitte l'équipe (soi-même).
* Le dernier OWNER doit d'abord transférer la propriété.
*/
export async function DELETE(_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 apiError("notFound", 404);

try {
return await removeMember(actor, teamId, memberId);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2034") {
return apiError("concurrentUpdate", 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(
Expand Down Expand Up @@ -68,3 +96,31 @@ async function updateRole(actor: TeamActor, teamId: string, memberId: string, ro
}
return NextResponse.json(result.member);
}

async function removeMember(actor: TeamActor, teamId: string, memberId: string) {
const result = await prisma.$transaction(
async (tx) => {
const target = await tx.teamMember.findFirst({
where: { id: memberId, teamId },
select: { id: true, userId: true, role: true },
});
if (!target) return { status: 404, error: "memberNotFound" } as const;

const ownerCount = await tx.teamMember.count({ where: { teamId, role: "OWNER" } });
if (!canRemoveMember(actor, target, ownerCount)) {
const isLastOwner = target.role === "OWNER" && ownerCount <= 1;
if (isLastOwner) return { status: 409, error: "lastOwner" } as const;
return { status: 403, error: "deleteForbidden" } as const;
}

await tx.teamMember.delete({ where: { id: target.id } });
return { status: 200, left: target.userId === actor.userId } as const;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);

if (result.status !== 200) {
return apiError(result.error, result.status);
}
return NextResponse.json({ ok: true, left: result.left });
}
99 changes: 96 additions & 3 deletions src/components/team/__tests__/team-client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ jest.mock("next/navigation", () => ({
useRouter: () => jest.requireActual("@/test-utils/mocks").routerMock,
}));

const joinedAt = new Date("2024-01-01T00:00:00.000Z");

const team: TeamOverview = {
id: "t1",
name: "Produit",
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" } },
{ id: "m-owner", role: "OWNER", joinedAt, user: { id: "owner", name: "Olga" } },
{ id: "m-me", role: "MEMBER", joinedAt, user: { id: "me", name: "Moi" } },
{ id: "m-other", role: "MEMBER", joinedAt, user: { id: "someone-else", name: "Sam" } },
],
};

Expand Down Expand Up @@ -173,4 +175,95 @@ describe("TeamClient — membres", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("Refusé");
expect(select).toHaveValue("MEMBER");
});

it("affiche la date d'arrivée des membres", () => {
renderWithIntl(<TeamClient team={team} initialCards={cards} actor={member} />);

const list = screen.getByRole("region", { name: "Membres" });
expect(within(list).getAllByText(/Rejoint /).length).toBeGreaterThanOrEqual(1);
});

it("propose à un MEMBER de quitter, sans pouvoir retirer les autres", () => {
renderWithIntl(<TeamClient team={team} initialCards={cards} actor={member} />);

const list = screen.getByRole("region", { name: "Membres" });
expect(within(list).getByRole("button", { name: "Quitter" })).toBeInTheDocument();
expect(within(list).queryByRole("button", { name: "Retirer" })).not.toBeInTheDocument();
});

it("propose à l'OWNER de retirer un membre", async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true, left: false }));
renderWithIntl(<TeamClient team={team} initialCards={cards} actor={owner} />);
jest.spyOn(window, "confirm").mockReturnValue(true);

const list = screen.getByRole("region", { name: "Membres" });
await userEvent.click(within(list).getAllByRole("button", { name: "Retirer" })[0]!);

expect(fetchMock).toHaveBeenCalledWith(
"/api/teams/t1/members/m-me",
expect.objectContaining({ method: "DELETE" })
);
expect(within(list).queryByText("Moi")).not.toBeInTheDocument();
});

it("redirige vers le dashboard après avoir quitté l'équipe", async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true, left: true }));
renderWithIntl(<TeamClient team={team} initialCards={cards} actor={member} />);
jest.spyOn(window, "confirm").mockReturnValue(true);

await userEvent.click(screen.getByRole("button", { name: "Quitter" }));

expect(fetchMock).toHaveBeenCalledWith(
"/api/teams/t1/members/m-me",
expect.objectContaining({ method: "DELETE" })
);
expect(routerMock.push).toHaveBeenCalledWith("/dashboard");
});

it("ne propose pas au dernier OWNER de quitter", () => {
const soloOwnerTeam: TeamOverview = {
...team,
_count: { members: 1 },
members: [{ id: "m-owner", role: "OWNER", joinedAt, user: { id: "owner", name: "Olga" } }],
};
renderWithIntl(<TeamClient team={soloOwnerTeam} initialCards={[]} actor={owner} />);

const list = screen.getByRole("region", { name: "Membres" });
expect(within(list).queryByRole("button", { name: "Quitter" })).not.toBeInTheDocument();
});
});

describe("TeamClient — code d'invitation", () => {
let fetchMock: ReturnType<typeof mockFetch>;

beforeEach(() => {
fetchMock = mockFetch();
jest.spyOn(window, "confirm").mockReturnValue(true);
});

it("propose la régénération à l'OWNER uniquement", () => {
const { unmount } = renderWithIntl(<TeamClient team={team} initialCards={cards} actor={member} />);
expect(screen.queryByRole("button", { name: "Régénérer le code" })).not.toBeInTheDocument();
unmount();

renderWithIntl(<TeamClient team={team} initialCards={cards} actor={owner} />);
expect(screen.getByRole("button", { name: "Régénérer le code" })).toBeInTheDocument();
});

it("met à jour le code après régénération", async () => {
fetchMock.mockResolvedValue(jsonResponse({ inviteCode: "NEW-CODE" }));
renderWithIntl(<TeamClient team={team} initialCards={cards} actor={owner} />);

await userEvent.click(screen.getByRole("button", { name: "Régénérer le code" }));

expect(fetchMock).toHaveBeenCalledWith(
"/api/teams/t1/invite-code",
expect.objectContaining({ method: "POST" })
);

const writeText = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
await userEvent.click(screen.getByRole("button", { name: "Code d'invitation" }));
expect(writeText).toHaveBeenCalledWith("NEW-CODE");
});
});
41 changes: 38 additions & 3 deletions src/components/team/team-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import Link from "next/link";
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import { apiRequest } from "@/lib/api-client";
import { canDeleteCard, canDeleteTeam, type TeamActor } from "@/lib/authz-rules";
import {
canDeleteCard,
canDeleteTeam,
canRegenerateInviteCode,
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";
Expand Down Expand Up @@ -34,21 +39,41 @@ export function TeamClient({ team, initialCards, actor }: Props) {
const tApi = useTranslations("apiClient");
const tDates = useTranslations("dates.relative");
const [cards, setCards] = useState(initialCards);
const [inviteCode, setInviteCode] = useState(team.inviteCode);
const [copied, setCopied] = useState(false);
const [regeneratingInvite, setRegeneratingInvite] = useState(false);
const [deletingTeam, setDeletingTeam] = useState(false);
const [deletingCardId, setDeletingCardId] = useState<string | null>(null);
const [error, setError] = useState("");

async function copyInviteCode() {
try {
await navigator.clipboard.writeText(team.inviteCode);
await navigator.clipboard.writeText(inviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setError(t("copyFailed", { code: team.inviteCode }));
setError(t("copyFailed", { code: inviteCode }));
}
}

async function regenerateInviteCode() {
if (!confirm(t("confirmRegenerateInvite"))) return;
setRegeneratingInvite(true);
setError("");
const result = await apiRequest<{ inviteCode: string }>(`/api/teams/${team.id}/invite-code`, {
method: "POST",
fallbackError: t("regenerateFailed"),
networkError: tApi("network"),
});
setRegeneratingInvite(false);
if (!result.ok) {
setError(result.error);
return;
}
setInviteCode(result.data.inviteCode);
setCopied(false);
}

async function deleteTeam() {
if (!confirm(t("confirmDeleteTeam", { name: team.name }))) return;
setDeletingTeam(true);
Expand Down Expand Up @@ -109,6 +134,16 @@ export function TeamClient({ team, initialCards, actor }: Props) {
<button type="button" onClick={() => void copyInviteCode()} className="btn-secondary py-2 text-sm">
{copied ? tCommon("copied") : t("inviteCode")}
</button>
{canRegenerateInviteCode(actor) && (
<button
type="button"
onClick={() => void regenerateInviteCode()}
disabled={regeneratingInvite}
className="btn-secondary py-2 text-sm"
>
{regeneratingInvite ? "…" : t("regenerateInvite")}
</button>
)}
<Link href={`/dashboard/teams/${team.id}/create`} className="btn-primary">
{t("newCard")}
</Link>
Expand Down
Loading
Loading