diff --git a/app/features/associations/AssociationRepository.server.ts b/app/features/associations/AssociationRepository.server.ts index f61618fff..8a9bfe359 100644 --- a/app/features/associations/AssociationRepository.server.ts +++ b/app/features/associations/AssociationRepository.server.ts @@ -90,25 +90,54 @@ async function findBy( return associations.map((a) => { const members = a.members ?? []; - const adminIds = members - .filter((member) => member.role === "ADMIN") - .map((user) => user.id); + const adminIds = memberIdsWithRole(members, "ADMIN"); + const managerIds = memberIdsWithRole(members, "MANAGER"); return { ...a, + members: a.members?.map((member) => ({ + ...member, + permissions: { + REMOVE: memberRemoverIds({ member, adminIds, managerIds }), + }, + })), permissions: { MANAGE: adminIds, - SHARE_INVITE_LINK: [ - ...adminIds, - ...members - .filter((member) => member.role === "MANAGER") - .map((user) => user.id), - ], + MANAGE_INVITE_LINK: [...adminIds, ...managerIds], }, }; }); } +function memberIdsWithRole( + members: Array<{ id: number; role: Tables["AssociationMember"]["role"] }>, + role: Tables["AssociationMember"]["role"], +) { + return members + .filter((member) => member.role === role) + .map((member) => member.id); +} + +/** Admins can remove anyone but themselves, managers only regular members. */ +function memberRemoverIds({ + member, + adminIds, + managerIds, +}: { + member: { id: number; role: Tables["AssociationMember"]["role"] }; + adminIds: Array; + managerIds: Array; +}) { + const removerIds = + member.role === "ADMIN" + ? [] + : member.role === "MANAGER" + ? adminIds + : [...adminIds, ...managerIds]; + + return removerIds.filter((id) => id !== member.id); +} + const DEFAULT_VIRTUAL_ASSOCIATIONS: Array = [ "FRIENDS", ]; diff --git a/app/features/associations/actions/associations.server.ts b/app/features/associations/actions/associations.server.ts index 5dd0264d6..7358e172e 100644 --- a/app/features/associations/actions/associations.server.ts +++ b/app/features/associations/actions/associations.server.ts @@ -22,13 +22,13 @@ export const action = async ({ request }: ActionFunctionArgs) => { switch (data._action) { case "REMOVE_MEMBER": { - await validateHasManagePermissions(data.associationId); - - errorToastIfFalsy( - data.userId !== user.id, - "Cannot remove yourself from the association", + const association = await findAssociation(data.associationId); + const memberToRemove = badRequestIfFalsy( + association.members!.find((member) => member.id === data.userId), ); + requirePermission(memberToRemove, "REMOVE"); + await AssociationRepository.deleteMember({ userId: data.userId, associationId: data.associationId, @@ -38,8 +38,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { } case "ADD_MANAGER": case "REMOVE_MANAGER": { - const association = await validateHasManagePermissions( + const association = await requireAssociationPermission( data.associationId, + "MANAGE", ); errorToastIfFalsy( @@ -58,14 +59,17 @@ export const action = async ({ request }: ActionFunctionArgs) => { break; } case "DELETE_ASSOCIATION": { - await validateHasManagePermissions(data.associationId); + await requireAssociationPermission(data.associationId, "MANAGE"); await AssociationRepository.deleteById(data.associationId); break; } case "REFRESH_INVITE_CODE": { - await validateHasManagePermissions(data.associationId); + await requireAssociationPermission( + data.associationId, + "MANAGE_INVITE_LINK", + ); await AssociationRepository.refreshInviteCode(data.associationId); @@ -105,11 +109,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { break; } case "LEAVE_ASSOCIATION": { - const association = badRequestIfFalsy( - await AssociationRepository.findById(data.associationId, { - withMembers: true, - }), - ); + const association = await findAssociation(data.associationId); const isAdmin = association.permissions.MANAGE.includes(user.id); const newAdmin = isAdmin @@ -137,12 +137,19 @@ export const action = async ({ request }: ActionFunctionArgs) => { return null; }; -async function validateHasManagePermissions(associationId: number) { - const association = badRequestIfFalsy( +async function findAssociation(associationId: number) { + return badRequestIfFalsy( await AssociationRepository.findById(associationId, { withMembers: true }), ); +} - requirePermission(association, "MANAGE"); +async function requireAssociationPermission( + associationId: number, + permission: "MANAGE" | "MANAGE_INVITE_LINK", +) { + const association = await findAssociation(associationId); + + requirePermission(association, permission); return association; } diff --git a/app/features/associations/loaders/associations.server.ts b/app/features/associations/loaders/associations.server.ts index ff276850f..575d5c6d1 100644 --- a/app/features/associations/loaders/associations.server.ts +++ b/app/features/associations/loaders/associations.server.ts @@ -18,7 +18,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const associationsWithInviteCodes = await Promise.all( associations.map(async (association) => ({ ...association, - inviteCode: association.permissions.SHARE_INVITE_LINK.includes(user.id) + inviteCode: association.permissions.MANAGE_INVITE_LINK.includes(user.id) ? await AssociationRepository.findInviteCodeById(association.id) : undefined, })), diff --git a/app/features/associations/routes/associations.test.ts b/app/features/associations/routes/associations.test.ts index 915c0c603..2d826c13e 100644 --- a/app/features/associations/routes/associations.test.ts +++ b/app/features/associations/routes/associations.test.ts @@ -70,6 +70,115 @@ describe("Associations page action", () => { expect(associations[0]!.inviteCode).toBeUndefined(); }); + test("starred member can reset the invite link", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId()] }, + ); + + const before = await associationsLoader({ user: memberId() }); + + await associationsAction( + { _action: "REFRESH_INVITE_CODE", associationId: association.id }, + { user: memberId() }, + ); + + const after = await associationsLoader({ user: memberId() }); + expect(after.associations[0]!.inviteCode).toBeTruthy(); + expect(after.associations[0]!.inviteCode).not.toBe( + before.associations[0]!.inviteCode, + ); + }); + + test("unstarred member can't reset the invite link", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { memberUserIds: [memberId()] }, + ); + + await expect( + associationsAction( + { _action: "REFRESH_INVITE_CODE", associationId: association.id }, + { user: memberId() }, + ), + ).rejects.toThrow(); + }); + + test("starred member can remove an unstarred member", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId()], memberUserIds: [otherMemberId()] }, + ); + + await associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: otherMemberId(), + }, + { user: memberId() }, + ); + + const { associations } = await associationsLoader({ user: memberId() }); + expect(associations[0]!.members!.map((member) => member.id)).not.toContain( + otherMemberId(), + ); + }); + + test("starred member can't remove another starred member", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId(), otherMemberId()] }, + ); + + await expect( + associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: otherMemberId(), + }, + { user: memberId() }, + ), + ).rejects.toThrow(); + }); + + test("starred member can't remove the admin", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { managerUserIds: [memberId()] }, + ); + + await expect( + associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: adminId(), + }, + { user: memberId() }, + ), + ).rejects.toThrow(); + }); + + test("admin can't remove themselves", async () => { + const association = await AssociationFactory.create( + { userId: adminId() }, + { memberUserIds: [memberId()] }, + ); + + await expect( + associationsAction( + { + _action: "REMOVE_MEMBER", + associationId: association.id, + userId: adminId(), + }, + { user: adminId() }, + ), + ).rejects.toThrow(); + }); + test("member can't star another member", async () => { const association = await AssociationFactory.create( { userId: adminId() }, diff --git a/app/features/associations/routes/associations.tsx b/app/features/associations/routes/associations.tsx index aba7b6405..f99140b54 100644 --- a/app/features/associations/routes/associations.tsx +++ b/app/features/associations/routes/associations.tsx @@ -177,7 +177,7 @@ function AssociationSection({ key={member.id} member={member} associationId={association.id} - showControls={canManage && member.id !== user?.id} + canStar={canManage && member.id !== user?.id} /> ))} @@ -190,7 +190,6 @@ function AssociationSection({ ) : null} @@ -200,11 +199,9 @@ function AssociationSection({ function AssociationInviteCodeActions({ associationId, inviteCode, - canResetLink, }: { associationId: number; inviteCode: string; - canResetLink: boolean; }) { const { t } = useTranslation(["common", "scrims"]); const { copyToClipboard, copySuccess } = useCopyToClipboard(); @@ -225,17 +222,15 @@ function AssociationInviteCodeActions({ aria-label="Copy to clipboard" /> - {canResetLink ? ( - - {t("scrims:associations.shareLink.reset")} - - ) : null} + + {t("scrims:associations.shareLink.reset")} + ); } @@ -243,21 +238,22 @@ function AssociationInviteCodeActions({ function AssociationMember({ member, associationId, - showControls, + canStar, }: { member: NonNullable< AssociationsLoaderData["associations"][number]["members"] >[number]; associationId: number; - showControls?: boolean; + canStar: boolean; }) { const { t } = useTranslation(["common", "scrims"]); + const canRemove = useHasPermission(member, "REMOVE"); return (
- {!showControls && member.role === "MANAGER" ? ( + {!canStar && member.role === "MANAGER" ? ( ) : null}
- {showControls ? ( + {canStar || canRemove ? (
- - } - aria-label={t( - member.role === "MANAGER" - ? "scrims:associations.manager.remove" - : "scrims:associations.manager.add", - { username: member.username }, - )} - /> - - } - className="small-text" - variant="minimal-destructive" + variant="minimal" size="small" - type="submit" + className="small-text" + icon={ + + } + aria-label={t( + member.role === "MANAGER" + ? "scrims:associations.manager.remove" + : "scrims:associations.manager.add", + { username: member.username }, + )} /> - + ) : null} + {canRemove ? ( + + } + className="small-text" + variant="minimal-destructive" + size="small" + type="submit" + /> + + ) : null}
) : null}
diff --git a/changelog/2026-09-20-association-managers.md b/changelog/2026-09-20-association-managers.md index 7b1eadbba..ffc7b34e6 100644 --- a/changelog/2026-09-20-association-managers.md +++ b/changelog/2026-09-20-association-managers.md @@ -2,4 +2,4 @@ navItem: associations type: feature --- -Association admins can star members to let them share the invite link, and hand the association over by leaving +Association admins can star members to share the work of running it. Starred members can share and reset the invite link and remove unstarred members. With at least one starred member the admin can leave, and the longest-standing starred member takes over. \ No newline at end of file diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index 53ae13b23..1a583bd2c 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -69,7 +69,8 @@ test.describe("Associations", () => { await associations.goto(); await expect(associations.locators.inviteLinkInputs).toHaveCount(1); - await isNotVisible(associations.locators.resetLinkButton); + await expect(associations.locators.resetLinkButton).toBeVisible(); + await isNotVisible(associations.locators.deleteButtons); await impersonate(page, ADMIN_ID); await associations.goto(); @@ -78,7 +79,7 @@ test.describe("Associations", () => { await impersonate(page, NZAP_TEST_ID); await associations.goto(); - await expect(associations.locators.resetLinkButton).toBeVisible(); + await expect(associations.locators.deleteButtons).toHaveCount(1); }); test("joins and leaves an association", async ({ page, factories }) => { diff --git a/locales/en/scrims.json b/locales/en/scrims.json index 436bdfaa6..96f3c83b3 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -58,10 +58,10 @@ "associations.leave.action": "Leave", "associations.shareLink.title": "Share link to add members", "associations.shareLink.reset": "Reset link", - "associations.manager.label": "Can share the invite link", - "associations.manager.explanation": "Starred members can share the invite link. With at least one starred member you can leave the association, and the longest-standing starred member will become its new admin.", - "associations.manager.add": "Let {{username}} share the invite link", - "associations.manager.remove": "Stop {{username}} from sharing the invite link", + "associations.manager.label": "Starred member", + "associations.manager.explanation": "Starred members can share and reset the invite link and remove unstarred members. With at least one starred member you can leave the association, and the longest-standing starred member will become its new admin.", + "associations.manager.add": "Star {{username}}", + "associations.manager.remove": "Unstar {{username}}", "associations.removeMember.title": "Remove {{username}} from the association?", "associations.forms.title": "Creating a new association", "banner.canceled.header": "Canceled by {{user}}",