From 94a24c7b8fcfd5663639796e84b517e882960b9c Mon Sep 17 00:00:00 2001 From: ikusteu Date: Mon, 16 Oct 2023 09:15:26 +0200 Subject: [PATCH 01/10] Update firebase-emulators-warning: * Remove the firebase-emulator-warning element attached to the DOM by the presence of emulators * Create our own version of the warning - one that could be removed to not impede on the styles of the app --- packages/client/src/AppContent.tsx | 119 +++++++++++++++----------- packages/ui/src/Layout/DevWarning.tsx | 27 ++++++ packages/ui/src/Layout/index.ts | 1 + 3 files changed, 97 insertions(+), 50 deletions(-) create mode 100644 packages/ui/src/Layout/DevWarning.tsx diff --git a/packages/client/src/AppContent.tsx b/packages/client/src/AppContent.tsx index be0ebe963..c8566587a 100644 --- a/packages/client/src/AppContent.tsx +++ b/packages/client/src/AppContent.tsx @@ -29,6 +29,7 @@ import AdminPreferencesPage from "@/pages/admin_preferences"; import SelfRegister from "@/pages/self_register"; import { getIsAdmin } from "@/store/selectors/auth"; +import { DevWarning } from "@eisbuk/ui"; /** * All of the App content (including routes) wrapper. @@ -52,58 +53,76 @@ const AppContent: React.FC = () => { useFirestoreSubscribe(getOrganization(), subscribedCollections); usePaginateFirestore(); + // Remove the firestore emulator warning as it's in the way. + // We're adding a warning of our own, incorporated nicely into out layout + React.useEffect(() => { + document.querySelector("p.firebase-emulator-warning")?.remove(); + }, []); + + const isDev = process.env.NODE_ENV === "development"; + return ( - - - - - - - - - + <> + + + + + + + + + + + + + + + + + + + - - - - - - - - - +
+ +
+ ); }; diff --git a/packages/ui/src/Layout/DevWarning.tsx b/packages/ui/src/Layout/DevWarning.tsx new file mode 100644 index 000000000..56518a7a1 --- /dev/null +++ b/packages/ui/src/Layout/DevWarning.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import { Close } from "@eisbuk/svg"; + +/** A warning displayed instead of firebase emulators generic warning: used in dev to let the user (developer in most cases) know this is not production. */ +const DevWarning: React.FC<{ open?: boolean }> = ({ open = false }) => { + const [isOpen, setIsOpen] = React.useState(open); + + const close = () => () => setIsOpen(false); + + return isOpen ? ( +

+ + Warning: using firestore emulators in dev mode: do not use with + production credentials + +

+ +
+

+ ) : null; +}; + +export default DevWarning; diff --git a/packages/ui/src/Layout/index.ts b/packages/ui/src/Layout/index.ts index f746b692e..49c0f8e3a 100644 --- a/packages/ui/src/Layout/index.ts +++ b/packages/ui/src/Layout/index.ts @@ -1,5 +1,6 @@ import Layout from "./Layout"; export { type LinkItem, LayoutContent } from "./Layout"; +export { default as DevWarning } from "./DevWarning"; export default Layout; From db8c0901201d49144f805c01c5b3ce1b36cc2bba Mon Sep 17 00:00:00 2001 From: ikusteu Date: Mon, 16 Oct 2023 16:14:21 +0200 Subject: [PATCH 02/10] Create privacy policy toast and display it to all non-admins (just a first pass - 'accept' doesn't do anything) --- packages/client/src/AppContent.tsx | 4 +- .../src/controllers/PrivacyPolicyToast.tsx | 17 +++++ packages/client/src/lib/data.ts | 4 + packages/shared/src/types/firestore.ts | 7 ++ packages/shared/src/ui/data.ts | 8 ++ packages/shared/src/ui/index.ts | 1 + packages/translations/src/dict/en.json | 5 +- packages/translations/src/dict/it.json | 5 +- packages/translations/src/translations.ts | 3 + .../PrivacyPolicyToast.stories.tsx | 11 +++ .../PrivacyPolicyToast/PrivacyPolicyToast.tsx | 44 +++++++++++ packages/ui/src/PrivacyPolicyToast/index.ts | 3 + packages/ui/src/index.ts | 76 ++++++------------- 13 files changed, 134 insertions(+), 54 deletions(-) create mode 100644 packages/client/src/controllers/PrivacyPolicyToast.tsx create mode 100644 packages/shared/src/ui/data.ts create mode 100644 packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.stories.tsx create mode 100644 packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.tsx create mode 100644 packages/ui/src/PrivacyPolicyToast/index.ts diff --git a/packages/client/src/AppContent.tsx b/packages/client/src/AppContent.tsx index c8566587a..258dd40b1 100644 --- a/packages/client/src/AppContent.tsx +++ b/packages/client/src/AppContent.tsx @@ -4,6 +4,7 @@ import { useSelector } from "react-redux"; import { Collection, OrgSubCollection } from "@eisbuk/shared"; import { Routes, PrivateRoutes } from "@eisbuk/shared/ui"; +import { DevWarning } from "@eisbuk/ui"; import { CollectionSubscription, usePaginateFirestore, @@ -15,6 +16,7 @@ import { getOrganization } from "@/lib/getters"; import PrivateRoute from "@/components/auth/PrivateRoute"; import Deleted from "@/components/auth/Deleted"; import LoginRoute from "@/components/auth/LoginRoute"; +import PrivacyPolicyToast from "@/controllers/PrivacyPolicyToast"; import AttendancePage from "@/pages/attendance"; import AthletesPage from "@/pages/customers"; @@ -29,7 +31,6 @@ import AdminPreferencesPage from "@/pages/admin_preferences"; import SelfRegister from "@/pages/self_register"; import { getIsAdmin } from "@/store/selectors/auth"; -import { DevWarning } from "@eisbuk/ui"; /** * All of the App content (including routes) wrapper. @@ -120,6 +121,7 @@ const AppContent: React.FC = () => {
+
diff --git a/packages/client/src/controllers/PrivacyPolicyToast.tsx b/packages/client/src/controllers/PrivacyPolicyToast.tsx new file mode 100644 index 000000000..6ffc9d095 --- /dev/null +++ b/packages/client/src/controllers/PrivacyPolicyToast.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +import { PrivacyPolicyToast as Toast } from "@eisbuk/ui"; + +import { getIsAdmin } from "@/store/selectors/auth"; + +const PrivacyPolicyToast: React.FC = () => { + const [accepted, setAccepted] = React.useState(false); + const isAdmin = useSelector(getIsAdmin); + + return ( + setAccepted(true)} /> + ); +}; + +export default PrivacyPolicyToast; diff --git a/packages/client/src/lib/data.ts b/packages/client/src/lib/data.ts index 2541aa77b..d73eac203 100644 --- a/packages/client/src/lib/data.ts +++ b/packages/client/src/lib/data.ts @@ -40,3 +40,7 @@ export const defaultCustomerFormValues: Omit = }; // #endregion CustomerForm + +// #region PrivacyPolicy +export const privacyPolicyLink = "Click here to check out our privacy policy."; +// #endregion PrivacyPolicy diff --git a/packages/shared/src/types/firestore.ts b/packages/shared/src/types/firestore.ts index b3848e6c3..a2c909417 100644 --- a/packages/shared/src/types/firestore.ts +++ b/packages/shared/src/types/firestore.ts @@ -8,6 +8,13 @@ import { DeliveryQueue, } from "../enums/firestore"; +export interface PrivacyPolicyParams { + prompt: string; + learnMoreLabel: string; + acceptLabel: string; + policy: string; +} + /** * Organization data record included in each organization (other than nested collections) */ diff --git a/packages/shared/src/ui/data.ts b/packages/shared/src/ui/data.ts new file mode 100644 index 000000000..a7e10ee04 --- /dev/null +++ b/packages/shared/src/ui/data.ts @@ -0,0 +1,8 @@ +export const defaultPrivacyPolicy = + "*This is a privacy policy placeholder: plese provide some privacy policy text here...*"; + +export const defaultPrivacyPolicyParams = { + prompt: "By using this app, you accept our privacy policy", + learnMoreLabel: "Learn more", + acceptLabel: "Accept", +}; diff --git a/packages/shared/src/ui/index.ts b/packages/shared/src/ui/index.ts index 6659eb43d..8f96e22e5 100644 --- a/packages/shared/src/ui/index.ts +++ b/packages/shared/src/ui/index.ts @@ -1,2 +1,3 @@ export * from "./enums"; export * from "./hooks"; +export * from "./data"; diff --git a/packages/translations/src/dict/en.json b/packages/translations/src/dict/en.json index 831a857e8..18c48aba1 100644 --- a/packages/translations/src/dict/en.json +++ b/packages/translations/src/dict/en.json @@ -245,6 +245,8 @@ "CustomInterval": "Custom Interval", + "LearnMore": "Learn more", + "Save": "Save", "Back": "Back", "Next": "Next", @@ -261,7 +263,8 @@ "Add": "Add", "Edit": "Edit", "Delete": "Delete", - "Saving": "Saving" + "Saving": "Saving", + "Accept": "Accept" }, "Notification": { diff --git a/packages/translations/src/dict/it.json b/packages/translations/src/dict/it.json index 51e2a2dca..6cc6e13b0 100644 --- a/packages/translations/src/dict/it.json +++ b/packages/translations/src/dict/it.json @@ -246,6 +246,8 @@ "CustomInterval": "Intervallo ad hoc", + "LearnMore": "Scopri di più", + "Save": "Salva", "Back": "Indietro", "Next": "Seguente", @@ -262,7 +264,8 @@ "Edit": "Modifica", "Add": "Aggiungi", "Delete": "Elimina", - "Saving": "Salvataggio" + "Saving": "Salvataggio", + "Accept": "Accetta" }, "Notification": { diff --git a/packages/translations/src/translations.ts b/packages/translations/src/translations.ts index 8a01c4f5e..54e090c1e 100644 --- a/packages/translations/src/translations.ts +++ b/packages/translations/src/translations.ts @@ -259,6 +259,8 @@ export enum ActionButton { CustomInterval = "ActionButton.CustomInterval", + LearnMore = "ActionButton.LearnMore", + Save = "ActionButton.Save", Back = "ActionButton.Back", Next = "ActionButton.Next", @@ -276,6 +278,7 @@ export enum ActionButton { Edit = "ActionButton.Edit", Delete = "ActionButton.Delete", Saving = "ActionButton.Saving", + Accept = "ActionButton.Accept", } // #endregion dialog diff --git a/packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.stories.tsx b/packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.stories.tsx new file mode 100644 index 000000000..c4ac86839 --- /dev/null +++ b/packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.stories.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { ComponentMeta } from "@storybook/react"; + +import PrivacyPolicyToast from "./PrivacyPolicyToast"; + +export default { + title: "Privacy Policy Toast", + component: PrivacyPolicyToast, +} as ComponentMeta; + +export const Default = (): JSX.Element => ; diff --git a/packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.tsx b/packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.tsx new file mode 100644 index 000000000..35cf77cc8 --- /dev/null +++ b/packages/ui/src/PrivacyPolicyToast/PrivacyPolicyToast.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +import Button from "../Button"; +import { type PrivacyPolicyParams } from "@eisbuk/shared"; +import { defaultPrivacyPolicyParams } from "@eisbuk/shared/ui"; + +interface Props { + show?: boolean; + policyParams?: Omit; + onLearnMore?: () => void; + onAccept?: () => void; +} + +const PrivacyPolicyToast: React.FC = ({ + policyParams = defaultPrivacyPolicyParams, + show = true, + onLearnMore = () => {}, + onAccept = () => {}, +}) => { + const { prompt = "", learnMoreLabel = "", acceptLabel = "" } = policyParams; + + if (!show) return null; + + return ( +
+
+

{prompt}

