Skip to content

Commit

Permalink
Add initial support for durable invitation links.
Browse files Browse the repository at this point in the history
- Add invitation codes to the hunt schema. Codes are randomly generated
  IDs.
- Allow admins and hunt operators to (re)generate and clear invitation
  links from the hunt profile list page.
- Allow any user with invitation permissions to see the current
  invitation link (if any) from the hunt profile list page.
- Add an authenticated /join/:invitationCode endpoint which adds the
  current user to the hunt with that code and redirects to that hunt
  page. (If a user is unauthenticated when they open this link, they
  will be redirected here after signing in.)

See deathandmayhem#2047
  • Loading branch information
jpd236 committed May 9, 2024
1 parent 909c301 commit 5cab723
Show file tree
Hide file tree
Showing 14 changed files with 278 additions and 1 deletion.
13 changes: 12 additions & 1 deletion imports/client/components/HuntProfileListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
listAllRolesForHunt,
userMayAddUsersToHunt,
userMayMakeOperatorForHunt,
userMayUpdateHuntInvitationCode,
userMayUseDiscordBotAPIs,
} from "../../lib/permission_stubs";
import ProfileList from "./ProfileList";
Expand All @@ -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(
Expand Down Expand Up @@ -63,6 +73,7 @@ const HuntProfileListPage = () => {
canInvite={canInvite}
canSyncDiscord={canSyncDiscord}
canMakeOperator={canMakeOperator}
canUpdateHuntInvitationCode={canUpdateHuntInvitationCode}
/>
);
};
Expand Down
24 changes: 24 additions & 0 deletions imports/client/components/JoinHunt.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("loading...");

const navigate = useNavigate();

useEffect(() => {
acceptHuntInvitationCode.call({ invitationCode }, (error, huntId) => {
if (error) {
setStatus(error.reason ?? "Unknown error");
} else {
navigate(`/hunts/${huntId}`);
}
});
}, [invitationCode, navigate]);

return <div>{status}</div>;
};

export default JoinHunt;
68 changes: 68 additions & 0 deletions imports/client/components/ProfileList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -272,13 +274,15 @@ const ProfileList = ({
canInvite,
canSyncDiscord,
canMakeOperator,
canUpdateHuntInvitationCode,
users,
roles,
}: {
hunt?: HuntType;
canInvite?: boolean;
canSyncDiscord?: boolean;
canMakeOperator?: boolean;
canUpdateHuntInvitationCode?: boolean;
users: Meteor.User[];
roles?: Record<string, string[]>;
}) => {
Expand Down Expand Up @@ -367,6 +371,67 @@ const ProfileList = ({
);
}, [hunt, canSyncDiscord, syncDiscord]);

const invitationLink = useMemo(() => {
if (!hunt || !canInvite || !hunt.invitationCode) {
return null;
}

return (
<p>
Invitation link:{" "}
<a href={`/join/${hunt.invitationCode}`}>
{`${window.location.origin}/join/${hunt.invitationCode}`}
</a>
</p>
);
}, [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 (
<FormGroup className="mb-3">
<Button variant="info" onClick={generateInvitationLink}>
{hunt.invitationCode
? "Regenerate invitation link"
: "Generate invitation link"}
</Button>
{hunt.invitationCode && (
<Button variant="info" className="ms-1" onClick={clearInvitationLink}>
Disable invitation link
</Button>
)}
<FormText>
Manage the public invitation link that can be used by anyone to join
this hunt
</FormText>
</FormGroup>
);
}, [
hunt,
canUpdateHuntInvitationCode,
clearInvitationLink,
generateInvitationLink,
]);

const inviteToHuntItem = useMemo(() => {
if (!hunt || !canInvite) {
return null;
Expand Down Expand Up @@ -411,6 +476,9 @@ const ProfileList = ({

{syncDiscordButton}

{invitationLink}
{invitationLinkManagementButtons}

<FormGroup className="mb-3">
<FormLabel htmlFor="jr-profile-list-search">Search</FormLabel>
<InputGroup>
Expand Down
2 changes: 2 additions & 0 deletions imports/client/components/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,6 +72,7 @@ export const AuthenticatedRouteList: RouteObject[] = [
},
{ path: "/setup", element: <SetupPage /> },
{ path: "/rtcdebug", element: <RTCDebugPage /> },
{ path: "/join/:invitationCode", element: <JoinHunt /> },
].map((r) => {
return {
...r,
Expand Down
4 changes: 4 additions & 0 deletions imports/lib/models/Hunts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof EditableHunt>;
const Hunt = withCommon(EditableHunt);
Expand All @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions imports/lib/permission_stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ export function userMayAddUsersToHunt(
return hunt.openSignups;
}

export function userMayUpdateHuntInvitationCode(
user: Pick<Meteor.User, "roles"> | null | undefined,
hunt: Pick<HuntType, "_id"> | 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<Meteor.User, "roles"> | null | undefined,
Expand Down
5 changes: 5 additions & 0 deletions imports/methods/acceptHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import TypedMethod from "./TypedMethod";

export default new TypedMethod<{ invitationCode: string }, string>(
"Hunts.methods.acceptHuntInvitationCode",
);
5 changes: 5 additions & 0 deletions imports/methods/clearHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import TypedMethod from "./TypedMethod";

export default new TypedMethod<{ huntId: string }, void>(
"Hunts.methods.clearHuntInvitationCode",
);
5 changes: 5 additions & 0 deletions imports/methods/generateHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import TypedMethod from "./TypedMethod";

export default new TypedMethod<{ huntId: string }, string>(
"Hunts.methods.generateHuntInvitationCode",
);
37 changes: 37 additions & 0 deletions imports/server/methods/acceptHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
},
});
44 changes: 44 additions & 0 deletions imports/server/methods/clearHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
);
},
});
49 changes: 49 additions & 0 deletions imports/server/methods/generateHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -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;
},
});
3 changes: 3 additions & 0 deletions imports/server/methods/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,6 +36,7 @@ import "./dismissPendingAnnouncement";
import "./ensurePuzzleDocument";
import "./fetchAPIKey";
import "./generateUploadToken";
import "./generateHuntInvitationCode";
import "./insertDocumentImage";
import "./linkUserDiscordAccount";
import "./linkUserGoogleAccount";
Expand Down
Loading

0 comments on commit 5cab723

Please sign in to comment.