From 68174b04d522dab9a2e7d8b41381583acbc5fe29 Mon Sep 17 00:00:00 2001 From: mcinquin Date: Fri, 6 Nov 2026 11:06:41 +0100 Subject: [PATCH] feat(member): add membership management re #123 --- .../api/teams/[teamId]/invite-code/route.ts | 45 +++++++ .../[teamId]/members/[memberId]/route.ts | 58 ++++++++- .../team/__tests__/team-client.test.tsx | 99 ++++++++++++++- src/components/team/team-client.tsx | 41 ++++++- src/components/team/team-members.tsx | 116 +++++++++++++----- src/i18n/messages/en.json | 13 +- src/i18n/messages/fr.json | 13 +- src/lib/__tests__/authz-rules.test.ts | 47 ++++++- src/lib/authz-rules.ts | 25 ++++ src/lib/data.ts | 7 +- 10 files changed, 422 insertions(+), 42 deletions(-) create mode 100644 src/app/api/teams/[teamId]/invite-code/route.ts diff --git a/src/app/api/teams/[teamId]/invite-code/route.ts b/src/app/api/teams/[teamId]/invite-code/route.ts new file mode 100644 index 0000000..5ee180c --- /dev/null +++ b/src/app/api/teams/[teamId]/invite-code/route.ts @@ -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); +} diff --git a/src/app/api/teams/[teamId]/members/[memberId]/route.ts b/src/app/api/teams/[teamId]/members/[memberId]/route.ts index 1fb6adf..b8d0e7e 100644 --- a/src/app/api/teams/[teamId]/members/[memberId]/route.ts +++ b/src/app/api/teams/[teamId]/members/[memberId]/route.ts @@ -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"; @@ -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( @@ -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 }); +} diff --git a/src/components/team/__tests__/team-client.test.tsx b/src/components/team/__tests__/team-client.test.tsx index c05f518..3acf692 100644 --- a/src/components/team/__tests__/team-client.test.tsx +++ b/src/components/team/__tests__/team-client.test.tsx @@ -11,6 +11,8 @@ 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", @@ -18,9 +20,9 @@ const team: TeamOverview = { 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" } }, ], }; @@ -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(); + + 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(); + + 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(); + 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(); + 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(); + + const list = screen.getByRole("region", { name: "Membres" }); + expect(within(list).queryByRole("button", { name: "Quitter" })).not.toBeInTheDocument(); + }); +}); + +describe("TeamClient — code d'invitation", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = mockFetch(); + jest.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("propose la régénération à l'OWNER uniquement", () => { + const { unmount } = renderWithIntl(); + expect(screen.queryByRole("button", { name: "Régénérer le code" })).not.toBeInTheDocument(); + unmount(); + + renderWithIntl(); + 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(); + + 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"); + }); }); diff --git a/src/components/team/team-client.tsx b/src/components/team/team-client.tsx index c3fbb10..32ba0cd 100644 --- a/src/components/team/team-client.tsx +++ b/src/components/team/team-client.tsx @@ -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"; @@ -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(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); @@ -109,6 +134,16 @@ export function TeamClient({ team, initialCards, actor }: Props) { + {canRegenerateInviteCode(actor) && ( + + )} {t("newCard")} diff --git a/src/components/team/team-members.tsx b/src/components/team/team-members.tsx index df7ac30..9d63fe1 100644 --- a/src/components/team/team-members.tsx +++ b/src/components/team/team-members.tsx @@ -3,8 +3,9 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import { formatDistanceToNow } from "@/app/dashboard/utils/date"; import { apiRequest } from "@/lib/api-client"; -import { canChangeRole, hasTeamRole, type TeamActor } from "@/lib/authz-rules"; +import { canChangeRole, canRemoveMember, hasTeamRole, type TeamActor } from "@/lib/authz-rules"; import type { TeamOverviewMember } from "@/lib/data"; import { TEAM_ROLES, type TeamRole } from "@/lib/team-roles"; @@ -14,12 +15,13 @@ type Props = { actor: TeamActor; }; -/** Liste des membres ; l'OWNER (ou l'admin du site) peut changer les rôles. */ +/** Liste des membres ; rôles (OWNER), retrait (ADMIN+) et départ. */ export function TeamMembers({ teamId, initialMembers, actor }: Props) { const router = useRouter(); const t = useTranslations("team.members"); const tRoles = useTranslations("roles"); const tApi = useTranslations("apiClient"); + const tDates = useTranslations("dates.relative"); const [members, setMembers] = useState(initialMembers); const [savingId, setSavingId] = useState(null); const [error, setError] = useState(""); @@ -51,6 +53,37 @@ export function TeamMembers({ teamId, initialMembers, actor }: Props) { if (member.user.id === actor.userId) router.refresh(); } + async function removeMember(member: TeamOverviewMember) { + const isSelf = member.user.id === actor.userId; + const name = member.user.name ?? t("noName"); + if (!confirm(isSelf ? t("confirmLeave") : t("confirmRemove", { name }))) return; + + const previous = members; + setMembers((prev) => prev.filter((m) => m.id !== member.id)); + setSavingId(member.id); + setError(""); + + const result = await apiRequest(`/api/teams/${teamId}/members/${member.id}`, { + method: "DELETE", + fallbackError: isSelf ? t("leaveFailed") : t("removeFailed"), + networkError: tApi("network"), + }); + setSavingId(null); + + if (!result.ok) { + setMembers(previous); + setError(result.error); + return; + } + + if (isSelf) { + router.push("/dashboard"); + router.refresh(); + return; + } + router.refresh(); + } + return (

