diff --git a/imports/client/components/HuntProfileListPage.tsx b/imports/client/components/HuntProfileListPage.tsx index ecc091cb1..8525ea26c 100644 --- a/imports/client/components/HuntProfileListPage.tsx +++ b/imports/client/components/HuntProfileListPage.tsx @@ -3,6 +3,7 @@ import { useSubscribe, useTracker } from "meteor/react-meteor-data"; import React from "react"; import { useParams } from "react-router-dom"; import Hunts from "../../lib/models/Hunts"; +import InvitationCodes from "../../lib/models/InvitationCodes"; import MeteorUsers from "../../lib/models/MeteorUsers"; import { listAllRolesForHunt, @@ -11,6 +12,8 @@ import { userMayUpdateHuntInvitationCode, userMayUseDiscordBotAPIs, } from "../../lib/permission_stubs"; +import invitationCodesForHunt from "../../lib/publications/invitationCodesForHunt"; +import useTypedSubscribe from "../hooks/useTypedSubscribe"; import ProfileList from "./ProfileList"; const HuntProfileListPage = () => { @@ -18,7 +21,11 @@ const HuntProfileListPage = () => { const profilesLoading = useSubscribe("huntProfiles", huntId); const userRolesLoading = useSubscribe("huntRoles", huntId); - const loading = profilesLoading() || userRolesLoading(); + const invitationCodesLoading = useTypedSubscribe(invitationCodesForHunt, { + huntId, + }); + const loading = + profilesLoading() || userRolesLoading() || invitationCodesLoading(); const users = useTracker( () => @@ -60,6 +67,10 @@ const HuntProfileListPage = () => { ), [huntId, hunt, loading, canMakeOperator], ); + const invitationCode = useTracker( + () => InvitationCodes.findOne({ hunt: huntId })?.code, + [huntId], + ); if (loading) { return
loading...
; @@ -74,6 +85,7 @@ const HuntProfileListPage = () => { canSyncDiscord={canSyncDiscord} canMakeOperator={canMakeOperator} canUpdateHuntInvitationCode={canUpdateHuntInvitationCode} + invitationCode={invitationCode} /> ); }; diff --git a/imports/client/components/ProfileList.tsx b/imports/client/components/ProfileList.tsx index 7cdd9726c..410cb258d 100644 --- a/imports/client/components/ProfileList.tsx +++ b/imports/client/components/ProfileList.tsx @@ -1,9 +1,10 @@ import { Meteor } from "meteor/meteor"; import { useTracker } from "meteor/react-meteor-data"; +import { faCopy } from "@fortawesome/free-solid-svg-icons/faCopy"; import { faEraser } from "@fortawesome/free-solid-svg-icons/faEraser"; import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import type { MouseEvent } from "react"; +import type { ComponentPropsWithRef, FC, MouseEvent } from "react"; import React, { useCallback, useEffect, @@ -12,6 +13,7 @@ import React, { useRef, useState, } from "react"; +import { OverlayTrigger } from "react-bootstrap"; import Alert from "react-bootstrap/Alert"; import Badge from "react-bootstrap/Badge"; import Button from "react-bootstrap/Button"; @@ -24,6 +26,8 @@ import InputGroup from "react-bootstrap/InputGroup"; import ListGroup from "react-bootstrap/ListGroup"; import ListGroupItem from "react-bootstrap/ListGroupItem"; import Modal from "react-bootstrap/Modal"; +import Tooltip from "react-bootstrap/Tooltip"; +import CopyToClipboard from "react-copy-to-clipboard"; import { createPortal } from "react-dom"; import { Link } from "react-router-dom"; import styled from "styled-components"; @@ -70,6 +74,13 @@ const OperatorBox = styled.div` } `; +const StyledLinkButton: FC> = styled( + Button, +)` + padding: 0; + vertical-align: baseline; +`; + type OperatorModalHandle = { show(): void; }; @@ -277,6 +288,7 @@ const ProfileList = ({ canUpdateHuntInvitationCode, users, roles, + invitationCode, }: { hunt?: HuntType; canInvite?: boolean; @@ -285,6 +297,7 @@ const ProfileList = ({ canUpdateHuntInvitationCode?: boolean; users: Meteor.User[]; roles?: Record; + invitationCode?: string; }) => { const [searchString, setSearchString] = useState(""); @@ -372,19 +385,30 @@ const ProfileList = ({ }, [hunt, canSyncDiscord, syncDiscord]); const invitationLink = useMemo(() => { - if (!hunt || !canInvite || !hunt.invitationCode) { + if (!hunt || !canInvite || !invitationCode) { return null; } + const copyTooltip = Copy to clipboard; + + const invitationUrl = `${window.location.origin}/join/${invitationCode}`; + return (

Invitation link:{" "} - - {`${window.location.origin}/join/${hunt.invitationCode}`} - + + {({ ref, ...triggerHandler }) => ( + + + + + + )} + {" "} + {invitationUrl}

); - }, [hunt, canInvite]); + }, [hunt, canInvite, invitationCode]); const generateInvitationLink = useCallback(() => { if (!hunt) { @@ -410,11 +434,11 @@ const ProfileList = ({ return ( - {hunt.invitationCode && ( + {invitationCode && ( @@ -430,6 +454,7 @@ const ProfileList = ({ canUpdateHuntInvitationCode, clearInvitationLink, generateInvitationLink, + invitationCode, ]); const inviteToHuntItem = useMemo(() => { diff --git a/imports/lib/models/Hunts.ts b/imports/lib/models/Hunts.ts index 38132f728..33240e611 100644 --- a/imports/lib/models/Hunts.ts +++ b/imports/lib/models/Hunts.ts @@ -47,9 +47,6 @@ const EditableHunt = z.object({ // If provided, then members of the hunt who have also linked their Discord // profile will be added to this role. memberDiscordRole: SavedDiscordObjectFields.optional(), - // If provided, this is an invitation code that can be used to join this hunt. - // This takes the place of a direct (user-to-user) invitation. - invitationCode: nonEmptyString.optional(), }); export type EditableHuntType = z.infer; const Hunt = withCommon(EditableHunt); @@ -71,7 +68,6 @@ export const HuntPattern = { puzzleHooksDiscordChannel: Match.Optional(SavedDiscordObjectPattern), firehoseDiscordChannel: Match.Optional(SavedDiscordObjectPattern), memberDiscordRole: Match.Optional(SavedDiscordObjectPattern), - invitationCode: Match.Optional(String), }; const Hunts = new SoftDeletedModel("jr_hunts", Hunt); diff --git a/imports/lib/models/InvitationCodes.ts b/imports/lib/models/InvitationCodes.ts new file mode 100644 index 000000000..0362ffe3a --- /dev/null +++ b/imports/lib/models/InvitationCodes.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import type { ModelType } from "./Model"; +import SoftDeletedModel from "./SoftDeletedModel"; +import { foreignKey, nonEmptyString } from "./customTypes"; +import withCommon from "./withCommon"; + +// Invitation codes that can be used to join a hunt. +// These take the place of direct (user-to-user) invitations. +export const InvitationCode = withCommon( + z.object({ + hunt: foreignKey, + code: nonEmptyString, + }), +); + +const InvitationCodes = new SoftDeletedModel( + "jr_invitation_codes", + InvitationCode, +); +InvitationCodes.addIndex({ hunt: 1 }); +InvitationCodes.addIndex({ code: 1 }); +export type InvitationCodeType = ModelType; + +export default InvitationCodes; diff --git a/imports/lib/publications/invitationCodesForHunt.ts b/imports/lib/publications/invitationCodesForHunt.ts new file mode 100644 index 000000000..d38ad8cd2 --- /dev/null +++ b/imports/lib/publications/invitationCodesForHunt.ts @@ -0,0 +1,5 @@ +import TypedPublication from "./TypedPublication"; + +export default new TypedPublication<{ huntId: string }>( + "InvitationCodes.publications.forHunt", +); diff --git a/imports/methods/fetchHuntInvitationCode.ts b/imports/methods/fetchHuntInvitationCode.ts new file mode 100644 index 000000000..364b4249c --- /dev/null +++ b/imports/methods/fetchHuntInvitationCode.ts @@ -0,0 +1,5 @@ +import TypedMethod from "./TypedMethod"; + +export default new TypedMethod<{ huntId: string }, string>( + "fetchHuntInvitationCode", +); diff --git a/imports/server/methods/acceptHuntInvitationCode.ts b/imports/server/methods/acceptHuntInvitationCode.ts index fb70a8032..c7c760c1d 100644 --- a/imports/server/methods/acceptHuntInvitationCode.ts +++ b/imports/server/methods/acceptHuntInvitationCode.ts @@ -1,6 +1,7 @@ import { check } from "meteor/check"; import { Meteor } from "meteor/meteor"; import Hunts from "../../lib/models/Hunts"; +import InvitationCodes from "../../lib/models/InvitationCodes"; import MeteorUsers from "../../lib/models/MeteorUsers"; import acceptHuntInvitationCode from "../../methods/acceptHuntInvitationCode"; import addUserToHunt from "../addUserToHunt"; @@ -17,11 +18,18 @@ defineMethod(acceptHuntInvitationCode, { async run({ invitationCode }): Promise { check(this.userId, String); + const invitation = await InvitationCodes.findOneAsync({ + code: invitationCode, + }); + if (!invitation) { + throw new Meteor.Error(404, "Invalid invitation code"); + } + const hunt = await Hunts.findOneAsync({ - invitationCode, + _id: invitation.hunt, }); if (!hunt) { - throw new Meteor.Error(404, "Invalid invitation code"); + throw new Meteor.Error(404, "Hunt does not exist for invitation"); } const user = await MeteorUsers.findOneAsync(this.userId); diff --git a/imports/server/methods/clearHuntInvitationCode.ts b/imports/server/methods/clearHuntInvitationCode.ts index 5dbce45fb..1d1f9707c 100644 --- a/imports/server/methods/clearHuntInvitationCode.ts +++ b/imports/server/methods/clearHuntInvitationCode.ts @@ -1,9 +1,11 @@ import { check } from "meteor/check"; import { Meteor } from "meteor/meteor"; import Hunts from "../../lib/models/Hunts"; +import InvitationCodes from "../../lib/models/InvitationCodes"; import MeteorUsers from "../../lib/models/MeteorUsers"; import { userMayUpdateHuntInvitationCode } from "../../lib/permission_stubs"; import clearHuntInvitationCode from "../../methods/clearHuntInvitationCode"; +import withLock from "../withLock"; import defineMethod from "./defineMethod"; // Clear the invitation code for the given hunt. @@ -32,13 +34,10 @@ defineMethod(clearHuntInvitationCode, { ); } - await Hunts.updateAsync( - { _id: huntId }, - { - $unset: { - invitationCode: 1, - }, - }, - ); + await withLock(`invitation_code:${huntId}`, async () => { + for await (const code of InvitationCodes.find({ hunt: huntId })) { + await InvitationCodes.destroyAsync(code._id); + } + }); }, }); diff --git a/imports/server/methods/generateHuntInvitationCode.ts b/imports/server/methods/generateHuntInvitationCode.ts index 845ead56a..e334decae 100644 --- a/imports/server/methods/generateHuntInvitationCode.ts +++ b/imports/server/methods/generateHuntInvitationCode.ts @@ -2,9 +2,11 @@ import { check } from "meteor/check"; import { Meteor } from "meteor/meteor"; import { Random } from "meteor/random"; import Hunts from "../../lib/models/Hunts"; +import InvitationCodes from "../../lib/models/InvitationCodes"; import MeteorUsers from "../../lib/models/MeteorUsers"; import { userMayUpdateHuntInvitationCode } from "../../lib/permission_stubs"; import generateHuntInvitationCode from "../../methods/generateHuntInvitationCode"; +import withLock from "../withLock"; import defineMethod from "./defineMethod"; // Generate (or regenerate) an invitation code for the given hunt. @@ -35,14 +37,16 @@ defineMethod(generateHuntInvitationCode, { const newInvitationCode = Random.id(); - await Hunts.updateAsync( - { _id: huntId }, - { - $set: { - invitationCode: newInvitationCode, - }, - }, - ); + await withLock(`invitation_code:${huntId}`, async () => { + for await (const code of InvitationCodes.find({ hunt: huntId })) { + await InvitationCodes.destroyAsync(code._id); + } + + await InvitationCodes.insertAsync({ + code: newInvitationCode, + hunt: huntId, + }); + }); return newInvitationCode; }, diff --git a/imports/server/publications/index.ts b/imports/server/publications/index.ts index 964a5d587..64e3aaa65 100644 --- a/imports/server/publications/index.ts +++ b/imports/server/publications/index.ts @@ -10,6 +10,7 @@ import "./featureFlagsAll"; import "./guessesForGuessQueue"; import "./huntForHuntApp"; import "./huntsAll"; +import "./invitationCodesForHunt"; import "./pendingAnnouncementsForSelf"; import "./pendingGuessesForSelf"; import "./puzzleActivityForHunt"; diff --git a/imports/server/publications/invitationCodesForHunt.ts b/imports/server/publications/invitationCodesForHunt.ts new file mode 100644 index 000000000..9e14cc5f0 --- /dev/null +++ b/imports/server/publications/invitationCodesForHunt.ts @@ -0,0 +1,32 @@ +import { check } from "meteor/check"; +import Hunts from "../../lib/models/Hunts"; +import InvitationCodes from "../../lib/models/InvitationCodes"; +import MeteorUsers from "../../lib/models/MeteorUsers"; +import { userMayAddUsersToHunt } from "../../lib/permission_stubs"; +import invitationCodesForHunt from "../../lib/publications/invitationCodesForHunt"; +import definePublication from "./definePublication"; + +definePublication(invitationCodesForHunt, { + validate(arg) { + check(arg, { + huntId: String, + }); + return arg; + }, + + async run({ huntId }) { + if (!this.userId) { + return []; + } + + const user = await MeteorUsers.findOneAsync(this.userId); + const hunt = await Hunts.findOneAsync({ _id: huntId }); + if (!userMayAddUsersToHunt(user, hunt)) { + return []; + } + + return InvitationCodes.find({ + hunt: huntId, + }); + }, +}); diff --git a/tests/acceptance/smoke.tsx b/tests/acceptance/smoke.tsx index cc93571ec..530643fbb 100644 --- a/tests/acceptance/smoke.tsx +++ b/tests/acceptance/smoke.tsx @@ -96,7 +96,6 @@ if (Meteor.isClient) { huntId: fixtureHunt, puzzleId: fixturePuzzle, userId: Meteor.userId()!, - invitationCode: "abcdef123456", }; const url = Object.entries(substitutions).reduce(