diff --git a/imports/client/components/HuntProfileListPage.tsx b/imports/client/components/HuntProfileListPage.tsx index 712a0d9b8..ecc091cb1 100644 --- a/imports/client/components/HuntProfileListPage.tsx +++ b/imports/client/components/HuntProfileListPage.tsx @@ -8,6 +8,7 @@ import { listAllRolesForHunt, userMayAddUsersToHunt, userMayMakeOperatorForHunt, + userMayUpdateHuntInvitationCode, userMayUseDiscordBotAPIs, } from "../../lib/permission_stubs"; import ProfileList from "./ProfileList"; @@ -31,11 +32,20 @@ const HuntProfileListPage = () => { ); const hunt = useTracker(() => Hunts.findOne(huntId), [huntId]); - const { canInvite, canSyncDiscord, canMakeOperator } = useTracker(() => { + const { + canInvite, + canSyncDiscord, + canMakeOperator, + canUpdateHuntInvitationCode, + } = useTracker(() => { return { canInvite: userMayAddUsersToHunt(Meteor.user(), hunt), canSyncDiscord: userMayUseDiscordBotAPIs(Meteor.user()), canMakeOperator: userMayMakeOperatorForHunt(Meteor.user(), hunt), + canUpdateHuntInvitationCode: userMayUpdateHuntInvitationCode( + Meteor.user(), + hunt, + ), }; }, [hunt]); const roles = useTracker( @@ -63,6 +73,7 @@ const HuntProfileListPage = () => { canInvite={canInvite} canSyncDiscord={canSyncDiscord} canMakeOperator={canMakeOperator} + canUpdateHuntInvitationCode={canUpdateHuntInvitationCode} /> ); }; diff --git a/imports/client/components/JoinHunt.tsx b/imports/client/components/JoinHunt.tsx new file mode 100644 index 000000000..428947dbb --- /dev/null +++ b/imports/client/components/JoinHunt.tsx @@ -0,0 +1,24 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import acceptHuntInvitationCode from "../../methods/acceptHuntInvitationCode"; + +const JoinHunt = () => { + const invitationCode = useParams<"invitationCode">().invitationCode!; + const [status, setStatus] = useState("loading..."); + + const navigate = useNavigate(); + + useEffect(() => { + acceptHuntInvitationCode.call({ invitationCode }, (error, huntId) => { + if (error) { + setStatus(error.reason ?? "Unknown error"); + } else { + navigate(`/hunts/${huntId}`); + } + }); + }, [invitationCode, navigate]); + + return
{status}
; +}; + +export default JoinHunt; diff --git a/imports/client/components/ProfileList.tsx b/imports/client/components/ProfileList.tsx index e9ec13831..7cdd9726c 100644 --- a/imports/client/components/ProfileList.tsx +++ b/imports/client/components/ProfileList.tsx @@ -31,7 +31,9 @@ import { formatDiscordName } from "../../lib/discord"; import isAdmin from "../../lib/isAdmin"; import type { HuntType } from "../../lib/models/Hunts"; import { userIsOperatorForHunt } from "../../lib/permission_stubs"; +import clearHuntInvitationCode from "../../methods/clearHuntInvitationCode"; import demoteOperator from "../../methods/demoteOperator"; +import generateHuntInvitationCode from "../../methods/generateHuntInvitationCode"; import promoteOperator from "../../methods/promoteOperator"; import syncHuntDiscordRole from "../../methods/syncHuntDiscordRole"; import Avatar from "./Avatar"; @@ -272,6 +274,7 @@ const ProfileList = ({ canInvite, canSyncDiscord, canMakeOperator, + canUpdateHuntInvitationCode, users, roles, }: { @@ -279,6 +282,7 @@ const ProfileList = ({ canInvite?: boolean; canSyncDiscord?: boolean; canMakeOperator?: boolean; + canUpdateHuntInvitationCode?: boolean; users: Meteor.User[]; roles?: Record; }) => { @@ -367,6 +371,67 @@ const ProfileList = ({ ); }, [hunt, canSyncDiscord, syncDiscord]); + const invitationLink = useMemo(() => { + if (!hunt || !canInvite || !hunt.invitationCode) { + return null; + } + + return ( +

+ Invitation link:{" "} + + {`${window.location.origin}/join/${hunt.invitationCode}`} + +

+ ); + }, [hunt, canInvite]); + + const generateInvitationLink = useCallback(() => { + if (!hunt) { + return; + } + + generateHuntInvitationCode.call({ huntId: hunt._id }); + }, [hunt]); + + const clearInvitationLink = useCallback(() => { + if (!hunt) { + return; + } + + clearHuntInvitationCode.call({ huntId: hunt._id }); + }, [hunt]); + + const invitationLinkManagementButtons = useMemo(() => { + if (!hunt || !canUpdateHuntInvitationCode) { + return null; + } + + return ( + + + {hunt.invitationCode && ( + + )} + + Manage the public invitation link that can be used by anyone to join + this hunt + + + ); + }, [ + hunt, + canUpdateHuntInvitationCode, + clearInvitationLink, + generateInvitationLink, + ]); + const inviteToHuntItem = useMemo(() => { if (!hunt || !canInvite) { return null; @@ -411,6 +476,9 @@ const ProfileList = ({ {syncDiscordButton} + {invitationLink} + {invitationLinkManagementButtons} + Search diff --git a/imports/client/components/Routes.tsx b/imports/client/components/Routes.tsx index b9b751043..6dabae9b5 100644 --- a/imports/client/components/Routes.tsx +++ b/imports/client/components/Routes.tsx @@ -14,6 +14,7 @@ import HuntListApp from "./HuntListApp"; import HuntListPage from "./HuntListPage"; import HuntProfileListPage from "./HuntProfileListPage"; import HuntersApp from "./HuntersApp"; +import JoinHunt from "./JoinHunt"; import Loading from "./Loading"; import LoginForm from "./LoginForm"; import PasswordResetForm from "./PasswordResetForm"; @@ -71,6 +72,7 @@ export const AuthenticatedRouteList: RouteObject[] = [ }, { path: "/setup", element: }, { path: "/rtcdebug", element: }, + { path: "/join/:invitationCode", element: }, ].map((r) => { return { ...r, diff --git a/imports/lib/models/Hunts.ts b/imports/lib/models/Hunts.ts index 33240e611..38132f728 100644 --- a/imports/lib/models/Hunts.ts +++ b/imports/lib/models/Hunts.ts @@ -47,6 +47,9 @@ 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); @@ -68,6 +71,7 @@ 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/permission_stubs.ts b/imports/lib/permission_stubs.ts index 146ac82c8..915c56232 100644 --- a/imports/lib/permission_stubs.ts +++ b/imports/lib/permission_stubs.ts @@ -83,6 +83,25 @@ export function userMayAddUsersToHunt( return hunt.openSignups; } +export function userMayUpdateHuntInvitationCode( + user: Pick | null | undefined, + hunt: Pick | null | undefined, +): boolean { + if (!user || !hunt) { + return false; + } + + if (isAdmin(user)) { + return true; + } + + if (isOperatorForHunt(user, hunt)) { + return true; + } + + return false; +} + // Admins and operators may add announcements to a hunt. export function userMayAddAnnouncementToHunt( user: Pick | null | undefined, diff --git a/imports/methods/acceptHuntInvitationCode.ts b/imports/methods/acceptHuntInvitationCode.ts new file mode 100644 index 000000000..5f4d454c9 --- /dev/null +++ b/imports/methods/acceptHuntInvitationCode.ts @@ -0,0 +1,5 @@ +import TypedMethod from "./TypedMethod"; + +export default new TypedMethod<{ invitationCode: string }, string>( + "Hunts.methods.acceptHuntInvitationCode", +); diff --git a/imports/methods/clearHuntInvitationCode.ts b/imports/methods/clearHuntInvitationCode.ts new file mode 100644 index 000000000..f6607caa1 --- /dev/null +++ b/imports/methods/clearHuntInvitationCode.ts @@ -0,0 +1,5 @@ +import TypedMethod from "./TypedMethod"; + +export default new TypedMethod<{ huntId: string }, void>( + "Hunts.methods.clearHuntInvitationCode", +); diff --git a/imports/methods/generateHuntInvitationCode.ts b/imports/methods/generateHuntInvitationCode.ts new file mode 100644 index 000000000..88f9c74b9 --- /dev/null +++ b/imports/methods/generateHuntInvitationCode.ts @@ -0,0 +1,5 @@ +import TypedMethod from "./TypedMethod"; + +export default new TypedMethod<{ huntId: string }, string>( + "Hunts.methods.generateHuntInvitationCode", +); diff --git a/imports/server/methods/acceptHuntInvitationCode.ts b/imports/server/methods/acceptHuntInvitationCode.ts new file mode 100644 index 000000000..fb70a8032 --- /dev/null +++ b/imports/server/methods/acceptHuntInvitationCode.ts @@ -0,0 +1,37 @@ +import { check } from "meteor/check"; +import { Meteor } from "meteor/meteor"; +import Hunts from "../../lib/models/Hunts"; +import MeteorUsers from "../../lib/models/MeteorUsers"; +import acceptHuntInvitationCode from "../../methods/acceptHuntInvitationCode"; +import addUserToHunt from "../addUserToHunt"; +import defineMethod from "./defineMethod"; + +defineMethod(acceptHuntInvitationCode, { + validate(arg) { + check(arg, { + invitationCode: String, + }); + return arg; + }, + + async run({ invitationCode }): Promise { + check(this.userId, String); + + const hunt = await Hunts.findOneAsync({ + invitationCode, + }); + if (!hunt) { + throw new Meteor.Error(404, "Invalid invitation code"); + } + + const user = await MeteorUsers.findOneAsync(this.userId); + const email = user?.emails?.[0]?.address; + if (!email) { + throw new Meteor.Error(500, "No email found for current user"); + } + + await addUserToHunt({ hunt, email, invitedBy: this.userId }); + + return hunt._id; + }, +}); diff --git a/imports/server/methods/clearHuntInvitationCode.ts b/imports/server/methods/clearHuntInvitationCode.ts new file mode 100644 index 000000000..5dbce45fb --- /dev/null +++ b/imports/server/methods/clearHuntInvitationCode.ts @@ -0,0 +1,44 @@ +import { check } from "meteor/check"; +import { Meteor } from "meteor/meteor"; +import Hunts from "../../lib/models/Hunts"; +import MeteorUsers from "../../lib/models/MeteorUsers"; +import { userMayUpdateHuntInvitationCode } from "../../lib/permission_stubs"; +import clearHuntInvitationCode from "../../methods/clearHuntInvitationCode"; +import defineMethod from "./defineMethod"; + +// Clear the invitation code for the given hunt. +defineMethod(clearHuntInvitationCode, { + validate(arg) { + check(arg, { + huntId: String, + }); + return arg; + }, + + async run({ huntId }) { + check(this.userId, String); + + const hunt = await Hunts.findOneAsync(huntId); + if (!hunt) { + throw new Meteor.Error(404, "Unknown hunt"); + } + + const user = await MeteorUsers.findOneAsync(this.userId); + + if (!userMayUpdateHuntInvitationCode(user, hunt)) { + throw new Meteor.Error( + 401, + `User ${this.userId} may not clear invitation code for ${huntId}`, + ); + } + + await Hunts.updateAsync( + { _id: huntId }, + { + $unset: { + invitationCode: 1, + }, + }, + ); + }, +}); diff --git a/imports/server/methods/generateHuntInvitationCode.ts b/imports/server/methods/generateHuntInvitationCode.ts new file mode 100644 index 000000000..845ead56a --- /dev/null +++ b/imports/server/methods/generateHuntInvitationCode.ts @@ -0,0 +1,49 @@ +import { check } from "meteor/check"; +import { Meteor } from "meteor/meteor"; +import { Random } from "meteor/random"; +import Hunts from "../../lib/models/Hunts"; +import MeteorUsers from "../../lib/models/MeteorUsers"; +import { userMayUpdateHuntInvitationCode } from "../../lib/permission_stubs"; +import generateHuntInvitationCode from "../../methods/generateHuntInvitationCode"; +import defineMethod from "./defineMethod"; + +// Generate (or regenerate) an invitation code for the given hunt. +defineMethod(generateHuntInvitationCode, { + validate(arg) { + check(arg, { + huntId: String, + }); + return arg; + }, + + async run({ huntId }) { + check(this.userId, String); + + const hunt = await Hunts.findOneAsync(huntId); + if (!hunt) { + throw new Meteor.Error(404, "Unknown hunt"); + } + + const user = await MeteorUsers.findOneAsync(this.userId); + + if (!userMayUpdateHuntInvitationCode(user, hunt)) { + throw new Meteor.Error( + 401, + `User ${this.userId} may not generate invitation codes for ${huntId}`, + ); + } + + const newInvitationCode = Random.id(); + + await Hunts.updateAsync( + { _id: huntId }, + { + $set: { + invitationCode: newInvitationCode, + }, + }, + ); + + return newInvitationCode; + }, +}); diff --git a/imports/server/methods/index.ts b/imports/server/methods/index.ts index d39b5fd41..d1438cfc3 100644 --- a/imports/server/methods/index.ts +++ b/imports/server/methods/index.ts @@ -1,9 +1,11 @@ +import "./acceptHuntInvitationCode"; import "./acceptUserHuntTerms"; import "./addHuntUser"; import "./addPuzzleAnswer"; import "./addPuzzleTag"; import "./bookmarkPuzzle"; import "./bulkAddHuntUsers"; +import "./clearHuntInvitationCode"; import "./configureClearGdriveCreds"; import "./configureCollectGoogleAccountIds"; import "./configureDiscordBot"; @@ -34,6 +36,7 @@ import "./dismissPendingAnnouncement"; import "./ensurePuzzleDocument"; import "./fetchAPIKey"; import "./generateUploadToken"; +import "./generateHuntInvitationCode"; import "./insertDocumentImage"; import "./linkUserDiscordAccount"; import "./linkUserGoogleAccount"; diff --git a/tests/acceptance/smoke.tsx b/tests/acceptance/smoke.tsx index 530643fbb..cc93571ec 100644 --- a/tests/acceptance/smoke.tsx +++ b/tests/acceptance/smoke.tsx @@ -96,6 +96,7 @@ if (Meteor.isClient) { huntId: fixtureHunt, puzzleId: fixturePuzzle, userId: Meteor.userId()!, + invitationCode: "abcdef123456", }; const url = Object.entries(substitutions).reduce(