Skip to content

Commit 61c32a6

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. It gets away with using a simple hashing function since it does not contain any sensible information. Users are re-prompted once anything in the T&Cs changes.
1 parent aea460c commit 61c32a6

File tree

3 files changed

+50
-3
lines changed

3 files changed

+50
-3
lines changed

frontend/src/layout/Root.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ 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";
19+
import CONFIG from "../config";
1820

1921

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

3234
return (
3335
<Outer disableScrolling={menu.state === "burger"}>
36+
{CONFIG.initialConsent && <InitialConsent />}
3437
<Header hideNavIcon={!navExists} />
3538
{menu.state === "burger" && navExists && (
3639
<BurgerMenu items={navElements} hide={() => menu.close()} />

frontend/src/ui/InitialConsent.tsx

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useRef } 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+
export const InitialConsent: React.FC = () => {
10+
const modalRef = useRef<ModalHandle>(null);
11+
const userConsent = localStorage.getItem("userConsent") ?? "";
12+
const { title, text, button } = CONFIG.initialConsent;
13+
14+
// Since this doesn't store any critical information, a simple, insecure hash
15+
// should suffice.
16+
// Source: https://gist.github.com/jlevy/c246006675becc446360a798e2b2d781
17+
const simpleHash = (str: string) => {
18+
let hash = 0;
19+
for (let i = 0; i < str.length; i++) {
20+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
21+
}
22+
return (hash >>> 0).toString(36);
23+
};
24+
25+
const hash = simpleHash(CONFIG.initialConsent.toString());
26+
27+
return userConsent !== hash ? (
28+
<Modal
29+
open
30+
ref={modalRef}
31+
title={useTranslatedConfig(title)}
32+
closable={false}
33+
>
34+
<TextBlock content={useTranslatedConfig(text)} />
35+
<Button autoFocus css={{ marginTop: 20 }} onClick={() => {
36+
localStorage.setItem("userConsent", hash);
37+
currentRef(modalRef).close?.();
38+
}}>{useTranslatedConfig(button)}</Button>
39+
</Modal>
40+
) : null;
41+
};
42+

frontend/src/ui/Modal.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type ModalProps = {
2727
closable?: boolean;
2828
className?: string;
2929
closeOnOutsideClick?: boolean;
30+
open?: boolean;
3031
};
3132

3233
export type ModalHandle = {
@@ -41,16 +42,17 @@ export const Modal = forwardRef<ModalHandle, PropsWithChildren<ModalProps>>(({
4142
children,
4243
className,
4344
closeOnOutsideClick = false,
45+
open = false,
4446
}, ref) => {
4547
const { t } = useTranslation();
46-
const [isOpen, setOpen] = useState(false);
48+
const [isOpen, setOpen] = useState(open);
4749
const isDark = useColorScheme().scheme === "dark";
4850

4951
useImperativeHandle(ref, () => ({
5052
isOpen: () => isOpen,
5153
open: () => setOpen(true),
52-
close: closable ? (() => setOpen(false)) : undefined,
53-
}), [isOpen, closable]);
54+
close: () => setOpen(false),
55+
}), [isOpen]);
5456

5557
useEffect(() => {
5658
const handleEscape = (event: KeyboardEvent) => {

0 commit comments

Comments
 (0)