Association starred members do more

This commit is contained in:
Kalle
2026-09-20 17:13:32 +03:00
parent 871093bd17
commit ce39bdddd9
8 changed files with 238 additions and 92 deletions

View File

@@ -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<number>;
managerIds: Array<number>;
}) {
const removerIds =
member.role === "ADMIN"
? []
: member.role === "MANAGER"
? adminIds
: [...adminIds, ...managerIds];
return removerIds.filter((id) => id !== member.id);
}
const DEFAULT_VIRTUAL_ASSOCIATIONS: Array<AssociationVirtualIdentifier> = [
"FRIENDS",
];

View File

@@ -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;
}

View File

@@ -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,
})),

View File

@@ -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() },

View File

@@ -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}
/>
))}
</div>
@@ -190,7 +190,6 @@ function AssociationSection({
<AssociationInviteCodeActions
associationId={association.id}
inviteCode={association.inviteCode}
canResetLink={canManage}
/>
) : null}
</section>
@@ -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"
/>
</div>
{canResetLink ? (
<ActionButton
schema={associationsPageActionSchema}
action="REFRESH_INVITE_CODE"
fields={{ associationId }}
variant="minimal-destructive"
size="small"
>
{t("scrims:associations.shareLink.reset")}
</ActionButton>
) : null}
<ActionButton
schema={associationsPageActionSchema}
action="REFRESH_INVITE_CODE"
fields={{ associationId }}
variant="minimal-destructive"
size="small"
>
{t("scrims:associations.shareLink.reset")}
</ActionButton>
</div>
);
}
@@ -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 (
<div className="stack horizontal sm items-center justify-between">
<div className="stack horizontal sm items-center">
<UserLink user={member} />
{!showControls && member.role === "MANAGER" ? (
{!canStar && member.role === "MANAGER" ? (
<Star
className="small-icon"
fill="currentColor"
@@ -266,51 +262,55 @@ function AssociationMember({
/>
) : null}
</div>
{showControls ? (
{canStar || canRemove ? (
<div className="stack horizontal sm items-center">
<ActionButton
schema={associationsPageActionSchema}
action={
member.role === "MANAGER" ? "REMOVE_MANAGER" : "ADD_MANAGER"
}
fields={{ userId: member.id, associationId }}
shape="square"
variant="minimal"
size="small"
className="small-text"
icon={
<Star
className="small-icon"
fill={member.role === "MANAGER" ? "currentColor" : "none"}
/>
}
aria-label={t(
member.role === "MANAGER"
? "scrims:associations.manager.remove"
: "scrims:associations.manager.add",
{ username: member.username },
)}
/>
<FormWithConfirm
dialogHeading={t("scrims:associations.removeMember.title", {
username: member.username,
})}
submitButtonText={t("common:actions.remove")}
fields={[
["userId", member.id],
["associationId", associationId],
["_action", "REMOVE_MEMBER"],
]}
>
<SendouButton
{canStar ? (
<ActionButton
schema={associationsPageActionSchema}
action={
member.role === "MANAGER" ? "REMOVE_MANAGER" : "ADD_MANAGER"
}
fields={{ userId: member.id, associationId }}
shape="square"
icon={<Trash className="small-icon" />}
className="small-text"
variant="minimal-destructive"
variant="minimal"
size="small"
type="submit"
className="small-text"
icon={
<Star
className="small-icon"
fill={member.role === "MANAGER" ? "currentColor" : "none"}
/>
}
aria-label={t(
member.role === "MANAGER"
? "scrims:associations.manager.remove"
: "scrims:associations.manager.add",
{ username: member.username },
)}
/>
</FormWithConfirm>
) : null}
{canRemove ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.removeMember.title", {
username: member.username,
})}
submitButtonText={t("common:actions.remove")}
fields={[
["userId", member.id],
["associationId", associationId],
["_action", "REMOVE_MEMBER"],
]}
>
<SendouButton
shape="square"
icon={<Trash className="small-icon" />}
className="small-text"
variant="minimal-destructive"
size="small"
type="submit"
/>
</FormWithConfirm>
) : null}
</div>
) : null}
</div>

View File

@@ -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.

View File

@@ -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 }) => {

View File

@@ -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}}",