From 75f089fe8b1994ddbbd27c1519ec304ae32f3268 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:38:31 +0300 Subject: [PATCH] Allow starring association members --- app/db/seed/factories/AssociationFactory.ts | 20 ++- app/db/tables.ts | 3 +- .../AssociationRepository.server.ts | 74 ++++++++-- .../actions/associations.server.ts | 36 ++++- .../associations/associations-schemas.ts | 14 ++ app/features/associations/core/Association.ts | 13 ++ .../loaders/associations.server.ts | 2 +- .../associations/routes/associations.test.ts | 122 ++++++++++++++++ .../associations/routes/associations.tsx | 130 +++++++++++++----- changelog/2026-09-20-association-managers.md | 5 + e2e/associations.spec.ts | 36 +++++ e2e/pages/associations/associations-page.ts | 8 ++ locales/da/scrims.json | 5 + locales/de/scrims.json | 5 + locales/en/scrims.json | 5 + locales/es-ES/scrims.json | 5 + locales/es-US/scrims.json | 5 + locales/fr-CA/scrims.json | 5 + locales/fr-EU/scrims.json | 5 + locales/he/scrims.json | 5 + locales/it/scrims.json | 5 + locales/ja/scrims.json | 5 + locales/ko/scrims.json | 5 + locales/nl/scrims.json | 5 + locales/pl/scrims.json | 5 + locales/pt-BR/scrims.json | 5 + locales/ru/scrims.json | 5 + locales/zh/scrims.json | 5 + 28 files changed, 490 insertions(+), 53 deletions(-) create mode 100644 app/features/associations/routes/associations.test.ts create mode 100644 changelog/2026-09-20-association-managers.md diff --git a/app/db/seed/factories/AssociationFactory.ts b/app/db/seed/factories/AssociationFactory.ts index e1911a66e..baa958916 100644 --- a/app/db/seed/factories/AssociationFactory.ts +++ b/app/db/seed/factories/AssociationFactory.ts @@ -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", + }); + } }, }); diff --git a/app/db/tables.ts b/app/db/tables.ts index 35b95d3b2..588959f8d 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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 { diff --git a/app/features/associations/AssociationRepository.server.ts b/app/features/associations/AssociationRepository.server.ts index 90605eafa..f61618fff 100644 --- a/app/features/associations/AssociationRepository.server.ts +++ b/app/features/associations/AssociationRepository.server.ts @@ -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 = [ @@ -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(); } diff --git a/app/features/associations/actions/associations.server.ts b/app/features/associations/actions/associations.server.ts index 2d444943e..5dd0264d6 100644 --- a/app/features/associations/actions/associations.server.ts +++ b/app/features/associations/actions/associations.server.ts @@ -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; } diff --git a/app/features/associations/associations-schemas.ts b/app/features/associations/associations-schemas.ts index cba8a65c5..452ef501b 100644 --- a/app/features/associations/associations-schemas.ts +++ b/app/features/associations/associations-schemas.ts @@ -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, diff --git a/app/features/associations/core/Association.ts b/app/features/associations/core/Association.ts index 16a9686a7..58f518635 100644 --- a/app/features/associations/core/Association.ts +++ b/app/features/associations/core/Association.ts @@ -60,3 +60,16 @@ export function isPublic(args: Omit) { 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( + members: Array, +) { + const managers = members.filter((member) => member.role === "MANAGER"); + + return managers.sort((a, b) => a.id - b.id).at(0) ?? null; +} diff --git a/app/features/associations/loaders/associations.server.ts b/app/features/associations/loaders/associations.server.ts index b939443c3..ff276850f 100644 --- a/app/features/associations/loaders/associations.server.ts +++ b/app/features/associations/loaders/associations.server.ts @@ -18,7 +18,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const associationsWithInviteCodes = await Promise.all( associations.map(async (association) => ({ ...association, - inviteCode: association.permissions.MANAGE.includes(user.id) + inviteCode: association.permissions.SHARE_INVITE_LINK.includes(user.id) ? await AssociationRepository.findInviteCodeById(association.id) : undefined, })), diff --git a/app/features/associations/routes/associations.test.ts b/app/features/associations/routes/associations.test.ts new file mode 100644 index 000000000..915c0c603 --- /dev/null +++ b/app/features/associations/routes/associations.test.ts @@ -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({ + action, +}); + +const associationsLoader = wrappedLoader>({ + 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(), + ); + }); +}); diff --git a/app/features/associations/routes/associations.tsx b/app/features/associations/routes/associations.tsx index f034dd565..83615d8de 100644 --- a/app/features/associations/routes/associations.tsx +++ b/app/features/associations/routes/associations.tsx @@ -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() { {data.associations.map((association) => ( - + ))} ); @@ -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 (
@@ -129,11 +133,18 @@ function Association({ ?.username, })} - {!canManage ? ( + {!canManage || newAdmin ? ( ))} + {canManage ? ( +
+ {t("scrims:associations.manager.explanation")} +
+ ) : null} {association.inviteCode ? ( ) : null}
@@ -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" /> - - {t("scrims:associations.shareLink.reset")} - + {canResetLink ? ( + + {t("scrims:associations.shareLink.reset")} + + ) : null} ); } @@ -225,28 +246,63 @@ function AssociationMember({ return (
- - {showControls ? ( - - } - className="small-text" - variant="minimal-destructive" - size="small" - type="submit" +
+ + {!showControls && member.role === "MANAGER" ? ( + - + ) : null} +
+ {showControls ? ( +
+ + } + aria-label={t( + member.role === "MANAGER" + ? "scrims:associations.manager.remove" + : "scrims:associations.manager.add", + { username: member.username }, + )} + /> + + } + className="small-text" + variant="minimal-destructive" + size="small" + type="submit" + /> + +
) : null}
); diff --git a/changelog/2026-09-20-association-managers.md b/changelog/2026-09-20-association-managers.md new file mode 100644 index 000000000..7b1eadbba --- /dev/null +++ b/changelog/2026-09-20-association-managers.md @@ -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 diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index 7a26dd642..53ae13b23 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -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 }); diff --git a/e2e/pages/associations/associations-page.ts b/e2e/pages/associations/associations-page.ts index 912d2a7c7..0f639725d 100644 --- a/e2e/pages/associations/associations-page.ts +++ b/e2e/pages/associations/associations-page.ts @@ -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); diff --git a/locales/da/scrims.json b/locales/da/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -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": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index 5177bb8e4..d00d2fc95 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -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": "", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index dfbf3a850..cf3cfbef4 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -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}}", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index 652f2c798..8f9767ab0 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -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}}", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 652f2c798..8f9767ab0 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -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}}", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -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": "", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index cc5dc281b..58fea6d1a 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -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": "", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -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": "", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -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": "", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -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": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -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": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -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": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -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": "", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -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": "", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index 3636ccaa0..fca64413d 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -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": "", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index 3ecb413f7..fed6f4d1a 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -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}} 取消",