+
+ + +
+
+
+ ); +}; + +export default PrivacyPolicyToast; diff --git a/packages/ui/src/PrivacyPolicyToast/index.ts b/packages/ui/src/PrivacyPolicyToast/index.ts new file mode 100644 index 000000000..e20123b50 --- /dev/null +++ b/packages/ui/src/PrivacyPolicyToast/index.ts @@ -0,0 +1,3 @@ +import PrivacyPolicyToast from "./PrivacyPolicyToast"; + +export default PrivacyPolicyToast; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c0c4fdb34..f44dc792e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,31 +2,33 @@ // in order to generate a PostCSS built css file next to the bundle import "./main.css"; -import EmptySpace from "./EmptySpace"; -import Fallback from "./Fallback"; -import NotificationToast from "./NotificationToast"; -import Button from "./Button"; -import SlotTypeIcon from "./SlotTypeIcon"; -import TabItem from "./TabItem"; -import CalendarNav from "./CalendarNav"; -import Layout from "./Layout"; -import IntervalCard from "./IntervalCard"; -import ActionDialog from "./ActionDialog"; -import IntervalCardGroup from "./IntervalCardGroup"; -import SlotsDayContainer from "./SlotsDayContainer"; -import BookingsCountdown from "./BookingsCountdown"; -import TextInput from "./TextInput"; -import DateInput from "./DateInput"; -import Checkbox from "./Checkbox"; -import HoverText from "./HoverText"; -import IconButton from "./IconButton"; -import TextareaEditable from "./TextareaEditable"; -import Table from "./Table"; -import PhoneInput from "./PhoneInput"; -import Dropdown, { DropdownFormik } from "./Dropdown"; -import CountryCodesDropdown, { +export { default as EmptySpace } from "./EmptySpace"; +export { default as Fallback } from "./Fallback"; +export { default as NotificationToast } from "./NotificationToast"; +export { default as Button } from "./Button"; +export { default as SlotTypeIcon } from "./SlotTypeIcon"; +export { default as TabItem } from "./TabItem"; +export { default as CalendarNav } from "./CalendarNav"; +export { default as Layout } from "./Layout"; +export { default as IntervalCard } from "./IntervalCard"; +export { default as ActionDialog } from "./ActionDialog"; +export { default as IntervalCardGroup } from "./IntervalCardGroup"; +export { default as SlotsDayContainer } from "./SlotsDayContainer"; +export { default as BookingsCountdown } from "./BookingsCountdown"; +export { default as TextInput } from "./TextInput"; +export { default as DateInput } from "./DateInput"; +export { default as Checkbox } from "./Checkbox"; +export { default as HoverText } from "./HoverText"; +export { default as IconButton } from "./IconButton"; +export { default as TextareaEditable } from "./TextareaEditable"; +export { default as Table } from "./Table"; +export { default as PhoneInput } from "./PhoneInput"; +export { default as Dropdown, DropdownFormik } from "./Dropdown"; +export { + default as CountryCodesDropdown, CountryCodesDropdownFormik, } from "./CountryCodesDropdown"; +export { default as PrivacyPolicyToast } from "./PrivacyPolicyToast"; export * from "./UserAvatar"; export * from "./NotificationToast"; @@ -50,31 +52,3 @@ export * from "./SlotCard"; export * from "./Forms/SlotForm"; export * from "./AttendanceSheet"; export * from "./AthletesApproval"; - -export { - NotificationToast, - EmptySpace, - Fallback, - Button, - Layout, - TabItem, - CalendarNav, - SlotTypeIcon, - IntervalCard, - ActionDialog, - IntervalCardGroup, - SlotsDayContainer, - BookingsCountdown, - TextInput, - DateInput, - Checkbox, - HoverText, - IconButton, - TextareaEditable, - Table, - PhoneInput, - Dropdown, - DropdownFormik, - CountryCodesDropdown, - CountryCodesDropdownFormik, -}; From e42db54782a10ffbbb241167566c878944f5ae35 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Mon, 16 Oct 2023 16:16:03 +0200 Subject: [PATCH 03/10] Add a privacy policy page: * Create a '/privacy_policy' route * Install a 'react-markdown' lib to easily style the markdown of the privacy policy * Add styles for 'privacy-policy-md' (formatted markdown) --- common/config/rush/pnpm-lock.yaml | 404 +++++++++++++++++- common/config/rush/repo-state.json | 2 +- packages/client/package.json | 3 +- packages/client/src/AppContent.tsx | 2 + .../src/controllers/PrivacyPolicyToast.tsx | 15 +- packages/client/src/lib/data.ts | 4 - packages/client/src/main.css | 33 ++ .../client/src/pages/privacy_policy/index.tsx | 44 ++ packages/shared/src/types/firestore.ts | 4 + packages/shared/src/ui/data.ts | 74 +++- packages/shared/src/ui/enums/routes.ts | 1 + 11 files changed, 567 insertions(+), 19 deletions(-) create mode 100644 packages/client/src/pages/privacy_policy/index.tsx diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index cc8aea9f7..6f3492bf7 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -143,6 +143,7 @@ importers: react: ^16.13.1 react-dom: ^16.13.1 react-error-boundary: ~4.0.11 + react-markdown: ~9.0.0 react-redux: ^7.2.1 react-router: 5.x react-router-dom: 5.x @@ -200,6 +201,7 @@ importers: react: 16.14.0 react-dom: 16.14.0_react@16.14.0 react-error-boundary: 4.0.11_react@16.14.0 + react-markdown: 9.0.0_94a207699ad2c47f9b967acc6afa228f react-redux: 7.2.9_react-dom@16.14.0+react@16.14.0 react-router: 5.3.4_react@16.14.0 react-router-dom: 5.3.4_react@16.14.0 @@ -5558,7 +5560,6 @@ packages: resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} dependencies: '@types/ms': 0.7.31 - dev: true /@types/detect-port/1.3.2: resolution: {integrity: sha512-xxgAGA2SAU4111QefXPSp5eGbDm/hW6zhvYl9IeEPZEry9F4d66QAHm5qpUXjb6IsevZV/7emAEx5MhP6O192g==} @@ -5649,6 +5650,12 @@ packages: '@types/node': 18.16.15 dev: true + /@types/hast/3.0.1: + resolution: {integrity: sha512-hs/iBJx2aydugBQx5ETV3ZgeSS0oIreQrFJ4bjBl0XvM4wAmDjFEALY7p0rTSLt2eL+ibjRAAs9dTPiCLtmbqQ==} + dependencies: + '@types/unist': 2.0.6 + dev: false + /@types/history/4.7.11: resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} dev: true @@ -5732,6 +5739,12 @@ packages: '@types/unist': 2.0.6 dev: true + /@types/mdast/4.0.1: + resolution: {integrity: sha512-IlKct1rUTJ1T81d8OHzyop15kGv9A/ff7Gz7IJgrk6jDb4Udw77pCJ+vq8oxZf4Ghpm+616+i1s/LNg/Vh7d+g==} + dependencies: + '@types/unist': 3.0.0 + dev: false + /@types/mdurl/1.0.2: resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==} @@ -5758,7 +5771,6 @@ packages: /@types/ms/0.7.31: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} - dev: true /@types/node-fetch/2.6.4: resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==} @@ -5920,7 +5932,10 @@ packages: /@types/unist/2.0.6: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} - dev: true + + /@types/unist/3.0.0: + resolution: {integrity: sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w==} + dev: false /@types/use-sync-external-store/0.0.3: resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} @@ -6176,6 +6191,10 @@ packages: resolution: {integrity: sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==} dev: true + /@ungap/structured-clone/1.2.0: + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + dev: false + /@vitejs/plugin-react/1.3.2: resolution: {integrity: sha512-aurBNmMo0kz1O4qRoY+FM4epSA39y3ShWGuqfLRA/3z0oEJAdtoSfgA3aO98/PCCHAqMaduLxIxErWrVKIFzXA==} engines: {node: '>=12.0.0'} @@ -7057,7 +7076,6 @@ packages: /bail/2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - dev: true /balanced-match/1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -7464,7 +7482,6 @@ packages: /character-entities/2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - dev: true /chardet/0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} @@ -7771,6 +7788,10 @@ packages: dependencies: delayed-stream: 1.0.0 + /comma-separated-tokens/2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + dev: false + /commander/10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -8332,7 +8353,6 @@ packages: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} dependencies: character-entities: 2.0.2 - dev: true /dedent/0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} @@ -8483,7 +8503,6 @@ packages: /dequal/2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - dev: true /destroy/1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} @@ -8526,6 +8545,12 @@ packages: minimist: 1.2.8 dev: true + /devlop/1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dependencies: + dequal: 2.0.3 + dev: false + /dialog-polyfill/0.4.10: resolution: {integrity: sha512-j5yGMkP8T00UFgyO+78OxiN5vC5dzRQF3BEio+LhNvDbyfxWBsi3sfPArDm54VloaJwy2hm3erEiDWqHRC8rzw==} dev: false @@ -10848,6 +10873,26 @@ packages: type-fest: 0.8.1 dev: true + /hast-util-to-jsx-runtime/2.2.0: + resolution: {integrity: sha512-wSlp23N45CMjDg/BPW8zvhEi3R+8eRE1qFbjEyAUzMCzu2l1Wzwakq+Tlia9nkCtEl5mDxa7nKHsvYJ6Gfn21A==} + dependencies: + '@types/hast': 3.0.1 + '@types/unist': 3.0.0 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + property-information: 6.3.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.4 + unist-util-position: 5.0.0 + vfile-message: 4.0.2 + dev: false + + /hast-util-whitespace/3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + dependencies: + '@types/hast': 3.0.1 + dev: false + /he/1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -10921,6 +10966,10 @@ packages: selderee: 0.10.0 dev: false + /html-url-attributes/3.0.0: + resolution: {integrity: sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow==} + dev: false + /htmlparser2/8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} dependencies: @@ -11130,6 +11179,10 @@ packages: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} + /inline-style-parser/0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + dev: false + /inquirer/8.2.5: resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==} engines: {node: '>=12.0.0'} @@ -11416,7 +11469,6 @@ packages: /is-plain-obj/4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - dev: true /is-plain-object/2.0.4: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} @@ -13169,6 +13221,25 @@ packages: - supports-color dev: true + /mdast-util-from-markdown/2.0.0: + resolution: {integrity: sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==} + dependencies: + '@types/mdast': 4.0.1 + '@types/unist': 3.0.0 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.0 + micromark-util-decode-numeric-character-reference: 2.0.0 + micromark-util-decode-string: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /mdast-util-gfm-autolink-literal/1.0.3: resolution: {integrity: sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==} dependencies: @@ -13232,6 +13303,19 @@ packages: unist-util-is: 5.2.1 dev: true + /mdast-util-to-hast/13.0.2: + resolution: {integrity: sha512-U5I+500EOOw9e3ZrclN3Is3fRpw8c19SMyNZlZ2IS+7vLsNzb2Om11VpIVOR+/0137GhZsFEF6YiKD5+0Hr2Og==} + dependencies: + '@types/hast': 3.0.1 + '@types/mdast': 4.0.1 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.0 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + dev: false + /mdast-util-to-markdown/1.5.0: resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==} dependencies: @@ -13255,6 +13339,12 @@ packages: '@types/mdast': 3.0.11 dev: true + /mdast-util-to-string/4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + dependencies: + '@types/mdast': 4.0.1 + dev: false + /mdn-data/2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} dev: true @@ -13329,6 +13419,27 @@ packages: uvu: 0.5.6 dev: true + /micromark-core-commonmark/2.0.0: + resolution: {integrity: sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==} + dependencies: + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.0.1 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-extension-gfm-autolink-literal/1.0.4: resolution: {integrity: sha512-WCssN+M9rUyfHN5zPBn3/f0mIA7tqArHL/EKbv3CZK+LT2rG77FEikIQEqBkv46fOqXQK4NEW/Pc7Z27gshpeg==} dependencies: @@ -13409,6 +13520,14 @@ packages: micromark-util-types: 1.0.2 dev: true + /micromark-factory-destination/2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + dependencies: + micromark-util-character: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-factory-label/1.0.2: resolution: {integrity: sha512-CTIwxlOnU7dEshXDQ+dsr2n+yxpP0+fn271pu0bwDIS8uqfFcumXpj5mLn3hSC8iw2MUr6Gx8EcKng1dD7i6hg==} dependencies: @@ -13418,6 +13537,15 @@ packages: uvu: 0.5.6 dev: true + /micromark-factory-label/2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-factory-space/1.0.0: resolution: {integrity: sha512-qUmqs4kj9a5yBnk3JMLyjtWYN6Mzfcx8uJfi5XAveBniDevmZasdGBba5b4QsvRcAkmvGo5ACmSUmyGiKTLZew==} dependencies: @@ -13425,6 +13553,13 @@ packages: micromark-util-types: 1.0.2 dev: true + /micromark-factory-space/2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + dependencies: + micromark-util-character: 2.0.1 + micromark-util-types: 2.0.0 + dev: false + /micromark-factory-title/1.0.2: resolution: {integrity: sha512-zily+Nr4yFqgMGRKLpTVsNl5L4PMu485fGFDOQJQBl2NFpjGte1e86zC0da93wf97jrc4+2G2GQudFMHn3IX+A==} dependencies: @@ -13435,6 +13570,15 @@ packages: uvu: 0.5.6 dev: true + /micromark-factory-title/2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-factory-whitespace/1.0.0: resolution: {integrity: sha512-Qx7uEyahU1lt1RnsECBiuEbfr9INjQTGa6Err+gF3g0Tx4YEviPbqqGKNv/NrBaE7dVHdn1bVZKM/n5I/Bak7A==} dependencies: @@ -13444,6 +13588,15 @@ packages: micromark-util-types: 1.0.2 dev: true + /micromark-factory-whitespace/2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-util-character/1.1.0: resolution: {integrity: sha512-agJ5B3unGNJ9rJvADMJ5ZiYjBRyDpzKAOk01Kpi1TKhlT1APx3XZk6eN7RtSz1erbWHC2L8T3xLZ81wdtGRZzg==} dependencies: @@ -13451,12 +13604,25 @@ packages: micromark-util-types: 1.0.2 dev: true + /micromark-util-character/2.0.1: + resolution: {integrity: sha512-3wgnrmEAJ4T+mGXAUfMvMAbxU9RDG43XmGce4j6CwPtVxB3vfwXSZ6KhFwDzZ3mZHhmPimMAXg71veiBGzeAZw==} + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-util-chunked/1.0.0: resolution: {integrity: sha512-5e8xTis5tEZKgesfbQMKRCyzvffRRUX+lK/y+DvsMFdabAicPkkZV6gO+FEWi9RfuKKoxxPwNL+dFF0SMImc1g==} dependencies: micromark-util-symbol: 1.0.1 dev: true + /micromark-util-chunked/2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + dependencies: + micromark-util-symbol: 2.0.0 + dev: false + /micromark-util-classify-character/1.0.0: resolution: {integrity: sha512-F8oW2KKrQRb3vS5ud5HIqBVkCqQi224Nm55o5wYLzY/9PwHGXC01tr3d7+TqHHz6zrKQ72Okwtvm/xQm6OVNZA==} dependencies: @@ -13465,6 +13631,14 @@ packages: micromark-util-types: 1.0.2 dev: true + /micromark-util-classify-character/2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + dependencies: + micromark-util-character: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-util-combine-extensions/1.0.0: resolution: {integrity: sha512-J8H058vFBdo/6+AsjHp2NF7AJ02SZtWaVUjsayNFeAiydTxUwViQPxN0Hf8dp4FmCQi0UUFovFsEyRSUmFH3MA==} dependencies: @@ -13472,12 +13646,25 @@ packages: micromark-util-types: 1.0.2 dev: true + /micromark-util-combine-extensions/2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-util-decode-numeric-character-reference/1.0.0: resolution: {integrity: sha512-OzO9AI5VUtrTD7KSdagf4MWgHMtET17Ua1fIpXTpuhclCqD8egFWo85GxSGvxgkGS74bEahvtM0WP0HjvV0e4w==} dependencies: micromark-util-symbol: 1.0.1 dev: true + /micromark-util-decode-numeric-character-reference/2.0.0: + resolution: {integrity: sha512-pIgcsGxpHEtTG/rPJRz/HOLSqp5VTuIIjXlPI+6JSDlK2oljApusG6KzpS8AF0ENUMCHlC/IBb5B9xdFiVlm5Q==} + dependencies: + micromark-util-symbol: 2.0.0 + dev: false + /micromark-util-decode-string/1.0.2: resolution: {integrity: sha512-DLT5Ho02qr6QWVNYbRZ3RYOSSWWFuH3tJexd3dgN1odEuPNxCngTCXJum7+ViRAd9BbdxCvMToPOD/IvVhzG6Q==} dependencies: @@ -13487,26 +13674,55 @@ packages: micromark-util-symbol: 1.0.1 dev: true + /micromark-util-decode-string/2.0.0: + resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.0 + micromark-util-symbol: 2.0.0 + dev: false + /micromark-util-encode/1.0.1: resolution: {integrity: sha512-U2s5YdnAYexjKDel31SVMPbfi+eF8y1U4pfiRW/Y8EFVCy/vgxk/2wWTxzcqE71LHtCuCzlBDRU2a5CQ5j+mQA==} dev: true + /micromark-util-encode/2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + dev: false + /micromark-util-html-tag-name/1.1.0: resolution: {integrity: sha512-BKlClMmYROy9UiV03SwNmckkjn8QHVaWkqoAqzivabvdGcwNGMMMH/5szAnywmsTBUzDsU57/mFi0sp4BQO6dA==} dev: true + /micromark-util-html-tag-name/2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + dev: false + /micromark-util-normalize-identifier/1.0.0: resolution: {integrity: sha512-yg+zrL14bBTFrQ7n35CmByWUTFsgst5JhA4gJYoty4Dqzj4Z4Fr/DHekSS5aLfH9bdlfnSvKAWsAgJhIbogyBg==} dependencies: micromark-util-symbol: 1.0.1 dev: true + /micromark-util-normalize-identifier/2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + dependencies: + micromark-util-symbol: 2.0.0 + dev: false + /micromark-util-resolve-all/1.0.0: resolution: {integrity: sha512-CB/AGk98u50k42kvgaMM94wzBqozSzDDaonKU7P7jwQIuH2RU0TeBqGYJz2WY1UdihhjweivStrJ2JdkdEmcfw==} dependencies: micromark-util-types: 1.0.2 dev: true + /micromark-util-resolve-all/2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + dependencies: + micromark-util-types: 2.0.0 + dev: false + /micromark-util-sanitize-uri/1.1.0: resolution: {integrity: sha512-RoxtuSCX6sUNtxhbmsEFQfWzs8VN7cTctmBPvYivo98xb/kDEoTCtJQX5wyzIYEmk/lvNFTat4hL8oW0KndFpg==} dependencies: @@ -13515,6 +13731,14 @@ packages: micromark-util-symbol: 1.0.1 dev: true + /micromark-util-sanitize-uri/2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + dependencies: + micromark-util-character: 2.0.1 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + dev: false + /micromark-util-subtokenize/1.0.2: resolution: {integrity: sha512-d90uqCnXp/cy4G881Ub4psE57Sf8YD0pim9QdjCRNjfas2M1u6Lbt+XZK9gnHL2XFhnozZiEdCa9CNfXSfQ6xA==} dependencies: @@ -13524,14 +13748,31 @@ packages: uvu: 0.5.6 dev: true + /micromark-util-subtokenize/2.0.0: + resolution: {integrity: sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==} + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + /micromark-util-symbol/1.0.1: resolution: {integrity: sha512-oKDEMK2u5qqAptasDAwWDXq0tG9AssVwAx3E9bBF3t/shRIGsWIRG+cGafs2p/SnDSOecnt6hZPCE2o6lHfFmQ==} dev: true + /micromark-util-symbol/2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + dev: false + /micromark-util-types/1.0.2: resolution: {integrity: sha512-DCfg/T8fcrhrRKTPjRrw/5LLvdGV7BHySf/1LOZx7TzWZdYRjogNtyNq885z3nNallwr3QUKARjqvHqX1/7t+w==} dev: true + /micromark-util-types/2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + dev: false + /micromark/3.1.0: resolution: {integrity: sha512-6Mj0yHLdUZjHnOPgr5xfWIMqMWS12zDN6iws9SLuSz76W8jTtAv24MN4/CL7gJrl5vtxGInkkqDv/JIoRsQOvA==} dependencies: @@ -13556,6 +13797,30 @@ packages: - supports-color dev: true + /micromark/4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + dependencies: + '@types/debug': 4.1.8 + debug: 4.3.4 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.1 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.0 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /micromatch/4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} @@ -14939,6 +15204,10 @@ packages: resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==} dev: false + /property-information/6.3.0: + resolution: {integrity: sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==} + dev: false + /proto-list/1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -15298,6 +15567,29 @@ packages: resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==} dev: true + /react-markdown/9.0.0_94a207699ad2c47f9b967acc6afa228f: + resolution: {integrity: sha512-v6yNf3AB8GfJ8lCpUvzxAXKxgsHpdmWPlcVRQ6Nocsezp255E/IDrF31kLQsPJeB/cKto/geUwjU36wH784FCA==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + dependencies: + '@types/hast': 3.0.1 + '@types/react': 17.0.60 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.2.0 + html-url-attributes: 3.0.0 + mdast-util-to-hast: 13.0.2 + micromark-util-sanitize-uri: 2.0.0 + react: 16.14.0 + remark-parse: 11.0.0 + remark-rehype: 11.0.0 + unified: 11.0.3 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + transitivePeerDependencies: + - supports-color + dev: false + /react-redux/7.2.9_react-dom@16.14.0+react@16.14.0: resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==} peerDependencies: @@ -15598,6 +15890,27 @@ packages: - supports-color dev: true + /remark-parse/11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + dependencies: + '@types/mdast': 4.0.1 + mdast-util-from-markdown: 2.0.0 + micromark-util-types: 2.0.0 + unified: 11.0.3 + transitivePeerDependencies: + - supports-color + dev: false + + /remark-rehype/11.0.0: + resolution: {integrity: sha512-vx8x2MDMcxuE4lBmQ46zYUDfcFMmvg80WYX+UNLeG6ixjdCCLcw1lrgAukwBTuOFsS78eoAedHGn9sNM0w7TPw==} + dependencies: + '@types/hast': 3.0.1 + '@types/mdast': 4.0.1 + mdast-util-to-hast: 13.0.2 + unified: 11.0.3 + vfile: 6.0.1 + dev: false + /remark-slug/6.1.0: resolution: {integrity: sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==} dependencies: @@ -16186,6 +16499,10 @@ packages: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} dev: true + /space-separated-tokens/2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + dev: false + /spawn-wrap/2.0.0: resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==} engines: {node: '>=8'} @@ -16522,6 +16839,12 @@ packages: webpack: 5.84.1 dev: true + /style-to-object/0.4.4: + resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + dependencies: + inline-style-parser: 0.1.1 + dev: false + /stylis/4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} dev: false @@ -17003,6 +17326,10 @@ packages: hasBin: true dev: true + /trim-lines/3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + dev: false + /trim-newlines/3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -17013,7 +17340,6 @@ packages: /trough/2.1.0: resolution: {integrity: sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==} - dev: true /ts-dedent/1.2.0: resolution: {integrity: sha512-6zSJp23uQI+Txyz5LlXMXAHpUhY4Hi0oluXny0OgIR7g/Cromq4vDBnhtbBdyIV34g0pgwxUvnvg+jLJe4c1NA==} @@ -17308,6 +17634,18 @@ packages: vfile: 5.3.7 dev: true + /unified/11.0.3: + resolution: {integrity: sha512-jlCV402P+YDcFcB2VcN/n8JasOddqIiaxv118wNBoZXEhOn+lYG7BR4Bfg2BwxvlK58dwbuH2w7GX2esAjL6Mg==} + dependencies: + '@types/unist': 3.0.0 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.1.0 + vfile: 6.0.1 + dev: false + /unique-filename/2.0.1: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -17338,12 +17676,30 @@ packages: '@types/unist': 2.0.6 dev: true + /unist-util-is/6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + dependencies: + '@types/unist': 3.0.0 + dev: false + + /unist-util-position/5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + dependencies: + '@types/unist': 3.0.0 + dev: false + /unist-util-stringify-position/3.0.3: resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} dependencies: '@types/unist': 2.0.6 dev: true + /unist-util-stringify-position/4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + dependencies: + '@types/unist': 3.0.0 + dev: false + /unist-util-visit-parents/3.1.1: resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==} dependencies: @@ -17358,6 +17714,13 @@ packages: unist-util-is: 5.2.1 dev: true + /unist-util-visit-parents/6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + dependencies: + '@types/unist': 3.0.0 + unist-util-is: 6.0.0 + dev: false + /unist-util-visit/2.0.3: resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==} dependencies: @@ -17374,6 +17737,14 @@ packages: unist-util-visit-parents: 5.1.3 dev: true + /unist-util-visit/5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + dependencies: + '@types/unist': 3.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + dev: false + /universal-analytics/0.5.3: resolution: {integrity: sha512-HXSMyIcf2XTvwZ6ZZQLfxfViRm/yTGoRgDeTbojtq6rezeyKB0sTBcKH2fhddnteAHRcHiKgr/ACpbgjGOC6RQ==} engines: {node: '>=12.18.2'} @@ -17610,6 +17981,13 @@ packages: unist-util-stringify-position: 3.0.3 dev: true + /vfile-message/4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + dependencies: + '@types/unist': 3.0.0 + unist-util-stringify-position: 4.0.0 + dev: false + /vfile/5.3.7: resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} dependencies: @@ -17619,6 +17997,14 @@ packages: vfile-message: 3.1.4 dev: true + /vfile/6.0.1: + resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} + dependencies: + '@types/unist': 3.0.0 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + dev: false + /vite-node/0.31.4_@types+node@18.16.15: resolution: {integrity: sha512-uzL377GjJtTbuc5KQxVbDu2xfU/x0wVjUtXQR2ihS21q/NK6ROr4oG0rsSkBBddZUVCwzfx22in76/0ZZHXgkQ==} engines: {node: '>=v14.18.0'} diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index cc4180ba2..98582b746 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "61ae9a690dfb715f3db40c276c5865936ef37dc4", + "pnpmShrinkwrapHash": "6ba2c5c75d7522e2ad44c04036a43eecc27e6110", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/packages/client/package.json b/packages/client/package.json index c06da93de..4e2929a5a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -43,7 +43,8 @@ "yup": "0.32.9", "variant": "~2.1.0", "firebase": "~9.22.0", - "react-error-boundary": "~4.0.11" + "react-error-boundary": "~4.0.11", + "react-markdown": "~9.0.0" }, "scripts": { "build": "echo \"Skipping @eisbuk/client build as part of rush's bulk 'build' script. To build the app for production run: 'rushx build:prod'\"", diff --git a/packages/client/src/AppContent.tsx b/packages/client/src/AppContent.tsx index 258dd40b1..39f53e8fb 100644 --- a/packages/client/src/AppContent.tsx +++ b/packages/client/src/AppContent.tsx @@ -29,6 +29,7 @@ import AttendancePrintable from "@/pages/attendance_printable"; import DebugPage from "@/pages/debug"; import AdminPreferencesPage from "@/pages/admin_preferences"; import SelfRegister from "@/pages/self_register"; +import PrivacyPolicy from "@/pages/privacy_policy"; import { getIsAdmin } from "@/store/selectors/auth"; @@ -118,6 +119,7 @@ const AppContent: React.FC = () => { +
diff --git a/packages/client/src/controllers/PrivacyPolicyToast.tsx b/packages/client/src/controllers/PrivacyPolicyToast.tsx index 6ffc9d095..d92501212 100644 --- a/packages/client/src/controllers/PrivacyPolicyToast.tsx +++ b/packages/client/src/controllers/PrivacyPolicyToast.tsx @@ -1,16 +1,29 @@ import React from "react"; +import { useHistory, useLocation } from "react-router-dom"; import { useSelector } from "react-redux"; +import { Routes } from "@eisbuk/shared/ui"; import { PrivacyPolicyToast as Toast } from "@eisbuk/ui"; import { getIsAdmin } from "@/store/selectors/auth"; const PrivacyPolicyToast: React.FC = () => { + const history = useHistory(); + const location = useLocation(); + const [accepted, setAccepted] = React.useState(false); + const isPrivacyPolicyRoute = React.useMemo( + () => location.pathname === Routes.PrivacyPolicy, + [location.pathname] + ); const isAdmin = useSelector(getIsAdmin); return ( - setAccepted(true)} /> + history.push(Routes.PrivacyPolicy)} + onAccept={() => setAccepted(true)} + /> ); }; diff --git a/packages/client/src/lib/data.ts b/packages/client/src/lib/data.ts index d73eac203..2541aa77b 100644 --- a/packages/client/src/lib/data.ts +++ b/packages/client/src/lib/data.ts @@ -40,7 +40,3 @@ export const defaultCustomerFormValues: Omit = }; // #endregion CustomerForm - -// #region PrivacyPolicy -export const privacyPolicyLink = "Click here to check out our privacy policy."; -// #endregion PrivacyPolicy diff --git a/packages/client/src/main.css b/packages/client/src/main.css index 2d2860c04..031531568 100644 --- a/packages/client/src/main.css +++ b/packages/client/src/main.css @@ -16,3 +16,36 @@ .center-absolute { @apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2; } + +.privacy-policy-md h1, +h2, +h3, +h4, +h5, +h6 { + @apply font-bold; +} +.privacy-policy-md h1 { + @apply my-12 text-3xl; +} +.privacy-policy-md h2 { + @apply mt-10 mb-6 text-2xl; +} +.privacy-policy-md h3 { + @apply mt-8 mb-6 text-xl; +} + +.privacy-policy-md p { + @apply my-4; +} + +.privacy-policy-md ul, +ol { + @apply ml-12; +} +.privacy-policy-md ul { + @apply list-disc; +} +.privacy-policy-md ol { + @apply list-decimal; +} diff --git a/packages/client/src/pages/privacy_policy/index.tsx b/packages/client/src/pages/privacy_policy/index.tsx new file mode 100644 index 000000000..06109773f --- /dev/null +++ b/packages/client/src/pages/privacy_policy/index.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import ReactMarkdown from "react-markdown"; +import { useHistory } from "react-router-dom"; + +import { LayoutContent } from "@eisbuk/ui"; +import { defaultPrivacyPolicy } from "@eisbuk/shared/ui"; +import { ChevronLeft } from "@eisbuk/svg"; +import { ActionButton, useTranslation } from "@eisbuk/translations"; + +import Layout from "@/controllers/Layout"; + +/** + * Customer area page component + */ +const CustomerArea: React.FC = () => { + const { t } = useTranslation(); + const history = useHistory(); + + const back = () => history.goBack(); + + return ( + + +
+ + + + {defaultPrivacyPolicy} + +
+
+
+ ); +}; + +export default CustomerArea; diff --git a/packages/shared/src/types/firestore.ts b/packages/shared/src/types/firestore.ts index a2c909417..4de3ea184 100644 --- a/packages/shared/src/types/firestore.ts +++ b/packages/shared/src/types/firestore.ts @@ -80,6 +80,10 @@ export interface OrganizationData { * A short (and weak) secret used by new athletes for self registration */ registrationCode?: string; + /** + * Data used for privacy policy compliance prompt (and the policy text iteslf) + */ + privacyPolicy?: PrivacyPolicyParams; } export type EmailTemplate = { diff --git a/packages/shared/src/ui/data.ts b/packages/shared/src/ui/data.ts index a7e10ee04..2ba6f6c45 100644 --- a/packages/shared/src/ui/data.ts +++ b/packages/shared/src/ui/data.ts @@ -1,8 +1,76 @@ -export const defaultPrivacyPolicy = - "*This is a privacy policy placeholder: plese provide some privacy policy text here...*"; - export const defaultPrivacyPolicyParams = { prompt: "By using this app, you accept our privacy policy", learnMoreLabel: "Learn more", acceptLabel: "Accept", }; + +export const defaultPrivacyPolicy = `# Privacy Policy + +## 1. Introduction + +Welcome to [Your Company Name] ("we", "our", or "us"). We are committed to protecting your personal information and your right to privacy. If you have any questions or concerns about our policy, or our practices with regards to your personal information, please contact us at [Insert Contact Information]. + +This Privacy Policy governs the privacy policies and practices of our Website, located at [Insert Website URL]. Please read our Privacy Policy carefully as it will help you make informed decisions about sharing your personal information with us. + +## 2. Information We Collect + +As a Visitor, you can browse our Website to find out more about our Website. You are not required to provide us with any personal information as a Visitor. + +We collect your personal information when you express an interest in obtaining information about us or our products and services, when you participate in activities on our Website or otherwise contacting us. + +Generally, you control the amount and type of information you provide to us when using our Website. The personal information that we collect depends on the context of your interaction with us and the Website, the choices you make and the products and features you use. The personal information we collect can include the following: + +- Name, Email Address, and Contact Data +- Payment Information +- Social Media Login Data +- ... + +## 3. Use of Your Information + +We use the information we receive from you as follows: + +- Improving our Website +- Personalizing our products and services for you +- Communicating with you +- Marketing and promotion +- Compliance with legal obligations +- ... + +## 4. Sharing Your Information + +We do not share, sell, rent, or trade your information with third parties without your consent, except to provide the services you’ve requested, if we are required to do so by law, or in the following circumstances: + +- Business transfers +- Consent +- Legal purposes +- ... + +## 5. Cookies and Other Tracking Technologies + +We use automatically collected information and other information collected on our Service through cookies and similar technologies to: personalize our Service, such as remembering a user’s or visitor’s information input on the Service; monitor and analyze the effectiveness of Service and third-party marketing activities; monitor aggregate site usage metrics such as total number of visitors and pages viewed; and track your entries, submissions, and status in any promotions or other activities on the Service. You can decline the use of cookies by modifying your browser settings. + +## 6. Security of Your Information + +We use administrative, technical, and physical security measures to help protect your personal information. While we have taken reasonable steps to secure the personal information you provide to us, please be aware that despite our efforts, no security measures are perfect or impenetrable, and no method of data transmission can be guaranteed against any interception or other type of misuse. + +## 7. Children’s Privacy + +Our Website does not address anyone under the age of 13. We do not knowingly collect personal identifiable information from children under 13. In the case we discover that a child under 13 has provided us with personal information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us so that we will be able to take necessary actions. + +## 8. External Websites + +Our Website may contain links to external websites that are not operated by us. We have no control over, and assume no responsibility for the content, privacy policies, or practices of any third-party sites, products, or services. + +## 9. Changes to This Privacy Policy + +We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page. + +## 10. Contact Us + +If you have any questions or concerns about our Privacy Policy, please contact us at: +- Email: [Insert Email Address] +- Phone: [Insert Phone Number] +- Address: [Insert Address] + +*Replace the placeholders (inside brackets [ ] ) with actual information to customize this generic privacy policy for your website or application.* +`; diff --git a/packages/shared/src/ui/enums/routes.ts b/packages/shared/src/ui/enums/routes.ts index 6d96fe7f8..23f49316e 100644 --- a/packages/shared/src/ui/enums/routes.ts +++ b/packages/shared/src/ui/enums/routes.ts @@ -8,6 +8,7 @@ export enum Routes { AttendancePrintable = "/attendance_printable", Debug = "/debug", Deleted = "/deleted", + PrivacyPolicy = "/privacy_policy", } /** */ From f88902c502394902eb25717acde96269f2376efc Mon Sep 17 00:00:00 2001 From: ikusteu Date: Tue, 17 Oct 2023 14:22:06 +0200 Subject: [PATCH 04/10] Enable admins to update the privacy policy (1st pass): * Extend admin preferences page to include the privacy policy setup * Load the privacy policy params from the org info * Update 'createPublicOrgInfo' to copy over (to the public org info) the 'policyParams' as well --- .../client/src/__tests__/dataTriggers.test.ts | 16 +++- .../src/controllers/PrivacyPolicyToast.tsx | 3 + .../src/pages/admin_preferences/index.tsx | 24 +++++- .../admin_preferences/views/PrivacyPolicy.tsx | 75 +++++++++++++++++++ .../client/src/pages/privacy_policy/index.tsx | 11 ++- .../client/src/store/selectors/orgInfo.ts | 6 +- packages/functions/src/dataTriggers.ts | 1 + packages/shared/src/types/firestore.ts | 6 +- 8 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx diff --git a/packages/client/src/__tests__/dataTriggers.test.ts b/packages/client/src/__tests__/dataTriggers.test.ts index 92cd2b9de..5f8e3ba61 100644 --- a/packages/client/src/__tests__/dataTriggers.test.ts +++ b/packages/client/src/__tests__/dataTriggers.test.ts @@ -405,12 +405,19 @@ describe("Cloud functions -> Data triggers ->", () => { smsTemplates, existingSecrets: ["authToken", "exampleSecret"], emailBcc: "gus@lospollos.hermanos", + privacyPolicy: { + prompt: "Do you accept", + learnMoreLabel: "Take the blue pill", + acceptLabel: "Take the red pill", + policy: "Wake up Neo, the Matrix has you!", + }, }; // use random string for organization to ensure test is ran in pristine environment each time // but avoid `setUpOrganization()` as we want to set up organization ourselves const organization = uuid(); - const { displayName, location, emailFrom } = organizationData; + const { displayName, location, emailFrom, privacyPolicy } = + organizationData; const publicOrgPath = `${Collection.PublicOrgInfo}/${organization}`; const orgPath = `${Collection.Organizations}/${organization}`; @@ -426,7 +433,12 @@ describe("Cloud functions -> Data triggers ->", () => { // check for publicOrgInfo await waitFor(async () => { const snap = await adminDb.doc(publicOrgPath).get(); - expect(snap.data()).toEqual({ displayName, location, emailFrom }); + expect(snap.data()).toEqual({ + displayName, + location, + emailFrom, + privacyPolicy, + }); }); // test non existence of publicOrgInfo after organization is deleted diff --git a/packages/client/src/controllers/PrivacyPolicyToast.tsx b/packages/client/src/controllers/PrivacyPolicyToast.tsx index d92501212..de73b941e 100644 --- a/packages/client/src/controllers/PrivacyPolicyToast.tsx +++ b/packages/client/src/controllers/PrivacyPolicyToast.tsx @@ -6,6 +6,7 @@ import { Routes } from "@eisbuk/shared/ui"; import { PrivacyPolicyToast as Toast } from "@eisbuk/ui"; import { getIsAdmin } from "@/store/selectors/auth"; +import { getPrivacyPolicy } from "@/store/selectors/orgInfo"; const PrivacyPolicyToast: React.FC = () => { const history = useHistory(); @@ -17,10 +18,12 @@ const PrivacyPolicyToast: React.FC = () => { [location.pathname] ); const isAdmin = useSelector(getIsAdmin); + const policyParams = useSelector(getPrivacyPolicy); return ( history.push(Routes.PrivacyPolicy)} onAccept={() => setAccepted(true)} /> diff --git a/packages/client/src/pages/admin_preferences/index.tsx b/packages/client/src/pages/admin_preferences/index.tsx index 3d6bc0dce..3b90802e6 100644 --- a/packages/client/src/pages/admin_preferences/index.tsx +++ b/packages/client/src/pages/admin_preferences/index.tsx @@ -21,6 +21,10 @@ import { TabItem, LayoutContent, } from "@eisbuk/ui"; +import { + defaultPrivacyPolicyParams, + defaultPrivacyPolicy, +} from "@eisbuk/shared/ui"; import { Cog, Mail } from "@eisbuk/svg"; import Layout from "@/controllers/Layout"; @@ -35,6 +39,7 @@ import { isEmpty } from "@/utils/helpers"; import EmailTemplateSettings from "./views/EmailTemplateSettings"; import GeneralSettings from "./views/GeneralSettings"; import SMSTemplateSettings from "./views/SMSTemplateSettings"; +import PrivacyPolicy from "./views/PrivacyPolicy"; // #region validations const OrganizationValidation = Yup.object().shape({ @@ -50,6 +55,7 @@ const OrganizationSettings: React.FC = () => { GeneralSettings = "GeneralSettings", EmailTemplates = "EmailTemplates", SMSTemplates = "SMSTemplates", + PrivacyPolicy = "PrivacyPolicy", } // Get appropriate view to render @@ -57,6 +63,7 @@ const OrganizationSettings: React.FC = () => { [View.GeneralSettings]: GeneralSettings, [View.EmailTemplates]: EmailTemplateSettings, [View.SMSTemplates]: SMSTemplateSettings, + [View.PrivacyPolicy]: PrivacyPolicy, }; const [view, setView] = useState( View.GeneralSettings @@ -111,6 +118,13 @@ const OrganizationSettings: React.FC = () => { onClick={() => setView(View.SMSTemplates)} active={view === View.SMSTemplates} /> + setView(View.PrivacyPolicy)} + active={view === View.PrivacyPolicy} + /> ); @@ -155,10 +169,14 @@ const OrganizationSettings: React.FC = () => {
) : view === View.EmailTemplates ? ( - ) : ( + ) : view === View.SMSTemplates ? (
+ ) : ( +
+ +
)} )} @@ -179,6 +197,10 @@ const emptyValues = { smsFrom: "", smsTemplates, emailBcc: "", + privacyPolicy: { + ...defaultPrivacyPolicyParams, + policy: defaultPrivacyPolicy, + }, }; export default OrganizationSettings; diff --git a/packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx b/packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx new file mode 100644 index 000000000..a11e7ea76 --- /dev/null +++ b/packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { useFormikContext } from "formik"; +import ReactMarkdown from "react-markdown"; + +import { + FormField, + FormFieldVariant, + FormSection, + PrivacyPolicyToast, +} from "@eisbuk/ui"; +import { MessageTemplateLabel, useTranslation } from "@eisbuk/translations"; +import { OrganizationData } from "@eisbuk/shared"; + +const EmailTemplateSettings: React.FC = () => { + const { t } = useTranslation(); + + const { + values: { + privacyPolicy: { policy, ...privacyPolicyPrompt }, + }, + } = useFormikContext>(); + + return ( +
+ +
+ +
+
+ +
+
+ +
+
+
Preview
+ +
+
+ +
+ +
+ +
+ +
+

+ {t(MessageTemplateLabel.Preview)} +

+ {policy} +
+
+ ); +}; + +export default EmailTemplateSettings; diff --git a/packages/client/src/pages/privacy_policy/index.tsx b/packages/client/src/pages/privacy_policy/index.tsx index 06109773f..f66248a6f 100644 --- a/packages/client/src/pages/privacy_policy/index.tsx +++ b/packages/client/src/pages/privacy_policy/index.tsx @@ -1,12 +1,15 @@ import React from "react"; import ReactMarkdown from "react-markdown"; import { useHistory } from "react-router-dom"; +import { useSelector } from "react-redux"; import { LayoutContent } from "@eisbuk/ui"; import { defaultPrivacyPolicy } from "@eisbuk/shared/ui"; import { ChevronLeft } from "@eisbuk/svg"; import { ActionButton, useTranslation } from "@eisbuk/translations"; +import { getPrivacyPolicy } from "@/store/selectors/orgInfo"; + import Layout from "@/controllers/Layout"; /** @@ -16,6 +19,10 @@ const CustomerArea: React.FC = () => { const { t } = useTranslation(); const history = useHistory(); + const { policy } = useSelector(getPrivacyPolicy) || { + policy: defaultPrivacyPolicy, + }; + const back = () => history.goBack(); return ( @@ -32,9 +39,7 @@ const CustomerArea: React.FC = () => { {t(ActionButton.Back)} - - {defaultPrivacyPolicy} - + {policy} diff --git a/packages/client/src/store/selectors/orgInfo.ts b/packages/client/src/store/selectors/orgInfo.ts index a55bf6d25..24820f502 100644 --- a/packages/client/src/store/selectors/orgInfo.ts +++ b/packages/client/src/store/selectors/orgInfo.ts @@ -1,6 +1,7 @@ -import { getOrganization } from "@/lib/getters"; import { LocalStore } from "@/types/store"; +import { getOrganization } from "@/lib/getters"; + const getPublicOrgInfo = (state: LocalStore) => state.firestore.data.publicOrgInfo ? state.firestore.data.publicOrgInfo[getOrganization()] @@ -14,3 +15,6 @@ export const getOrgEmail = (state: LocalStore) => export const getDefaultCountryCode = (state: LocalStore) => getPublicOrgInfo(state)?.defaultCountryCode; + +export const getPrivacyPolicy = (state: LocalStore) => + getPublicOrgInfo(state)?.privacyPolicy; diff --git a/packages/functions/src/dataTriggers.ts b/packages/functions/src/dataTriggers.ts index f11c1f87b..8057eae4b 100644 --- a/packages/functions/src/dataTriggers.ts +++ b/packages/functions/src/dataTriggers.ts @@ -356,6 +356,7 @@ export const createPublicOrgInfo = functions "location", "emailFrom", "defaultCountryCode", + "privacyPolicy", ].reduce( (acc, curr) => (orgData[curr] ? { ...acc, [curr]: orgData[curr] } : acc), {} diff --git a/packages/shared/src/types/firestore.ts b/packages/shared/src/types/firestore.ts index 4de3ea184..b75768c7b 100644 --- a/packages/shared/src/types/firestore.ts +++ b/packages/shared/src/types/firestore.ts @@ -94,7 +94,11 @@ export type EmailTemplate = { /** Organization data copied over to a new collection shared publicly */ export type PublicOrganizationData = Pick< OrganizationData, - "displayName" | "location" | "emailFrom" | "defaultCountryCode" + | "displayName" + | "location" + | "emailFrom" + | "defaultCountryCode" + | "privacyPolicy" >; // #endregion organizations From 202b41c2e99f32f5216fa2214ad3433b1a24db9b Mon Sep 17 00:00:00 2001 From: ikusteu Date: Tue, 17 Oct 2023 15:37:04 +0200 Subject: [PATCH 05/10] Enable for customers to accept the privacy policy: * Show the privacy policy toast only in the customer area (and only if not yet accepted) * Update customer structure in the db to include 'privacyPolicyAccepted' field (with the timestamp) * Create an `acceptPrivacyPolicy` cloud function (to bypass admin-only access) * Create an `acceptPrivacyPolicy` thunk (handling cloud function call and notification functionality) --- packages/client/src/AppContent.tsx | 2 - .../src/__tests__/cloudFunctions.test.ts | 104 ++++++++++++++++++ .../src/controllers/PrivacyPolicyToast.tsx | 33 ------ .../PrivacyPolicyToast/PrivacyPolicyToast.tsx | 43 ++++++++ .../controllers/PrivacyPolicyToast/index.ts | 3 + .../client/src/pages/customer_area/index.tsx | 6 +- .../__tests__/bookingOperations.test.ts | 68 ++++++++++++ .../src/store/actions/bookingOperations.ts | 42 +++++++ packages/functions/src/https.ts | 58 ++++++++++ packages/shared/src/types/firestore.ts | 3 + packages/shared/src/ui/enums/misc.ts | 1 + packages/translations/src/dict/en.json | 3 + packages/translations/src/dict/it.json | 3 + packages/translations/src/translations.ts | 2 + 14 files changed, 335 insertions(+), 36 deletions(-) delete mode 100644 packages/client/src/controllers/PrivacyPolicyToast.tsx create mode 100644 packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx create mode 100644 packages/client/src/controllers/PrivacyPolicyToast/index.ts diff --git a/packages/client/src/AppContent.tsx b/packages/client/src/AppContent.tsx index 39f53e8fb..efa1bd5ea 100644 --- a/packages/client/src/AppContent.tsx +++ b/packages/client/src/AppContent.tsx @@ -16,7 +16,6 @@ import { getOrganization } from "@/lib/getters"; import PrivateRoute from "@/components/auth/PrivateRoute"; import Deleted from "@/components/auth/Deleted"; import LoginRoute from "@/components/auth/LoginRoute"; -import PrivacyPolicyToast from "@/controllers/PrivacyPolicyToast"; import AttendancePage from "@/pages/attendance"; import AthletesPage from "@/pages/customers"; @@ -123,7 +122,6 @@ const AppContent: React.FC = () => {
-
diff --git a/packages/client/src/__tests__/cloudFunctions.test.ts b/packages/client/src/__tests__/cloudFunctions.test.ts index 7178c94de..632b413a4 100644 --- a/packages/client/src/__tests__/cloudFunctions.test.ts +++ b/packages/client/src/__tests__/cloudFunctions.test.ts @@ -32,6 +32,7 @@ import { testWithEmulator } from "@/__testUtils__/envUtils"; import { waitFor } from "@/__testUtils__/helpers"; import { saul } from "@eisbuk/testing/customers"; +import { DateTime } from "luxon"; describe("Cloud functions", () => { describe("ping", () => { @@ -293,6 +294,109 @@ describe("Cloud functions", () => { ); }); + describe("acceptPrivacyPolicy", () => { + testWithEmulator( + "should store the timestamp of confirmation to the customer's structure in the db", + async () => { + // set up test state + const { organization } = await setUpOrganization(); + const saulRef = adminDb.doc(getCustomerDocPath(organization, saul.id)); + await saulRef.set(saul); + // wait for bookings to get created (through data trigger) + await waitFor(() => + adminDb.doc(getBookingsDocPath(organization, saul.secretKey)).get() + ); + // Timestamp used for the test, the actual time doesn't matter, + // only that it's stord in the db after the function is ran. + const timestamp = DateTime.now().toISO(); + // run the function + await httpsCallable( + functions, + CloudFunction.AcceptPrivacyPolicy + )({ + id: saul.id, + organization, + secretKey: saul.secretKey, + timestamp, + }); + const customerSnap = await saulRef.get(); + expect(customerSnap.data()?.privacyPolicyAccepted).toEqual({ + timestamp, + }); + // wait for the bookings data to update + await waitFor(async () => { + const bookingsSnap = await adminDb + .doc(getBookingsDocPath(organization, saul.secretKey)) + .get(); + // The privacy policy confirmation timestamp should be stored in the db + expect(bookingsSnap.data()?.privacyPolicyAccepted).toEqual({ + timestamp, + }); + }); + } + ); + + testWithEmulator( + "should return an error if no payload provided", + async () => { + await expect( + httpsCallable(functions, CloudFunction.AcceptPrivacyPolicy)() + ).rejects.toThrow(HTTPSErrors.NoPayload); + } + ); + + testWithEmulator( + "should return an error if no organziation, id, secretKey or timestamp provided", + async () => { + try { + await httpsCallable(functions, CloudFunction.AcceptPrivacyPolicy)({}); + } catch (error) { + expect((error as FunctionsError).message).toEqual( + `${HTTPSErrors.MissingParameter}: id, organization, secretKey, timestamp` + ); + } + } + ); + + testWithEmulator( + "should return an error if customer id and secretKey mismatch", + async () => { + const { organization } = await setUpOrganization(); + const saulRef = adminDb.doc(getCustomerDocPath(organization, saul.id)); + await saulRef.set(saul); + await expect( + httpsCallable( + functions, + CloudFunction.AcceptPrivacyPolicy + )({ + organization, + id: saul.id, + secretKey: "wrong-key", + timestamp: DateTime.now().toISO(), + }) + ).rejects.toThrow(BookingsErrors.SecretKeyMismatch); + } + ); + + testWithEmulator( + "should return an error if customer not found", + async () => { + const { organization } = await setUpOrganization(); + await expect( + httpsCallable( + functions, + CloudFunction.AcceptPrivacyPolicy + )({ + organization, + id: saul.id, + secretKey: saul.secretKey, + timestamp: DateTime.now().toISO(), + }) + ).rejects.toThrow(BookingsErrors.CustomerNotFound); + } + ); + }); + describe("customerSelfUpdate", () => { testWithEmulator( "should update customer data in customer collection and then bookings collection by data trigger", diff --git a/packages/client/src/controllers/PrivacyPolicyToast.tsx b/packages/client/src/controllers/PrivacyPolicyToast.tsx deleted file mode 100644 index de73b941e..000000000 --- a/packages/client/src/controllers/PrivacyPolicyToast.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import { useHistory, useLocation } from "react-router-dom"; -import { useSelector } from "react-redux"; - -import { Routes } from "@eisbuk/shared/ui"; -import { PrivacyPolicyToast as Toast } from "@eisbuk/ui"; - -import { getIsAdmin } from "@/store/selectors/auth"; -import { getPrivacyPolicy } from "@/store/selectors/orgInfo"; - -const PrivacyPolicyToast: React.FC = () => { - const history = useHistory(); - const location = useLocation(); - - const [accepted, setAccepted] = React.useState(false); - const isPrivacyPolicyRoute = React.useMemo( - () => location.pathname === Routes.PrivacyPolicy, - [location.pathname] - ); - const isAdmin = useSelector(getIsAdmin); - const policyParams = useSelector(getPrivacyPolicy); - - return ( - history.push(Routes.PrivacyPolicy)} - onAccept={() => setAccepted(true)} - /> - ); -}; - -export default PrivacyPolicyToast; diff --git a/packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx b/packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx new file mode 100644 index 000000000..696bf1f00 --- /dev/null +++ b/packages/client/src/controllers/PrivacyPolicyToast/PrivacyPolicyToast.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { useHistory } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; + +import { Routes } from "@eisbuk/shared/ui"; +import { PrivacyPolicyToast as Toast } from "@eisbuk/ui"; + +import { getIsAdmin } from "@/store/selectors/auth"; +import { getPrivacyPolicy } from "@/store/selectors/orgInfo"; +import { getBookingsCustomer } from "@/store/selectors/bookings"; +import { getSecretKey } from "@/store/selectors/app"; + +import { acceptPrivacyPolicy } from "@/store/actions/bookingOperations"; + +const PrivacyPolicyToast: React.FC = () => { + const history = useHistory(); + + const policyParams = useSelector(getPrivacyPolicy); + + const bookingsCustomer = useSelector(getBookingsCustomer) || {}; + const secretKey = useSelector(getSecretKey) || ""; + + const policyAccepted = Boolean(bookingsCustomer.privacyPolicyAccepted); + const isAdmin = useSelector(getIsAdmin); + + const dispatch = useDispatch(); + + const accept = () => + dispatch(acceptPrivacyPolicy({ ...bookingsCustomer, secretKey })); + + return ( + history.push(Routes.PrivacyPolicy)} + onAccept={accept} + /> + ); +}; + +export default PrivacyPolicyToast; diff --git a/packages/client/src/controllers/PrivacyPolicyToast/index.ts b/packages/client/src/controllers/PrivacyPolicyToast/index.ts new file mode 100644 index 000000000..e20123b50 --- /dev/null +++ b/packages/client/src/controllers/PrivacyPolicyToast/index.ts @@ -0,0 +1,3 @@ +import PrivacyPolicyToast from "./PrivacyPolicyToast"; + +export default PrivacyPolicyToast; diff --git a/packages/client/src/pages/customer_area/index.tsx b/packages/client/src/pages/customer_area/index.tsx index 7b524c196..c129c9a51 100644 --- a/packages/client/src/pages/customer_area/index.tsx +++ b/packages/client/src/pages/customer_area/index.tsx @@ -19,9 +19,9 @@ import BookView from "./views/Book"; import CalendarView from "./views/Calendar"; import ProfileView from "./views/Profile"; import { useSecretKey, useDate } from "./hooks"; +import PrivacyPolicyToast from "@/controllers/PrivacyPolicyToast"; import ErrorBoundary from "@/components/atoms/ErrorBoundary"; -// import AddToCalendar from "@/components/atoms/AddToCalendar"; import Layout from "@/controllers/Layout"; @@ -113,6 +113,10 @@ const CustomerArea: React.FC = () => { + +
+ +
); }; diff --git a/packages/client/src/store/actions/__tests__/bookingOperations.test.ts b/packages/client/src/store/actions/__tests__/bookingOperations.test.ts index b8e3f4ccc..618344c34 100644 --- a/packages/client/src/store/actions/__tests__/bookingOperations.test.ts +++ b/packages/client/src/store/actions/__tests__/bookingOperations.test.ts @@ -29,6 +29,7 @@ import { updateBookingNotes, customerSelfUpdate, customerSelfRegister, + acceptPrivacyPolicy, } from "../bookingOperations"; import { enqueueNotification } from "@/features/notifications/actions"; @@ -528,4 +529,71 @@ describe("Booking operations", () => { } ); }); + + describe("'acceptPrivacyPolicy'", () => { + testWithEmulator( + "should store timestamp of policy acceptance", + async () => { + // set up initial state + const store = getNewStore(); + const { organization, db } = await getTestEnv({ + auth: false, + setup: (db, { organization }) => + setupTestCustomer({ + db, + customer: saul, + organization, + store, + }), + }); + const mockDispatch = vi.fn(); + // make sure tested thunk uses test generated organization + getOrganizationSpy.mockReturnValue(organization); + // create a thunk curried with test input values + const testThunk = acceptPrivacyPolicy(saul); + const timestampDate = DateTime.now().toISO().slice(0, 10); + // test updating of the db using created thunk and middleware args from stores' setup + await runThunk(testThunk, mockDispatch, store.getState); + // Check updates + await waitFor(async () => { + const bookingsSnap = await getDoc( + doc(db, getBookingsDocPath(organization, saul.secretKey)) + ); + expect(bookingsSnap.data()?.privacyPolicyAccepted).toEqual({ + timestamp: expect.stringContaining(timestampDate), + }); + }); + expect(mockDispatch).toHaveBeenCalledWith( + enqueueNotification({ + message: i18n.t(NotificationMessage.SelectionSaved), + variant: NotifVariant.Success, + }) + ); + } + ); + + testWithEmulator( + "should enqueue error notification if operation failed", + async () => { + // intentionally cause an error + const testError = new Error("test"); + const getFunctions = () => { + throw testError; + }; + // run the thunk + const testThunk = acceptPrivacyPolicy(saul); + const mockDispatch = vi.fn(); + await runThunk(testThunk, mockDispatch, () => ({} as any), { + getFunctions, + }); + expect(mockDispatch).toHaveBeenCalledWith( + enqueueNotification({ + message: i18n.t(NotificationMessage.Error), + variant: NotifVariant.Error, + error: testError, + }) + ); + } + ); + }); }); diff --git a/packages/client/src/store/actions/bookingOperations.ts b/packages/client/src/store/actions/bookingOperations.ts index c52fb4cd4..a77f3cb6b 100644 --- a/packages/client/src/store/actions/bookingOperations.ts +++ b/packages/client/src/store/actions/bookingOperations.ts @@ -229,3 +229,45 @@ export const customerSelfRegister: { return { id: "", secretKey: "", codeOk: false }; } }; + +/** + * Updates `privacyPolicyAccepted` field in customer document (as well as in bookings copy) + * @param payload.customer {Customer} - cutomer type + * @returns FirestoreThunk + */ +export const acceptPrivacyPolicy: { + (paylod: Customer): FirestoreThunk; +} = + (customer) => + async (dispatch, _, { getFunctions }) => { + try { + const organization = getOrganization(); + + const { id, secretKey } = customer; + const handler = CloudFunction.AcceptPrivacyPolicy; + const timestamp = DateTime.now().toISO(); + const payload = { + organization, + id, + secretKey, + timestamp, + }; + + await createFunctionCaller(getFunctions(), handler, payload)(); + + dispatch( + enqueueNotification({ + variant: NotifVariant.Success, + message: i18n.t(NotificationMessage.SelectionSaved), + }) + ); + } catch (err) { + dispatch( + enqueueNotification({ + variant: NotifVariant.Error, + message: i18n.t(NotificationMessage.Error), + error: err as Error, + }) + ); + } + }; diff --git a/packages/functions/src/https.ts b/packages/functions/src/https.ts index a6e510746..bde59c0c7 100644 --- a/packages/functions/src/https.ts +++ b/packages/functions/src/https.ts @@ -199,3 +199,61 @@ To verify the athlete, add them to a category/categories on their respective pro return fullCustomer; } ); + +/** + * This should be triggered (by https request) when the customer clicks 'accept' (or an equivalent) on + * the privacy policy prompt. + */ +export const acceptPrivacyPolicy = functions + .region("europe-west6") + .https.onCall(async (payload) => { + checkRequiredFields(payload, [ + "id", + "organization", + "secretKey", + // The timestamp is passed in by the caller to ensure the user's time is used, not the server + // time, which can be in a different timezone + "timestamp", + ]); + + const { id, organization, secretKey, timestamp } = + (payload as { + id: string; + organization: string; + secretKey: string; + timestamp: string; + }) || {}; + + // we check "auth" by matching secretKey with customerId + const customerRef = admin + .firestore() + .collection(Collection.Organizations) + .doc(organization) + .collection(OrgSubCollection.Customers) + .doc(id); + + const customerInStore = await customerRef.get(); + + if (!customerInStore.exists) { + throw new functions.https.HttpsError( + "not-found", + BookingsErrors.CustomerNotFound + ); + } + + const { secretKey: existingSecretKey } = + customerInStore.data() as CustomerFull; + + if (secretKey !== existingSecretKey) { + throw new functions.https.HttpsError( + "invalid-argument", + BookingsErrors.SecretKeyMismatch + ); + } + + // Store the accepted privacy policy timestamp to the customer structure + await customerRef.set( + { privacyPolicyAccepted: { timestamp } }, + { merge: true } + ); + }); diff --git a/packages/shared/src/types/firestore.ts b/packages/shared/src/types/firestore.ts index b75768c7b..7d78b313b 100644 --- a/packages/shared/src/types/firestore.ts +++ b/packages/shared/src/types/firestore.ts @@ -227,6 +227,9 @@ export interface CustomerBase { birthday?: string; certificateExpiration?: string; photoURL?: string; + privacyPolicyAccepted?: { + timestamp: string; + }; } /** * A standard customer entry available to both the customer themself as well as to the admin's full profile view diff --git a/packages/shared/src/ui/enums/misc.ts b/packages/shared/src/ui/enums/misc.ts index c1543b38f..fdce81b9d 100644 --- a/packages/shared/src/ui/enums/misc.ts +++ b/packages/shared/src/ui/enums/misc.ts @@ -6,6 +6,7 @@ export enum CloudFunction { QueryAuthStatus = "queryAuthStatus", FinalizeBookings = "finalizeBookings", + AcceptPrivacyPolicy = "acceptPrivacyPolicy", CustomerSelfRegister = "customerSelfRegister", CustomerSelfUpdate = "customerSelfUpdate", diff --git a/packages/translations/src/dict/en.json b/packages/translations/src/dict/en.json index 18c48aba1..892238cba 100644 --- a/packages/translations/src/dict/en.json +++ b/packages/translations/src/dict/en.json @@ -306,6 +306,9 @@ "SelfRegSuccess": "Registration successful", "SelfRegError": "Error registering", + + "SelectionSaved": "Selection saved", + "CustomerProfileUpdated": "Profile Updated", "CustomerProfileError": "Error updating profile", "CustomerUpdated": "Customer Profile Updated: {{ name }} {{ surname }}", diff --git a/packages/translations/src/dict/it.json b/packages/translations/src/dict/it.json index 6cc6e13b0..7bff2b662 100644 --- a/packages/translations/src/dict/it.json +++ b/packages/translations/src/dict/it.json @@ -307,6 +307,9 @@ "SelfRegSuccess": "Registrazione completata", "SelfRegError": "Errore durante la registrazione", + + "SelectionSaved": "Selection saved", + "CustomerProfileUpdated": "Profilo aggiornato", "CustomerProfileError": "Errore durante l'aggiornamento del profilo", "CustomerUpdated": "Profilo cliente aggiornato: {{ name }} {{ surname }}", diff --git a/packages/translations/src/translations.ts b/packages/translations/src/translations.ts index 54e090c1e..93f70c32b 100644 --- a/packages/translations/src/translations.ts +++ b/packages/translations/src/translations.ts @@ -329,6 +329,8 @@ export enum NotificationMessage { SelfRegSuccess = "Notification.SelfRegSuccess", SelfRegError = "Notification.SelfRegError", + SelectionSaved = "Notification.SelectionSaved", + CustomerProfileUpdated = "Notification.CustomerProfileUpdated", CustomerProfileError = "Notification.CustomerProfileUpdated", CustomerUpdated = "Notification.CustomerUpdated", From 6203b03cbe465fa6f4df46edb5c4d87aa0df08b3 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Tue, 17 Oct 2023 22:11:39 +0200 Subject: [PATCH 06/10] Silence TS on cjs/esm mismatch --- .../src/pages/admin_preferences/views/PrivacyPolicy.tsx | 3 +++ packages/client/src/pages/privacy_policy/index.tsx | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx b/packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx index a11e7ea76..e9b708aa0 100644 --- a/packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx +++ b/packages/client/src/pages/admin_preferences/views/PrivacyPolicy.tsx @@ -1,5 +1,8 @@ import React from "react"; import { useFormikContext } from "formik"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore import ReactMarkdown from "react-markdown"; import { diff --git a/packages/client/src/pages/privacy_policy/index.tsx b/packages/client/src/pages/privacy_policy/index.tsx index f66248a6f..54c128062 100644 --- a/packages/client/src/pages/privacy_policy/index.tsx +++ b/packages/client/src/pages/privacy_policy/index.tsx @@ -1,8 +1,11 @@ import React from "react"; -import ReactMarkdown from "react-markdown"; import { useHistory } from "react-router-dom"; import { useSelector } from "react-redux"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import ReactMarkdown from "react-markdown"; + import { LayoutContent } from "@eisbuk/ui"; import { defaultPrivacyPolicy } from "@eisbuk/shared/ui"; import { ChevronLeft } from "@eisbuk/svg"; From d2712746488a8b5a5dd48df249ebb448d616d1b2 Mon Sep 17 00:00:00 2001 From: ikusteu Date: Thu, 19 Oct 2023 09:57:21 +0200 Subject: [PATCH 07/10] Attempt to fix unit test flakiness - split the booking -> attendance data trigger test: * Test the booking being added trigger * Test the booking being removed trigger --- .../client/src/__tests__/dataTriggers.test.ts | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/packages/client/src/__tests__/dataTriggers.test.ts b/packages/client/src/__tests__/dataTriggers.test.ts index 5f8e3ba61..38d48c910 100644 --- a/packages/client/src/__tests__/dataTriggers.test.ts +++ b/packages/client/src/__tests__/dataTriggers.test.ts @@ -40,54 +40,84 @@ const testMonth = testDate.substring(0, 7); describe("Cloud functions -> Data triggers ->", () => { describe("createAttendanceForBooking", () => { + const baseAttendance = { + date: baseSlot.date, + attendances: { + ["dummy-customer"]: { + bookedInterval: Object.keys(baseSlot.intervals)[0], + attendedInterval: Object.keys(baseSlot.intervals)[1], + }, + }, + }; + + const bookedSlot = { + data: baseSlot.date, + interval: Object.keys(baseSlot.intervals)[0], + }; + + const attendanceWithTestBooking = { + ...baseAttendance, + attendances: { + ...baseAttendance.attendances, + [saul.id]: { + bookedInterval: bookedSlot.interval, + attendedInterval: bookedSlot.interval, + }, + }, + }; + testWithEmulator( "should create attendance entry for booking and not overwrite existing data in slot", async () => { const { organization } = await setUpOrganization(); - // set up Saul's bookings entry - await adminDb - .doc(getBookingsDocPath(organization, saul.secretKey)) - .set(sanitizeCustomer(saul)); - // set up dummy data in the base slot, not to be overwritten by Saul's attendance - const baseAttendance = { - date: baseSlot.date, - attendances: { - ["dummy-customer"]: { - bookedInterval: Object.keys(baseSlot.intervals)[0], - attendedInterval: Object.keys(baseSlot.intervals)[1], - }, - }, - }; - await adminDb - .doc(getAttendanceDocPath(organization, baseSlot.id)) - .set(baseAttendance); + await Promise.all([ + // set up Saul's bookings entry + adminDb + .doc(getBookingsDocPath(organization, saul.secretKey)) + .set(sanitizeCustomer(saul)), + // set up dummy data in the base slot, not to be overwritten by Saul's attendance + adminDb + .doc(getAttendanceDocPath(organization, baseSlot.id)) + .set(baseAttendance), + ]); // add new booking trying to trigger attendance entry - const bookedSlotDocRef = adminDb.doc( - getBookedSlotDocPath(organization, saul.secretKey, baseSlot.id) - ); - const bookedSlot = { - data: baseSlot.date, - interval: Object.keys(baseSlot.intervals)[0], - }; - await bookedSlotDocRef.set(bookedSlot); + await adminDb + .doc(getBookedSlotDocPath(organization, saul.secretKey, baseSlot.id)) + .set(bookedSlot); // check proper updates triggerd by write to bookings await waitFor(async () => { const snap = await adminDb .doc(getAttendanceDocPath(organization, baseSlot.id)) .get(); - expect(snap.data()).toEqual({ - ...baseAttendance, - attendances: { - ...baseAttendance.attendances, - [saul.id]: { - bookedInterval: bookedSlot.interval, - attendedInterval: bookedSlot.interval, - }, - }, - }); + expect(snap.data()).toEqual(attendanceWithTestBooking); }); - // test customer's attendnace being removed from slot's attendnace - await bookedSlotDocRef.delete(); + } + ); + + testWithEmulator( + "should remove the attendance entry for booking when the booking is deleted", + async () => { + const { organization } = await setUpOrganization(); + await Promise.all([ + // set up Saul's bookings entry + adminDb + .doc(getBookingsDocPath(organization, saul.secretKey)) + .set(sanitizeCustomer(saul)), + // add the booked slot + adminDb + .doc( + getBookedSlotDocPath(organization, saul.secretKey, baseSlot.id) + ) + .set(bookedSlot), + // set up dummy data in the base slot, including the booked slot + adminDb + .doc(getAttendanceDocPath(organization, baseSlot.id)) + .set(attendanceWithTestBooking), + ]); + // deleting the booking should remove it from attendance doc + await adminDb + .doc(getBookingsDocPath(organization, saul.secretKey)) + .delete(); await waitFor(async () => { const snap = await adminDb .doc(getAttendanceDocPath(organization, baseSlot.id)) From 9449602501d289bca459e75d9a85dbbca9e7382e Mon Sep 17 00:00:00 2001 From: ikusteu Date: Thu, 19 Oct 2023 10:21:42 +0200 Subject: [PATCH 08/10] Try and fix cypress flakiness by removing elements obscuring the view: * In athlete self update tests: remove the privacy policy toast (by setting up athlete as having accepted the policy) * Don't display the emulators warning in test builds --- packages/client/src/AppContent.tsx | 9 +++++++-- packages/client/src/lib/constants.ts | 2 ++ packages/e2e/integration/athlete_self_update.ts | 10 ++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/client/src/AppContent.tsx b/packages/client/src/AppContent.tsx index efa1bd5ea..be76107f1 100644 --- a/packages/client/src/AppContent.tsx +++ b/packages/client/src/AppContent.tsx @@ -11,6 +11,8 @@ import { useFirestoreSubscribe, } from "@eisbuk/react-redux-firebase-firestore"; +import { __isDevStrict__ } from "@/lib/constants"; + import { getOrganization } from "@/lib/getters"; import PrivateRoute from "@/components/auth/PrivateRoute"; @@ -60,7 +62,10 @@ const AppContent: React.FC = () => { document.querySelector("p.firebase-emulator-warning")?.remove(); }, []); - const isDev = process.env.NODE_ENV === "development"; + // We're showing the emulators warning strictly in "development" mode, + // not in test mode (which can loosely be considered dev mode) so as to not obscure the + // parts of the UI in cypress tests. + const showEmulatorsWarining = __isDevStrict__; return ( <> @@ -122,7 +127,7 @@ const AppContent: React.FC = () => {
- +
); diff --git a/packages/client/src/lib/constants.ts b/packages/client/src/lib/constants.ts index ea28e55da..b32030c39 100644 --- a/packages/client/src/lib/constants.ts +++ b/packages/client/src/lib/constants.ts @@ -17,6 +17,8 @@ export const __isStorybook__ = Boolean(process.env.STORYBOOK_IS_STORYBOOK); // env info variable (production, test, etc) export const __isDev__ = __buildEnv__ !== "production"; +// check for explicit "development" environment (excluding test, which, in looser definition, is also a development environment) +export const __isDevStrict__ = __buildEnv__ === "development"; // check for explicit "test" environment export const __isTest__ = __buildEnv__ === "test"; diff --git a/packages/e2e/integration/athlete_self_update.ts b/packages/e2e/integration/athlete_self_update.ts index 092f02b2a..e840e497f 100644 --- a/packages/e2e/integration/athlete_self_update.ts +++ b/packages/e2e/integration/athlete_self_update.ts @@ -10,7 +10,13 @@ import i18n, { import { customers } from "../__testData__/customers.json"; // extract saul from test data .json -const saul = customers.saul as Customer; +const saul = { + ...customers.saul, + // The exact timestamp is irrelevant (it's only important it's there). + // We're setting this here so as to not show publicy policy toast (obstructing the test runner's view) + privacyPolicyAccepted: { timestamp: "2022-01-01" }, +} as Customer; + // Remove the "dial code" from saul's phone const saulsDialCode = "IT (+39)"; const saulsPhone = saul.phone!.substring(3); @@ -19,7 +25,7 @@ describe("athlete profile", () => { beforeEach(() => { // Initialize app, create default user, cy.initAdminApp().then((organization) => - cy.updateCustomers(organization, customers as Record) + cy.updateCustomers(organization, { saul } as Record) ); cy.signOut(); From 6fb8ab6db157f3f6373f866fecf02719adf99e6e Mon Sep 17 00:00:00 2001 From: ikusteu Date: Thu, 19 Oct 2023 10:34:05 +0200 Subject: [PATCH 09/10] Attempt to fix flaky unit test by extending the test timeout --- packages/client/src/__testUtils__/envUtils.ts | 6 ++-- .../client/src/__tests__/dataTriggers.test.ts | 28 ++----------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/packages/client/src/__testUtils__/envUtils.ts b/packages/client/src/__testUtils__/envUtils.ts index c5827c7f4..f920c4dbb 100644 --- a/packages/client/src/__testUtils__/envUtils.ts +++ b/packages/client/src/__testUtils__/envUtils.ts @@ -1,6 +1,4 @@ -import { test, TestFunction } from "vitest"; - -type TestClosure = (name: string, fn?: TestFunction, timeout?: number) => void; +import { test, TestAPI } from "vitest"; /** * A boolean flag set to `true` if the emulators exist in current environment @@ -13,7 +11,7 @@ export const __withEmulators__ = Boolean(process.env.FIRESTORE_EMULATOR_HOST); * Skips test provided (runs `xtest`) if no firestore emulator found * @param testArgs paramaters of `test` function */ -export const testWithEmulator: TestClosure = (...args) => { +export const testWithEmulator = (...args: Parameters) => { if (__withEmulators__) { test(...args); } else { diff --git a/packages/client/src/__tests__/dataTriggers.test.ts b/packages/client/src/__tests__/dataTriggers.test.ts index 38d48c910..bdb3471c8 100644 --- a/packages/client/src/__tests__/dataTriggers.test.ts +++ b/packages/client/src/__tests__/dataTriggers.test.ts @@ -91,32 +91,9 @@ describe("Cloud functions -> Data triggers ->", () => { .get(); expect(snap.data()).toEqual(attendanceWithTestBooking); }); - } - ); - - testWithEmulator( - "should remove the attendance entry for booking when the booking is deleted", - async () => { - const { organization } = await setUpOrganization(); - await Promise.all([ - // set up Saul's bookings entry - adminDb - .doc(getBookingsDocPath(organization, saul.secretKey)) - .set(sanitizeCustomer(saul)), - // add the booked slot - adminDb - .doc( - getBookedSlotDocPath(organization, saul.secretKey, baseSlot.id) - ) - .set(bookedSlot), - // set up dummy data in the base slot, including the booked slot - adminDb - .doc(getAttendanceDocPath(organization, baseSlot.id)) - .set(attendanceWithTestBooking), - ]); // deleting the booking should remove it from attendance doc await adminDb - .doc(getBookingsDocPath(organization, saul.secretKey)) + .doc(getBookedSlotDocPath(organization, saul.secretKey, baseSlot.id)) .delete(); await waitFor(async () => { const snap = await adminDb @@ -125,7 +102,8 @@ describe("Cloud functions -> Data triggers ->", () => { // check that only the test customer's attendance's deleted, but not the rest of the data expect(snap.data()).toEqual(baseAttendance); }); - } + }, + { timeout: 20000 } ); }); From c2a7c050549ab8216d19c63be3bd3160f713c30d Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Fri, 20 Oct 2023 15:15:22 +0200 Subject: [PATCH 10/10] Disable flapping test and leave a note about it --- .../client/src/__tests__/dataTriggers.test.ts | 129 +++++++++--------- 1 file changed, 63 insertions(+), 66 deletions(-) diff --git a/packages/client/src/__tests__/dataTriggers.test.ts b/packages/client/src/__tests__/dataTriggers.test.ts index bdb3471c8..c148fb7b5 100644 --- a/packages/client/src/__tests__/dataTriggers.test.ts +++ b/packages/client/src/__tests__/dataTriggers.test.ts @@ -3,7 +3,7 @@ */ import { v4 as uuid } from "uuid"; -import { describe, expect } from "vitest"; +import { describe, expect, test } from "vitest"; import { Collection, @@ -66,7 +66,8 @@ describe("Cloud functions -> Data triggers ->", () => { }, }; - testWithEmulator( + test.skip( + // FIXME - this test was flapping too much, so we stop running it "should create attendance entry for booking and not overwrite existing data in slot", async () => { const { organization } = await setUpOrganization(); @@ -113,70 +114,66 @@ describe("Cloud functions -> Data triggers ->", () => { const newSlotId = "new-slot"; const newSlot = { ...baseSlot, date: dayAfter, id: newSlotId }; - testWithEmulator( - "should create slotsByDay entry for slot on create", - async () => { - const { organization } = await setUpOrganization(); - // add new slot to trigger slot aggregation - await adminDb - .doc(getSlotDocPath(organization, baseSlot.id)) - .set(baseSlot); - // check that the slot has been aggregated to `slotsByDay` - const expectedSlotsByDay = { [testDate]: { [baseSlot.id]: baseSlot } }; - const slotsByDayEntry = await waitFor(async () => { - const snap = await adminDb - .doc(getSlotsByDayDocPath(organization, testMonth)) - .get(); - expect(snap.data()).toEqual(expectedSlotsByDay); - return snap.data(); - }); - // test adding another slot on different day of the same month - await adminDb.doc(getSlotDocPath(organization, newSlotId)).set(newSlot); - await waitFor(async () => { - const snap = await adminDb - .doc(getSlotsByDayDocPath(organization, testMonth)) - .get(); - expect(snap.data()).toEqual({ - ...slotsByDayEntry, - [dayAfter]: { [newSlotId]: newSlot }, - }); - }); - } - ); - - testWithEmulator( - "should update aggregated slotsByDay on slot update", - async () => { - // set up test state - const { organization } = await setUpOrganization(); - const slotRef = adminDb.doc(getSlotDocPath(organization, baseSlot.id)); - await slotRef.set(baseSlot); - await waitFor(async () => { - const snap = await adminDb - .doc(getSlotsByDayDocPath(organization, testMonth)) - .get(); - expect(snap.exists).toEqual(true); - expect(Boolean(snap.data()![testDate])).toEqual(true); - }); - // test slot updating - const newIntervals = createIntervals(18); - const updatedSlot = { - ...baseSlot, - intervals: newIntervals, - type: SlotType.OffIce, - }; - slotRef.set(updatedSlot); - const expectedSlotsByDay = { - [testDate]: { [baseSlot.id]: updatedSlot }, - }; - await waitFor(async () => { - const snap = await adminDb - .doc(getSlotsByDayDocPath(organization, testMonth)) - .get(); - expect(snap.data()).toEqual(expectedSlotsByDay); - }); - } - ); + test.skip(// FIXME - this test was flapping too much, so we stop running it + "should create slotsByDay entry for slot on create", async () => { + const { organization } = await setUpOrganization(); + // add new slot to trigger slot aggregation + await adminDb + .doc(getSlotDocPath(organization, baseSlot.id)) + .set(baseSlot); + // check that the slot has been aggregated to `slotsByDay` + const expectedSlotsByDay = { [testDate]: { [baseSlot.id]: baseSlot } }; + const slotsByDayEntry = await waitFor(async () => { + const snap = await adminDb + .doc(getSlotsByDayDocPath(organization, testMonth)) + .get(); + expect(snap.data()).toEqual(expectedSlotsByDay); + return snap.data(); + }); + // test adding another slot on different day of the same month + await adminDb.doc(getSlotDocPath(organization, newSlotId)).set(newSlot); + await waitFor(async () => { + const snap = await adminDb + .doc(getSlotsByDayDocPath(organization, testMonth)) + .get(); + expect(snap.data()).toEqual({ + ...slotsByDayEntry, + [dayAfter]: { [newSlotId]: newSlot }, + }); + }); + }); + + test.skip(// FIXME - this test was flapping too much, so we stop running it + "should update aggregated slotsByDay on slot update", async () => { + // set up test state + const { organization } = await setUpOrganization(); + const slotRef = adminDb.doc(getSlotDocPath(organization, baseSlot.id)); + await slotRef.set(baseSlot); + await waitFor(async () => { + const snap = await adminDb + .doc(getSlotsByDayDocPath(organization, testMonth)) + .get(); + expect(snap.exists).toEqual(true); + expect(Boolean(snap.data()![testDate])).toEqual(true); + }); + // test slot updating + const newIntervals = createIntervals(18); + const updatedSlot = { + ...baseSlot, + intervals: newIntervals, + type: SlotType.OffIce, + }; + slotRef.set(updatedSlot); + const expectedSlotsByDay = { + [testDate]: { [baseSlot.id]: updatedSlot }, + }; + await waitFor(async () => { + const snap = await adminDb + .doc(getSlotsByDayDocPath(organization, testMonth)) + .get(); + expect(snap.data()).toEqual(expectedSlotsByDay); + }); + }); testWithEmulator( "should remove slot from slotsByDay on slot delete",