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(