Allow starring association members

This commit is contained in:
Kalle
2026-09-20 13:38:31 +03:00
parent 5906cdeb77
commit 75f089fe8b
28 changed files with 490 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.MANAGE.includes(user.id)
inviteCode: association.permissions.SHARE_INVITE_LINK.includes(user.id)
? await AssociationRepository.findInviteCodeById(association.id)
: undefined,
})),

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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