Skip to content

Commit

Permalink
Add per-hunt "terms of use"
Browse files Browse the repository at this point in the history
This is a way to surface rules of engagement for a hunt and require
hunters to agree to them before interacting with the hunt.
  • Loading branch information
ebroder committed Jan 9, 2024
1 parent 3f11204 commit 2ef5e20
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 5 deletions.
43 changes: 38 additions & 5 deletions imports/client/components/HuntApp.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Meteor } from "meteor/meteor";
import { useTracker } from "meteor/react-meteor-data";
import React, { useCallback, useMemo } from "react";
import { Modal, ModalBody, ModalFooter } from "react-bootstrap";
import Alert from "react-bootstrap/Alert";
import Button from "react-bootstrap/Button";
import ButtonToolbar from "react-bootstrap/ButtonToolbar";
import { createPortal } from "react-dom";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import Hunts from "../../lib/models/Hunts";
import type { HuntType } from "../../lib/models/Hunts";
Expand All @@ -12,6 +14,7 @@ import {
userMayUpdateHunt,
} from "../../lib/permission_stubs";
import huntForHuntApp from "../../lib/publications/huntForHuntApp";
import acceptUserHuntTerms from "../../methods/acceptUserHuntTerms";
import addHuntUser from "../../methods/addHuntUser";
import undestroyHunt from "../../methods/undestroyHunt";
import { useBreadcrumb } from "../hooks/breadcrumb";
Expand Down Expand Up @@ -111,14 +114,22 @@ const HuntApp = React.memo(() => {
const loading = huntLoading();

const hunt = useTracker(() => Hunts.findOneAllowingDeleted(huntId), [huntId]);
const { member, canUndestroy, canJoin } = useTracker(() => {
const { member, canUndestroy, canJoin, mustAcceptTerms } = useTracker(() => {
const user = Meteor.user();
const termsAccepted = user?.huntTermsAcceptedAt?.[huntId] ?? false;
return {
member: Meteor.user()?.hunts?.includes(huntId) ?? false,
canUndestroy: userMayUpdateHunt(Meteor.user(), hunt),
canJoin: userMayAddUsersToHunt(Meteor.user(), hunt),
member: user?.hunts?.includes(huntId) ?? false,
canUndestroy: userMayUpdateHunt(user, hunt),
canJoin: userMayAddUsersToHunt(user, hunt),
mustAcceptTerms: hunt?.termsOfUse && !termsAccepted,
};
}, [huntId, hunt]);

const acceptTerms = useCallback(
() => acceptUserHuntTerms.call({ huntId }),
[huntId],
);

useBreadcrumb({
title: loading || !hunt ? "loading..." : hunt.name,
path: `/hunts/${huntId}`,
Expand All @@ -144,7 +155,29 @@ const HuntApp = React.memo(() => {
return <HuntMemberError hunt={hunt} canJoin={canJoin} />;
}

return <Outlet />;
let termsModal = null;
if (mustAcceptTerms) {
termsModal = createPortal(
<Modal show size="lg">
<ModalBody>
<Markdown text={hunt.termsOfUse!} />
</ModalBody>
<ModalFooter>
<Button variant="primary" onClick={acceptTerms}>
Accept
</Button>
</ModalFooter>
</Modal>,
document.body,
);
}

return (
<>
{termsModal}
<Outlet />
</>
);
});

export default HuntApp;
66 changes: 66 additions & 0 deletions imports/client/components/HuntEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useTracker } from "meteor/react-meteor-data";
import { faInfo } from "@fortawesome/free-solid-svg-icons/faInfo";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { useCallback, useRef, useState } from "react";
import { Modal, ModalBody, ModalFooter } from "react-bootstrap";
import Alert from "react-bootstrap/Alert";
import Button from "react-bootstrap/Button";
import Col from "react-bootstrap/Col";
Expand All @@ -15,6 +16,7 @@ import FormGroup from "react-bootstrap/FormGroup";
import FormLabel from "react-bootstrap/FormLabel";
import FormText from "react-bootstrap/FormText";
import Row from "react-bootstrap/Row";
import { createPortal } from "react-dom";
import { useNavigate, useParams } from "react-router-dom";
import DiscordCache from "../../lib/models/DiscordCache";
import Hunts from "../../lib/models/Hunts";
Expand All @@ -31,6 +33,7 @@ import updateHunt from "../../methods/updateHunt";
import { useBreadcrumb } from "../hooks/breadcrumb";
import useTypedSubscribe from "../hooks/useTypedSubscribe";
import ActionButtonRow from "./ActionButtonRow";
import Markdown from "./Markdown";

enum SubmitState {
IDLE = "idle",
Expand Down Expand Up @@ -225,6 +228,13 @@ const HuntEditPage = () => {
const [hasGuessQueue, setHasGuessQueue] = useState<boolean>(
hunt?.hasGuessQueue ?? true,
);
const [termsOfUse, setTermsOfUse] = useState<string>(hunt?.termsOfUse ?? "");
const [showTermsOfUsePreview, setShowTermsOfUsePreview] =
useState<boolean>(false);
const toggleShowTermsOfUsePreview = useCallback(
() => setShowTermsOfUsePreview((prev) => !prev),
[],
);
const [homepageUrl, setHomepageUrl] = useState<string>(
hunt?.homepageUrl ?? "",
);
Expand Down Expand Up @@ -274,6 +284,12 @@ const HuntEditPage = () => {
[],
);

const onTermsOfUseChanged = useCallback<
NonNullable<FormControlProps["onChange"]>
>((e) => {
setTermsOfUse(e.currentTarget.value);
}, []);

const onHomepageUrlChanged = useCallback<
NonNullable<FormControlProps["onChange"]>
>((e) => {
Expand Down Expand Up @@ -341,6 +357,7 @@ const HuntEditPage = () => {
signupMessage: signupMessage === "" ? undefined : signupMessage,
openSignups,
hasGuessQueue,
termsOfUse: termsOfUse === "" ? undefined : termsOfUse,
homepageUrl: homepageUrl === "" ? undefined : homepageUrl,
submitTemplate: submitTemplate === "" ? undefined : submitTemplate,
puzzleHooksDiscordChannel,
Expand All @@ -361,6 +378,7 @@ const HuntEditPage = () => {
signupMessage,
openSignups,
hasGuessQueue,
termsOfUse,
homepageUrl,
submitTemplate,
puzzleHooksDiscordChannel,
Expand All @@ -377,6 +395,24 @@ const HuntEditPage = () => {

const disableForm = submitState === SubmitState.SUBMITTING;

const termsOfUsePreview = createPortal(
<Modal
show={showTermsOfUsePreview}
size="lg"
onHide={toggleShowTermsOfUsePreview}
>
<ModalBody>
<Markdown text={termsOfUse} />
</ModalBody>
<ModalFooter>
<Button variant="primary" onClick={toggleShowTermsOfUsePreview}>
Close
</Button>
</ModalFooter>
</Modal>,
document.body,
);

return (
<Container>
<h1>{huntId ? "Edit Hunt" : "New Hunt"}</h1>
Expand Down Expand Up @@ -457,6 +493,36 @@ const HuntEditPage = () => {
</Col>
</FormGroup>

<FormGroup as={Row} className="mb-3">
<FormLabel column xs={3} htmlFor="hunt-form-terms-of-use">
Terms of use
</FormLabel>
<Col xs={9}>
<FormControl
id="hunt-form-terms-of-use"
as="textarea"
value={termsOfUse}
onChange={onTermsOfUseChanged}
disabled={disableForm}
/>
<FormText>
If specified, this text (rendered as Markdown) will be shown to
users when the first visit the hunt website, and they will be
required to accept it before they can proceed.
<ActionButtonRow>
<Button
variant="secondary"
onClick={toggleShowTermsOfUsePreview}
>
Preview
</Button>
</ActionButtonRow>
</FormText>
</Col>
</FormGroup>

{termsOfUsePreview}

<h3>Hunt website</h3>

<FormGroup as={Row} className="mb-3">
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 @@ -25,6 +25,9 @@ const EditableHunt = z.object({
// If this is true, an operator must mark guesses as correct or not.
// If this is false, users enter answers directly without the guess step.
hasGuessQueue: z.boolean(),
// If provided, users will be presented with this text as a modal to agree to
// before accessing the hunt.
termsOfUse: nonEmptyString.optional(),
// If this is provided, then this is used to generate links to puzzles' guess
// submission pages. The format is interpreted as a Mustache template
// (https://mustache.github.io/). It's passed as context a parsed URL
Expand Down Expand Up @@ -59,6 +62,7 @@ export const HuntPattern = {
signupMessage: Match.Optional(String),
openSignups: Boolean,
hasGuessQueue: Boolean,
termsOfUse: Match.Optional(String),
submitTemplate: Match.Optional(String),
homepageUrl: Match.Optional(String),
puzzleHooksDiscordChannel: Match.Optional(SavedDiscordObjectPattern),
Expand Down
2 changes: 2 additions & 0 deletions imports/lib/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare module "meteor/meteor" {
interface User {
lastLogin?: Date;
hunts?: string[];
huntTermsAcceptedAt?: Record<string, Date>;
roles?: Record<string, string[]>; // scope -> roles
displayName?: string;
googleAccount?: string;
Expand Down Expand Up @@ -40,6 +41,7 @@ export const User = z.object({
profile: z.object({}).optional(),
roles: z.record(z.string(), nonEmptyString.array()).optional(),
hunts: foreignKey.array().optional(),
huntTermsAcceptedAt: z.record(z.string(), z.date()).optional(),
displayName: nonEmptyString.optional(),
googleAccount: nonEmptyString.optional(),
googleAccountId: nonEmptyString.optional(),
Expand Down
5 changes: 5 additions & 0 deletions imports/methods/acceptUserHuntTerms.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>(
"Users.methods.acceptHuntTerms",
);
19 changes: 19 additions & 0 deletions imports/server/methods/acceptUserHuntTerms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { check } from "meteor/check";
import MeteorUsers from "../../lib/models/MeteorUsers";
import acceptUserHuntTerms from "../../methods/acceptUserHuntTerms";
import defineMethod from "./defineMethod";

defineMethod(acceptUserHuntTerms, {
validate(arg) {
check(arg, { huntId: String });

return arg;
},

async run({ huntId }) {
check(this.userId, String);
await MeteorUsers.updateAsync(this.userId, {
$set: { [`huntTermsAcceptedAt.${huntId}`]: new Date() },
});
},
});
1 change: 1 addition & 0 deletions imports/server/methods/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "./acceptUserHuntTerms";
import "./addHuntUser";
import "./addPuzzleAnswer";
import "./addPuzzleTag";
Expand Down
1 change: 1 addition & 0 deletions imports/server/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Accounts.setDefaultPublishFields({
emails: 1,
roles: 1,
hunts: 1,
huntTermsAcceptedAt: 1,
...profileFields,
});

Expand Down

0 comments on commit 2ef5e20

Please sign in to comment.