Skip to content

Commit

Permalink
Tweaks to invitation codes from review feedback.
Browse files Browse the repository at this point in the history
- Move to a dedicated model. This prevents invitation codes from leaking
unintentionally as part of the hunt, and also lets us track old code
generation, who generated codes, etc.

- Change invitation view from a link to plain text with a Copy to
Clipboard button.
  • Loading branch information
jpd236 committed Apr 11, 2024
1 parent a928420 commit cba64d9
Show file tree
Hide file tree
Showing 12 changed files with 142 additions and 32 deletions.
14 changes: 13 additions & 1 deletion imports/client/components/HuntProfileListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -11,14 +12,20 @@ import {
userMayUpdateHuntInvitationCode,
userMayUseDiscordBotAPIs,
} from "../../lib/permission_stubs";
import invitationCodesForHunt from "../../lib/publications/invitationCodesForHunt";
import useTypedSubscribe from "../hooks/useTypedSubscribe";
import ProfileList from "./ProfileList";

const HuntProfileListPage = () => {
const huntId = useParams<"huntId">().huntId!;

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(
() =>
Expand Down Expand Up @@ -60,6 +67,10 @@ const HuntProfileListPage = () => {
),
[huntId, hunt, loading, canMakeOperator],
);
const invitationCode = useTracker(
() => InvitationCodes.findOne({ hunt: huntId })?.code,
[huntId],
);

if (loading) {
return <div>loading...</div>;
Expand All @@ -74,6 +85,7 @@ const HuntProfileListPage = () => {
canSyncDiscord={canSyncDiscord}
canMakeOperator={canMakeOperator}
canUpdateHuntInvitationCode={canUpdateHuntInvitationCode}
invitationCode={invitationCode}
/>
);
};
Expand Down
41 changes: 33 additions & 8 deletions imports/client/components/ProfileList.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -70,6 +74,13 @@ const OperatorBox = styled.div`
}
`;

const StyledLinkButton: FC<ComponentPropsWithRef<typeof Button>> = styled(
Button,
)`
padding: 0;
vertical-align: baseline;
`;

type OperatorModalHandle = {
show(): void;
};
Expand Down Expand Up @@ -277,6 +288,7 @@ const ProfileList = ({
canUpdateHuntInvitationCode,
users,
roles,
invitationCode,
}: {
hunt?: HuntType;
canInvite?: boolean;
Expand All @@ -285,6 +297,7 @@ const ProfileList = ({
canUpdateHuntInvitationCode?: boolean;
users: Meteor.User[];
roles?: Record<string, string[]>;
invitationCode?: string;
}) => {
const [searchString, setSearchString] = useState<string>("");

Expand Down Expand Up @@ -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 = <Tooltip>Copy to clipboard</Tooltip>;

const invitationUrl = `${window.location.origin}/join/${invitationCode}`;

return (
<p>
Invitation link:{" "}
<a href={`/join/${hunt.invitationCode}`}>
{`${window.location.origin}/join/${hunt.invitationCode}`}
</a>
<OverlayTrigger placement="top" overlay={copyTooltip}>
{({ ref, ...triggerHandler }) => (
<CopyToClipboard text={invitationUrl} {...triggerHandler}>
<StyledLinkButton ref={ref} variant="link" aria-label="Copy">
<FontAwesomeIcon icon={faCopy} fixedWidth />
</StyledLinkButton>
</CopyToClipboard>
)}
</OverlayTrigger>{" "}
{invitationUrl}
</p>
);
}, [hunt, canInvite]);
}, [hunt, canInvite, invitationCode]);

const generateInvitationLink = useCallback(() => {
if (!hunt) {
Expand All @@ -410,11 +434,11 @@ const ProfileList = ({
return (
<FormGroup className="mb-3">
<Button variant="info" onClick={generateInvitationLink}>
{hunt.invitationCode
{invitationCode
? "Regenerate invitation link"
: "Generate invitation link"}
</Button>
{hunt.invitationCode && (
{invitationCode && (
<Button variant="info" className="ms-1" onClick={clearInvitationLink}>
Disable invitation link
</Button>
Expand All @@ -430,6 +454,7 @@ const ProfileList = ({
canUpdateHuntInvitationCode,
clearInvitationLink,
generateInvitationLink,
invitationCode,
]);

const inviteToHuntItem = useMemo(() => {
Expand Down
4 changes: 0 additions & 4 deletions imports/lib/models/Hunts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof EditableHunt>;
const Hunt = withCommon(EditableHunt);
Expand All @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions imports/lib/models/InvitationCodes.ts
Original file line number Diff line number Diff line change
@@ -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<typeof InvitationCodes>;

export default InvitationCodes;
5 changes: 5 additions & 0 deletions imports/lib/publications/invitationCodesForHunt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import TypedPublication from "./TypedPublication";

export default new TypedPublication<{ huntId: string }>(
"InvitationCodes.publications.forHunt",
);
5 changes: 5 additions & 0 deletions imports/methods/fetchHuntInvitationCode.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>(
"fetchHuntInvitationCode",
);
12 changes: 10 additions & 2 deletions imports/server/methods/acceptHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,11 +18,18 @@ defineMethod(acceptHuntInvitationCode, {
async run({ invitationCode }): Promise<string> {
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);
Expand Down
15 changes: 7 additions & 8 deletions imports/server/methods/clearHuntInvitationCode.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
}
});
},
});
20 changes: 12 additions & 8 deletions imports/server/methods/generateHuntInvitationCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
},
Expand Down
1 change: 1 addition & 0 deletions imports/server/publications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import "./featureFlagsAll";
import "./guessesForGuessQueue";
import "./huntForHuntApp";
import "./huntsAll";
import "./invitationCodesForHunt";
import "./pendingAnnouncementsForSelf";
import "./pendingGuessesForSelf";
import "./puzzleActivityForHunt";
Expand Down
32 changes: 32 additions & 0 deletions imports/server/publications/invitationCodesForHunt.ts
Original file line number Diff line number Diff line change
@@ -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,
});
},
});
1 change: 0 additions & 1 deletion tests/acceptance/smoke.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ if (Meteor.isClient) {
huntId: fixtureHunt,
puzzleId: fixturePuzzle,
userId: Meteor.userId()!,
invitationCode: "abcdef123456",
};

const url = Object.entries(substitutions).reduce(
Expand Down

0 comments on commit cba64d9

Please sign in to comment.