@@ -67,36 +100,59 @@ export function TeamMembers({ teamId, initialMembers, actor }: Props) { {members.map((member) => { const name = member.user.name ?? t("noName"); const isSelf = member.user.id === actor.userId; + const canRemove = canRemoveMember(actor, { userId: member.user.id, role: member.role }, ownerCount); + const joinedLabel = t("joined", { + date: formatDistanceToNow(member.joinedAt, (key, values) => + values ? tDates(key as "days", values) : tDates(key as "today") + ), + }); + return ( -
  • - - {name} - {isSelf && {t("you")}} - - - {canEditRoles ? ( - - ) : ( - - {tRoles(member.role)} +
  • +
    + + {name} + {isSelf && {t("you")}} - )} + {joinedLabel} +
    + +
    + {canEditRoles ? ( + + ) : ( + + {tRoles(member.role)} + + )} + + {canRemove && ( + + )} +
  • ); })} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9b11f2b..bdcf0f4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -60,6 +60,7 @@ "roleInvalid": "Invalid role", "ownerOnlyRoles": "Only the team owner can change roles", "lastOwner": "The team must keep at least one owner", + "inviteCodeForbidden": "Only the team owner can regenerate the invite code", "concurrentUpdate": "Concurrent update, please try again", "labelRequired": "Label required", "labelTooLong": "Label too long (max {maxLabel} characters)", @@ -191,6 +192,9 @@ "summary": "{members, plural, one {# member} other {# members}} · {cards, plural, one {# card} other {# cards}}", "inviteCode": "Invite code", "copyFailed": "Copy failed, the code is: {code}", + "regenerateInvite": "Regenerate code", + "confirmRegenerateInvite": "Regenerate the invite code? The old code will stop working.", + "regenerateFailed": "Unable to regenerate the invite code", "newCard": "+ New card", "deleteTeam": "Delete team", "confirmDeleteTeam": "Delete the team “{name}” and all its cards? This cannot be undone.", @@ -206,8 +210,15 @@ "title": "Members", "noName": "No name", "you": "(you)", + "joined": "Joined {date}", "roleOf": "Role of {name}", - "changeFailed": "Unable to change the role" + "changeFailed": "Unable to change the role", + "remove": "Remove", + "leave": "Leave", + "confirmRemove": "Remove {name} from the team?", + "confirmLeave": "Leave this team?", + "removeFailed": "Unable to remove this member", + "leaveFailed": "Unable to leave the team" } }, "createCard": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index abeeb1c..6634fc3 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -60,6 +60,7 @@ "roleInvalid": "Rôle invalide", "ownerOnlyRoles": "Seul le propriétaire de l'équipe peut changer les rôles", "lastOwner": "L'équipe doit garder au moins un propriétaire", + "inviteCodeForbidden": "Seul le propriétaire peut régénérer le code d'invitation", "concurrentUpdate": "Modification concurrente, réessaie", "labelRequired": "Label requis", "labelTooLong": "Label trop long (max {maxLabel} caractères)", @@ -191,6 +192,9 @@ "summary": "{members, plural, one {# membre} other {# membres}} · {cards, plural, one {# grille} other {# grilles}}", "inviteCode": "Code d'invitation", "copyFailed": "Copie impossible, le code est : {code}", + "regenerateInvite": "Régénérer le code", + "confirmRegenerateInvite": "Régénérer le code d'invitation ? L'ancien code ne fonctionnera plus.", + "regenerateFailed": "Impossible de régénérer le code", "newCard": "+ Nouvelle grille", "deleteTeam": "Supprimer l'équipe", "confirmDeleteTeam": "Supprimer l'équipe « {name} » et toutes ses grilles ? Cette action est définitive.", @@ -206,8 +210,15 @@ "title": "Membres", "noName": "Sans nom", "you": "(toi)", + "joined": "Rejoint {date}", "roleOf": "Rôle de {name}", - "changeFailed": "Changement de rôle impossible" + "changeFailed": "Changement de rôle impossible", + "remove": "Retirer", + "leave": "Quitter", + "confirmRemove": "Retirer {name} de l'équipe ?", + "confirmLeave": "Quitter cette équipe ?", + "removeFailed": "Impossible de retirer ce membre", + "leaveFailed": "Impossible de quitter l'équipe" } }, "createCard": { diff --git a/src/lib/__tests__/authz-rules.test.ts b/src/lib/__tests__/authz-rules.test.ts index f0331c5..3244d00 100644 --- a/src/lib/__tests__/authz-rules.test.ts +++ b/src/lib/__tests__/authz-rules.test.ts @@ -3,13 +3,15 @@ import { canDeleteCard, canDeleteTeam, canManagePhrases, + canRegenerateInviteCode, + canRemoveMember, hasTeamRole, type TeamActor, } from "@/lib/authz-rules"; import type { TeamRole } from "@/lib/team-roles"; -function actor(role: TeamRole | null, isSiteAdmin = false): TeamActor { - return { userId: "me", role, isSiteAdmin }; +function actor(role: TeamRole | null, isSiteAdmin = false, userId = "me"): TeamActor { + return { userId, role, isSiteAdmin }; } describe("hasTeamRole", () => { @@ -91,3 +93,44 @@ describe("canChangeRole", () => { expect(canChangeRole(actor("OWNER"), { role: "OWNER" }, "ADMIN", 2)).toBe(true); }); }); + +describe("canRemoveMember", () => { + it("permet à un membre de quitter l'équipe", () => { + expect(canRemoveMember(actor("MEMBER"), { userId: "me", role: "MEMBER" }, 1)).toBe(true); + expect(canRemoveMember(actor("ADMIN"), { userId: "me", role: "ADMIN" }, 1)).toBe(true); + }); + + it("bloque le départ du dernier OWNER", () => { + expect(canRemoveMember(actor("OWNER"), { userId: "me", role: "OWNER" }, 1)).toBe(false); + expect(canRemoveMember(actor("OWNER"), { userId: "me", role: "OWNER" }, 2)).toBe(true); + }); + + it("autorise ADMIN/OWNER à retirer un MEMBER", () => { + expect(canRemoveMember(actor("MEMBER"), { userId: "other", role: "MEMBER" }, 1)).toBe(false); + expect(canRemoveMember(actor("ADMIN"), { userId: "other", role: "MEMBER" }, 1)).toBe(true); + expect(canRemoveMember(actor("OWNER"), { userId: "other", role: "MEMBER" }, 1)).toBe(true); + }); + + it("interdit à un ADMIN de retirer un OWNER", () => { + expect(canRemoveMember(actor("ADMIN"), { userId: "other", role: "OWNER" }, 2)).toBe(false); + expect(canRemoveMember(actor("OWNER"), { userId: "other", role: "OWNER" }, 2)).toBe(true); + }); + + it("bloque le retrait du dernier OWNER", () => { + expect(canRemoveMember(actor("OWNER"), { userId: "other", role: "OWNER" }, 1)).toBe(false); + expect(canRemoveMember(actor(null, true), { userId: "other", role: "OWNER" }, 1)).toBe(false); + }); + + it("autorise l'admin du site à retirer un membre", () => { + expect(canRemoveMember(actor(null, true), { userId: "other", role: "MEMBER" }, 1)).toBe(true); + }); +}); + +describe("canRegenerateInviteCode", () => { + it("réserve la régénération à OWNER et à l'admin du site", () => { + expect(canRegenerateInviteCode(actor("MEMBER"))).toBe(false); + expect(canRegenerateInviteCode(actor("ADMIN"))).toBe(false); + expect(canRegenerateInviteCode(actor("OWNER"))).toBe(true); + expect(canRegenerateInviteCode(actor(null, true))).toBe(true); + }); +}); diff --git a/src/lib/authz-rules.ts b/src/lib/authz-rules.ts index 5993a29..89f1331 100644 --- a/src/lib/authz-rules.ts +++ b/src/lib/authz-rules.ts @@ -47,3 +47,28 @@ export function canChangeRole( if (target.role === "OWNER" && nextRole !== "OWNER" && ownerCount <= 1) return false; return true; } + +/** + * Retirer un membre (ADMIN+) ou quitter soi-même. + * Le dernier OWNER ne peut pas partir / être retiré : il doit d'abord transférer la propriété. + * Un ADMIN ne peut pas retirer un OWNER (seul un OWNER / admin du site le peut). + */ +export function canRemoveMember( + actor: TeamActor, + target: { userId: string; role: TeamRole }, + ownerCount: number +): boolean { + if (target.role === "OWNER" && ownerCount <= 1) return false; + + const isSelf = target.userId === actor.userId; + if (isSelf) return actor.role !== null; + + if (!hasTeamRole(actor, "ADMIN")) return false; + if (target.role === "OWNER" && !hasTeamRole(actor, "OWNER")) return false; + return true; +} + +/** Régénérer le code d'invitation : OWNER (ou admin du site). */ +export function canRegenerateInviteCode(actor: TeamActor): boolean { + return hasTeamRole(actor, "OWNER"); +} diff --git a/src/lib/data.ts b/src/lib/data.ts index b61ae5a..5148811 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -61,7 +61,12 @@ const teamOverviewSelect = { _count: { select: { members: true } }, members: { orderBy: { joinedAt: "asc" }, - select: { id: true, role: true, user: { select: { id: true, name: true } } }, + select: { + id: true, + role: true, + joinedAt: true, + user: { select: { id: true, name: true } }, + }, }, } satisfies Prisma.TeamSelect;