From 8f156fb917b710536d00e04b2810c71980e19da5 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 4 Feb 2025 10:56:33 +0200 Subject: [PATCH] Team editors in addition to the owner (#2077) * Initial * Handle owner leaving * Remove old team queries * Progress * Retire old toggle * e2e tests * Divide loaders/actions of team pages --- app/components/Toggle.tsx | 32 --- app/components/elements/Button.tsx | 4 +- app/components/elements/Popover.tsx | 2 +- app/components/elements/Switch.tsx | 22 +++ app/components/form/ToggleFormField.tsx | 4 +- app/db/seed/index.ts | 9 +- app/db/tables.ts | 5 +- app/db/types.ts | 2 +- app/features/api-private/routes/seed.tsx | 8 +- app/features/art/routes/art.new.tsx | 11 +- app/features/art/routes/art.tsx | 8 +- .../components/PerInkTankGrid.tsx | 2 +- .../build-analyzer/routes/analyzer.tsx | 13 +- .../components/BracketProgressionSelector.tsx | 40 ++-- app/features/calendar/routes/calendar.new.tsx | 60 +++--- app/features/calendar/routes/calendar.tsx | 9 +- .../img-upload/actions/upload.server.ts | 6 +- app/features/img-upload/routes/upload.tsx | 7 +- .../map-list-generator/routes/maps.tsx | 9 +- .../routes/object-damage-calculator.tsx | 13 +- .../sendouq-settings/routes/q.settings.tsx | 9 +- app/features/sendouq/components/GroupCard.tsx | 2 +- app/features/sendouq/routes/q.match.$id.tsx | 13 +- .../team/TeamMemberRepository.server.ts | 14 ++ app/features/team/TeamRepository.server.ts | 39 +++- .../team/actions/t.$customUrl.edit.server.ts | 68 +++++++ .../team/actions/t.$customUrl.join.server.ts | 44 +++++ .../actions/t.$customUrl.roster.server.ts | 89 +++++++++ .../team/actions/t.$customUrl.server.ts | 17 +- app/features/team/index.ts | 3 - .../team/loaders/t.$customUrl.edit.server.ts | 22 +++ .../team/loaders/t.$customUrl.join.server.ts | 78 ++++++++ .../loaders/t.$customUrl.roster.server.ts | 29 +++ app/features/team/queries/editRole.server.ts | 21 -- .../team/queries/inviteCodeById.server.ts | 11 -- .../team/queries/resetInviteLink.server.ts | 13 -- .../team/queries/transferOwnership.server.ts | 23 --- .../team/routes/t.$customUrl.edit.tsx | 117 ++--------- .../team/routes/t.$customUrl.join.tsx | 115 +---------- .../team/routes/t.$customUrl.roster.tsx | 182 +++++++----------- app/features/team/routes/t.$customUrl.tsx | 17 +- app/features/team/team-schemas.server.ts | 8 +- app/features/team/team-utils.ts | 36 ++++ .../components/BracketMapListDialog.tsx | 13 +- .../components/StartedMatch.tsx | 2 +- .../components/TournamentTeamActions.tsx | 2 +- .../routes/to.$id.brackets.tsx | 2 +- .../tournament-bracket/tournament-bracket.css | 5 + .../tournament-subs/routes/to.$id.subs.tsx | 2 +- .../tournament/routes/to.$id.register.tsx | 4 +- .../user-page/routes/u.$identifier.builds.tsx | 4 +- .../user-page/routes/u.$identifier.edit.tsx | 15 +- .../user-page/routes/u.$identifier.index.tsx | 2 +- app/styles/common.css | 49 ----- app/styles/elements.css | 73 ++++++- db-test.sqlite3 | Bin 884736 -> 884736 bytes e2e/object-damage-calculator.spec.ts | 2 +- e2e/team.spec.ts | 47 ++++- e2e/tournament-bracket.spec.ts | 15 +- locales/da/team.json | 1 - locales/de/team.json | 1 - locales/en/team.json | 7 +- locales/es-ES/team.json | 1 - locales/es-US/team.json | 1 - locales/fr-CA/team.json | 1 - locales/fr-EU/team.json | 1 - locales/he/team.json | 1 - locales/ja/team.json | 1 - locales/pl/team.json | 1 - locales/pt-BR/team.json | 1 - locales/ru/team.json | 1 - locales/zh/team.json | 1 - migrations/080-team-manager.js | 7 + 73 files changed, 842 insertions(+), 657 deletions(-) delete mode 100644 app/components/Toggle.tsx create mode 100644 app/components/elements/Switch.tsx create mode 100644 app/features/team/TeamMemberRepository.server.ts create mode 100644 app/features/team/actions/t.$customUrl.edit.server.ts create mode 100644 app/features/team/actions/t.$customUrl.join.server.ts create mode 100644 app/features/team/actions/t.$customUrl.roster.server.ts delete mode 100644 app/features/team/index.ts create mode 100644 app/features/team/loaders/t.$customUrl.edit.server.ts create mode 100644 app/features/team/loaders/t.$customUrl.join.server.ts create mode 100644 app/features/team/loaders/t.$customUrl.roster.server.ts delete mode 100644 app/features/team/queries/editRole.server.ts delete mode 100644 app/features/team/queries/inviteCodeById.server.ts delete mode 100644 app/features/team/queries/resetInviteLink.server.ts delete mode 100644 app/features/team/queries/transferOwnership.server.ts create mode 100644 migrations/080-team-manager.js diff --git a/app/components/Toggle.tsx b/app/components/Toggle.tsx deleted file mode 100644 index 24aee4690..000000000 --- a/app/components/Toggle.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Switch } from "@headlessui/react"; -import clsx from "clsx"; - -export function Toggle({ - checked, - setChecked, - tiny, - id, - name, - disabled, -}: { - checked: boolean; - setChecked: (checked: boolean) => void; - tiny?: boolean; - id?: string; - name?: string; - disabled?: boolean; -}) { - return ( - - - - ); -} diff --git a/app/components/elements/Button.tsx b/app/components/elements/Button.tsx index 822f3fcca..2100ec346 100644 --- a/app/components/elements/Button.tsx +++ b/app/components/elements/Button.tsx @@ -14,7 +14,7 @@ interface MyDatePickerProps extends ReactAriaButtonProps { | "minimal" | "minimal-success" | "minimal-destructive"; - size?: "miniscule" | "tiny" | "big"; + size?: "miniscule" | "small" | "medium" | "big"; icon?: JSX.Element; children?: React.ReactNode; } @@ -34,7 +34,7 @@ export function SendouButton({ "react-aria-Button", variant, { - tiny: size === "tiny", + small: size === "small", big: size === "big", miniscule: size === "miniscule", }, diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index ad58214d9..584650cd6 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -21,7 +21,7 @@ export function SendouPopover({ {trigger} {children} diff --git a/app/components/elements/Switch.tsx b/app/components/elements/Switch.tsx new file mode 100644 index 000000000..6572a9414 --- /dev/null +++ b/app/components/elements/Switch.tsx @@ -0,0 +1,22 @@ +import clsx from "clsx"; +import { + Switch as ReactAriaSwitch, + type SwitchProps as ReactAriaSwitchProps, +} from "react-aria-components"; + +interface SendouSwitchProps extends ReactAriaSwitchProps { + children?: React.ReactNode; + size?: "small" | "medium"; +} + +export function SendouSwitch({ children, size, ...rest }: SendouSwitchProps) { + return ( + +
+ {children} + + ); +} diff --git a/app/components/form/ToggleFormField.tsx b/app/components/form/ToggleFormField.tsx index 4d448fca8..340fcd592 100644 --- a/app/components/form/ToggleFormField.tsx +++ b/app/components/form/ToggleFormField.tsx @@ -8,7 +8,7 @@ import { } from "react-hook-form"; import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; -import { Toggle } from "../Toggle"; +import { SendouSwitch } from "../elements/Switch"; export function ToggleFormField({ label, @@ -27,7 +27,7 @@ export function ToggleFormField({ control={methods.control} name={name} render={({ field: { value, onChange } }) => ( - + )} /> {error && ( diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index f3946836c..72015c799 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -158,7 +158,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ tournamentSubs, adminBuilds, manySplattershotBuilds, - detailedTeam, + detailedTeam(variation), otherTeams, realVideo, realVideoCast, @@ -1560,7 +1560,7 @@ async function manySplattershotBuilds() { } } -function detailedTeam() { +const detailedTeam = (seedVariation?: SeedVariation | null) => () => { sql .prepare( /* sql */ ` @@ -1591,6 +1591,9 @@ function detailedTeam() { const userIds = userIdsInRandomOrder(true).filter( (id) => id !== NZAP_TEST_ID, ); + if (seedVariation === "NZAP_IN_TEAM") { + userIds.unshift(NZAP_TEST_ID); + } for (let i = 0; i < 5; i++) { const userId = i === 0 ? ADMIN_ID : userIds.shift()!; @@ -1609,7 +1612,7 @@ function detailedTeam() { ) .run(); } -} +}; function otherTeams() { const usersInTeam = ( diff --git a/app/db/tables.ts b/app/db/tables.ts index d7fabf8e0..099b324d4 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -4,9 +4,10 @@ import type { Insertable, Selectable, SqlBool, + Updateable, } from "kysely"; import type { TieredSkill } from "~/features/mmr/tiered.server"; -import type { TEAM_MEMBER_ROLES } from "~/features/team"; +import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; import type { ParticipantResult } from "~/modules/brackets-model"; import type { @@ -40,6 +41,7 @@ export interface Team { export interface TeamMember { createdAt: Generated; isOwner: Generated; + isManager: Generated; leftAt: number | null; role: MemberRole | null; teamId: number; @@ -871,6 +873,7 @@ export interface XRankPlacement { export type Tables = { [P in keyof DB]: Selectable }; export type TablesInsertable = { [P in keyof DB]: Insertable }; +export type TablesUpdatable = { [P in keyof DB]: Updateable }; export interface DB { AllTeam: Team; diff --git a/app/db/types.ts b/app/db/types.ts index 1c1ffef56..fb3ee63f6 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -3,7 +3,7 @@ import type { tags, } from "~/features/calendar/calendar-constants"; import type { TieredSkill } from "~/features/mmr/tiered.server"; -import type { TEAM_MEMBER_ROLES } from "~/features/team"; +import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants"; import type { Ability, MainWeaponId, diff --git a/app/features/api-private/routes/seed.tsx b/app/features/api-private/routes/seed.tsx index 899597748..f0a2517a0 100644 --- a/app/features/api-private/routes/seed.tsx +++ b/app/features/api-private/routes/seed.tsx @@ -5,7 +5,13 @@ import { parseRequestPayload } from "~/utils/remix.server"; const seedSchema = z.object({ variation: z - .enum(["NO_TOURNAMENT_TEAMS", "DEFAULT", "REG_OPEN", "SMALL_SOS"]) + .enum([ + "NO_TOURNAMENT_TEAMS", + "DEFAULT", + "REG_OPEN", + "SMALL_SOS", + "NZAP_IN_TEAM", + ]) .nullish(), }); diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index 4e601d237..7c9fca6ab 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -18,8 +18,8 @@ import { Combobox } from "~/components/Combobox"; import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; -import { Toggle } from "~/components/Toggle"; import { UserSearch } from "~/components/UserSearch"; +import { SendouSwitch } from "~/components/elements/Switch"; import { CrossIcon } from "~/components/icons/Cross"; import { useUser } from "~/features/auth/core/user"; import { requireUser } from "~/features/auth/core/user.server"; @@ -481,11 +481,12 @@ function ShowcaseToggle() { return (
- {t("art:forms.showcase.info")}
diff --git a/app/features/art/routes/art.tsx b/app/features/art/routes/art.tsx index 6d99dbc15..307401904 100644 --- a/app/features/art/routes/art.tsx +++ b/app/features/art/routes/art.tsx @@ -10,7 +10,7 @@ import { Button } from "~/components/Button"; import { Combobox } from "~/components/Combobox"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; -import { Toggle } from "~/components/Toggle"; +import { SendouSwitch } from "~/components/elements/Switch"; import { CrossIcon } from "~/components/icons/Cross"; import i18next from "~/modules/i18n/i18next.server"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -85,9 +85,9 @@ export default function ArtPage() {
- + setSearchParams((prev) => { prev.set(OPEN_COMMISIONS_KEY, String(!showOpenCommissions)); return prev; diff --git a/app/features/build-analyzer/components/PerInkTankGrid.tsx b/app/features/build-analyzer/components/PerInkTankGrid.tsx index db81ada65..4938cf565 100644 --- a/app/features/build-analyzer/components/PerInkTankGrid.tsx +++ b/app/features/build-analyzer/components/PerInkTankGrid.tsx @@ -21,7 +21,7 @@ export function PerInkTankGrid(props: PerInkTankGridProps) { + {t("analyzer:button.showConsumptionGrid")} } diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index dad8b227f..ae789c1cc 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -12,7 +12,6 @@ import { Image } from "~/components/Image"; import { Main } from "~/components/Main"; import { Table } from "~/components/Table"; import { Tab, Tabs } from "~/components/Tabs"; -import { Toggle } from "~/components/Toggle"; import { BeakerIcon } from "~/components/icons/Beaker"; import { MAX_AP } from "~/constants"; import { useUser } from "~/features/auth/core/user"; @@ -80,8 +79,8 @@ import { isMainOnlyAbility, isStackableAbility, } from "../core/utils"; - import "../analyzer.css"; +import { SendouSwitch } from "~/components/elements/Switch"; export const CURRENT_PATCH = "9.2"; @@ -1299,14 +1298,14 @@ function EffectsSelector({ })} ) : ( - - checked + + isSelected ? handleAddEffect(effect.type) : handleRemoveEffect(effect.type) } - tiny + size="small" /> )}
diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx index 6e25809b2..9127d9f25 100644 --- a/app/features/calendar/components/BracketProgressionSelector.tsx +++ b/app/features/calendar/components/BracketProgressionSelector.tsx @@ -6,7 +6,7 @@ import { DateInput } from "~/components/DateInput"; import { FormMessage } from "~/components/FormMessage"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; -import { Toggle } from "~/components/Toggle"; +import { SendouSwitch } from "~/components/elements/Switch"; import { PlusIcon } from "~/components/icons/Plus"; import { TOURNAMENT } from "~/features/tournament"; import * as Progression from "~/features/tournament-bracket/core/Progression"; @@ -207,12 +207,13 @@ function TournamentFormatBracketSelector({ {bracket.sources ? (
- - updateBracket({ requiresCheckIn: checked }) + + updateBracket({ requiresCheckIn: isSelected }) } - disabled={bracket.disabled} + isDisabled={bracket.disabled} /> Check-in starts 1 hour before start time or right after the @@ -247,14 +248,18 @@ function TournamentFormatBracketSelector({ - + updateBracket({ - settings: { ...bracket.settings, thirdPlaceMatch: checked }, + settings: { + ...bracket.settings, + thirdPlaceMatch: isSelected, + }, }) } - disabled={bracket.disabled} + isDisabled={bracket.disabled} />
) : null} @@ -347,18 +352,19 @@ function TournamentFormatBracketSelector({
{!isFirstBracket ? (
- + size="small" + isSelected={Boolean(bracket.sources)} + onChange={(isSelected) => updateBracket({ - sources: checked ? [] : undefined, + sources: isSelected ? [] : undefined, requiresCheckIn: false, startTime: undefined, }) } - disabled={bracket.disabled || isTournamentInProgress} + isDisabled={bracket.disabled || isTournamentInProgress} + data-testid="follow-up-bracket-switch" />
diff --git a/app/features/img-upload/actions/upload.server.ts b/app/features/img-upload/actions/upload.server.ts index a820678a9..59d2b9497 100644 --- a/app/features/img-upload/actions/upload.server.ts +++ b/app/features/img-upload/actions/upload.server.ts @@ -7,8 +7,8 @@ import { } from "@remix-run/node"; import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; -import { isTeamOwner } from "~/features/team"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { isTeamManager } from "~/features/team/team-utils"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import { canEditTournamentOrganization } from "~/features/tournament-organization/tournament-organization-utils"; import { dateToDatabaseTimestamp } from "~/utils/dates"; @@ -104,8 +104,8 @@ async function validatedTeam({ ); const detailedTeam = await TeamRepository.findByCustomUrl(team.customUrl); validate( - detailedTeam && isTeamOwner({ team: detailedTeam, user }), - "You must be the team owner to upload images", + detailedTeam && isTeamManager({ team: detailedTeam, user }), + "You must be the team manager to upload images", ); return team; diff --git a/app/features/img-upload/routes/upload.tsx b/app/features/img-upload/routes/upload.tsx index e0a058f04..c59f68af3 100644 --- a/app/features/img-upload/routes/upload.tsx +++ b/app/features/img-upload/routes/upload.tsx @@ -7,15 +7,14 @@ import { useTranslation } from "react-i18next"; import { Button } from "~/components/Button"; import { Main } from "~/components/Main"; import { requireUser } from "~/features/auth/core/user.server"; -import { isTeamOwner } from "~/features/team"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { isTeamManager } from "~/features/team/team-utils"; import invariant from "~/utils/invariant"; +import { action } from "../actions/upload.server"; import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server"; import { imgTypeToDimensions, imgTypeToStyle } from "../upload-constants"; import type { ImageUploadType } from "../upload-types"; import { requestToImgType } from "../upload-utils"; - -import { action } from "../actions/upload.server"; export { action }; export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -38,7 +37,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const detailedTeam = await TeamRepository.findByCustomUrl(team.customUrl); - if (!detailedTeam || !isTeamOwner({ team: detailedTeam, user })) { + if (!detailedTeam || !isTeamManager({ team: detailedTeam, user })) { throw redirect("/"); } } diff --git a/app/features/map-list-generator/routes/maps.tsx b/app/features/map-list-generator/routes/maps.tsx index db681406a..ac32297c0 100644 --- a/app/features/map-list-generator/routes/maps.tsx +++ b/app/features/map-list-generator/routes/maps.tsx @@ -12,7 +12,6 @@ import { Button } from "~/components/Button"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector"; -import { Toggle } from "~/components/Toggle"; import { EditIcon } from "~/components/icons/Edit"; import type { CalendarEvent } from "~/db/types"; import { getUserId } from "~/features/auth/core/user.server"; @@ -32,8 +31,8 @@ import { generateMapList } from "../core/map-list-generator/map-list"; import { modesOrder } from "../core/map-list-generator/modes"; import { mapPoolToNonEmptyModes } from "../core/map-list-generator/utils"; import { MapPool } from "../core/map-pool"; - import "~/styles/maps.css"; +import { SendouSwitch } from "~/components/elements/Switch"; const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2; @@ -219,7 +218,11 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
- +
) : null} diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx index 3be157f94..e274704c1 100644 --- a/app/features/sendouq-settings/routes/q.settings.tsx +++ b/app/features/sendouq-settings/routes/q.settings.tsx @@ -14,7 +14,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm"; import { ModeImage, WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; -import { Toggle } from "~/components/Toggle"; import { CrossIcon } from "~/components/icons/Cross"; import { MapIcon } from "~/components/icons/Map"; import { MicrophoneFilledIcon } from "~/components/icons/MicrophoneFilled"; @@ -52,8 +51,8 @@ import { SENDOUQ_WEAPON_POOL_MAX_SIZE, } from "../q-settings-constants"; import { settingsActionSchema } from "../q-settings-schemas.server"; - import "../q-settings.css"; +import { SendouSwitch } from "~/components/elements/Switch"; export const handle: SendouRouteHandle = { i18n: ["q"], @@ -799,9 +798,9 @@ function Misc() {
- diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index fd7800032..aede42d2a 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -817,7 +817,7 @@ function VoiceChatInfo({ trigger={ } /> } diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index aa1e04230..b5a52c659 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -28,7 +28,6 @@ import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; import { NewTabs } from "~/components/NewTabs"; import { SubmitButton } from "~/components/SubmitButton"; -import { Toggle } from "~/components/Toggle"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox"; @@ -112,8 +111,8 @@ import { findMatchById } from "../queries/findMatchById.server"; import { reportScore } from "../queries/reportScore.server"; import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; import { setGroupAsInactive } from "../queries/setGroupAsInactive.server"; - import "../q.css"; +import { SendouSwitch } from "~/components/elements/Switch"; export const meta: MetaFunction = (args) => { const data = args.data as SerializeFrom | null; @@ -733,7 +732,7 @@ function DisputePopover() { popoverClassName="text-main-forced" trigger={ @@ -1304,7 +1303,7 @@ function ScreenLegalityInfo({ ban }: { ban: boolean }) { trigger={ @@ -1437,10 +1436,10 @@ function MapList({ {scoreCanBeReported && isMod(user) ? (
- Report as admin
diff --git a/app/features/team/TeamMemberRepository.server.ts b/app/features/team/TeamMemberRepository.server.ts new file mode 100644 index 000000000..db03d403a --- /dev/null +++ b/app/features/team/TeamMemberRepository.server.ts @@ -0,0 +1,14 @@ +import { db } from "~/db/sql"; +import type { TablesUpdatable } from "~/db/tables"; + +export function update( + where: { teamId: number; userId: number }, + values: TablesUpdatable["TeamMember"], +) { + return db + .updateTable("AllTeamMember") + .set(values) + .where("AllTeamMember.teamId", "=", where.teamId) + .where("AllTeamMember.userId", "=", where.userId) + .execute(); +} diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 32af27f8a..dc05ae934 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -51,7 +51,10 @@ export type findByCustomUrl = NonNullable< Awaited> >; -export function findByCustomUrl(customUrl: string) { +export function findByCustomUrl( + customUrl: string, + { includeInviteCode = false } = {}, +) { return db .selectFrom("Team") .leftJoin( @@ -81,6 +84,7 @@ export function findByCustomUrl(customUrl: string) { ...COMMON_USER_FIELDS, "TeamMemberWithSecondary.role", "TeamMemberWithSecondary.isOwner", + "TeamMemberWithSecondary.isManager", "TeamMemberWithSecondary.isMainTeam", "User.country", "User.patronTier", @@ -94,6 +98,7 @@ export function findByCustomUrl(customUrl: string) { .whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"), ).as("members"), ]) + .$if(includeInviteCode, (qb) => qb.select("Team.inviteCode")) .where("Team.customUrl", "=", customUrl.toLowerCase()) .executeTakeFirst(); } @@ -245,6 +250,16 @@ export function del(teamId: number) { }); } +export function resetInviteCode(teamId: number) { + return db + .updateTable("AllTeam") + .set({ + inviteCode: nanoid(INVITE_CODE_LENGTH), + }) + .where("id", "=", teamId) + .execute(); +} + export function addNewTeamMember({ userId, teamId, @@ -276,19 +291,24 @@ export function addNewTeamMember({ }); } -export function removeTeamMember({ +export function handleMemberLeaving({ userId, teamId, + newOwnerUserId, }: { userId: number; teamId: number; + newOwnerUserId?: number; }) { return db.transaction().execute(async (trx) => { const currentTeams = await teamsByMemberUserId(userId, trx); const teamToLeave = currentTeams.find((team) => team.id === teamId); invariant(teamToLeave, "User is not a member of this team"); - invariant(!teamToLeave.isOwner, "Owner cannot leave the team"); + invariant( + !teamToLeave.isOwner || newOwnerUserId, + "New owner id must be provided when old is leaving", + ); const wasMainTeam = teamToLeave.isMainTeam; const newMainTeam = currentTeams.find((team) => team.id !== teamId); @@ -308,9 +328,22 @@ export function removeTeamMember({ .set({ leftAt: databaseTimestampNow(), isMainTeam: 0, + isOwner: 0, + isManager: 0, }) .where("userId", "=", userId) .where("teamId", "=", teamId) .execute(); + if (newOwnerUserId) { + await trx + .updateTable("AllTeamMember") + .set({ + isOwner: 1, + isManager: 0, + }) + .where("userId", "=", newOwnerUserId) + .where("teamId", "=", teamId) + .execute(); + } }); } diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts new file mode 100644 index 000000000..08ab099db --- /dev/null +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -0,0 +1,68 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { isAdmin } from "~/permissions"; +import { + notFoundIfFalsy, + parseRequestPayload, + validate, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { TEAM_SEARCH_PAGE, mySlugify, teamPage } from "~/utils/urls"; +import * as TeamRepository from "../TeamRepository.server"; +import { editTeamSchema, teamParamsSchema } from "../team-schemas.server"; +import { isTeamManager, isTeamOwner } from "../team-utils"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUserId(request); + const { customUrl } = teamParamsSchema.parse(params); + + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); + + validate( + isTeamManager({ team, user }) || isAdmin(user), + "You are not a team manager", + ); + + const data = await parseRequestPayload({ + request, + schema: editTeamSchema, + }); + + switch (data._action) { + case "DELETE": { + validate(isTeamOwner({ team, user }), "You are not the team owner"); + + await TeamRepository.del(team.id); + + throw redirect(TEAM_SEARCH_PAGE); + } + case "EDIT": { + const newCustomUrl = mySlugify(data.name); + const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl); + + validate( + newCustomUrl.length > 0, + "Team name can't be only special characters", + ); + + // can't take someone else's custom url + if (existingTeam && existingTeam.id !== team.id) { + return { + errors: ["forms.errors.duplicateName"], + }; + } + + const editedTeam = await TeamRepository.update({ + id: team.id, + customUrl: newCustomUrl, + ...data, + }); + + throw redirect(teamPage(editedTeam.customUrl)); + } + default: { + assertUnreachable(data); + } + } +}; diff --git a/app/features/team/actions/t.$customUrl.join.server.ts b/app/features/team/actions/t.$customUrl.join.server.ts new file mode 100644 index 000000000..fd7d6b316 --- /dev/null +++ b/app/features/team/actions/t.$customUrl.join.server.ts @@ -0,0 +1,44 @@ +import { type ActionFunction, redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { notFoundIfFalsy, validate } from "~/utils/remix.server"; +import { teamPage } from "~/utils/urls"; +import * as TeamRepository from "../TeamRepository.server"; +import { validateInviteCode } from "../loaders/t.$customUrl.join.server"; +import { TEAM } from "../team-constants"; +import { teamParamsSchema } from "../team-schemas.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUser(request); + const { customUrl } = teamParamsSchema.parse(params); + + const team = notFoundIfFalsy( + await TeamRepository.findByCustomUrl(customUrl, { + includeInviteCode: true, + }), + ); + + const inviteCode = new URL(request.url).searchParams.get("code") ?? ""; + const realInviteCode = team.inviteCode!; + + validate( + validateInviteCode({ + inviteCode, + realInviteCode, + team, + user, + reachedTeamCountLimit: false, // checked in the DB transaction + }) === "VALID", + "Invite code is invalid", + ); + + await TeamRepository.addNewTeamMember({ + maxTeamsAllowed: + user.patronTier && user.patronTier >= 2 + ? TEAM.MAX_TEAM_COUNT_PATRON + : TEAM.MAX_TEAM_COUNT_NON_PATRON, + teamId: team.id, + userId: user.id, + }); + + throw redirect(teamPage(team.customUrl)); +}; diff --git a/app/features/team/actions/t.$customUrl.roster.server.ts b/app/features/team/actions/t.$customUrl.roster.server.ts new file mode 100644 index 000000000..f331b1830 --- /dev/null +++ b/app/features/team/actions/t.$customUrl.roster.server.ts @@ -0,0 +1,89 @@ +import type { ActionFunction } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { isAdmin } from "~/permissions"; +import { + notFoundIfFalsy, + parseRequestPayload, + validate, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import * as TeamMemberRepository from "../TeamMemberRepository.server"; +import * as TeamRepository from "../TeamRepository.server"; +import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server"; +import { isTeamManager } from "../team-utils"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUserId(request); + + const { customUrl } = teamParamsSchema.parse(params); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); + validate( + isTeamManager({ team, user }) || isAdmin(user), + "Only team manager or owner can manage roster", + ); + + const data = await parseRequestPayload({ + request, + schema: manageRosterSchema, + }); + + switch (data._action) { + case "DELETE_MEMBER": { + const member = team.members.find((m) => m.id === data.userId); + + validate(member, "Member not found"); + validate(member.id !== user.id, "Can't delete yourself"); + validate(!member.isOwner, "Can't delete owner"); + + await TeamRepository.handleMemberLeaving({ + teamId: team.id, + userId: data.userId, + }); + break; + } + case "RESET_INVITE_LINK": { + await TeamRepository.resetInviteCode(team.id); + + break; + } + case "ADD_MANAGER": { + await TeamMemberRepository.update( + { teamId: team.id, userId: data.userId }, + { + isManager: 1, + }, + ); + + break; + } + case "REMOVE_MANAGER": { + const member = team.members.find((m) => m.id === data.userId); + validate(member, "Member not found"); + validate(member.id !== user.id, "Can't remove yourself as manager"); + + await TeamMemberRepository.update( + { teamId: team.id, userId: data.userId }, + { + isManager: 0, + }, + ); + + break; + } + case "UPDATE_MEMBER_ROLE": { + await TeamMemberRepository.update( + { teamId: team.id, userId: data.userId }, + { + role: data.role || null, + }, + ); + + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/team/actions/t.$customUrl.server.ts b/app/features/team/actions/t.$customUrl.server.ts index 7d23e96e7..ac35e00ea 100644 --- a/app/features/team/actions/t.$customUrl.server.ts +++ b/app/features/team/actions/t.$customUrl.server.ts @@ -11,7 +11,7 @@ import { teamParamsSchema, teamProfilePageActionSchema, } from "../team-schemas.server"; -import { isTeamMember, isTeamOwner } from "../team-utils"; +import { isTeamMember, isTeamOwner, resolveNewOwner } from "../team-utils"; export const action: ActionFunction = async ({ request, params }) => { const user = await requireUserId(request); @@ -26,13 +26,22 @@ export const action: ActionFunction = async ({ request, params }) => { switch (data._action) { case "LEAVE_TEAM": { validate( - isTeamMember({ user, team }) && !isTeamOwner({ user, team }), - "You are not a regular member of this team", + isTeamMember({ user, team }), + "You are not a member of this team", ); - await TeamRepository.removeTeamMember({ + const newOwner = isTeamOwner({ user, team }) + ? resolveNewOwner(team.members) + : null; + validate( + !isTeamOwner({ user, team }) || newOwner, + "You can't leave the team if you are the owner and there is no other member to become the owner", + ); + + await TeamRepository.handleMemberLeaving({ teamId: team.id, userId: user.id, + newOwnerUserId: newOwner?.id, }); break; diff --git a/app/features/team/index.ts b/app/features/team/index.ts deleted file mode 100644 index ecac30131..000000000 --- a/app/features/team/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { TEAM_MEMBER_ROLES } from "./team-constants"; - -export { isTeamOwner } from "./team-utils"; diff --git a/app/features/team/loaders/t.$customUrl.edit.server.ts b/app/features/team/loaders/t.$customUrl.edit.server.ts new file mode 100644 index 000000000..085bdef69 --- /dev/null +++ b/app/features/team/loaders/t.$customUrl.edit.server.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { isAdmin } from "~/permissions"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { teamPage } from "~/utils/urls"; +import * as TeamRepository from "../TeamRepository.server"; +import { teamParamsSchema } from "../team-schemas.server"; +import { isTeamManager } from "../team-utils"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUserId(request); + const { customUrl } = teamParamsSchema.parse(params); + + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); + + if (!isTeamManager({ team, user }) && !isAdmin(user)) { + throw redirect(teamPage(customUrl)); + } + + return { team, css: team.css }; +}; diff --git a/app/features/team/loaders/t.$customUrl.join.server.ts b/app/features/team/loaders/t.$customUrl.join.server.ts new file mode 100644 index 000000000..37909b7ba --- /dev/null +++ b/app/features/team/loaders/t.$customUrl.join.server.ts @@ -0,0 +1,78 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { INVITE_CODE_LENGTH } from "~/constants"; +import { requireUser } from "~/features/auth/core/user.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { teamPage } from "~/utils/urls"; +import * as TeamRepository from "../TeamRepository.server"; +import { TEAM } from "../team-constants"; +import { teamParamsSchema } from "../team-schemas.server"; +import { isTeamFull, isTeamMember } from "../team-utils"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const { customUrl } = teamParamsSchema.parse(params); + + const team = notFoundIfFalsy( + await TeamRepository.findByCustomUrl(customUrl, { + includeInviteCode: true, + }), + ); + + const inviteCode = new URL(request.url).searchParams.get("code") ?? ""; + const realInviteCode = team.inviteCode!; + + const teamCount = (await TeamRepository.teamsByMemberUserId(user.id)).length; + + const validation = validateInviteCode({ + inviteCode, + realInviteCode, + team, + user, + reachedTeamCountLimit: + user.patronTier && user.patronTier >= 2 + ? teamCount >= TEAM.MAX_TEAM_COUNT_PATRON + : teamCount >= TEAM.MAX_TEAM_COUNT_NON_PATRON, + }); + + if (validation === "ALREADY_JOINED") { + throw redirect(teamPage(team.customUrl)); + } + + return { + validation, + teamName: team.name, + }; +}; + +export function validateInviteCode({ + inviteCode, + realInviteCode, + team, + user, + reachedTeamCountLimit, +}: { + inviteCode: string; + realInviteCode: string; + team: TeamRepository.findByCustomUrl; + user?: { id: number; team?: { name: string } }; + reachedTeamCountLimit: boolean; +}) { + if (inviteCode.length !== INVITE_CODE_LENGTH) { + return "SHORT_CODE"; + } + if (inviteCode !== realInviteCode) { + return "INVITE_CODE_WRONG"; + } + if (isTeamFull(team)) { + return "TEAM_FULL"; + } + if (isTeamMember({ team, user })) { + return "ALREADY_JOINED"; + } + if (reachedTeamCountLimit) { + return "REACHED_TEAM_COUNT_LIMIT"; + } + + return "VALID"; +} diff --git a/app/features/team/loaders/t.$customUrl.roster.server.ts b/app/features/team/loaders/t.$customUrl.roster.server.ts new file mode 100644 index 000000000..f2173d542 --- /dev/null +++ b/app/features/team/loaders/t.$customUrl.roster.server.ts @@ -0,0 +1,29 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { isAdmin } from "~/permissions"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { teamPage } from "~/utils/urls"; +import * as TeamRepository from "../TeamRepository.server"; +import { teamParamsSchema } from "../team-schemas.server"; +import { isTeamManager } from "../team-utils"; +import "../team.css"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUserId(request); + const { customUrl } = teamParamsSchema.parse(params); + + const team = notFoundIfFalsy( + await TeamRepository.findByCustomUrl(customUrl, { + includeInviteCode: true, + }), + ); + + if (!isTeamManager({ team, user }) && !isAdmin(user)) { + throw redirect(teamPage(customUrl)); + } + + return { + team, + }; +}; diff --git a/app/features/team/queries/editRole.server.ts b/app/features/team/queries/editRole.server.ts deleted file mode 100644 index ace608633..000000000 --- a/app/features/team/queries/editRole.server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { sql } from "~/db/sql"; -import type { MemberRole } from "~/db/types"; - -const stm = sql.prepare(/* sql */ ` - update "AllTeamMember" - set "role" = @role - where "teamId" = @teamId - and "userId" = @userId -`); - -export function editRole({ - userId, - teamId, - role, -}: { - userId: number; - teamId: number; - role: MemberRole | null; -}) { - return stm.run({ userId, teamId, role }); -} diff --git a/app/features/team/queries/inviteCodeById.server.ts b/app/features/team/queries/inviteCodeById.server.ts deleted file mode 100644 index b3fcaaebe..000000000 --- a/app/features/team/queries/inviteCodeById.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - select "inviteCode" - from "Team" - where "id" = @teamId -`); - -export function inviteCodeById(teamId: number): string | null { - return (stm.get({ teamId }) as any)?.inviteCode ?? null; -} diff --git a/app/features/team/queries/resetInviteLink.server.ts b/app/features/team/queries/resetInviteLink.server.ts deleted file mode 100644 index eb8dd7050..000000000 --- a/app/features/team/queries/resetInviteLink.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { nanoid } from "nanoid"; -import { INVITE_CODE_LENGTH } from "~/constants"; -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - update "AllTeam" - set "inviteCode" = @inviteCode - where "id" = @teamId -`); - -export function resetInviteLink(teamId: number) { - stm.run({ teamId, inviteCode: nanoid(INVITE_CODE_LENGTH) }); -} diff --git a/app/features/team/queries/transferOwnership.server.ts b/app/features/team/queries/transferOwnership.server.ts deleted file mode 100644 index a19f82307..000000000 --- a/app/features/team/queries/transferOwnership.server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - update "AllTeamMember" - set "isOwner" = @isOwner - where "teamId" = @teamId - and "userId" = @userId -`); - -export const transferOwnership = sql.transaction( - ({ - teamId, - oldOwnerUserId, - newOwnerUserId, - }: { - teamId: number; - oldOwnerUserId: number; - newOwnerUserId: number; - }) => { - stm.run({ teamId, userId: oldOwnerUserId, isOwner: 0 }); - stm.run({ teamId, userId: newOwnerUserId, isOwner: 1 }); - }, -); diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index f26a2e267..e45496d17 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -1,10 +1,4 @@ -import type { - ActionFunction, - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Form, Link, useLoaderData } from "@remix-run/react"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -17,30 +11,23 @@ import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; -import { requireUserId } from "~/features/auth/core/user.server"; -import { isAdmin } from "~/permissions"; -import { - type SendouRouteHandle, - notFoundIfFalsy, - parseRequestPayload, - validate, -} from "~/utils/remix.server"; +import { useUser } from "~/features/auth/core/user"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; -import { assertUnreachable } from "~/utils/types"; import { TEAM_SEARCH_PAGE, - mySlugify, navIconUrl, teamPage, uploadImagePage, } from "~/utils/urls"; -import * as TeamRepository from "../TeamRepository.server"; +import { action } from "../actions/t.$customUrl.edit.server"; +import { loader } from "../loaders/t.$customUrl.edit.server"; import { TEAM } from "../team-constants"; -import { editTeamSchema, teamParamsSchema } from "../team-schemas.server"; import { canAddCustomizedColors, isTeamOwner } from "../team-utils"; - import "../team.css"; +export { action, loader }; + export const meta: MetaFunction = ({ data }) => { if (!data) return []; @@ -69,89 +56,27 @@ export const handle: SendouRouteHandle = { }, }; -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUserId(request); - const { customUrl } = teamParamsSchema.parse(params); - - const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - - validate( - isTeamOwner({ team, user }) || isAdmin(user), - "You are not the team owner", - ); - - const data = await parseRequestPayload({ - request, - schema: editTeamSchema, - }); - - switch (data._action) { - case "DELETE": { - await TeamRepository.del(team.id); - - throw redirect(TEAM_SEARCH_PAGE); - } - case "EDIT": { - const newCustomUrl = mySlugify(data.name); - const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl); - - validate( - newCustomUrl.length > 0, - "Team name can't be only special characters", - ); - - // can't take someone else's custom url - if (existingTeam && existingTeam.id !== team.id) { - return { - errors: ["forms.errors.duplicateName"], - }; - } - - const editedTeam = await TeamRepository.update({ - id: team.id, - customUrl: newCustomUrl, - ...data, - }); - - throw redirect(teamPage(editedTeam.customUrl)); - } - default: { - assertUnreachable(data); - } - } -}; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUserId(request); - const { customUrl } = teamParamsSchema.parse(params); - - const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - - if (!isTeamOwner({ team, user }) && !isAdmin(user)) { - throw redirect(teamPage(customUrl)); - } - - return { team, css: team.css }; -}; - export default function EditTeamPage() { const { t } = useTranslation(["common", "team"]); + const user = useUser(); const { team, css } = useLoaderData(); return (
- - - + + + ) : null}
{canAddCustomizedColors(team) ? ( diff --git a/app/features/team/routes/t.$customUrl.join.tsx b/app/features/team/routes/t.$customUrl.join.tsx index c60ebe207..75b11a171 100644 --- a/app/features/team/routes/t.$customUrl.join.tsx +++ b/app/features/team/routes/t.$customUrl.join.tsx @@ -1,125 +1,18 @@ -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; -import { INVITE_CODE_LENGTH } from "~/constants"; -import { requireUser } from "~/features/auth/core/user.server"; -import { - type SendouRouteHandle, - notFoundIfFalsy, - validate, -} from "~/utils/remix.server"; -import { teamPage } from "~/utils/urls"; -import * as TeamRepository from "../TeamRepository.server"; -import { inviteCodeById } from "../queries/inviteCodeById.server"; -import { TEAM } from "../team-constants"; -import { teamParamsSchema } from "../team-schemas.server"; -import { isTeamFull, isTeamMember } from "../team-utils"; - +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { action } from "../actions/t.$customUrl.join.server"; +import { loader } from "../loaders/t.$customUrl.join.server"; import "../team.css"; -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUser(request); - const { customUrl } = teamParamsSchema.parse(params); - - const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - - const inviteCode = new URL(request.url).searchParams.get("code") ?? ""; - const realInviteCode = inviteCodeById(team.id)!; - - validate( - validateInviteCode({ - inviteCode, - realInviteCode, - team, - user, - reachedTeamCountLimit: false, // checked in the DB transaction - }) === "VALID", - "Invite code is invalid", - ); - - await TeamRepository.addNewTeamMember({ - maxTeamsAllowed: - user.patronTier && user.patronTier >= 2 - ? TEAM.MAX_TEAM_COUNT_PATRON - : TEAM.MAX_TEAM_COUNT_NON_PATRON, - teamId: team.id, - userId: user.id, - }); - - throw redirect(teamPage(team.customUrl)); -}; +export { loader, action }; export const handle: SendouRouteHandle = { i18n: ["team"], }; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUser(request); - const { customUrl } = teamParamsSchema.parse(params); - - const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - - const inviteCode = new URL(request.url).searchParams.get("code") ?? ""; - const realInviteCode = inviteCodeById(team.id)!; - - const teamCount = (await TeamRepository.teamsByMemberUserId(user.id)).length; - - const validation = validateInviteCode({ - inviteCode, - realInviteCode, - team, - user, - reachedTeamCountLimit: - user.patronTier && user.patronTier >= 2 - ? teamCount >= TEAM.MAX_TEAM_COUNT_PATRON - : teamCount >= TEAM.MAX_TEAM_COUNT_NON_PATRON, - }); - - if (validation === "ALREADY_JOINED") { - throw redirect(teamPage(team.customUrl)); - } - - return { - validation, - teamName: team.name, - }; -}; - -function validateInviteCode({ - inviteCode, - realInviteCode, - team, - user, - reachedTeamCountLimit, -}: { - inviteCode: string; - realInviteCode: string; - team: TeamRepository.findByCustomUrl; - user?: { id: number; team?: { name: string } }; - reachedTeamCountLimit: boolean; -}) { - if (inviteCode.length !== INVITE_CODE_LENGTH) { - return "SHORT_CODE"; - } - if (inviteCode !== realInviteCode) { - return "INVITE_CODE_WRONG"; - } - if (isTeamFull(team)) { - return "TEAM_FULL"; - } - if (isTeamMember({ team, user })) { - return "ALREADY_JOINED"; - } - if (reachedTeamCountLimit) { - return "REACHED_TEAM_COUNT_LIMIT"; - } - - return "VALID"; -} - export default function JoinTeamPage() { const { t } = useTranslation(["team", "common"]); const { validation, teamName } = useLoaderData<{ diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index 9c387ec92..df82e3063 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -1,10 +1,4 @@ -import { redirect } from "@remix-run/node"; -import type { - ActionFunction, - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Form, useFetcher, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; @@ -15,33 +9,27 @@ import { Button } from "~/components/Button"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { TrashIcon } from "~/components/icons/Trash"; import { useUser } from "~/features/auth/core/user"; -import { requireUserId } from "~/features/auth/core/user.server"; -import { isAdmin } from "~/permissions"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { - notFoundIfFalsy, - parseRequestPayload, - validate, -} from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; -import { assertUnreachable } from "~/utils/types"; import { TEAM_SEARCH_PAGE, joinTeamPage, navIconUrl, teamPage, } from "~/utils/urls"; -import * as TeamRepository from "../TeamRepository.server"; -import { editRole } from "../queries/editRole.server"; -import { inviteCodeById } from "../queries/inviteCodeById.server"; -import { resetInviteLink } from "../queries/resetInviteLink.server"; -import { transferOwnership } from "../queries/transferOwnership.server"; +import type * as TeamRepository from "../TeamRepository.server"; import { TEAM_MEMBER_ROLES } from "../team-constants"; -import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server"; -import { isTeamFull, isTeamOwner } from "../team-utils"; - +import { isTeamFull } from "../team-utils"; import "../team.css"; +import { action } from "../actions/t.$customUrl.roster.server"; +import { loader } from "../loaders/t.$customUrl.roster.server"; + +export { loader, action }; export const meta: MetaFunction = ({ data }) => { if (!data) return []; @@ -49,59 +37,6 @@ export const meta: MetaFunction = ({ data }) => { return [{ title: makeTitle(data.team.name) }]; }; -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUserId(request); - - const { customUrl } = teamParamsSchema.parse(params); - const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - validate( - isTeamOwner({ team, user }) || isAdmin(user), - "Only team owner can manage roster", - ); - - const data = await parseRequestPayload({ - request, - schema: manageRosterSchema, - }); - - switch (data._action) { - case "DELETE_MEMBER": { - validate(data.userId !== user.id, "Can't delete yourself"); - await TeamRepository.removeTeamMember({ - teamId: team.id, - userId: data.userId, - }); - break; - } - case "RESET_INVITE_LINK": { - resetInviteLink(team.id); - break; - } - case "TRANSFER_OWNERSHIP": { - transferOwnership({ - teamId: team.id, - newOwnerUserId: data.newOwnerId, - oldOwnerUserId: user.id, - }); - - throw redirect(teamPage(customUrl)); - } - case "UPDATE_MEMBER_ROLE": { - editRole({ - role: data.role || null, - teamId: team.id, - userId: data.userId, - }); - break; - } - default: { - assertUnreachable(data); - } - } - - return null; -}; - export const handle: SendouRouteHandle = { i18n: ["team"], breadcrumb: ({ match }) => { @@ -124,26 +59,26 @@ export const handle: SendouRouteHandle = { }, }; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUserId(request); - const { customUrl } = teamParamsSchema.parse(params); - - const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - - if (!isTeamOwner({ team, user }) && !isAdmin(user)) { - throw redirect(teamPage(customUrl)); - } - - return { - team: { ...team, inviteCode: inviteCodeById(team.id)! }, - }; -}; - export default function ManageTeamRosterPage() { + const { t } = useTranslation(["team"]); + return (
+ + {t("team:editorsInfo.button")} + + } + > + {t("team:editorsInfo.popover")} +
); } @@ -163,7 +98,7 @@ function InviteCodeSection() { const inviteLink = `${import.meta.env.VITE_SITE_DOMAIN}${joinTeamPage({ customUrl: team.customUrl, - inviteCode: team.inviteCode, + inviteCode: team.inviteCode!, })}`; return ( @@ -219,11 +154,25 @@ function MemberRow({ const { team } = useLoaderData(); const { t } = useTranslation(["team"]); const user = useUser(); + const roleFetcher = useFetcher(); + const editorFetcher = useFetcher(); const isSelf = user!.id === member.id; const role = team.members.find((m) => m.id === member.id)?.role ?? NO_ROLE; + const isThisMemberOwner = Boolean( + team.members.find((m) => m.id === member.id)?.isOwner, + ); + const isThisMemberManager = Boolean( + team.members.find((m) => m.id === member.id)?.isManager, + ); + + const editorIsBeingAdded = + editorFetcher.formData?.get("_action") === "ADD_MANAGER"; + const editorIsBeingRemoved = + editorFetcher.formData?.get("_action") === "REMOVE_MANAGER"; + return (
-
+
+ + editorFetcher.submit( + { + _action: isSelected ? "ADD_MANAGER" : "REMOVE_MANAGER", + userId: String(member.id), + }, + { method: "post" }, + ) + } + isSelected={ + editorIsBeingAdded + ? true + : editorIsBeingRemoved + ? false + : isThisMemberManager + } + data-testid="editor-switch" + > + {t("team:editor.label")} + +
+
-
- - - -

); diff --git a/app/features/team/routes/t.$customUrl.tsx b/app/features/team/routes/t.$customUrl.tsx index 77122f869..5a466ccea 100644 --- a/app/features/team/routes/t.$customUrl.tsx +++ b/app/features/team/routes/t.$customUrl.tsx @@ -30,13 +30,11 @@ import { userSubmittedImage, } from "~/utils/urls"; import type * as TeamRepository from "../TeamRepository.server"; -import { isTeamMember, isTeamOwner } from "../team-utils"; - import { action } from "../actions/t.$customUrl.server"; import { loader } from "../loaders/t.$customUrl.server"; -export { action, loader }; - +import { isTeamManager, isTeamMember, resolveNewOwner } from "../team-utils"; import "../team.css"; +export { action, loader }; export const meta: MetaFunction = ({ data }) => { if (!data) return []; @@ -192,9 +190,12 @@ function ActionButtons() { {isTeamMember({ user, team }) && !isMainTeam ? ( ) : null} - {!isTeamOwner({ user, team }) && isTeamMember({ user, team }) ? ( + {isTeamMember({ user, team }) ? ( @@ -207,7 +208,7 @@ function ActionButtons() { ) : null} - {isTeamOwner({ user, team }) || isAdmin(user) ? ( + {isTeamManager({ user, team }) || isAdmin(user) ? ( ) : null} - {isTeamOwner({ user, team }) || isAdmin(user) ? ( + {isTeamManager({ user, team }) || isAdmin(user) ? ( member.isOwner && member.id === user.id); } +export function isTeamManager({ + team, + user, +}: { + team: TeamRepository.findByCustomUrl; + user?: { id: number }; +}) { + if (!user) return false; + + return team.members.some( + (member) => (member.isManager || member.isOwner) && member.id === user.id, + ); +} + export function isTeamMember({ team, user, @@ -36,3 +50,25 @@ export function canAddCustomizedColors(team: { (member) => member.patronTier && member.patronTier >= 2, ); } + +/** Returns the user who will become the new owner after old one leaves */ +export function resolveNewOwner( + members: Array<{ + id: number; + username: string; + isOwner: number; + isManager: number; + }>, +) { + const managers = members.filter((m) => m.isManager && !m.isOwner); + if (managers.length > 0) { + return managers.sort((a, b) => a.id - b.id)[0]; + } + + const regularMembers = members.filter((m) => !m.isOwner); + if (regularMembers.length > 0) { + return regularMembers.sort((a, b) => a.id - b.id)[0]; + } + + return null; +} diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 9e618c489..7440191db 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -7,7 +7,7 @@ import { Dialog } from "~/components/Dialog"; import { ModeImage, StageImage } from "~/components/Image"; import { Label } from "~/components/Label"; import { SubmitButton } from "~/components/SubmitButton"; -import { Toggle } from "~/components/Toggle"; +import { SendouSwitch } from "~/components/elements/Switch"; import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows"; import type { TournamentRoundMaps } from "~/db/tables"; import { @@ -683,6 +683,7 @@ function PickBanSelect({