Skip to content

Commit c492a32

Browse files
committed
Add modal for prompting t&c consent
This is a really basic consent modal, that is only closeable by agreeing to the configured terms and conditions. Users are prompted when first visiting Tobira. The consent is saved in local storage, meaning it is done on a per-device basis rather than per-user. Users are re-prompted once anything in the T&Cs changes.
1 parent 87c3046 commit c492a32

File tree

3 files changed

+70
-4
lines changed

3 files changed

+70
-4
lines changed

frontend/src/layout/Root.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { OperationType } from "relay-runtime";
1515
import { UserData$key } from "../__generated__/UserData.graphql";
1616
import { useNoindexTag } from "../util";
1717
import { screenWidthAtMost } from "@opencast/appkit";
18+
import { InitialConsent } from "../ui/InitialConsent";
1819

1920

2021
export const MAIN_PADDING = 16;
@@ -31,6 +32,7 @@ export const Root: React.FC<Props> = ({ nav, children }) => {
3132

3233
return (
3334
<Outer disableScrolling={menu.state === "burger"}>
35+
<InitialConsent />
3436
<Header hideNavIcon={!navExists} />
3537
{menu.state === "burger" && navExists && (
3638
<BurgerMenu items={navElements} hide={() => menu.close()} />

frontend/src/ui/InitialConsent.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import CONFIG from "../config";
3+
import { currentRef, useTranslatedConfig } from "../util";
4+
import { Modal, ModalHandle } from "./Modal";
5+
import { Button } from "./Button";
6+
import { TextBlock } from "./Blocks/Text";
7+
8+
9+
const USER_CONSENT = "tobiraUserConsent";
10+
11+
export const InitialConsent: React.FC = () => {
12+
if (!CONFIG.initialConsent) {
13+
return null;
14+
}
15+
const [hash, setHash] = useState("");
16+
const userConsent = localStorage.getItem(USER_CONSENT);
17+
const modalRef = useRef<ModalHandle>(null);
18+
const title = useTranslatedConfig(CONFIG.initialConsent.title);
19+
const text = useTranslatedConfig(CONFIG.initialConsent.text);
20+
const buttonLabel = useTranslatedConfig(CONFIG.initialConsent.button);
21+
22+
useEffect(() => {
23+
const makeHash = async () => {
24+
const msgUint8 = new TextEncoder().encode(JSON.stringify(CONFIG.initialConsent));
25+
const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
26+
const hashArray = Array.from(new Uint8Array(hashBuffer));
27+
const hashHex = hashArray
28+
.map(b => b.toString(16).padStart(2, "0"))
29+
.join("");
30+
setHash(hashHex);
31+
};
32+
makeHash();
33+
}, []);
34+
35+
return hash === userConsent ? null : (
36+
<Modal
37+
open
38+
ref={modalRef}
39+
title={title}
40+
closable={false}
41+
initialFocus={false}
42+
>
43+
<TextBlock content={text} />
44+
<div css={{ display: "flex" }}>
45+
<Button css={{ marginTop: 20, marginLeft: "auto" }} onClick={() => {
46+
localStorage.setItem(USER_CONSENT, hash);
47+
currentRef(modalRef).close?.();
48+
}}>{buttonLabel}</Button>
49+
</div>
50+
</Modal>
51+
);
52+
};
53+

frontend/src/ui/Modal.tsx

+15-4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ type ModalProps = {
2727
closable?: boolean;
2828
className?: string;
2929
closeOnOutsideClick?: boolean;
30+
open?: boolean;
31+
/**
32+
* Determines if an element in the modal should be initially focused.
33+
* Instead of passing this an HTMLElement, it is probably easier to use
34+
* react's `autoFocus` prop on the element that should be focused.
35+
* Default is the first element in tab order.
36+
* Use `false` if no element should be focused.
37+
*/
38+
initialFocus?: HTMLElement | false;
3039
};
3140

3241
export type ModalHandle = {
@@ -41,16 +50,18 @@ export const Modal = forwardRef<ModalHandle, PropsWithChildren<ModalProps>>(({
4150
children,
4251
className,
4352
closeOnOutsideClick = false,
53+
open = false,
54+
initialFocus,
4455
}, ref) => {
4556
const { t } = useTranslation();
46-
const [isOpen, setOpen] = useState(false);
57+
const [isOpen, setOpen] = useState(open);
4758
const isDark = useColorScheme().scheme === "dark";
4859

4960
useImperativeHandle(ref, () => ({
5061
isOpen: () => isOpen,
5162
open: () => setOpen(true),
52-
close: closable ? (() => setOpen(false)) : undefined,
53-
}), [isOpen, closable]);
63+
close: () => setOpen(false),
64+
}), [isOpen]);
5465

5566
useEffect(() => {
5667
const handleEscape = (event: KeyboardEvent) => {
@@ -63,7 +74,7 @@ export const Modal = forwardRef<ModalHandle, PropsWithChildren<ModalProps>>(({
6374
}, [closable]);
6475

6576
return ReactDOM.createPortal(
66-
isOpen && <FocusTrap>
77+
isOpen && <FocusTrap focusTrapOptions={{ initialFocus }}>
6778
<div
6879
{...(closable && closeOnOutsideClick && { onClick: e => {
6980
if (e.target === e.currentTarget) {

0 commit comments

Comments
 (0)