+
+
+ {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;