mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 06:10:01 -05:00
Allow starring association members
This commit is contained in:
@@ -5,6 +5,8 @@ import { faker } from "../core/faker";
|
||||
type Options = {
|
||||
/** Members besides the owner, who is an admin of the association regardless. */
|
||||
memberUserIds?: number[];
|
||||
/** Members who can also share the association's invite link. */
|
||||
managerUserIds?: number[];
|
||||
};
|
||||
|
||||
/** Creates associations. `userId` is the owner, added as its admin by the repository. */
|
||||
@@ -13,12 +15,26 @@ export const { create } = defineFactory({
|
||||
name: faker.company.name(),
|
||||
}),
|
||||
insert: AssociationRepository.insert,
|
||||
applyOptions: async (association, { memberUserIds }: Options) => {
|
||||
for (const userId of memberUserIds ?? []) {
|
||||
applyOptions: async (
|
||||
association,
|
||||
{ memberUserIds, managerUserIds }: Options,
|
||||
) => {
|
||||
for (const userId of [
|
||||
...(memberUserIds ?? []),
|
||||
...(managerUserIds ?? []),
|
||||
]) {
|
||||
await AssociationRepository.insertMember({
|
||||
associationId: association.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const userId of managerUserIds ?? []) {
|
||||
await AssociationRepository.updateMemberRole({
|
||||
associationId: association.id,
|
||||
userId,
|
||||
role: "MANAGER",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1269,7 +1269,8 @@ export interface Association {
|
||||
export interface AssociationMember {
|
||||
userId: number;
|
||||
associationId: number;
|
||||
role: "MEMBER" | "ADMIN";
|
||||
/** MANAGER can also share the invite link, ADMIN (one per association) can also manage the members */
|
||||
role: "MEMBER" | "MANAGER" | "ADMIN";
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
|
||||
import { ASSOCIATION } from "~/features/associations/associations-constants";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
@@ -88,14 +88,25 @@ async function findBy(
|
||||
.where("Association.id", "=", args.associationId)
|
||||
.execute();
|
||||
|
||||
return associations.map((a) => ({
|
||||
...a,
|
||||
permissions: {
|
||||
MANAGE: (a.members ?? [])
|
||||
.filter((member) => member.role === "ADMIN")
|
||||
.map((user) => user.id),
|
||||
},
|
||||
}));
|
||||
return associations.map((a) => {
|
||||
const members = a.members ?? [];
|
||||
const adminIds = members
|
||||
.filter((member) => member.role === "ADMIN")
|
||||
.map((user) => user.id);
|
||||
|
||||
return {
|
||||
...a,
|
||||
permissions: {
|
||||
MANAGE: adminIds,
|
||||
SHARE_INVITE_LINK: [
|
||||
...adminIds,
|
||||
...members
|
||||
.filter((member) => member.role === "MANAGER")
|
||||
.map((user) => user.id),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_VIRTUAL_ASSOCIATIONS: Array<AssociationVirtualIdentifier> = [
|
||||
@@ -191,6 +202,23 @@ export function insertMember({
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function updateMemberRole({
|
||||
associationId,
|
||||
userId,
|
||||
role,
|
||||
}: {
|
||||
associationId: number;
|
||||
userId: number;
|
||||
role: Tables["AssociationMember"]["role"];
|
||||
}) {
|
||||
return db
|
||||
.updateTable("AssociationMember")
|
||||
.set({ role })
|
||||
.where("associationId", "=", associationId)
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function deleteMember({
|
||||
associationId,
|
||||
userId,
|
||||
@@ -205,6 +233,34 @@ export function deleteMember({
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Removes the member and, when they were the admin, promotes `newAdminUserId` in their place. */
|
||||
export function handleMemberLeaving({
|
||||
associationId,
|
||||
userId,
|
||||
newAdminUserId,
|
||||
}: {
|
||||
associationId: number;
|
||||
userId: number;
|
||||
newAdminUserId?: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.deleteFrom("AssociationMember")
|
||||
.where("associationId", "=", associationId)
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
if (typeof newAdminUserId === "number") {
|
||||
await trx
|
||||
.updateTable("AssociationMember")
|
||||
.set({ role: "ADMIN" })
|
||||
.where("associationId", "=", associationId)
|
||||
.where("userId", "=", newAdminUserId)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteById(associationId: number) {
|
||||
return db.deleteFrom("Association").where("id", "=", associationId).execute();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { ASSOCIATION } from "~/features/associations/associations-constants";
|
||||
import { associationsPageActionSchema } from "~/features/associations/associations-schemas";
|
||||
import * as Association from "~/features/associations/core/Association";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
@@ -35,6 +36,27 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
||||
break;
|
||||
}
|
||||
case "ADD_MANAGER":
|
||||
case "REMOVE_MANAGER": {
|
||||
const association = await validateHasManagePermissions(
|
||||
data.associationId,
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
association.members!.some(
|
||||
(member) => member.id === data.userId && member.role !== "ADMIN",
|
||||
),
|
||||
"Not a member of the association",
|
||||
);
|
||||
|
||||
await AssociationRepository.updateMemberRole({
|
||||
associationId: data.associationId,
|
||||
userId: data.userId,
|
||||
role: data._action === "ADD_MANAGER" ? "MANAGER" : "MEMBER",
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_ASSOCIATION": {
|
||||
await validateHasManagePermissions(data.associationId);
|
||||
|
||||
@@ -89,14 +111,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}),
|
||||
);
|
||||
|
||||
const isAdmin = association.permissions.MANAGE.includes(user.id);
|
||||
const newAdmin = isAdmin
|
||||
? Association.resolveNewAdmin(association.members!)
|
||||
: null;
|
||||
|
||||
errorToastIfFalsy(
|
||||
!association.permissions.MANAGE.includes(user.id),
|
||||
"You cannot leave an association you manage",
|
||||
!isAdmin || newAdmin,
|
||||
"You cannot leave an association you manage without a starred member to take over",
|
||||
);
|
||||
|
||||
await AssociationRepository.deleteMember({
|
||||
await AssociationRepository.handleMemberLeaving({
|
||||
userId: user.id,
|
||||
associationId: data.associationId,
|
||||
newAdminUserId: newAdmin?.id,
|
||||
});
|
||||
|
||||
return successToast("Left association");
|
||||
@@ -115,4 +143,6 @@ async function validateHasManagePermissions(associationId: number) {
|
||||
);
|
||||
|
||||
requirePermission(association, "MANAGE");
|
||||
|
||||
return association;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,18 @@ const removeMemberSchema = v.object({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
const addManagerSchema = v.object({
|
||||
_action: _action("ADD_MANAGER"),
|
||||
associationId: id,
|
||||
userId: id,
|
||||
});
|
||||
|
||||
const removeManagerSchema = v.object({
|
||||
_action: _action("REMOVE_MANAGER"),
|
||||
associationId: id,
|
||||
userId: id,
|
||||
});
|
||||
|
||||
const deleteAssociationSchema = v.object({
|
||||
_action: _action("DELETE_ASSOCIATION"),
|
||||
associationId: id,
|
||||
@@ -38,6 +50,8 @@ const leaveAssociationSchema = v.object({
|
||||
|
||||
export const associationsPageActionSchema = v.union([
|
||||
removeMemberSchema,
|
||||
addManagerSchema,
|
||||
removeManagerSchema,
|
||||
deleteAssociationSchema,
|
||||
refreshInviteCodeSchema,
|
||||
joinAssociationSchema,
|
||||
|
||||
@@ -60,3 +60,16 @@ export function isPublic(args: Omit<IsVisibleArgs, "associations">) {
|
||||
visibility: args.visibility,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Who becomes the admin after the current one leaves. `null` if no manager can take over,
|
||||
* in which case the admin can't leave. Of several managers the one with the lowest user id
|
||||
* (the oldest account) is picked, same as team ownership passing on.
|
||||
*/
|
||||
export function resolveNewAdmin<T extends { id: number; role: string }>(
|
||||
members: Array<T>,
|
||||
) {
|
||||
const managers = members.filter((member) => member.role === "MANAGER");
|
||||
|
||||
return managers.sort((a, b) => a.id - b.id).at(0) ?? null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const associationsWithInviteCodes = await Promise.all(
|
||||
associations.map(async (association) => ({
|
||||
...association,
|
||||
inviteCode: association.permissions.MANAGE.includes(user.id)
|
||||
inviteCode: association.permissions.SHARE_INVITE_LINK.includes(user.id)
|
||||
? await AssociationRepository.findInviteCodeById(association.id)
|
||||
: undefined,
|
||||
})),
|
||||
|
||||
122
app/features/associations/routes/associations.test.ts
Normal file
122
app/features/associations/routes/associations.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as AssociationFactory from "~/db/seed/factories/AssociationFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import {
|
||||
assertResponseErrored,
|
||||
wrappedAction,
|
||||
wrappedLoader,
|
||||
} from "~/utils/Test";
|
||||
import { action } from "../actions/associations.server";
|
||||
import type { associationsPageActionSchema } from "../associations-schemas";
|
||||
import { loader } from "../loaders/associations.server";
|
||||
|
||||
const associationsAction = wrappedAction<typeof associationsPageActionSchema>({
|
||||
action,
|
||||
});
|
||||
|
||||
const associationsLoader = wrappedLoader<SerializeFrom<typeof loader>>({
|
||||
loader,
|
||||
});
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const adminId = () => users.id(1);
|
||||
const memberId = () => users.id(2);
|
||||
const otherMemberId = () => users.id(3);
|
||||
|
||||
describe("Associations page action", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(3);
|
||||
});
|
||||
|
||||
test("starred member can share the invite link", async () => {
|
||||
const association = await AssociationFactory.create(
|
||||
{ userId: adminId() },
|
||||
{ memberUserIds: [memberId()] },
|
||||
);
|
||||
|
||||
const before = await associationsLoader({ user: memberId() });
|
||||
expect(before.associations[0]!.inviteCode).toBeUndefined();
|
||||
|
||||
await associationsAction(
|
||||
{
|
||||
_action: "ADD_MANAGER",
|
||||
associationId: association.id,
|
||||
userId: memberId(),
|
||||
},
|
||||
{ user: adminId() },
|
||||
);
|
||||
|
||||
const after = await associationsLoader({ user: memberId() });
|
||||
expect(after.associations[0]!.inviteCode).toBeTruthy();
|
||||
});
|
||||
|
||||
test("unstarring takes the invite link away again", async () => {
|
||||
const association = await AssociationFactory.create(
|
||||
{ userId: adminId() },
|
||||
{ managerUserIds: [memberId()] },
|
||||
);
|
||||
|
||||
await associationsAction(
|
||||
{
|
||||
_action: "REMOVE_MANAGER",
|
||||
associationId: association.id,
|
||||
userId: memberId(),
|
||||
},
|
||||
{ user: adminId() },
|
||||
);
|
||||
|
||||
const { associations } = await associationsLoader({ user: memberId() });
|
||||
expect(associations[0]!.inviteCode).toBeUndefined();
|
||||
});
|
||||
|
||||
test("member can't star another member", async () => {
|
||||
const association = await AssociationFactory.create(
|
||||
{ userId: adminId() },
|
||||
{ memberUserIds: [memberId(), otherMemberId()] },
|
||||
);
|
||||
|
||||
await expect(
|
||||
associationsAction(
|
||||
{
|
||||
_action: "ADD_MANAGER",
|
||||
associationId: association.id,
|
||||
userId: otherMemberId(),
|
||||
},
|
||||
{ user: memberId() },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("admin can't leave without a starred member", async () => {
|
||||
const association = await AssociationFactory.create(
|
||||
{ userId: adminId() },
|
||||
{ memberUserIds: [memberId()] },
|
||||
);
|
||||
|
||||
const response = await associationsAction(
|
||||
{ _action: "LEAVE_ASSOCIATION", associationId: association.id },
|
||||
{ user: adminId() },
|
||||
);
|
||||
|
||||
assertResponseErrored(response, "starred member to take over");
|
||||
});
|
||||
|
||||
test("oldest starred member becomes the admin when the admin leaves", async () => {
|
||||
const association = await AssociationFactory.create(
|
||||
{ userId: adminId() },
|
||||
{ managerUserIds: [otherMemberId(), memberId()] },
|
||||
);
|
||||
|
||||
await associationsAction(
|
||||
{ _action: "LEAVE_ASSOCIATION", associationId: association.id },
|
||||
{ user: adminId() },
|
||||
);
|
||||
|
||||
const { associations } = await associationsLoader({ user: memberId() });
|
||||
expect(associations[0]!.permissions.MANAGE).toEqual([memberId()]);
|
||||
expect(associations[0]!.members!.map((member) => member.id)).not.toContain(
|
||||
adminId(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Clipboard, Trash } from "lucide-react";
|
||||
import { Check, Clipboard, Star, Trash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MetaFunction, Outlet, useLoaderData } from "react-router";
|
||||
@@ -10,6 +10,7 @@ import { Main } from "~/components/Main";
|
||||
import { UserLink } from "~/components/UserLink";
|
||||
import { action } from "~/features/associations/actions/associations.server";
|
||||
import { associationsPage } from "~/features/associations/associations-urls";
|
||||
import * as Association from "~/features/associations/core/Association";
|
||||
import {
|
||||
type AssociationsLoaderData,
|
||||
loader,
|
||||
@@ -45,7 +46,7 @@ export default function AssociationsPage() {
|
||||
</div>
|
||||
<JoinForm />
|
||||
{data.associations.map((association) => (
|
||||
<Association key={association.id} association={association} />
|
||||
<AssociationSection key={association.id} association={association} />
|
||||
))}
|
||||
</Main>
|
||||
);
|
||||
@@ -89,7 +90,7 @@ function JoinForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function Association({
|
||||
function AssociationSection({
|
||||
association,
|
||||
}: {
|
||||
association: AssociationsLoaderData["associations"][number];
|
||||
@@ -97,6 +98,9 @@ function Association({
|
||||
const { t } = useTranslation(["common", "scrims"]);
|
||||
const user = useUser();
|
||||
const canManage = useHasPermission(association, "MANAGE");
|
||||
const newAdmin = canManage
|
||||
? Association.resolveNewAdmin(association.members ?? [])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -129,11 +133,18 @@ function Association({
|
||||
?.username,
|
||||
})}
|
||||
</div>
|
||||
{!canManage ? (
|
||||
{!canManage || newAdmin ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("scrims:associations.leave.title", {
|
||||
name: association.name,
|
||||
})}
|
||||
dialogHeading={
|
||||
newAdmin
|
||||
? t("scrims:associations.leave.titleWithNewAdmin", {
|
||||
name: association.name,
|
||||
username: newAdmin.username,
|
||||
})
|
||||
: t("scrims:associations.leave.title", {
|
||||
name: association.name,
|
||||
})
|
||||
}
|
||||
fields={[
|
||||
["_action", "LEAVE_ASSOCIATION"],
|
||||
["associationId", association.id],
|
||||
@@ -161,10 +172,16 @@ function Association({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="text-xs text-lighter mt-2">
|
||||
{t("scrims:associations.manager.explanation")}
|
||||
</div>
|
||||
) : null}
|
||||
{association.inviteCode ? (
|
||||
<AssociationInviteCodeActions
|
||||
associationId={association.id}
|
||||
inviteCode={association.inviteCode}
|
||||
canResetLink={canManage}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
@@ -174,9 +191,11 @@ function Association({
|
||||
function AssociationInviteCodeActions({
|
||||
associationId,
|
||||
inviteCode,
|
||||
canResetLink,
|
||||
}: {
|
||||
associationId: number;
|
||||
inviteCode: string;
|
||||
canResetLink: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "scrims"]);
|
||||
const { copyToClipboard, copySuccess } = useCopyToClipboard();
|
||||
@@ -197,15 +216,17 @@ function AssociationInviteCodeActions({
|
||||
aria-label="Copy to clipboard"
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
schema={associationsPageActionSchema}
|
||||
action="REFRESH_INVITE_CODE"
|
||||
fields={{ associationId }}
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
>
|
||||
{t("scrims:associations.shareLink.reset")}
|
||||
</ActionButton>
|
||||
{canResetLink ? (
|
||||
<ActionButton
|
||||
schema={associationsPageActionSchema}
|
||||
action="REFRESH_INVITE_CODE"
|
||||
fields={{ associationId }}
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
>
|
||||
{t("scrims:associations.shareLink.reset")}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -225,28 +246,63 @@ function AssociationMember({
|
||||
|
||||
return (
|
||||
<div className="stack horizontal sm items-center justify-between">
|
||||
<UserLink user={member} />
|
||||
{showControls ? (
|
||||
<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"
|
||||
<div className="stack horizontal sm items-center">
|
||||
<UserLink user={member} />
|
||||
{!showControls && member.role === "MANAGER" ? (
|
||||
<Star
|
||||
className="small-icon"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label={t("scrims:associations.manager.label")}
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
</div>
|
||||
{showControls ? (
|
||||
<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
|
||||
shape="square"
|
||||
icon={<Trash className="small-icon" />}
|
||||
className="small-text"
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
type="submit"
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
5
changelog/2026-09-20-association-managers.md
Normal file
5
changelog/2026-09-20-association-managers.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: associations
|
||||
type: feature
|
||||
---
|
||||
Association admins can star members to let them share the invite link, and hand the association over by leaving
|
||||
@@ -45,6 +45,42 @@ test.describe("Associations", () => {
|
||||
await expect(associations.locators.deleteButtons).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("stars a member, who then takes over when the admin leaves", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await factories.AssociationFactory.create(
|
||||
{ userId: ADMIN_ID },
|
||||
{ memberUserIds: [NZAP_TEST_ID] },
|
||||
);
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
|
||||
const associations = new AssociationsPage(page);
|
||||
await associations.goto();
|
||||
|
||||
await isNotVisible(associations.locators.leaveButton);
|
||||
|
||||
await associations.toggleManager("N-ZAP");
|
||||
|
||||
await expect(associations.locators.leaveButton).toBeVisible();
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
await associations.goto();
|
||||
|
||||
await expect(associations.locators.inviteLinkInputs).toHaveCount(1);
|
||||
await isNotVisible(associations.locators.resetLinkButton);
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await associations.goto();
|
||||
await associations.leave();
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
await associations.goto();
|
||||
|
||||
await expect(associations.locators.resetLinkButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("joins and leaves an association", async ({ page, factories }) => {
|
||||
await factories.AssociationFactory.create({ userId: ADMIN_ID });
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export class AssociationsPage {
|
||||
deleteButtons: page.getByTestId("delete-association"),
|
||||
leaveButton: page.getByTestId("leave-team-button"),
|
||||
inviteLinkInputs: page.getByLabel("Share link to add members"),
|
||||
resetLinkButton: page.getByText("Reset link"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +53,13 @@ export class AssociationsPage {
|
||||
await modalClickConfirmButton(this.page);
|
||||
}
|
||||
|
||||
/** The star that lets the member share the invite link, or takes that away. */
|
||||
async toggleManager(username: string) {
|
||||
await this.page
|
||||
.getByRole("button", { name: username, exact: false })
|
||||
.click();
|
||||
}
|
||||
|
||||
async leave() {
|
||||
await this.locators.leaveButton.click();
|
||||
await modalClickConfirmButton(this.page);
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "Assoziation {{name}} löschen?",
|
||||
"associations.admin": "Admin: {{username}}",
|
||||
"associations.leave.title": "Assoziation {{name}} verlassen?",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "Verlassen",
|
||||
"associations.shareLink.title": "Link teilen, um Mitglieder hinzuzufügen",
|
||||
"associations.shareLink.reset": "Link zurücksetzen",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "{{username}} aus der Assoziation entfernen?",
|
||||
"associations.forms.title": "Neue Assoziation erstellen",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "Delete {{name}} association?",
|
||||
"associations.admin": "Admin: {{username}}",
|
||||
"associations.leave.title": "Leave {{name}} association?",
|
||||
"associations.leave.titleWithNewAdmin": "Leave {{name}} association? {{username}} will become the new admin",
|
||||
"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.removeMember.title": "Remove {{username}} from the association?",
|
||||
"associations.forms.title": "Creating a new association",
|
||||
"banner.canceled.header": "Canceled by {{user}}",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "¿Eliminar la asociación {{name}}?",
|
||||
"associations.admin": "Administrador: {{username}}",
|
||||
"associations.leave.title": "¿Salir de la asociación {{name}}?",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "Salir",
|
||||
"associations.shareLink.title": "Compartir enlace para añadir miembros",
|
||||
"associations.shareLink.reset": "Restablecer enlace",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "¿Eliminar a {{username}} de la asociación?",
|
||||
"associations.forms.title": "Creando una nueva asociación",
|
||||
"banner.canceled.header": "Cancelado por {{user}}",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "¿Eliminar la asociación {{name}}?",
|
||||
"associations.admin": "Administrador: {{username}}",
|
||||
"associations.leave.title": "¿Salir de la asociación {{name}}?",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "Salir",
|
||||
"associations.shareLink.title": "Compartir enlace para añadir miembros",
|
||||
"associations.shareLink.reset": "Restablecer enlace",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "¿Eliminar a {{username}} de la asociación?",
|
||||
"associations.forms.title": "Creando una nueva asociación",
|
||||
"banner.canceled.header": "Cancelado por {{user}}",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "Supprimer l'association {{name}} ?",
|
||||
"associations.admin": "Admin: {{username}}",
|
||||
"associations.leave.title": "Quitter l'association {{name}} ?",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "Quitter",
|
||||
"associations.shareLink.title": "Partager le lien pour ajouter des membres",
|
||||
"associations.shareLink.reset": "Réinitialiser le lien",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "Enlever {{username}} de l'association?",
|
||||
"associations.forms.title": "Créer une nouvelle association",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "",
|
||||
"associations.admin": "",
|
||||
"associations.leave.title": "",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "",
|
||||
"associations.shareLink.title": "",
|
||||
"associations.shareLink.reset": "",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "",
|
||||
"associations.forms.title": "",
|
||||
"banner.canceled.header": "",
|
||||
|
||||
@@ -52,9 +52,14 @@
|
||||
"associations.delete.title": "删除群组 {{name}} ?",
|
||||
"associations.admin": "管理员: {{username}}",
|
||||
"associations.leave.title": "退出群组 {{name}} ?",
|
||||
"associations.leave.titleWithNewAdmin": "",
|
||||
"associations.leave.action": "退出",
|
||||
"associations.shareLink.title": "分享链接以邀请成员",
|
||||
"associations.shareLink.reset": "重置链接",
|
||||
"associations.manager.label": "",
|
||||
"associations.manager.explanation": "",
|
||||
"associations.manager.add": "",
|
||||
"associations.manager.remove": "",
|
||||
"associations.removeMember.title": "将 {{username}} 从群组中移除?",
|
||||
"associations.forms.title": "创建新群组",
|
||||
"banner.canceled.header": "由 {{user}} 取消",
|
||||
|
||||
Reference in New Issue
Block a user