mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-25 12:49:26 -05:00
Association starred members do more
This commit is contained in:
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
Reference in New Issue
Block a user