From 92cd98021078fd784b3fd3d97dd23c8073457f11 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Mon, 16 Oct 2023 12:58:44 -0700 Subject: [PATCH 01/25] Passwordless login prototype. First stab at adding auth client to context object --- .eslintrc.js | 1 + app/App.tsx | 54 ++- app/pages/Auth/Auth.module.scss | 28 ++ app/pages/Auth/PasswordlessAuthentication.tsx | 90 ++++ app/pages/Auth/SignInPage.tsx | 95 +++++ app/pages/Auth/SignUpPage.tsx | 111 +++++ app/pages/Auth/VerificationModal.tsx | 90 ++++ app/pages/HomePage/HomePage.tsx | 33 ++ app/utils/useAppContext.ts | 34 ++ package-lock.json | 389 +++++++++++++++++- package.json | 2 + 11 files changed, 908 insertions(+), 19 deletions(-) create mode 100644 app/pages/Auth/Auth.module.scss create mode 100644 app/pages/Auth/PasswordlessAuthentication.tsx create mode 100644 app/pages/Auth/SignInPage.tsx create mode 100644 app/pages/Auth/SignUpPage.tsx create mode 100644 app/pages/Auth/VerificationModal.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 90fbff819..5deb32bde 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -74,6 +74,7 @@ module.exports = { "@typescript-eslint/require-await": "off", "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/unbound-method": "off", + "arrow-body-style": "off", "no-unused-expressions": "off", "no-unused-vars": "off", "no-use-before-define": "off", diff --git a/app/App.tsx b/app/App.tsx index d97a1726f..ecee7d2d5 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; // Todo: Once GA sunsets the UA analytics tracking come July 2023, we can remove the "react-ga" // package and all references to it: @@ -9,6 +9,7 @@ import ReactGA_4 from "react-ga4"; import Intercom from "react-intercom"; import { Helmet } from "react-helmet-async"; import { Redirect, Route, Switch, useHistory } from "react-router-dom"; +import auth0 from "auth0-js"; import { AppContext, GeoCoordinates, getLocation, whiteLabel } from "./utils"; import { @@ -42,9 +43,14 @@ import OrganizationEditPage from "./pages/OrganizationEditPage"; import { EditBreakingNewsPage } from "./pages/EditBreakingNewsPage"; import { ServiceDiscoveryForm } from "./pages/ServiceDiscoveryForm"; import { ServiceDiscoveryResults } from "./pages/ServiceDiscoveryResults"; +import { SignInPage } from "./pages/Auth/SignInPage"; +import { SignUpPage } from "./pages/Auth/SignUpPage"; import styles from "./App.module.scss"; + + + const { homePageComponent, intercom, @@ -96,10 +102,52 @@ export const App = () => { }); }, [history]); + console.log(AppContext); + + const contextValue = useMemo(() => { + + + const passwordlessStart = ( + evt: React.SyntheticEvent, + email: string, + callback: () => void + ) => { + evt.preventDefault(); + webAuth.passwordlessStart( + { + connection: "email", + send: "code", + email, + }, + (err) => { + if (err) { + console.log(err); + return; + } + + callback(); + } + ); + }; + + return { + userLocation, + authState: { + isAuthenticated: false, + user: { + name: '', + email: '', + } + }, + webAuth, + passwordlessStart, + } + }, [userLocation]); + return (
{/* eslint-disable-next-line react/jsx-no-constructed-context-values */} - + {title} @@ -213,6 +261,8 @@ export const App = () => { path="/breaking-news/edit" component={EditBreakingNewsPage} /> + + {/* UCSF white label paths */} { + const [modalIsOpen, setModalIsOpen] = useState(false); + const [email, setEmail] = useState(""); + + const webAuth = new auth0.WebAuth({ + clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", + domain: "dev-nykixf8szsm220fi.us.auth0.com", + redirectUri: "http://localhost:8080", + responseType: "token id_token", + }); + + const startPasswordlessAuth = (evt: React.SyntheticEvent) => { + evt.preventDefault(); + + webAuth.passwordlessStart( + { + connection: "email", + send: "code", + email, + }, + (err) => { + if (err) { + console.log(err); + return; + } + + setModalIsOpen(true); + } + ); + }; + + const verifyCode = (verificationCode: string) => { + webAuth.passwordlessLogin( + { + connection: "email", + email, + verificationCode, + }, + (err) => { + if (err) { + console.log(err); + } + } + ); + }; + + return ( +
+

For Case Managers

+ + {`${ + mode === "signIn" + ? "New here? Sign up!" + : "Already have an account? Log in!" + }`} + + +
+ + { + setEmail(evt.target.value); + }} + /> + +
+
+ ); +}; diff --git a/app/pages/Auth/SignInPage.tsx b/app/pages/Auth/SignInPage.tsx new file mode 100644 index 000000000..93f231fa0 --- /dev/null +++ b/app/pages/Auth/SignInPage.tsx @@ -0,0 +1,95 @@ +import React, { useState } from "react"; +import auth0 from "auth0-js"; +import { Link } from "react-router-dom"; +import { Button } from "components/ui/inline/Button/Button"; +import { useAppContext } from "utils"; + + +import { VerificationModal } from "./VerificationModal"; + +import styles from "./Auth.module.scss"; + +export const SignInPage = () => { + const [modalIsOpen, setModalIsOpen] = useState(false); + const [email, setEmail] = useState(""); + // const { passwordlessStart } = useAppContext(); + const { passwordlessStart } = useAppContext(); + + const webAuth = new auth0.WebAuth({ + audience: "http://localhost:8080/api", + clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", + domain: "dev-nykixf8szsm220fi.us.auth0.com", + redirectUri: "http://localhost:8080", + responseType: "token id_token", + }); + + + // const signIn = (evt: React.SyntheticEvent) => { + // evt.preventDefault(); + // webAuth.passwordlessStart( + // { + // connection: "email", + // send: "code", + // email, + // }, + // (err) => { + // if (err) { + // console.log(err); + // return; + // } + + // setModalIsOpen(true); + // } + // ); + // }; + + const signIn = (evt: React.SyntheticEvent) => { + evt.preventDefault(); + console.log('hi!', passwordlessStart); + passwordlessStart(evt, email, () => setModalIsOpen(true)); + }; + + const loginWithCode = (verificationCode: string) => { + webAuth.passwordlessLogin( + { + connection: "email", + email, + verificationCode, + }, + (err) => { + if (err) { + console.log(err); + } + } + ); + }; + + return ( +
+

For Case Managers

+ New here? Sign up! + +
+ + +
+
+ ); +}; diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx new file mode 100644 index 000000000..47626ff89 --- /dev/null +++ b/app/pages/Auth/SignUpPage.tsx @@ -0,0 +1,111 @@ +import React, { useState } from "react"; +import auth0 from "auth0-js"; +import { Link } from "react-router-dom"; +import { Button } from "components/ui/inline/Button/Button"; + +import { VerificationModal } from "./VerificationModal"; + +import styles from "./Auth.module.scss"; + +export const SignUpPage = () => { + const [modalIsOpen, setModalIsOpen] = useState(false); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [organization, setOrganization] = useState(""); + + const webAuth = new auth0.WebAuth({ + clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", + domain: "dev-nykixf8szsm220fi.us.auth0.com", + redirectUri: "http://localhost:8080", + responseType: "token id_token", + }); + + const startPasswordlessAuth = (evt: React.SyntheticEvent) => { + evt.preventDefault(); + webAuth.passwordlessStart( + { + connection: "email", + send: "code", + email, + }, + (err) => { + if (err) { + console.log(err); + return; + } + + setModalIsOpen(true); + } + ); + }; + + const verifyCode = (verificationCode: string) => { + webAuth.passwordlessLogin( + { + connection: "email", + email, + verificationCode, + }, + (err) => { + if (err) { + console.log(err); + } + } + ); + }; + + return ( +
+

For Case Managers

+ Already have an account? Log in! + +
+ + + + + + +
+
+ ); +}; diff --git a/app/pages/Auth/VerificationModal.tsx b/app/pages/Auth/VerificationModal.tsx new file mode 100644 index 000000000..1d7036b47 --- /dev/null +++ b/app/pages/Auth/VerificationModal.tsx @@ -0,0 +1,90 @@ +import React, { useState, createRef } from "react"; +import { Modal } from "components/ui/Modal/Modal"; +import { Button } from "components/ui/inline/Button/Button"; + +import styles from "./Auth.module.scss"; + +export const VerificationModal = ({ + verifyCode, + modalIsOpen, + setModalIsOpen, +}: { + verifyCode: (code: string) => void; + modalIsOpen: boolean; + setModalIsOpen: (isOpen: boolean) => void; +}) => { + const initialVerificationCode = ["", "", "", "", "", ""]; + const [verificationCode, setVerificationCode] = useState( + initialVerificationCode + ); + const inputRefs: React.RefObject[] = + initialVerificationCode.map(() => createRef()); + + const handleVerificationCodeChange = (index: number, value: string) => { + const updatedVerificationCode = [...verificationCode]; + updatedVerificationCode[index] = value; + setVerificationCode(updatedVerificationCode); + + const nextRef = inputRefs[index + 1]; + if (value && nextRef && nextRef.current) { + nextRef.current.focus(); + } + }; + + const handlePaste = (event: React.ClipboardEvent) => { + event.preventDefault(); + const clipboardData = event.clipboardData || window.Clipboard; + const pastedData = clipboardData.getData("text"); + + if (pastedData.length === 6) { + const pastedCodeArray = pastedData.split(""); + const updatedVerificationCode = [...verificationCode]; + + pastedCodeArray.forEach((digit, index) => { + const inputRef = inputRefs[index]; + if (inputRef.current) { + updatedVerificationCode[index] = digit; + inputRef.current.value = digit; + } + }); + + setVerificationCode(updatedVerificationCode); + inputRefs?.[0]?.current?.focus(); + } + }; + + const onSubmitCode = () => { + const codeString = verificationCode.join(""); + verifyCode(codeString); + }; + + return ( + setModalIsOpen(false)} + > +
+

Please check your email

+
+ {verificationCode.map((digit, index) => ( + + handleVerificationCodeChange(index, evt.target.value ?? "") + } + onPaste={handlePaste} + ref={inputRefs[index]} + maxLength={1} + /> + ))} +
+ +
+
+ ); +}; diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index 45a889eb7..ed254af7e 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import qs from "qs"; +import auth0 from "auth0-js"; import { getResourceCount } from "utils/DataService"; import { Footer, NewsArticles } from "components/ui"; import { Partners } from "./components/Partners/Partners"; @@ -81,13 +82,45 @@ export const HomePage = () => { useEffect(() => { getResourceCount().then((count: number) => setResourceCount(count)); + + + + webAuth.parseHash({ hash: window.location.hash }, (err, authResult) => { + if (err) { + console.log(err); + } + console.log(authResult) + if (authResult?.accessToken) { + window.foobar = authResult.accessToken; + webAuth.client.userInfo(authResult.accessToken, (tokenErr, user) => { + if (tokenErr) { + console.log(tokenErr); + } + console.log(user); + // alert(`Welcome, ${user.email}!`); + }); + } + }); }, []); + const secureApiCall = () => { + console.log('hi: ', window.foobar) + fetch("/api/resources/548", { + method: "GET", + headers: { + Authorization: `Bearer ${window.foobar}`, + }, + }).then(resp => { + console.log(resp) + }); + }; + return ( <> {showBreakingNews && } + void) => { + evt.preventDefault(); + webAuth.passwordlessStart( + { + connection: "email", + send: "code", + email, + }, + (err) => { + if (err) { + console.log(err); + return; + } + + callback(); + } + ); +}; + +// export const InitialContext = + export const AppContext = createContext({ userLocation: null, + authState: { + isAuthenticated: false, + user: { + name: '', + email: '', + } + }, + webAuth, + passwordlessStart }); +console.log(AppContext) export const useAppContext = () => useContext(AppContext); diff --git a/package-lock.json b/package-lock.json index 08e52db0d..29b284678 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@babel/polyfill": "^7.4.4", "@sentry/browser": "^4.0.6", "algoliasearch": "^4.10.5", + "auth0-js": "^9.22.1", "classnames": "^2.2.6", "copy-webpack-plugin": "^11.0.0", "google-map-react": "^1.1.4", @@ -50,6 +51,7 @@ "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.13.0", "@babel/register": "^7.4.4", + "@types/auth0-js": "^9.21.0", "@types/chai": "^4.2.22", "@types/enzyme": "^3.10.9", "@types/google-map-react": "^2.1.3", @@ -4638,6 +4640,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/auth0-js": { + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@types/auth0-js/-/auth0-js-9.21.0.tgz", + "integrity": "sha512-tnF0BKFwI+Vzqwb9p7KgpaKStg/WHqbiGWz5GPpn+ZeBvJ1iY7NkmeNJUsHIN/4c7CF2zr8FT5JRhs3F5aAPNw==", + "dev": true + }, "node_modules/@types/cacheable-request": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", @@ -6595,7 +6603,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -6620,6 +6627,21 @@ "node": ">= 4.5.0" } }, + "node_modules/auth0-js": { + "version": "9.22.1", + "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-9.22.1.tgz", + "integrity": "sha512-AcyJiWhsyG5zdx40O9i/okpLLEvB23/6CivWynmGtP43s2C4GSq3E+XdCRw64ifmZ7t6ZK4Yzfpiqy5KVXEtJg==", + "dependencies": { + "base64-js": "^1.5.1", + "idtoken-verifier": "^2.2.2", + "js-cookie": "^2.2.0", + "minimist": "^1.2.5", + "qs": "^6.10.1", + "superagent": "^7.1.5", + "url-join": "^4.0.1", + "winchan": "^0.2.2" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -8984,7 +9006,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -9271,6 +9292,11 @@ "dev": true, "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" + }, "node_modules/copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -9609,6 +9635,11 @@ "node": "*" } }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "node_modules/crypto-md5": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/crypto-md5/-/crypto-md5-1.0.0.tgz", @@ -10634,7 +10665,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -10699,6 +10729,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -13060,6 +13099,11 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fast-xml-parser": { "version": "3.21.1", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz", @@ -13636,6 +13680,20 @@ "node": ">= 0.12" } }, + "node_modules/formidable": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -14570,6 +14628,14 @@ "node": ">= 8" } }, + "node_modules/hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "engines": { + "node": ">=8" + } + }, "node_modules/highlight-es": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/highlight-es/-/highlight-es-1.0.3.tgz", @@ -15076,6 +15142,24 @@ "postcss": "^8.1.0" } }, + "node_modules/idtoken-verifier": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/idtoken-verifier/-/idtoken-verifier-2.2.3.tgz", + "integrity": "sha512-hhpzB+MRgEvbwqzRLFdVbG55lKdXQVfeYEjAA2qu0UC72MSLeR0nX7P7rY5Dycz1aISHPOwq80hIPFoJ/+SItA==", + "dependencies": { + "base64-js": "^1.5.1", + "crypto-js": "^4.1.1", + "es6-promise": "^4.2.8", + "jsbn": "^1.1.0", + "unfetch": "^4.2.0", + "url-join": "^4.0.1" + } + }, + "node_modules/idtoken-verifier/node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -17108,6 +17192,11 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -18463,7 +18552,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -18636,7 +18724,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -25755,6 +25842,99 @@ "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", "peer": true }, + "node_modules/superagent": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.5.tgz", + "integrity": "sha512-HQYyGuDRFGmZ6GNC4hq2f37KnsY9Lr0/R1marNZTgMweVDQLTLJJ6DGQ9Tj/xVVs5HEnop9EMmTbywb5P30aqw==", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "^2.5.0", + "qs": "^6.10.3", + "readable-stream": "^3.6.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/superagent/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -27716,6 +27896,11 @@ "through": "^2.3.8" } }, + "node_modules/unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==" + }, "node_modules/unherit": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz", @@ -28004,6 +28189,11 @@ "querystring": "0.2.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -29493,6 +29683,11 @@ "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", "dev": true }, + "node_modules/winchan": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/winchan/-/winchan-0.2.2.tgz", + "integrity": "sha512-pvN+IFAbRP74n/6mc6phNyCH8oVkzXsto4KCHPJ2AScniAnA1AmeLI03I2BzjePpaClGSI4GUMowzsD3qz5PRQ==" + }, "node_modules/windows-release": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-5.0.1.tgz", @@ -29708,8 +29903,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { "version": "1.10.2", @@ -33070,6 +33264,12 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "@types/auth0-js": { + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@types/auth0-js/-/auth0-js-9.21.0.tgz", + "integrity": "sha512-tnF0BKFwI+Vzqwb9p7KgpaKStg/WHqbiGWz5GPpn+ZeBvJ1iY7NkmeNJUsHIN/4c7CF2zr8FT5JRhs3F5aAPNw==", + "dev": true + }, "@types/cacheable-request": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", @@ -34561,8 +34761,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "at-least-node": { "version": "1.0.0", @@ -34575,6 +34774,21 @@ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==" }, + "auth0-js": { + "version": "9.22.1", + "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-9.22.1.tgz", + "integrity": "sha512-AcyJiWhsyG5zdx40O9i/okpLLEvB23/6CivWynmGtP43s2C4GSq3E+XdCRw64ifmZ7t6ZK4Yzfpiqy5KVXEtJg==", + "requires": { + "base64-js": "^1.5.1", + "idtoken-verifier": "^2.2.2", + "js-cookie": "^2.2.0", + "minimist": "^1.2.5", + "qs": "^6.10.1", + "superagent": "^7.1.5", + "url-join": "^4.0.1", + "winchan": "^0.2.2" + } + }, "available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -36357,7 +36571,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -36575,6 +36788,11 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -36822,6 +37040,11 @@ "randomfill": "^1.0.3" } }, + "crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "crypto-md5": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/crypto-md5/-/crypto-md5-1.0.0.tgz", @@ -37583,8 +37806,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "denodeify": { "version": "1.2.1", @@ -37632,6 +37854,15 @@ "integrity": "sha512-fYXbFSeilT7bnKWFi4OERSPHdtaEoDGn4aUhV5Nly6/I+Tp6JZ/6Icmd7LVIF5euyodGpxz2e/bfUmDnIdSIDw==", "dev": true }, + "dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "requires": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -39384,6 +39615,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "fast-xml-parser": { "version": "3.21.1", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz", @@ -39773,6 +40009,17 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "requires": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -40419,6 +40666,11 @@ } } }, + "hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" + }, "highlight-es": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/highlight-es/-/highlight-es-1.0.3.tgz", @@ -40815,6 +41067,26 @@ "dev": true, "requires": {} }, + "idtoken-verifier": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/idtoken-verifier/-/idtoken-verifier-2.2.3.tgz", + "integrity": "sha512-hhpzB+MRgEvbwqzRLFdVbG55lKdXQVfeYEjAA2qu0UC72MSLeR0nX7P7rY5Dycz1aISHPOwq80hIPFoJ/+SItA==", + "requires": { + "base64-js": "^1.5.1", + "crypto-js": "^4.1.1", + "es6-promise": "^4.2.8", + "jsbn": "^1.1.0", + "unfetch": "^4.2.0", + "url-join": "^4.0.1" + }, + "dependencies": { + "jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + } + } + }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -42225,6 +42497,11 @@ "@sideway/pinpoint": "^2.0.0" } }, + "js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -43261,7 +43538,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -43388,8 +43664,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "metro": { "version": "0.70.3", @@ -48661,6 +48936,72 @@ "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", "peer": true }, + "superagent": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.5.tgz", + "integrity": "sha512-HQYyGuDRFGmZ6GNC4hq2f37KnsY9Lr0/R1marNZTgMweVDQLTLJJ6DGQ9Tj/xVVs5HEnop9EMmTbywb5P30aqw==", + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "^2.5.0", + "qs": "^6.10.3", + "readable-stream": "^3.6.0", + "semver": "^7.3.7" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -50093,6 +50434,11 @@ "through": "^2.3.8" } }, + "unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==" + }, "unherit": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz", @@ -50303,6 +50649,11 @@ } } }, + "url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" + }, "url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -51396,6 +51747,11 @@ "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", "dev": true }, + "winchan": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/winchan/-/winchan-0.2.2.tgz", + "integrity": "sha512-pvN+IFAbRP74n/6mc6phNyCH8oVkzXsto4KCHPJ2AScniAnA1AmeLI03I2BzjePpaClGSI4GUMowzsD3qz5PRQ==" + }, "windows-release": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-5.0.1.tgz", @@ -51536,8 +51892,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yaml": { "version": "1.10.2", diff --git a/package.json b/package.json index 86044618b..e60ec37a6 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@babel/polyfill": "^7.4.4", "@sentry/browser": "^4.0.6", "algoliasearch": "^4.10.5", + "auth0-js": "^9.22.1", "classnames": "^2.2.6", "copy-webpack-plugin": "^11.0.0", "google-map-react": "^1.1.4", @@ -49,6 +50,7 @@ "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.13.0", "@babel/register": "^7.4.4", + "@types/auth0-js": "^9.21.0", "@types/chai": "^4.2.22", "@types/enzyme": "^3.10.9", "@types/google-map-react": "^2.1.3", From f74af9feb7b68a4ddd8a3f902b308e09d58760df Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Wed, 25 Oct 2023 15:09:43 -0700 Subject: [PATCH 02/25] Begin building out AuthService SessionStorage API and Context object for managing user session --- app/App.tsx | 59 +++------------------ app/components/AppProvider.tsx | 47 +++++++++++++++++ app/pages/Auth/SignInPage.tsx | 58 ++++++++------------- app/pages/HomePage/HomePage.tsx | 41 +++------------ app/utils/AuthService.ts | 90 +++++++++++++++++++++++++++++++++ app/utils/SessionCacher.ts | 52 +++++++++++++++++++ app/utils/index.ts | 1 + app/utils/useAppContext.ts | 37 +++----------- 8 files changed, 233 insertions(+), 152 deletions(-) create mode 100644 app/components/AppProvider.tsx create mode 100644 app/utils/AuthService.ts create mode 100644 app/utils/SessionCacher.ts diff --git a/app/App.tsx b/app/App.tsx index ecee7d2d5..881ff6f2f 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; // Todo: Once GA sunsets the UA analytics tracking come July 2023, we can remove the "react-ga" // package and all references to it: @@ -9,9 +9,8 @@ import ReactGA_4 from "react-ga4"; import Intercom from "react-intercom"; import { Helmet } from "react-helmet-async"; import { Redirect, Route, Switch, useHistory } from "react-router-dom"; -import auth0 from "auth0-js"; -import { AppContext, GeoCoordinates, getLocation, whiteLabel } from "./utils"; +import { GeoCoordinates, getLocation, whiteLabel } from "./utils"; import { Banner, HamburgerMenu, @@ -20,6 +19,10 @@ import { PopupMessageProp, UserWay, } from "./components/ui"; +import { + AppProvider +} from "./components/AppProvider"; + import config from "./config"; import MetaImage from "./assets/img/sfsg-preview.png"; @@ -48,9 +51,6 @@ import { SignUpPage } from "./pages/Auth/SignUpPage"; import styles from "./App.module.scss"; - - - const { homePageComponent, intercom, @@ -102,52 +102,9 @@ export const App = () => { }); }, [history]); - console.log(AppContext); - - const contextValue = useMemo(() => { - - - const passwordlessStart = ( - evt: React.SyntheticEvent, - email: string, - callback: () => void - ) => { - evt.preventDefault(); - webAuth.passwordlessStart( - { - connection: "email", - send: "code", - email, - }, - (err) => { - if (err) { - console.log(err); - return; - } - - callback(); - } - ); - }; - - return { - userLocation, - authState: { - isAuthenticated: false, - user: { - name: '', - email: '', - } - }, - webAuth, - passwordlessStart, - } - }, [userLocation]); - return (
- {/* eslint-disable-next-line react/jsx-no-constructed-context-values */} - + {title} @@ -289,7 +246,7 @@ export const App = () => {
{popUpMessage && }
- + ); }; diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx new file mode 100644 index 000000000..d11d1609a --- /dev/null +++ b/app/components/AppProvider.tsx @@ -0,0 +1,47 @@ +import React, { useState, useMemo, useEffect } from "react"; +import auth0 from "auth0-js"; + +import { AppContext, GeoCoordinates } from "utils"; +import SessionCacher from "utils/SessionCacher"; + +export const AppProvider = ({ + children, + userLocation, +}: { + children: React.ReactNode; + userLocation: GeoCoordinates | null; +}) => { + const [authState, setAuthState] = useState({ + isAuthenticated: false, + user: { + id: "", + email: "", + }, + accessToken: "", + }); + + useEffect(() => { + SessionCacher.setAuthObject(authState); + }, [authState]); + + const contextValue = useMemo(() => { + const webAuth = new auth0.WebAuth({ + audience: "http://localhost:8080/api", + clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", + domain: "dev-nykixf8szsm220fi.us.auth0.com", + redirectUri: "http://localhost:8080", + responseType: "token id_token", + }); + + return { + userLocation, + authState, + setAuthState, + webAuth, + }; + }, [authState, userLocation]); + + return ( + {children} + ); +}; diff --git a/app/pages/Auth/SignInPage.tsx b/app/pages/Auth/SignInPage.tsx index 93f231fa0..9941c93b8 100644 --- a/app/pages/Auth/SignInPage.tsx +++ b/app/pages/Auth/SignInPage.tsx @@ -1,9 +1,7 @@ import React, { useState } from "react"; -import auth0 from "auth0-js"; import { Link } from "react-router-dom"; import { Button } from "components/ui/inline/Button/Button"; -import { useAppContext } from "utils"; - +import { useAppContext, AuthService } from "utils"; import { VerificationModal } from "./VerificationModal"; @@ -12,42 +10,26 @@ import styles from "./Auth.module.scss"; export const SignInPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); - // const { passwordlessStart } = useAppContext(); - const { passwordlessStart } = useAppContext(); - - const webAuth = new auth0.WebAuth({ - audience: "http://localhost:8080/api", - clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", - domain: "dev-nykixf8szsm220fi.us.auth0.com", - redirectUri: "http://localhost:8080", - responseType: "token id_token", - }); - - - // const signIn = (evt: React.SyntheticEvent) => { - // evt.preventDefault(); - // webAuth.passwordlessStart( - // { - // connection: "email", - // send: "code", - // email, - // }, - // (err) => { - // if (err) { - // console.log(err); - // return; - // } + const { webAuth } = useAppContext(); - // setModalIsOpen(true); - // } - // ); - // }; + const signIn = (evt: React.SyntheticEvent) => { + evt.preventDefault(); + webAuth.passwordlessStart( + { + connection: "email", + send: "code", + email, + }, + (err) => { + if (err) { + console.log(err); + return; + } - const signIn = (evt: React.SyntheticEvent) => { - evt.preventDefault(); - console.log('hi!', passwordlessStart); - passwordlessStart(evt, email, () => setModalIsOpen(true)); - }; + setModalIsOpen(true); + } + ); + }; const loginWithCode = (verificationCode: string) => { webAuth.passwordlessLogin( @@ -69,7 +51,7 @@ export const SignInPage = () => {

For Case Managers

New here? Sign up! loginWithCode(code)} modalIsOpen={modalIsOpen} setModalIsOpen={setModalIsOpen} /> diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index ed254af7e..7bcf09dd8 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -2,14 +2,13 @@ import React, { useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import qs from "qs"; -import auth0 from "auth0-js"; import { getResourceCount } from "utils/DataService"; import { Footer, NewsArticles } from "components/ui"; import { Partners } from "./components/Partners/Partners"; import { SearchBar } from "./components/SearchBar/SearchBar"; import { HomePageSection } from "./components/Section/Section"; import ResourceList from "./components/ResourceList/ResourceList"; -import { whiteLabel } from "../../utils"; +import { whiteLabel, useAppContext, AuthService } from "../../utils"; const { showBreakingNews } = whiteLabel; @@ -69,6 +68,7 @@ const covidResources = [ ]; export const HomePage = () => { + const { webAuth, setAuthState } = useAppContext(); const [resourceCount, setResourceCount] = useState(); const [searchValue, setSearchValue] = useState(""); const history = useHistory(); @@ -82,45 +82,20 @@ export const HomePage = () => { useEffect(() => { getResourceCount().then((count: number) => setResourceCount(count)); + }); + useEffect(() => { + if (!window.location.hash) return; - - webAuth.parseHash({ hash: window.location.hash }, (err, authResult) => { - if (err) { - console.log(err); - } - console.log(authResult) - if (authResult?.accessToken) { - window.foobar = authResult.accessToken; - webAuth.client.userInfo(authResult.accessToken, (tokenErr, user) => { - if (tokenErr) { - console.log(tokenErr); - } - console.log(user); - // alert(`Welcome, ${user.email}!`); - }); - } - }); - }, []); - - const secureApiCall = () => { - console.log('hi: ', window.foobar) - fetch("/api/resources/548", { - method: "GET", - headers: { - Authorization: `Bearer ${window.foobar}`, - }, - }).then(resp => { - console.log(resp) - }); - }; + AuthService.persistUser(window.location.hash, webAuth, setAuthState); + history.replace(window.location.pathname + window.location.search); + }, [history, setAuthState, webAuth]); return ( <> {showBreakingNews && } - { + if (err) { + console.log(err) + } + + console.log(authResult); + + if (authResult?.accessToken) { + const { accessToken, expiresIn, idTokenPayload } = authResult; + const authObject = { + isAuthenticated: true, + user: { + email: idTokenPayload.email, + id: idTokenPayload.sub, + }, + accessTokenObject: { + token: accessToken, + expiresAt: expiresIn ? this.calculateExpirationTime(expiresIn) : null, + } + }; + + setAuthState(authObject); + // return Promise.resolve(null); + } + + // return Promise.reject(err); + }); + } + + static passwordlessStart = ( + evt: React.SyntheticEvent, + email: string, + webAuth: any, + callback: () => void + ) => { + console.log("calling from here!"); + evt.preventDefault(); + webAuth.passwordlessStart( + { + connection: "emaidl", + send: "code", + email, + }, + (err: AuthError) => { + if (err) { + console.log(err); + return; + } + + callback(); + } + ); + }; + + static passwordlessVerify = (email: string, verificationCode: string, webAuth: any) => { + webAuth.passwordlessLogin( + { + connection: "email", + email, + verificationCode, + }, + (err: AuthError) => { + if (err) { + console.log(err); + } + } + ); + }; +} + diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts new file mode 100644 index 000000000..2d8ef4e8c --- /dev/null +++ b/app/utils/SessionCacher.ts @@ -0,0 +1,52 @@ +// interface AccessTokenObject { +// token: string; +// expiresAt: Date; +// } + +// interface UserObject { +// email: string; +// id: string; +// } + +interface AuthObject { + isAuthenticated: boolean; + user: { + id: string; + email: string; + }; + accessToken: string; +} + +export default class SessionCacher { + // static getAccessTokenObject() { + // const object = localStorage.getItem('accessTokenObject') || ""; + // return JSON.parse(object); + // } + + // static setAccessTokenObject(accessTokenObject: AccessTokenObject) { + // sessionStorage.setItem("accessTokenObject", JSON.stringify(accessTokenObject)); + // } + + // static getUserObject() { + // const object = localStorage.getItem('userObject') || ""; + // return JSON.parse(object); + // } + + // static setUserObject(userObject: UserObject) { + // sessionStorage.setItem("userObject", JSON.stringify(userObject)); + // } + + + static getAuthObject() { + const object = localStorage.getItem('authObject') || ""; + return JSON.parse(object); + } + + static setAuthObject(authObject: AuthObject) { + sessionStorage.setItem("authObject", JSON.stringify(authObject)); + } + + static clearSession() { + sessionStorage.removeItem("authObject"); + } +} diff --git a/app/utils/index.ts b/app/utils/index.ts index d70b57e5c..a8259a73c 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -2,4 +2,5 @@ export * from "./location"; export * from "./numbers"; export * from "./time"; export * from "./useAppContext"; +export { default as AuthService } from "./AuthService"; export { default as whiteLabel } from "./whitelabel"; diff --git a/app/utils/useAppContext.ts b/app/utils/useAppContext.ts index d1e3af628..69518866d 100644 --- a/app/utils/useAppContext.ts +++ b/app/utils/useAppContext.ts @@ -1,42 +1,19 @@ import { createContext, useContext } from "react"; -import auth0 from "auth0-js"; +import { WebAuth } from "auth0-js"; import { GeoCoordinates } from "./location"; - - -const passwordlessStart = (evt: React.SyntheticEvent, email: string, callback: () => void) => { - evt.preventDefault(); - webAuth.passwordlessStart( - { - connection: "email", - send: "code", - email, - }, - (err) => { - if (err) { - console.log(err); - return; - } - - callback(); - } - ); -}; - -// export const InitialContext = - export const AppContext = createContext({ userLocation: null, authState: { isAuthenticated: false, user: { - name: '', - email: '', - } + id: "", + email: "", + }, + accessToken: "" }, - webAuth, - passwordlessStart + setAuthState: <(state: any) => void>null, + webAuth: null, }); -console.log(AppContext) export const useAppContext = () => useContext(AppContext); From ac1abeee6580c19a748bcff984f58fb6286aa55b Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Tue, 31 Oct 2023 14:25:02 -0700 Subject: [PATCH 03/25] Read sessionStorage for user session upon loading page --- app/components/AppProvider.tsx | 13 ++- app/pages/Auth/PasswordlessAuthentication.tsx | 90 ------------------- app/pages/Auth/SignInPage.tsx | 35 +------- app/pages/HomePage/HomePage.tsx | 3 +- app/utils/AuthService.ts | 23 +++-- app/utils/SessionCacher.ts | 21 ++--- app/utils/useAppContext.ts | 5 +- 7 files changed, 36 insertions(+), 154 deletions(-) delete mode 100644 app/pages/Auth/PasswordlessAuthentication.tsx diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index d11d1609a..382f6c2bc 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -3,6 +3,7 @@ import auth0 from "auth0-js"; import { AppContext, GeoCoordinates } from "utils"; import SessionCacher from "utils/SessionCacher"; +import type AuthObject from "utils/SessionCacher"; export const AppProvider = ({ children, @@ -11,14 +12,20 @@ export const AppProvider = ({ children: React.ReactNode; userLocation: GeoCoordinates | null; }) => { - const [authState, setAuthState] = useState({ + const defaultAuthObject: AuthObject = { isAuthenticated: false, user: { id: "", email: "", }, - accessToken: "", - }); + accessToken: { + token: "", + expiresAt: new Date(1970, 0, 1), + }, + }; + + const authObject = SessionCacher.getAuthObject() ?? defaultAuthObject; + const [authState, setAuthState] = useState(authObject); useEffect(() => { SessionCacher.setAuthObject(authState); diff --git a/app/pages/Auth/PasswordlessAuthentication.tsx b/app/pages/Auth/PasswordlessAuthentication.tsx deleted file mode 100644 index b1040a592..000000000 --- a/app/pages/Auth/PasswordlessAuthentication.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - Auth0's passwordlessAPI is the same for signing up and is it for signing in. As such, - we can share this component across the two pages -*/ - -import React, { useState } from "react"; -import auth0 from "auth0-js"; -import { VerificationModal } from "./VerificationModal"; - -import styles from "./Auth.module.scss"; - -type Mode = "signIn" | "signUp"; - -export const PasswordlessAuthentication = ({ mode }: { mode: Mode }) => { - const [modalIsOpen, setModalIsOpen] = useState(false); - const [email, setEmail] = useState(""); - - const webAuth = new auth0.WebAuth({ - clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", - domain: "dev-nykixf8szsm220fi.us.auth0.com", - redirectUri: "http://localhost:8080", - responseType: "token id_token", - }); - - const startPasswordlessAuth = (evt: React.SyntheticEvent) => { - evt.preventDefault(); - - webAuth.passwordlessStart( - { - connection: "email", - send: "code", - email, - }, - (err) => { - if (err) { - console.log(err); - return; - } - - setModalIsOpen(true); - } - ); - }; - - const verifyCode = (verificationCode: string) => { - webAuth.passwordlessLogin( - { - connection: "email", - email, - verificationCode, - }, - (err) => { - if (err) { - console.log(err); - } - } - ); - }; - - return ( -
-

For Case Managers

- - {`${ - mode === "signIn" - ? "New here? Sign up!" - : "Already have an account? Log in!" - }`} - - -
- - { - setEmail(evt.target.value); - }} - /> - -
-
- ); -}; diff --git a/app/pages/Auth/SignInPage.tsx b/app/pages/Auth/SignInPage.tsx index 9941c93b8..240ae0446 100644 --- a/app/pages/Auth/SignInPage.tsx +++ b/app/pages/Auth/SignInPage.tsx @@ -11,39 +11,10 @@ export const SignInPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); const { webAuth } = useAppContext(); - + const { passwordlessStart, passwordlessVerify } = AuthService; const signIn = (evt: React.SyntheticEvent) => { evt.preventDefault(); - webAuth.passwordlessStart( - { - connection: "email", - send: "code", - email, - }, - (err) => { - if (err) { - console.log(err); - return; - } - - setModalIsOpen(true); - } - ); - }; - - const loginWithCode = (verificationCode: string) => { - webAuth.passwordlessLogin( - { - connection: "email", - email, - verificationCode, - }, - (err) => { - if (err) { - console.log(err); - } - } - ); + passwordlessStart(evt, webAuth, email, () => setModalIsOpen(true)); }; return ( @@ -51,7 +22,7 @@ export const SignInPage = () => {

For Case Managers

New here? Sign up! loginWithCode(code)} + verifyCode={(code) => passwordlessVerify(webAuth, email, code)} modalIsOpen={modalIsOpen} setModalIsOpen={setModalIsOpen} /> diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index 7bcf09dd8..84ffac7fb 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import qs from "qs"; +import type { WebAuth } from "auth0-js"; import { getResourceCount } from "utils/DataService"; import { Footer, NewsArticles } from "components/ui"; @@ -87,7 +88,7 @@ export const HomePage = () => { useEffect(() => { if (!window.location.hash) return; - AuthService.persistUser(window.location.hash, webAuth, setAuthState); + AuthService.persistUser(window.location.hash, webAuth as WebAuth, setAuthState); history.replace(window.location.pathname + window.location.search); }, [history, setAuthState, webAuth]); diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index fcb49d4be..7ce53acc4 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -20,10 +20,9 @@ export default class AuthService { static persistUser(hash: string, webAuth: WebAuth, setAuthState: any) { webAuth.parseHash({ hash }, (err, authResult) => { if (err) { - console.log(err) + // TODO: Handle errors } - console.log(authResult); if (authResult?.accessToken) { const { accessToken, expiresIn, idTokenPayload } = authResult; @@ -40,39 +39,37 @@ export default class AuthService { }; setAuthState(authObject); - // return Promise.resolve(null); } - - // return Promise.reject(err); }); } static passwordlessStart = ( evt: React.SyntheticEvent, - email: string, webAuth: any, - callback: () => void + email: string, + callback?: () => void ) => { - console.log("calling from here!"); evt.preventDefault(); webAuth.passwordlessStart( { - connection: "emaidl", + connection: "email", send: "code", email, }, (err: AuthError) => { if (err) { - console.log(err); + // TODO: Handle errors return; } - callback(); + if (callback) { + callback(); + } } ); }; - static passwordlessVerify = (email: string, verificationCode: string, webAuth: any) => { + static passwordlessVerify = (webAuth: any, email: string, verificationCode: string) => { webAuth.passwordlessLogin( { connection: "email", @@ -81,7 +78,7 @@ export default class AuthService { }, (err: AuthError) => { if (err) { - console.log(err); + // TODO: Handle errors } } ); diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index 2d8ef4e8c..8a3f23cb3 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -1,20 +1,13 @@ -// interface AccessTokenObject { -// token: string; -// expiresAt: Date; -// } - -// interface UserObject { -// email: string; -// id: string; -// } - interface AuthObject { isAuthenticated: boolean; user: { id: string; email: string; }; - accessToken: string; + accessTokenObject: { + token: string; + expiresAt: Date; + }; } export default class SessionCacher { @@ -37,9 +30,9 @@ export default class SessionCacher { // } - static getAuthObject() { - const object = localStorage.getItem('authObject') || ""; - return JSON.parse(object); + static getAuthObject(): AuthObject { + const object = sessionStorage.getItem('authObject'); + return object ? JSON.parse(object) : null; } static setAuthObject(authObject: AuthObject) { diff --git a/app/utils/useAppContext.ts b/app/utils/useAppContext.ts index 69518866d..00169eb10 100644 --- a/app/utils/useAppContext.ts +++ b/app/utils/useAppContext.ts @@ -10,7 +10,10 @@ export const AppContext = createContext({ id: "", email: "", }, - accessToken: "" + accessTokenObject: { + expiresAt: new Date(1970, 0, 1), + token: "", + } }, setAuthState: <(state: any) => void>null, webAuth: null, From 7d8f932d6ab160dde88f73a484d4d5c2ec7d545e Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Wed, 1 Nov 2023 16:00:04 -0700 Subject: [PATCH 04/25] 689 Create logout functionality. Create call to Auth0 that resets access token when it has expired --- app/components/AppProvider.tsx | 59 +++++++++++++++------ app/components/ui/Navigation.tsx | 89 ++++++++++++++++++++------------ app/utils/AuthService.ts | 53 ++++++++++++------- app/utils/SessionCacher.ts | 19 ------- app/utils/index.ts | 1 + 5 files changed, 133 insertions(+), 88 deletions(-) diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 382f6c2bc..0f14516f2 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -1,9 +1,20 @@ import React, { useState, useMemo, useEffect } from "react"; -import auth0 from "auth0-js"; +import auth0, { Auth0Result } from "auth0-js"; -import { AppContext, GeoCoordinates } from "utils"; -import SessionCacher from "utils/SessionCacher"; -import type AuthObject from "utils/SessionCacher"; +import { AppContext, GeoCoordinates, SessionCacher, AuthService } from "utils"; +import AuthObject from "utils/SessionCacher"; + +export const defaultAuthObject: AuthObject = { + isAuthenticated: false, + user: { + id: "", + email: "", + }, + accessTokenObject: { + token: "", + expiresAt: new Date(1970, 0, 1), + }, +}; export const AppProvider = ({ children, @@ -12,22 +23,10 @@ export const AppProvider = ({ children: React.ReactNode; userLocation: GeoCoordinates | null; }) => { - const defaultAuthObject: AuthObject = { - isAuthenticated: false, - user: { - id: "", - email: "", - }, - accessToken: { - token: "", - expiresAt: new Date(1970, 0, 1), - }, - }; - const authObject = SessionCacher.getAuthObject() ?? defaultAuthObject; const [authState, setAuthState] = useState(authObject); - useEffect(() => { + // This ensures that the sessionStorage authObject is synced to the AppContext's authState SessionCacher.setAuthObject(authState); }, [authState]); @@ -48,6 +47,32 @@ export const AppProvider = ({ }; }, [authState, userLocation]); + if ( + authObject.isAuthenticated && authObject.accessTokenObject.expiresAt && + AuthService.tokenExpired(new Date(authObject.accessTokenObject.expiresAt)) + ) { + AuthService.refreshAuthToken(contextValue.webAuth) + .then((result: unknown) => { + const authResult = result as Auth0Result; + if (authResult.accessToken && typeof authResult.expiresIn !== "undefined") { + setAuthState({ + ...authState, + accessTokenObject: { + token: authResult.accessToken, + expiresAt: AuthService.calculateExpirationTime( + authResult.expiresIn + ), + }, + }); + } else { + throw new Error("Unexpected result format"); + } + }) + .catch((err) => { + console.log(err); + }); + } + return ( {children} ); diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index 68eb96991..1eebfbf63 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -2,6 +2,7 @@ import React, { FormEvent, useState } from "react"; import { Link, useHistory } from "react-router-dom"; import cn from "classnames"; import qs from "qs"; +import { useAppContext, AuthService } from "utils"; import Translate from "./Translate"; import whiteLabel from "../../utils/whitelabel"; import styles from "./Navigation.module.scss"; @@ -89,46 +90,66 @@ const SiteLogo = () => ); -const SiteLinks = () => ( -
    -
  • - About -
  • -
  • - - FAQ - -
  • -
  • - - Contact Us - -
  • - {showReportCrisis && ( +const SiteLinks = () => { + const context = useAppContext(); + const { authState, webAuth, setAuthState } = context; + + return ( + -); + {showReportCrisis && ( +
  • + + Report Street Crisis + +
  • + )} + +
+ );}; const SiteSearch = ({ query, diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 7ce53acc4..c176329d4 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,19 +1,11 @@ -import type { WebAuth, Auth0Error } from "auth0-js"; - - -// We need to make the error prop on the Auth0Error interface optional and this -// is the only way that I could figure out how to do so w/o making the TS -// compiler complain -type OptionalAuth0Error = { - [K in keyof Auth0Error]?: Auth0Error[K] | undefined; -}; -interface AuthError extends OptionalAuth0Error {} +import type { WebAuth, Auth0Result } from "auth0-js"; +import { defaultAuthObject } from "components/AppProvider"; export default class AuthService { - private static calculateExpirationTime(secondsUntilExpiration: number) { + static calculateExpirationTime(secondsUntilExpiration: number) { const currentTime = new Date(); const expirationTime = new Date(currentTime.getTime() + secondsUntilExpiration * 1000); - + console.log(expirationTime); return expirationTime; } @@ -23,7 +15,6 @@ export default class AuthService { // TODO: Handle errors } - if (authResult?.accessToken) { const { accessToken, expiresIn, idTokenPayload } = authResult; const authObject = { @@ -45,7 +36,7 @@ export default class AuthService { static passwordlessStart = ( evt: React.SyntheticEvent, - webAuth: any, + webAuth: WebAuth, email: string, callback?: () => void ) => { @@ -56,7 +47,7 @@ export default class AuthService { send: "code", email, }, - (err: AuthError) => { + (err) => { if (err) { // TODO: Handle errors return; @@ -69,19 +60,45 @@ export default class AuthService { ); }; - static passwordlessVerify = (webAuth: any, email: string, verificationCode: string) => { + static passwordlessVerify = (webAuth: WebAuth, email: string, verificationCode: string) => { webAuth.passwordlessLogin( { connection: "email", email, verificationCode, }, - (err: AuthError) => { + (err) => { if (err) { // TODO: Handle errors } } ); }; -} + static logout = (webAuth: WebAuth, clientId: string, setAuthState) => { + // Resets authState which in turn triggers an effect that clears sessionStorage + setAuthState(defaultAuthObject); + + webAuth.logout({ + returnTo: 'http://localhost:8080', + clientID: clientId + }); + }; + + static tokenExpired = (tokenExpiration: Date) => { + return tokenExpiration && (new Date(tokenExpiration) < new Date()); + } + + static refreshAuthToken = (webAuth: WebAuth) => { + return new Promise((resolve, reject) => { + webAuth.checkSession({}, (err, authResult: Auth0Result) => { + if (err) { + reject(err); + } else { + resolve(authResult); + } + }) + }); + } + +} diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index 8a3f23cb3..b277287f4 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -11,25 +11,6 @@ interface AuthObject { } export default class SessionCacher { - // static getAccessTokenObject() { - // const object = localStorage.getItem('accessTokenObject') || ""; - // return JSON.parse(object); - // } - - // static setAccessTokenObject(accessTokenObject: AccessTokenObject) { - // sessionStorage.setItem("accessTokenObject", JSON.stringify(accessTokenObject)); - // } - - // static getUserObject() { - // const object = localStorage.getItem('userObject') || ""; - // return JSON.parse(object); - // } - - // static setUserObject(userObject: UserObject) { - // sessionStorage.setItem("userObject", JSON.stringify(userObject)); - // } - - static getAuthObject(): AuthObject { const object = sessionStorage.getItem('authObject'); return object ? JSON.parse(object) : null; diff --git a/app/utils/index.ts b/app/utils/index.ts index a8259a73c..5b12e9608 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -3,4 +3,5 @@ export * from "./numbers"; export * from "./time"; export * from "./useAppContext"; export { default as AuthService } from "./AuthService"; +export { default as SessionCacher } from "./SessionCacher"; export { default as whiteLabel } from "./whitelabel"; From 4e46dbe9158563658f723e8056e063efd186d475 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Tue, 7 Nov 2023 15:41:57 -0800 Subject: [PATCH 05/25] 689 add sign out functionality and update sign-in/sign-up UI and verification modal --- app/App.tsx | 9 +- app/components/AppProvider.tsx | 29 ++++-- app/components/ui/Navigation.tsx | 22 ++--- app/pages/Auth/SignInPage.tsx | 49 ++++++---- app/pages/Auth/SignOutPage.tsx | 20 ++++ app/pages/Auth/SignUpPage.tsx | 132 ++++++++++----------------- app/pages/Auth/VerificationModal.tsx | 50 +++++++++- app/pages/HomePage/HomePage.tsx | 6 +- app/utils/AuthService.ts | 76 ++++++++------- app/utils/SessionCacher.ts | 18 +--- app/utils/useAppContext.ts | 4 +- 11 files changed, 230 insertions(+), 185 deletions(-) create mode 100644 app/pages/Auth/SignOutPage.tsx diff --git a/app/App.tsx b/app/App.tsx index 881ff6f2f..12e90b923 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -19,9 +19,7 @@ import { PopupMessageProp, UserWay, } from "./components/ui"; -import { - AppProvider -} from "./components/AppProvider"; +import { AppProvider } from "./components/AppProvider"; import config from "./config"; import MetaImage from "./assets/img/sfsg-preview.png"; @@ -48,6 +46,7 @@ import { ServiceDiscoveryForm } from "./pages/ServiceDiscoveryForm"; import { ServiceDiscoveryResults } from "./pages/ServiceDiscoveryResults"; import { SignInPage } from "./pages/Auth/SignInPage"; import { SignUpPage } from "./pages/Auth/SignUpPage"; +import { SignOutPage } from "./pages/Auth/SignOutPage"; import styles from "./App.module.scss"; @@ -148,7 +147,6 @@ export const App = () => { /> - {/* NB: /organizations/new must be listed before /organizations/:id or else the /new step will be interpreted as an ID and will thus break the OrganizationEditPage */} { /> + {/* UCSF white label paths */} { path="/find-services/:selectedResourceSlug" component={UcsfDiscoveryForm} /> - {/* Legacy redirects */} { path="/resource" component={RedirectToOrganizations} /> - diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 0f14516f2..732fe0657 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -1,10 +1,21 @@ import React, { useState, useMemo, useEffect } from "react"; import auth0, { Auth0Result } from "auth0-js"; - +import * as Sentry from "@sentry/browser"; import { AppContext, GeoCoordinates, SessionCacher, AuthService } from "utils"; -import AuthObject from "utils/SessionCacher"; -export const defaultAuthObject: AuthObject = { +export interface AuthState { + isAuthenticated: boolean; + user: { + id: string; + email: string; + }; + accessTokenObject: { + token: string; + expiresAt: Date; + }; +} + +export const defaultAuthObject: AuthState = { isAuthenticated: false, user: { id: "", @@ -48,13 +59,17 @@ export const AppProvider = ({ }, [authState, userLocation]); if ( - authObject.isAuthenticated && authObject.accessTokenObject.expiresAt && + authObject.isAuthenticated && + authObject.accessTokenObject.expiresAt && AuthService.tokenExpired(new Date(authObject.accessTokenObject.expiresAt)) ) { AuthService.refreshAuthToken(contextValue.webAuth) .then((result: unknown) => { const authResult = result as Auth0Result; - if (authResult.accessToken && typeof authResult.expiresIn !== "undefined") { + if ( + authResult.accessToken && + typeof authResult.expiresIn !== "undefined" + ) { setAuthState({ ...authState, accessTokenObject: { @@ -65,11 +80,11 @@ export const AppProvider = ({ }, }); } else { - throw new Error("Unexpected result format"); + throw new Error("Token does not exist or is in unexpected token"); } }) .catch((err) => { - console.log(err); + Sentry.captureException(err); }); } diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index 1eebfbf63..e20d0597c 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -2,9 +2,9 @@ import React, { FormEvent, useState } from "react"; import { Link, useHistory } from "react-router-dom"; import cn from "classnames"; import qs from "qs"; -import { useAppContext, AuthService } from "utils"; +import { WebAuth } from "auth0-js"; +import { useAppContext, AuthService, whiteLabel } from "utils"; import Translate from "./Translate"; -import whiteLabel from "../../utils/whitelabel"; import styles from "./Navigation.module.scss"; const { @@ -92,24 +92,13 @@ const SiteLogo = () => const SiteLinks = () => { const context = useAppContext(); - const { authState, webAuth, setAuthState } = context; + const { authState } = context; return (
    {authState.isAuthenticated && (
  • - + Sign Out
  • )}
  • @@ -149,7 +138,8 @@ const SiteLinks = () => { )}
- );}; + ); +}; const SiteSearch = ({ query, diff --git a/app/pages/Auth/SignInPage.tsx b/app/pages/Auth/SignInPage.tsx index 240ae0446..7e750712c 100644 --- a/app/pages/Auth/SignInPage.tsx +++ b/app/pages/Auth/SignInPage.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; +import { WebAuth } from "auth0-js"; import { Button } from "components/ui/inline/Button/Button"; import { useAppContext, AuthService } from "utils"; @@ -10,39 +11,49 @@ import styles from "./Auth.module.scss"; export const SignInPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); - const { webAuth } = useAppContext(); + const webAuth = useAppContext().webAuth as WebAuth; const { passwordlessStart, passwordlessVerify } = AuthService; + const signIn = (evt: React.SyntheticEvent) => { evt.preventDefault(); - passwordlessStart(evt, webAuth, email, () => setModalIsOpen(true)); + passwordlessStart(webAuth, email).then(() => { + setModalIsOpen(true); + }); }; return (

For Case Managers

New here? Sign up! - passwordlessVerify(webAuth, email, code)} - modalIsOpen={modalIsOpen} - setModalIsOpen={setModalIsOpen} - /> +

+ We want to make sure that your account information is safe, so you will + be sent a verification code to your email each time you log in. Please + enter in your email address and then check your email to find a 5-number + verification code. +

- + { + setEmail(evt.target.value); + }} + />
+ + passwordlessVerify(webAuth, email, code)} + resendCode={() => passwordlessStart(webAuth, email)} + buttonText="Log in" + />
); }; diff --git a/app/pages/Auth/SignOutPage.tsx b/app/pages/Auth/SignOutPage.tsx new file mode 100644 index 000000000..4e0a4c7cf --- /dev/null +++ b/app/pages/Auth/SignOutPage.tsx @@ -0,0 +1,20 @@ +import React, { useEffect } from "react"; +import { Redirect } from "react-router-dom"; +import { WebAuth } from "auth0-js"; +import { useAppContext, AuthService } from "../../utils"; + +export const SignOutPage = () => { + const context = useAppContext(); + const { setAuthState } = context; + const webAuth = context.webAuth as WebAuth; + + useEffect(() => { + AuthService.logout( + webAuth, + "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", + setAuthState + ); + }); + + return ; +}; diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 47626ff89..f06813594 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -1,7 +1,8 @@ import React, { useState } from "react"; -import auth0 from "auth0-js"; +import { WebAuth } from "auth0-js"; import { Link } from "react-router-dom"; import { Button } from "components/ui/inline/Button/Button"; +import { useAppContext, AuthService } from "utils"; import { VerificationModal } from "./VerificationModal"; @@ -12,100 +13,65 @@ export const SignUpPage = () => { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [organization, setOrganization] = useState(""); + const webAuth = useAppContext().webAuth as WebAuth; + const { passwordlessStart, passwordlessVerify } = AuthService; - const webAuth = new auth0.WebAuth({ - clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", - domain: "dev-nykixf8szsm220fi.us.auth0.com", - redirectUri: "http://localhost:8080", - responseType: "token id_token", - }); - - const startPasswordlessAuth = (evt: React.SyntheticEvent) => { + const signUp = (evt: React.SyntheticEvent) => { evt.preventDefault(); - webAuth.passwordlessStart( - { - connection: "email", - send: "code", - email, - }, - (err) => { - if (err) { - console.log(err); - return; - } - - setModalIsOpen(true); - } - ); - }; - - const verifyCode = (verificationCode: string) => { - webAuth.passwordlessLogin( - { - connection: "email", - email, - verificationCode, - }, - (err) => { - if (err) { - console.log(err); - } - } - ); + /* Todo: We will need to: + a) save the user's name/org to our database + b) check if the user email already exists and display an error message if so + */ + passwordlessStart(webAuth, email).then(() => { + setModalIsOpen(true); + }); }; return (

For Case Managers

Already have an account? Log in! - -
- - - - - + + { + setName(evt.target.value); + }} + /> + { + setEmail(evt.target.value); + }} + /> + { + setOrganization(evt.target.value); + }} + />
+ + passwordlessVerify(webAuth, email, code)} + resendCode={() => passwordlessStart(webAuth, email)} + buttonText="Sign up" + />
); }; diff --git a/app/pages/Auth/VerificationModal.tsx b/app/pages/Auth/VerificationModal.tsx index 1d7036b47..50e7c74f0 100644 --- a/app/pages/Auth/VerificationModal.tsx +++ b/app/pages/Auth/VerificationModal.tsx @@ -1,17 +1,23 @@ -import React, { useState, createRef } from "react"; +import React, { useState, createRef, useEffect } from "react"; import { Modal } from "components/ui/Modal/Modal"; import { Button } from "components/ui/inline/Button/Button"; import styles from "./Auth.module.scss"; export const VerificationModal = ({ + email, verifyCode, modalIsOpen, setModalIsOpen, + resendCode, + buttonText, }: { + email: string; verifyCode: (code: string) => void; modalIsOpen: boolean; setModalIsOpen: (isOpen: boolean) => void; + resendCode: () => Promise; + buttonText: string; }) => { const initialVerificationCode = ["", "", "", "", "", ""]; const [verificationCode, setVerificationCode] = useState( @@ -66,6 +72,7 @@ export const VerificationModal = ({ >

Please check your email

+

We've sent a code to {email}

{verificationCode.map((digit, index) => ( ))}
- + +
); }; + +const ResendCode = ({ resendCode }: { resendCode: () => Promise }) => { + const [timeLeft, setTimeLeft] = useState(60); + const [codeResent, setCodeResent] = useState(false); + + useEffect(() => { + const interval = setInterval(() => { + if (timeLeft === 0) { + clearInterval(interval); + resendCode(); + setCodeResent(true); + return; + } + + setTimeLeft(timeLeft - 1); + }, 1000); + + return () => clearInterval(interval); + }, [resendCode, setTimeLeft, timeLeft]); + + return ( +
+ {codeResent ? ( +

+ Didn't receive a code? + +

+ ) : ( + <> + Resend code in: + {timeLeft} + + )} +
+ ); +}; diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index 84ffac7fb..c9b84de80 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -88,7 +88,11 @@ export const HomePage = () => { useEffect(() => { if (!window.location.hash) return; - AuthService.persistUser(window.location.hash, webAuth as WebAuth, setAuthState); + AuthService.persistUser( + window.location.hash, + webAuth as WebAuth, + setAuthState + ); history.replace(window.location.pathname + window.location.search); }, [history, setAuthState, webAuth]); diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index c176329d4..9dc7647d8 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,11 +1,14 @@ import type { WebAuth, Auth0Result } from "auth0-js"; import { defaultAuthObject } from "components/AppProvider"; +import type { AuthState } from "components/AppProvider"; export default class AuthService { static calculateExpirationTime(secondsUntilExpiration: number) { const currentTime = new Date(); - const expirationTime = new Date(currentTime.getTime() + secondsUntilExpiration * 1000); - console.log(expirationTime); + const expirationTime = new Date( + currentTime.getTime() + secondsUntilExpiration * 1000 + ); + return expirationTime; } @@ -25,8 +28,10 @@ export default class AuthService { }, accessTokenObject: { token: accessToken, - expiresAt: expiresIn ? this.calculateExpirationTime(expiresIn) : null, - } + expiresAt: expiresIn + ? this.calculateExpirationTime(expiresIn) + : null, + }, }; setAuthState(authObject); @@ -34,33 +39,31 @@ export default class AuthService { }); } - static passwordlessStart = ( - evt: React.SyntheticEvent, - webAuth: WebAuth, - email: string, - callback?: () => void - ) => { - evt.preventDefault(); - webAuth.passwordlessStart( - { - connection: "email", - send: "code", - email, - }, - (err) => { - if (err) { - // TODO: Handle errors - return; - } + static passwordlessStart = (webAuth: WebAuth, email: string) => { + return new Promise((resolve, reject) => { + webAuth.passwordlessStart( + { + connection: "email", + send: "code", + email, + }, + (err) => { + if (err) { + reject(err); + return; + } - if (callback) { - callback(); + resolve(true); } - } - ); + ); + }); }; - static passwordlessVerify = (webAuth: WebAuth, email: string, verificationCode: string) => { + static passwordlessVerify = ( + webAuth: WebAuth, + email: string, + verificationCode: string + ) => { webAuth.passwordlessLogin( { connection: "email", @@ -75,19 +78,23 @@ export default class AuthService { ); }; - static logout = (webAuth: WebAuth, clientId: string, setAuthState) => { + static logout = ( + webAuth: WebAuth, + clientId: string, + setAuthState: (state: AuthState) => void + ) => { // Resets authState which in turn triggers an effect that clears sessionStorage setAuthState(defaultAuthObject); webAuth.logout({ - returnTo: 'http://localhost:8080', - clientID: clientId + returnTo: "http://localhost:8080", + clientID: clientId, }); }; static tokenExpired = (tokenExpiration: Date) => { - return tokenExpiration && (new Date(tokenExpiration) < new Date()); - } + return tokenExpiration && new Date(tokenExpiration) < new Date(); + }; static refreshAuthToken = (webAuth: WebAuth) => { return new Promise((resolve, reject) => { @@ -97,8 +104,7 @@ export default class AuthService { } else { resolve(authResult); } - }) + }); }); - } - + }; } diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index b277287f4..c89ab17d9 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -1,22 +1,12 @@ -interface AuthObject { - isAuthenticated: boolean; - user: { - id: string; - email: string; - }; - accessTokenObject: { - token: string; - expiresAt: Date; - }; -} +import type { AuthState } from "components/AppProvider"; export default class SessionCacher { - static getAuthObject(): AuthObject { - const object = sessionStorage.getItem('authObject'); + static getAuthObject(): AuthState { + const object = sessionStorage.getItem("authObject"); return object ? JSON.parse(object) : null; } - static setAuthObject(authObject: AuthObject) { + static setAuthObject(authObject: AuthState) { sessionStorage.setItem("authObject", JSON.stringify(authObject)); } diff --git a/app/utils/useAppContext.ts b/app/utils/useAppContext.ts index 00169eb10..fd827a584 100644 --- a/app/utils/useAppContext.ts +++ b/app/utils/useAppContext.ts @@ -13,9 +13,9 @@ export const AppContext = createContext({ accessTokenObject: { expiresAt: new Date(1970, 0, 1), token: "", - } + }, }, - setAuthState: <(state: any) => void>null, + setAuthState: <(state: any) => void>(null), webAuth: null, }); From 7bf9444edabb2a6e2bf555953af5d07603c306df Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Thu, 7 Dec 2023 16:01:16 -0800 Subject: [PATCH 06/25] Complete basic user sign up flow and check if user exists prior to signing user up --- app/pages/Auth/SignInPage.tsx | 2 +- app/pages/Auth/SignUpPage.tsx | 14 +++++++------- app/pages/Auth/VerificationModal.tsx | 2 +- app/utils/AuthService.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/pages/Auth/SignInPage.tsx b/app/pages/Auth/SignInPage.tsx index 7e750712c..658011b19 100644 --- a/app/pages/Auth/SignInPage.tsx +++ b/app/pages/Auth/SignInPage.tsx @@ -28,7 +28,7 @@ export const SignInPage = () => {

We want to make sure that your account information is safe, so you will be sent a verification code to your email each time you log in. Please - enter in your email address and then check your email to find a 5-number + enter in your email address and then check your email to find a 6 digit verification code.

diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index f06813594..16e159c28 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -14,17 +14,17 @@ export const SignUpPage = () => { const [name, setName] = useState(""); const [organization, setOrganization] = useState(""); const webAuth = useAppContext().webAuth as WebAuth; - const { passwordlessStart, passwordlessVerify } = AuthService; + const { passwordlessStart, passwordlessVerify, signUpUser } = AuthService; const signUp = (evt: React.SyntheticEvent) => { evt.preventDefault(); - /* Todo: We will need to: - a) save the user's name/org to our database - b) check if the user email already exists and display an error message if so - */ - passwordlessStart(webAuth, email).then(() => { + signUpUser(webAuth, email).then(() => { setModalIsOpen(true); - }); + }, (error) => { + if (error.message === 'userExists') { + alert('Oops, there is already a user with that email in our system. Please try logging in instead.'); + } + }) }; return ( diff --git a/app/pages/Auth/VerificationModal.tsx b/app/pages/Auth/VerificationModal.tsx index 50e7c74f0..d84a027cc 100644 --- a/app/pages/Auth/VerificationModal.tsx +++ b/app/pages/Auth/VerificationModal.tsx @@ -122,7 +122,7 @@ const ResendCode = ({ resendCode }: { resendCode: () => Promise }) => {

Didn't receive a code?

) : ( diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 9dc7647d8..fb203f988 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,5 +1,6 @@ import type { WebAuth, Auth0Result } from "auth0-js"; import { defaultAuthObject } from "components/AppProvider"; +import { get } from "utils/DataService"; import type { AuthState } from "components/AppProvider"; export default class AuthService { @@ -39,6 +40,22 @@ export default class AuthService { }); } + static signUpUser = (webAuth: WebAuth, email: string) => { + return new Promise((resolve, reject) => { + AuthService.userExists(email).then((exists) => { + if (!exists) { + // Todo: save the user's email/name/org to our database along with the ID generated by + // auth0 on successful sign up + resolve(AuthService.passwordlessStart(webAuth, email)); + } else { + reject(new Error('userExists')); + } + }); + }); + }; + + // This method initaties the log in process by sending a code + // to the user's inbox. static passwordlessStart = (webAuth: WebAuth, email: string) => { return new Promise((resolve, reject) => { webAuth.passwordlessStart( @@ -107,4 +124,15 @@ export default class AuthService { }); }); }; + + static userExists = (email: string) => { + return new Promise((resolve, reject) => { + const response = get(`/api/users/user_exists?email=${email}`); + response.then((result) => { + resolve(result.user_exists); + }, (error) => { + reject(error); + }) + }); + } } From b541a93da961398b4951a6f4255b3c885d6ebb65 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Wed, 3 Jan 2024 13:46:36 -0800 Subject: [PATCH 07/25] Update user exists route --- app/utils/AuthService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index fb203f988..2d10475b6 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -127,7 +127,7 @@ export default class AuthService { static userExists = (email: string) => { return new Promise((resolve, reject) => { - const response = get(`/api/users/user_exists?email=${email}`); + const response = get(`/api/auth/user_exists?email=${email}`); response.then((result) => { resolve(result.user_exists); }, (error) => { From bbf2da23d41127c6def6c5894c2a626bd676ed63 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Fri, 5 Jan 2024 12:16:55 -0800 Subject: [PATCH 08/25] PR cleanup, clarifications, and comments. --- app/components/AppProvider.tsx | 22 +++++++++-------- app/components/ui/Navigation.tsx | 7 ++++-- app/config.ts | 17 ++++++++++++- app/pages/Auth/SignInPage.tsx | 8 +++--- app/pages/Auth/SignOutPage.tsx | 7 +++--- app/pages/Auth/SignUpPage.tsx | 10 +++++--- app/pages/HomePage/HomePage.tsx | 12 ++++++--- app/utils/AuthService.ts | 42 ++++++++++++++++++-------------- app/utils/SessionCacher.ts | 6 +++++ app/utils/useAppContext.ts | 2 +- config.example.yml | 6 +++++ 11 files changed, 92 insertions(+), 47 deletions(-) diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 732fe0657..010f8b2ad 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect } from "react"; import auth0, { Auth0Result } from "auth0-js"; import * as Sentry from "@sentry/browser"; import { AppContext, GeoCoordinates, SessionCacher, AuthService } from "utils"; +import config from "../config"; export interface AuthState { isAuthenticated: boolean; @@ -37,16 +38,17 @@ export const AppProvider = ({ const authObject = SessionCacher.getAuthObject() ?? defaultAuthObject; const [authState, setAuthState] = useState(authObject); useEffect(() => { - // This ensures that the sessionStorage authObject is synced to the AppContext's authState + // This effect runs after any changes to the AppContext's authState and syncs the changes + // to the authObject in sessionStorage. SessionCacher.setAuthObject(authState); }, [authState]); const contextValue = useMemo(() => { - const webAuth = new auth0.WebAuth({ - audience: "http://localhost:8080/api", - clientID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", - domain: "dev-nykixf8szsm220fi.us.auth0.com", - redirectUri: "http://localhost:8080", + const authClient = new auth0.WebAuth({ + audience: config.AUTH0_AUDIENCE, + clientID: config.AUTH0_CLIENT_ID, + domain: config.AUTH0_DOMAIN, + redirectUri: config.AUTH0_REDIRECT_URI, responseType: "token id_token", }); @@ -54,16 +56,16 @@ export const AppProvider = ({ userLocation, authState, setAuthState, - webAuth, + authClient, }; }, [authState, userLocation]); if ( authObject.isAuthenticated && authObject.accessTokenObject.expiresAt && - AuthService.tokenExpired(new Date(authObject.accessTokenObject.expiresAt)) + AuthService.hasAccessTokenExpired(new Date(authObject.accessTokenObject.expiresAt)) ) { - AuthService.refreshAuthToken(contextValue.webAuth) + AuthService.refreshAccessToken(contextValue.authClient) .then((result: unknown) => { const authResult = result as Auth0Result; if ( @@ -74,7 +76,7 @@ export const AppProvider = ({ ...authState, accessTokenObject: { token: authResult.accessToken, - expiresAt: AuthService.calculateExpirationTime( + expiresAt: AuthService.calculateSessionExpiration( authResult.expiresIn ), }, diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index e20d0597c..702f5d110 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -2,8 +2,7 @@ import React, { FormEvent, useState } from "react"; import { Link, useHistory } from "react-router-dom"; import cn from "classnames"; import qs from "qs"; -import { WebAuth } from "auth0-js"; -import { useAppContext, AuthService, whiteLabel } from "utils"; +import { useAppContext, whiteLabel } from "utils"; import Translate from "./Translate"; import styles from "./Navigation.module.scss"; @@ -96,6 +95,10 @@ const SiteLinks = () => { return (
    + {/* Todo: This will eventually be replaced by a user icon with a dropdown menu of account related options. + The designs are still forthcoming. For now, it serves as a basic sign-out functionality for the purposes + of development and testing. + */} {authState.isAuthenticated && (
  • Sign Out diff --git a/app/config.ts b/app/config.ts index d9fa5ab6c..be2ecf972 100644 --- a/app/config.ts +++ b/app/config.ts @@ -3,20 +3,35 @@ declare global { } interface Config { + ALGOLIA_APPLICATION_ID: string; + ALGOLIA_INDEX_PREFIX: string; + ALGOLIA_READ_ONLY_API_KEY: string; + AUTH0_AUDIENCE: string; + AUTH0_CLIENT_ID: string; + AUTH0_DOMAIN: string; + AUTH0_REDIRECT_URI: string; GOOGLE_ANALYTICS_ID: string; GOOGLE_ANALYTICS_GA4_ID: string; + GOOGLE_API_KEY: string; + INTERCOM_APP_ID: string; LINKSF_DOMAIN: string; MOHCD_DOMAIN: string; MOHCD_SUBDOMAIN: string; + SENTRY_PROJECT_ID: string; + SENTRY_PUBLIC_KEY: string; SFFAMILIES_DOMAIN: string; + SFFAMILIES_USERWAY_APP_ID: string; UCSF_DOMAIN: string; - [key: string]: any; } const config: Config = { ALGOLIA_APPLICATION_ID: CONFIG.ALGOLIA_APPLICATION_ID, ALGOLIA_INDEX_PREFIX: CONFIG.ALGOLIA_INDEX_PREFIX, ALGOLIA_READ_ONLY_API_KEY: CONFIG.ALGOLIA_READ_ONLY_API_KEY, + AUTH0_AUDIENCE: CONFIG.AUTH0_AUDIENCE, + AUTH0_CLIENT_ID: CONFIG.AUTH0_CLIENT_ID, + AUTH0_DOMAIN: CONFIG.AUTH0_DOMAIN, + AUTH0_REDIRECT_URI: CONFIG.AUTH0_REDIRECT_URI, // When GA sunsets Universal Analytics wit GA4 in July 2023, this prop can be removed GOOGLE_ANALYTICS_ID: process.env.NODE_ENV === "production" ? "UA-116318550-1" : "UA-116318550-2", diff --git a/app/pages/Auth/SignInPage.tsx b/app/pages/Auth/SignInPage.tsx index 658011b19..b345ba32f 100644 --- a/app/pages/Auth/SignInPage.tsx +++ b/app/pages/Auth/SignInPage.tsx @@ -11,12 +11,12 @@ import styles from "./Auth.module.scss"; export const SignInPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); - const webAuth = useAppContext().webAuth as WebAuth; + const authClient = useAppContext().authClient as WebAuth; const { passwordlessStart, passwordlessVerify } = AuthService; const signIn = (evt: React.SyntheticEvent) => { evt.preventDefault(); - passwordlessStart(webAuth, email).then(() => { + passwordlessStart(authClient, email).then(() => { setModalIsOpen(true); }); }; @@ -50,8 +50,8 @@ export const SignInPage = () => { email={email} modalIsOpen={modalIsOpen} setModalIsOpen={setModalIsOpen} - verifyCode={(code) => passwordlessVerify(webAuth, email, code)} - resendCode={() => passwordlessStart(webAuth, email)} + verifyCode={(code) => passwordlessVerify(authClient, email, code)} + resendCode={() => passwordlessStart(authClient, email)} buttonText="Log in" /> diff --git a/app/pages/Auth/SignOutPage.tsx b/app/pages/Auth/SignOutPage.tsx index 4e0a4c7cf..9d7ad8d07 100644 --- a/app/pages/Auth/SignOutPage.tsx +++ b/app/pages/Auth/SignOutPage.tsx @@ -2,16 +2,17 @@ import React, { useEffect } from "react"; import { Redirect } from "react-router-dom"; import { WebAuth } from "auth0-js"; import { useAppContext, AuthService } from "../../utils"; +import Config from "../../config"; export const SignOutPage = () => { const context = useAppContext(); const { setAuthState } = context; - const webAuth = context.webAuth as WebAuth; + const authClient = context.authClient as WebAuth; useEffect(() => { AuthService.logout( - webAuth, - "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc", + authClient, + Config.AUTH0_CLIENT_ID, setAuthState ); }); diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 16e159c28..af4d3327b 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -13,15 +13,17 @@ export const SignUpPage = () => { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [organization, setOrganization] = useState(""); - const webAuth = useAppContext().webAuth as WebAuth; + const authClient = useAppContext().authClient as WebAuth; const { passwordlessStart, passwordlessVerify, signUpUser } = AuthService; const signUp = (evt: React.SyntheticEvent) => { evt.preventDefault(); - signUpUser(webAuth, email).then(() => { + signUpUser(authClient, email).then(() => { setModalIsOpen(true); }, (error) => { if (error.message === 'userExists') { + // eslint-disable-next-line no-alert + // Todo: Handle this case with a proper error message alert('Oops, there is already a user with that email in our system. Please try logging in instead.'); } }) @@ -68,8 +70,8 @@ export const SignUpPage = () => { email={email} modalIsOpen={modalIsOpen} setModalIsOpen={setModalIsOpen} - verifyCode={(code) => passwordlessVerify(webAuth, email, code)} - resendCode={() => passwordlessStart(webAuth, email)} + verifyCode={(code) => passwordlessVerify(authClient, email, code)} + resendCode={() => passwordlessStart(authClient, email)} buttonText="Sign up" /> diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index c9b84de80..c68544441 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -69,7 +69,7 @@ const covidResources = [ ]; export const HomePage = () => { - const { webAuth, setAuthState } = useAppContext(); + const { authClient, setAuthState } = useAppContext(); const [resourceCount, setResourceCount] = useState(); const [searchValue, setSearchValue] = useState(""); const history = useHistory(); @@ -86,15 +86,19 @@ export const HomePage = () => { }); useEffect(() => { - if (!window.location.hash) return; + // Todo: This effect should be moved to the case worker UI homepage when that page is created + const { hash } = window.location; + if (!hash || !hash.includes("access_token")) return; AuthService.persistUser( window.location.hash, - webAuth as WebAuth, + authClient as WebAuth, setAuthState ); + + // Remove the url query params set by Auth0 history.replace(window.location.pathname + window.location.search); - }, [history, setAuthState, webAuth]); + }, [history, setAuthState, authClient]); return ( <> diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 2d10475b6..530a4d347 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -3,8 +3,13 @@ import { defaultAuthObject } from "components/AppProvider"; import { get } from "utils/DataService"; import type { AuthState } from "components/AppProvider"; +/* + This class provides a set of methods that serve as an interface between our application + and the Auth0 servers where the user's state and data is stored. +*/ + export default class AuthService { - static calculateExpirationTime(secondsUntilExpiration: number) { + static calculateSessionExpiration(secondsUntilExpiration: number) { const currentTime = new Date(); const expirationTime = new Date( currentTime.getTime() + secondsUntilExpiration * 1000 @@ -13,8 +18,8 @@ export default class AuthService { return expirationTime; } - static persistUser(hash: string, webAuth: WebAuth, setAuthState: any) { - webAuth.parseHash({ hash }, (err, authResult) => { + static persistUser(hash: string, authClient: WebAuth, setAuthState: any) { + authClient.parseHash({ hash }, (err, authResult) => { if (err) { // TODO: Handle errors } @@ -30,7 +35,7 @@ export default class AuthService { accessTokenObject: { token: accessToken, expiresAt: expiresIn - ? this.calculateExpirationTime(expiresIn) + ? this.calculateSessionExpiration(expiresIn) : null, }, }; @@ -40,13 +45,13 @@ export default class AuthService { }); } - static signUpUser = (webAuth: WebAuth, email: string) => { + static signUpUser = (authClient: WebAuth, email: string) => { return new Promise((resolve, reject) => { AuthService.userExists(email).then((exists) => { if (!exists) { // Todo: save the user's email/name/org to our database along with the ID generated by // auth0 on successful sign up - resolve(AuthService.passwordlessStart(webAuth, email)); + resolve(AuthService.passwordlessStart(authClient, email)); } else { reject(new Error('userExists')); } @@ -54,11 +59,11 @@ export default class AuthService { }); }; - // This method initaties the log in process by sending a code + // This method initiates the sign-in/sign-up process by sending a code // to the user's inbox. - static passwordlessStart = (webAuth: WebAuth, email: string) => { + static passwordlessStart = (authClient: WebAuth, email: string) => { return new Promise((resolve, reject) => { - webAuth.passwordlessStart( + authClient.passwordlessStart( { connection: "email", send: "code", @@ -76,12 +81,14 @@ export default class AuthService { }); }; + // This method passes the user's verification code to Auth0's server, which + // completes their sign-up/sign-in action static passwordlessVerify = ( - webAuth: WebAuth, + authClient: WebAuth, email: string, verificationCode: string ) => { - webAuth.passwordlessLogin( + authClient.passwordlessLogin( { connection: "email", email, @@ -96,26 +103,25 @@ export default class AuthService { }; static logout = ( - webAuth: WebAuth, + authClient: WebAuth, clientId: string, setAuthState: (state: AuthState) => void ) => { - // Resets authState which in turn triggers an effect that clears sessionStorage setAuthState(defaultAuthObject); - webAuth.logout({ + authClient.logout({ returnTo: "http://localhost:8080", clientID: clientId, }); }; - static tokenExpired = (tokenExpiration: Date) => { - return tokenExpiration && new Date(tokenExpiration) < new Date(); + static hasAccessTokenExpired = (tokenExpiration: Date) => { + return !tokenExpiration || (new Date(tokenExpiration) < new Date()); }; - static refreshAuthToken = (webAuth: WebAuth) => { + static refreshAccessToken = (authClient: WebAuth) => { return new Promise((resolve, reject) => { - webAuth.checkSession({}, (err, authResult: Auth0Result) => { + authClient.checkSession({}, (err, authResult: Auth0Result) => { if (err) { reject(err); } else { diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index c89ab17d9..5db4d4102 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -1,5 +1,11 @@ import type { AuthState } from "components/AppProvider"; +/* + This class exists to sync a user's auth state, which is managed by the AppProvider + component, with the browser's sessionStorage; this enables the app to get the user's auth data + upon refreshing the page, etc., and to then reset the auth state. +*/ + export default class SessionCacher { static getAuthObject(): AuthState { const object = sessionStorage.getItem("authObject"); diff --git a/app/utils/useAppContext.ts b/app/utils/useAppContext.ts index fd827a584..f06e53735 100644 --- a/app/utils/useAppContext.ts +++ b/app/utils/useAppContext.ts @@ -16,7 +16,7 @@ export const AppContext = createContext({ }, }, setAuthState: <(state: any) => void>(null), - webAuth: null, + authClient: null, }); export const useAppContext = () => useContext(AppContext); diff --git a/config.example.yml b/config.example.yml index 0c170f951..072bb9abc 100644 --- a/config.example.yml +++ b/config.example.yml @@ -19,3 +19,9 @@ MOHCD_SUBDOMAIN: "testing" # uncomment this to simulate sfserviceguide whitelabel UI locally, assuming that # you're pointing to localhost:8080 # MOHCD_DOMAIN: 'localhost:8080' + +# Auth0 config values +AUTH0_AUDIENCE: "http://localhost:8080/api" +AUTH0_CLIENT_ID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc" +AUTH0_DOMAIN: "dev-nykixf8szsm220fi.us.auth0.com" +AUTH0_REDIRECT_URI: "http://localhost:8080" \ No newline at end of file From d763060c6fcd94ca6de52943ce374abd75f5c53e Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Mon, 8 Jan 2024 14:17:58 -0800 Subject: [PATCH 09/25] Rename sign in and sign out pages and routes to log in and log out --- app/App.tsx | 8 ++++---- app/pages/Auth/{SignInPage.tsx => LoginPage.tsx} | 6 +++--- app/pages/Auth/{SignOutPage.tsx => LogoutPage.tsx} | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) rename app/pages/Auth/{SignInPage.tsx => LoginPage.tsx} (92%) rename app/pages/Auth/{SignOutPage.tsx => LogoutPage.tsx} (93%) diff --git a/app/App.tsx b/app/App.tsx index 12e90b923..3792dbf90 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -44,9 +44,9 @@ import OrganizationEditPage from "./pages/OrganizationEditPage"; import { EditBreakingNewsPage } from "./pages/EditBreakingNewsPage"; import { ServiceDiscoveryForm } from "./pages/ServiceDiscoveryForm"; import { ServiceDiscoveryResults } from "./pages/ServiceDiscoveryResults"; -import { SignInPage } from "./pages/Auth/SignInPage"; +import { LoginPage } from "./pages/Auth/LoginPage"; import { SignUpPage } from "./pages/Auth/SignUpPage"; -import { SignOutPage } from "./pages/Auth/SignOutPage"; +import { LogoutPage } from "./pages/Auth/LogoutPage"; import styles from "./App.module.scss"; @@ -216,9 +216,9 @@ export const App = () => { path="/breaking-news/edit" component={EditBreakingNewsPage} /> - + - + {/* UCSF white label paths */} { +export const LoginPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); const authClient = useAppContext().authClient as WebAuth; const { passwordlessStart, passwordlessVerify } = AuthService; - const signIn = (evt: React.SyntheticEvent) => { + const logIn = (evt: React.SyntheticEvent) => { evt.preventDefault(); passwordlessStart(authClient, email).then(() => { setModalIsOpen(true); @@ -31,7 +31,7 @@ export const SignInPage = () => { enter in your email address and then check your email to find a 6 digit verification code.

    - + { +export const LogoutPage = () => { const context = useAppContext(); const { setAuthState } = context; const authClient = context.authClient as WebAuth; From 67e35a82e58365650fa52c68ac846723a1fdd681 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Mon, 5 Feb 2024 10:41:08 -0800 Subject: [PATCH 10/25] 689 Change a few instances of sign-in to log-in --- app/components/ui/Navigation.tsx | 4 ++-- app/pages/Auth/SignUpPage.tsx | 2 +- app/utils/AuthService.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index 702f5d110..0a07de53e 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -96,12 +96,12 @@ const SiteLinks = () => { return (
      {/* Todo: This will eventually be replaced by a user icon with a dropdown menu of account related options. - The designs are still forthcoming. For now, it serves as a basic sign-out functionality for the purposes + The designs are still forthcoming. For now, it serves as a basic log-out functionality for the purposes of development and testing. */} {authState.isAuthenticated && (
    • - Sign Out + Sign Out
    • )}
    • diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index af4d3327b..575f33cff 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -32,7 +32,7 @@ export const SignUpPage = () => { return (

      For Case Managers

      - Already have an account? Log in! + Already have an account? Log in! Date: Wed, 7 Feb 2024 16:43:48 -0800 Subject: [PATCH 11/25] 689 save user to database after creating them in Auth0 --- app/pages/Auth/LoginPage.tsx | 4 +-- app/pages/Auth/SignUpPage.tsx | 7 ++-- app/utils/AuthService.ts | 62 ++++++++++++++++++++++++++++------- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/app/pages/Auth/LoginPage.tsx b/app/pages/Auth/LoginPage.tsx index 69ab21de4..bdec46a95 100644 --- a/app/pages/Auth/LoginPage.tsx +++ b/app/pages/Auth/LoginPage.tsx @@ -12,7 +12,7 @@ export const LoginPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); const authClient = useAppContext().authClient as WebAuth; - const { passwordlessStart, passwordlessVerify } = AuthService; + const { passwordlessStart, passwordlessLogin } = AuthService; const logIn = (evt: React.SyntheticEvent) => { evt.preventDefault(); @@ -50,7 +50,7 @@ export const LoginPage = () => { email={email} modalIsOpen={modalIsOpen} setModalIsOpen={setModalIsOpen} - verifyCode={(code) => passwordlessVerify(authClient, email, code)} + verifyCode={(code) => passwordlessLogin(authClient, email, code)} resendCode={() => passwordlessStart(authClient, email)} buttonText="Log in" /> diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 575f33cff..7c35eda82 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -14,11 +14,12 @@ export const SignUpPage = () => { const [name, setName] = useState(""); const [organization, setOrganization] = useState(""); const authClient = useAppContext().authClient as WebAuth; - const { passwordlessStart, passwordlessVerify, signUpUser } = AuthService; + const { passwordlessStart, completeUserSignup, initializeUserSignUp } = + AuthService; const signUp = (evt: React.SyntheticEvent) => { evt.preventDefault(); - signUpUser(authClient, email).then(() => { + initializeUserSignUp(authClient, email).then(() => { setModalIsOpen(true); }, (error) => { if (error.message === 'userExists') { @@ -70,7 +71,7 @@ export const SignUpPage = () => { email={email} modalIsOpen={modalIsOpen} setModalIsOpen={setModalIsOpen} - verifyCode={(code) => passwordlessVerify(authClient, email, code)} + verifyCode={(code) => completeUserSignup(authClient, code, email, name, organization)} resendCode={() => passwordlessStart(authClient, email)} buttonText="Sign up" /> diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 734d0be37..91550ab60 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,6 +1,6 @@ import type { WebAuth, Auth0Result } from "auth0-js"; import { defaultAuthObject } from "components/AppProvider"; -import { get } from "utils/DataService"; +import { post } from "utils/DataService"; import type { AuthState } from "components/AppProvider"; /* @@ -45,20 +45,42 @@ export default class AuthService { }); } - static signUpUser = (authClient: WebAuth, email: string) => { + static initializeUserSignUp = (authClient: WebAuth, email: string) => { return new Promise((resolve, reject) => { - AuthService.userExists(email).then((exists) => { - if (!exists) { - // Todo: save the user's email/name/org to our database along with the ID generated by - // auth0 on successful sign up - resolve(AuthService.passwordlessStart(authClient, email)); - } else { + this.userExists(email).then((exists) => { + if (exists) { reject(new Error('userExists')); + } else { + this.passwordlessStart(authClient, email).then((result) => { + resolve(result); + }) } }); }); }; + // Invokes the passwordlessLogin method and following that saves the user to our database + static completeUserSignup = (authClient: WebAuth, + verificationCode: string, + email: string, + name: string, + organization: (string | null) = null) => { + this.passwordlessLogin(authClient, email, verificationCode); + // We need to optimistically save the user to our database here. The user is saved to the _Auth0_ + // database after the passwordlessLogin method succeeds. We also want to save user data in our + // backend. This should be done after a success callback after passwordlessLogin succceds; however, + // the passwordlessLogin success callback does not fire within our app, because, upon success, Auth0 + // triggers a redirect to our home page. At that point, we do not have the user's name or organization, + // which we need to save in our database. Thus, we save the user here. + // + // If for some reason, the passwordlessLogin method errors, this code still save the user in our DB. + // At that point, the worst case scenario is that the user will be informed that they have already + // signed up if they try to sign up again and to log in instead. Since the Auth0 passwordless flow + // does not have a sign-up process separate from its log-in process, and thus the user will still + // be created within Auth0 upon going through our site's log-in flow. + this.saveUser(email, name, organization); + }; + // This method initiates the sign-in/sign-up process by sending a code // to the user's inbox. static passwordlessStart = (authClient: WebAuth, email: string) => { @@ -83,11 +105,11 @@ export default class AuthService { // This method passes the user's verification code to Auth0's server, which // completes their sign-up/log-in action - static passwordlessVerify = ( + static passwordlessLogin = ( authClient: WebAuth, email: string, verificationCode: string - ) => { + ) => { authClient.passwordlessLogin( { connection: "email", @@ -133,9 +155,25 @@ export default class AuthService { static userExists = (email: string) => { return new Promise((resolve, reject) => { - const response = get(`/api/auth/user_exists?email=${email}`); + post('/api/users/user_exists', { + email + }).then((resp) => { + resp.json().then(result => resolve(result.user_exists)); + }, (error) => { + reject(error); + }) + }); + } + + static saveUser = (email: string, name: string, organization: (string | null) = null) => { + return new Promise((resolve, reject) => { + const response = post('/api/users', { + email, + name, + organization, + }); response.then((result) => { - resolve(result.user_exists); + resolve(result); }, (error) => { reject(error); }) From 84b26d0f7a2cb6215aa0664b51440fc8454c5abe Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Fri, 9 Feb 2024 11:19:27 -0800 Subject: [PATCH 12/25] 689 Run prettier and lean up auth PR --- app/components/AppProvider.tsx | 6 +- app/components/ui/Navigation.tsx | 2 +- app/pages/Auth/LogoutPage.tsx | 6 +- app/pages/Auth/SignUpPage.tsx | 25 +++++--- app/pages/Auth/VerificationModal.tsx | 44 ++++--------- app/pages/HomePage/HomePage.tsx | 4 +- app/utils/AuthService.ts | 94 ++++++++++++++++------------ config.example.yml | 2 +- 8 files changed, 92 insertions(+), 91 deletions(-) diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 010f8b2ad..c840d4ef5 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -63,7 +63,9 @@ export const AppProvider = ({ if ( authObject.isAuthenticated && authObject.accessTokenObject.expiresAt && - AuthService.hasAccessTokenExpired(new Date(authObject.accessTokenObject.expiresAt)) + AuthService.hasAccessTokenExpired( + new Date(authObject.accessTokenObject.expiresAt) + ) ) { AuthService.refreshAccessToken(contextValue.authClient) .then((result: unknown) => { @@ -82,7 +84,7 @@ export const AppProvider = ({ }, }); } else { - throw new Error("Token does not exist or is in unexpected token"); + throw new Error("Token does not exist or is unexpected token"); } }) .catch((err) => { diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index 0a07de53e..b02586092 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -101,7 +101,7 @@ const SiteLinks = () => { */} {authState.isAuthenticated && (
    • - Sign Out + Log Out
    • )}
    • diff --git a/app/pages/Auth/LogoutPage.tsx b/app/pages/Auth/LogoutPage.tsx index aebe5a174..711dc0d15 100644 --- a/app/pages/Auth/LogoutPage.tsx +++ b/app/pages/Auth/LogoutPage.tsx @@ -10,11 +10,7 @@ export const LogoutPage = () => { const authClient = context.authClient as WebAuth; useEffect(() => { - AuthService.logout( - authClient, - Config.AUTH0_CLIENT_ID, - setAuthState - ); + AuthService.logout(authClient, Config.AUTH0_CLIENT_ID, setAuthState); }); return ; diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 7c35eda82..3626d0235 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -19,15 +19,20 @@ export const SignUpPage = () => { const signUp = (evt: React.SyntheticEvent) => { evt.preventDefault(); - initializeUserSignUp(authClient, email).then(() => { - setModalIsOpen(true); - }, (error) => { - if (error.message === 'userExists') { - // eslint-disable-next-line no-alert - // Todo: Handle this case with a proper error message - alert('Oops, there is already a user with that email in our system. Please try logging in instead.'); + initializeUserSignUp(authClient, email).then( + () => { + setModalIsOpen(true); + }, + (error) => { + if (error.message === "userExists") { + // eslint-disable-next-line no-alert + // TODO: Handle this case with a proper error message + alert( + "Oops, it looks like you may have already signed up. Please try logging in instead." + ); + } } - }) + ); }; return ( @@ -71,7 +76,9 @@ export const SignUpPage = () => { email={email} modalIsOpen={modalIsOpen} setModalIsOpen={setModalIsOpen} - verifyCode={(code) => completeUserSignup(authClient, code, email, name, organization)} + verifyCode={(code) => + completeUserSignup(authClient, code, email, name, organization) + } resendCode={() => passwordlessStart(authClient, email)} buttonText="Sign up" /> diff --git a/app/pages/Auth/VerificationModal.tsx b/app/pages/Auth/VerificationModal.tsx index d84a027cc..809308119 100644 --- a/app/pages/Auth/VerificationModal.tsx +++ b/app/pages/Auth/VerificationModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, createRef, useEffect } from "react"; +import React, { useState, createRef } from "react"; import { Modal } from "components/ui/Modal/Modal"; import { Button } from "components/ui/inline/Button/Button"; @@ -98,39 +98,19 @@ export const VerificationModal = ({ }; const ResendCode = ({ resendCode }: { resendCode: () => Promise }) => { - const [timeLeft, setTimeLeft] = useState(60); - const [codeResent, setCodeResent] = useState(false); - - useEffect(() => { - const interval = setInterval(() => { - if (timeLeft === 0) { - clearInterval(interval); - resendCode(); - setCodeResent(true); - return; - } - - setTimeLeft(timeLeft - 1); - }, 1000); - - return () => clearInterval(interval); - }, [resendCode, setTimeLeft, timeLeft]); - return (
      - {codeResent ? ( -

      - Didn't receive a code? - -

      - ) : ( - <> - Resend code in: - {timeLeft} - - )} +

      + Didn't receive a code? Please check your spam folder. + +

      ); }; diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index c68544441..2a55b8c81 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -86,11 +86,11 @@ export const HomePage = () => { }); useEffect(() => { - // Todo: This effect should be moved to the case worker UI homepage when that page is created + // TODO: This effect should be moved to the case worker UI homepage when that page is created const { hash } = window.location; if (!hash || !hash.includes("access_token")) return; - AuthService.persistUser( + AuthService.initializeUserSession( window.location.hash, authClient as WebAuth, setAuthState diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 91550ab60..9c10c168a 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -5,7 +5,7 @@ import type { AuthState } from "components/AppProvider"; /* This class provides a set of methods that serve as an interface between our application - and the Auth0 servers where the user's state and data is stored. + and the Auth0 servers where the user's auth state and data is stored. */ export default class AuthService { @@ -18,7 +18,11 @@ export default class AuthService { return expirationTime; } - static persistUser(hash: string, authClient: WebAuth, setAuthState: any) { + static initializeUserSession( + hash: string, + authClient: WebAuth, + setAuthState: any + ) { authClient.parseHash({ hash }, (err, authResult) => { if (err) { // TODO: Handle errors @@ -49,39 +53,41 @@ export default class AuthService { return new Promise((resolve, reject) => { this.userExists(email).then((exists) => { if (exists) { - reject(new Error('userExists')); + reject(new Error("userExists")); } else { this.passwordlessStart(authClient, email).then((result) => { resolve(result); - }) + }); } }); }); }; // Invokes the passwordlessLogin method and following that saves the user to our database - static completeUserSignup = (authClient: WebAuth, + static completeUserSignup = ( + authClient: WebAuth, verificationCode: string, email: string, name: string, - organization: (string | null) = null) => { - this.passwordlessLogin(authClient, email, verificationCode); - // We need to optimistically save the user to our database here. The user is saved to the _Auth0_ - // database after the passwordlessLogin method succeeds. We also want to save user data in our - // backend. This should be done after a success callback after passwordlessLogin succceds; however, - // the passwordlessLogin success callback does not fire within our app, because, upon success, Auth0 - // triggers a redirect to our home page. At that point, we do not have the user's name or organization, - // which we need to save in our database. Thus, we save the user here. - // - // If for some reason, the passwordlessLogin method errors, this code still save the user in our DB. - // At that point, the worst case scenario is that the user will be informed that they have already - // signed up if they try to sign up again and to log in instead. Since the Auth0 passwordless flow - // does not have a sign-up process separate from its log-in process, and thus the user will still - // be created within Auth0 upon going through our site's log-in flow. - this.saveUser(email, name, organization); + organization: string | null = null + ) => { + this.passwordlessLogin(authClient, email, verificationCode); + // We need to optimistically save the user to our database here. The user is saved to the _Auth0_ + // database after the passwordlessLogin method succeeds. Following that we need to save user data in our + // backend. Ideally, this should be done after a success callback after passwordlessLogin succceds; + // however, the passwordlessLogin success callback does not fire within our app, because, upon success, Auth0 + // triggers a redirect to our home page. At that point, we do not have the user's name or organization, + // which we need to save in our database. Thus, we save the user here. + // + // If for some reason, the passwordlessLogin method errors, this code still save the user in our DB. + // At that point, the worst case scenario is that the user will be informed that they have already + // signed up if they try to sign up again and to log in instead. The Auth0 passwordless flow does + // not have a sign-up process separate from its log-in process, and thus the user will still be + // created within Auth0 upon going through our site's log-in flow. + this.saveUser(email, name, organization); }; - // This method initiates the sign-in/sign-up process by sending a code + // This method initiates the log-in/sign-up process by sending a code // to the user's inbox. static passwordlessStart = (authClient: WebAuth, email: string) => { return new Promise((resolve, reject) => { @@ -109,7 +115,7 @@ export default class AuthService { authClient: WebAuth, email: string, verificationCode: string - ) => { + ) => { authClient.passwordlessLogin( { connection: "email", @@ -138,7 +144,7 @@ export default class AuthService { }; static hasAccessTokenExpired = (tokenExpiration: Date) => { - return !tokenExpiration || (new Date(tokenExpiration) < new Date()); + return !tokenExpiration || new Date(tokenExpiration) < new Date(); }; static refreshAccessToken = (authClient: WebAuth) => { @@ -155,28 +161,38 @@ export default class AuthService { static userExists = (email: string) => { return new Promise((resolve, reject) => { - post('/api/users/user_exists', { - email - }).then((resp) => { - resp.json().then(result => resolve(result.user_exists)); - }, (error) => { - reject(error); - }) + post("/api/users/user_exists", { + email, + }).then( + (resp) => { + resp.json().then((result) => resolve(result.user_exists)); + }, + (error) => { + reject(error); + } + ); }); - } + }; - static saveUser = (email: string, name: string, organization: (string | null) = null) => { + static saveUser = ( + email: string, + name: string, + organization: string | null = null + ) => { return new Promise((resolve, reject) => { - const response = post('/api/users', { + const response = post("/api/users", { email, name, organization, }); - response.then((result) => { - resolve(result); - }, (error) => { - reject(error); - }) + response.then( + (result) => { + resolve(result); + }, + (error) => { + reject(error); + } + ); }); - } + }; } diff --git a/config.example.yml b/config.example.yml index 072bb9abc..ee577734e 100644 --- a/config.example.yml +++ b/config.example.yml @@ -24,4 +24,4 @@ MOHCD_SUBDOMAIN: "testing" AUTH0_AUDIENCE: "http://localhost:8080/api" AUTH0_CLIENT_ID: "UcnuRrX6S0SeDEhW9PRe01wEhcvIRuwc" AUTH0_DOMAIN: "dev-nykixf8szsm220fi.us.auth0.com" -AUTH0_REDIRECT_URI: "http://localhost:8080" \ No newline at end of file +AUTH0_REDIRECT_URI: "http://localhost:8080" From e732230e32284a8a5603b5cc2d310847edb8f97f Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Wed, 21 Feb 2024 14:47:44 -0800 Subject: [PATCH 13/25] 689 no longer check if user exists prior to creating user. For various security reasons related to Auth0's passwordless protocol, we will just log the user in without incident --- app/components/AppProvider.tsx | 16 +++++++++ app/pages/Auth/SignUpPage.tsx | 12 +++---- app/utils/AuthService.ts | 61 +++++----------------------------- app/utils/SessionCacher.ts | 15 ++++++++- 4 files changed, 43 insertions(+), 61 deletions(-) diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index c840d4ef5..39933e0f7 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -16,6 +16,12 @@ export interface AuthState { }; } +export interface UserSignUpData { + email: string; + name: string; + organization: string | null; +} + export const defaultAuthObject: AuthState = { isAuthenticated: false, user: { @@ -41,6 +47,16 @@ export const AppProvider = ({ // This effect runs after any changes to the AppContext's authState and syncs the changes // to the authObject in sessionStorage. SessionCacher.setAuthObject(authState); + + // If the SessionCacher has userSignUpData object, that means a user has just been created + // in Auth0 and a redirect has occurred. Therefore, we save the new user data to our database. + // The authState must also have propagated or else we won't have the token yet; thus the + // `authState.isAuthenticated` check + const newUserData = SessionCacher.getUserSignUpData(); + if (newUserData && authState.isAuthenticated) { + SessionCacher.clearUserSignUpData(); + AuthService.saveUser(newUserData, authState.user.id, authState.accessTokenObject.token); + } }, [authState]); const contextValue = useMemo(() => { diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 3626d0235..3c01a7282 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -14,22 +14,18 @@ export const SignUpPage = () => { const [name, setName] = useState(""); const [organization, setOrganization] = useState(""); const authClient = useAppContext().authClient as WebAuth; - const { passwordlessStart, completeUserSignup, initializeUserSignUp } = + const { passwordlessStart, completeUserSignup } = AuthService; const signUp = (evt: React.SyntheticEvent) => { evt.preventDefault(); - initializeUserSignUp(authClient, email).then( + passwordlessStart(authClient, email).then( () => { setModalIsOpen(true); }, (error) => { - if (error.message === "userExists") { - // eslint-disable-next-line no-alert - // TODO: Handle this case with a proper error message - alert( - "Oops, it looks like you may have already signed up. Please try logging in instead." - ); + if (error) { + // TODO: Handle errors } } ); diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 9c10c168a..3850e665c 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,7 +1,8 @@ import type { WebAuth, Auth0Result } from "auth0-js"; import { defaultAuthObject } from "components/AppProvider"; import { post } from "utils/DataService"; -import type { AuthState } from "components/AppProvider"; +import { SessionCacher } from "utils"; +import type { AuthState, UserSignUpData } from "components/AppProvider"; /* This class provides a set of methods that serve as an interface between our application @@ -49,21 +50,7 @@ export default class AuthService { }); } - static initializeUserSignUp = (authClient: WebAuth, email: string) => { - return new Promise((resolve, reject) => { - this.userExists(email).then((exists) => { - if (exists) { - reject(new Error("userExists")); - } else { - this.passwordlessStart(authClient, email).then((result) => { - resolve(result); - }); - } - }); - }); - }; - - // Invokes the passwordlessLogin method and following that saves the user to our database + // Invokes the passwordlessLogin method and following that stores the new user data in sessionStorage static completeUserSignup = ( authClient: WebAuth, verificationCode: string, @@ -72,19 +59,8 @@ export default class AuthService { organization: string | null = null ) => { this.passwordlessLogin(authClient, email, verificationCode); - // We need to optimistically save the user to our database here. The user is saved to the _Auth0_ - // database after the passwordlessLogin method succeeds. Following that we need to save user data in our - // backend. Ideally, this should be done after a success callback after passwordlessLogin succceds; - // however, the passwordlessLogin success callback does not fire within our app, because, upon success, Auth0 - // triggers a redirect to our home page. At that point, we do not have the user's name or organization, - // which we need to save in our database. Thus, we save the user here. - // - // If for some reason, the passwordlessLogin method errors, this code still save the user in our DB. - // At that point, the worst case scenario is that the user will be informed that they have already - // signed up if they try to sign up again and to log in instead. The Auth0 passwordless flow does - // not have a sign-up process separate from its log-in process, and thus the user will still be - // created within Auth0 upon going through our site's log-in flow. - this.saveUser(email, name, organization); + // Store user sign up data, which will be saved to our backend after Auth0 success redirect + SessionCacher.setUserSignUpData({email, name, organization}); }; // This method initiates the log-in/sign-up process by sending a code @@ -159,32 +135,13 @@ export default class AuthService { }); }; - static userExists = (email: string) => { - return new Promise((resolve, reject) => { - post("/api/users/user_exists", { - email, - }).then( - (resp) => { - resp.json().then((result) => resolve(result.user_exists)); - }, - (error) => { - reject(error); - } - ); - }); - }; - static saveUser = ( - email: string, - name: string, - organization: string | null = null + userSignUpData: UserSignUpData, + auth0UserId: string, + authToken: string ) => { return new Promise((resolve, reject) => { - const response = post("/api/users", { - email, - name, - organization, - }); + const response = post("/api/users", {...userSignUpData, auth0_user_id: auth0UserId}, {"Authorization": `Bearer ${authToken}`}); response.then( (result) => { resolve(result); diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index 5db4d4102..d3429eb81 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -1,4 +1,4 @@ -import type { AuthState } from "components/AppProvider"; +import type { AuthState, UserSignUpData } from "components/AppProvider"; /* This class exists to sync a user's auth state, which is managed by the AppProvider @@ -19,4 +19,17 @@ export default class SessionCacher { static clearSession() { sessionStorage.removeItem("authObject"); } + + static setUserSignUpData(userData: UserSignUpData) { + sessionStorage.setItem("userSignUpData", JSON.stringify(userData)); + } + + static getUserSignUpData(): UserSignUpData { + const object = sessionStorage.getItem("userSignUpData"); + return object ? JSON.parse(object) : null; + } + + static clearUserSignUpData() { + sessionStorage.removeItem("userSignUpData"); + } } From 9d7f87cf330b535cba96be52c56fa2c9b50783df Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Fri, 23 Feb 2024 14:04:52 -0800 Subject: [PATCH 14/25] Create user endpoint argument name change --- app/utils/AuthService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 3850e665c..14f0e8fc6 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -137,11 +137,11 @@ export default class AuthService { static saveUser = ( userSignUpData: UserSignUpData, - auth0UserId: string, + userExternalId: string, authToken: string ) => { return new Promise((resolve, reject) => { - const response = post("/api/users", {...userSignUpData, auth0_user_id: auth0UserId}, {"Authorization": `Bearer ${authToken}`}); + const response = post("/api/users", {...userSignUpData, user_external_id: userExternalId}, {"Authorization": `Bearer ${authToken}`}); response.then( (result) => { resolve(result); From 379cd0245e59ec5506eceef450fa2bcb979b4b68 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Fri, 23 Feb 2024 14:11:02 -0800 Subject: [PATCH 15/25] Prettier --- app/components/AppProvider.tsx | 6 +++++- app/pages/Auth/SignUpPage.tsx | 3 +-- app/utils/AuthService.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 39933e0f7..02a72bac9 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -55,7 +55,11 @@ export const AppProvider = ({ const newUserData = SessionCacher.getUserSignUpData(); if (newUserData && authState.isAuthenticated) { SessionCacher.clearUserSignUpData(); - AuthService.saveUser(newUserData, authState.user.id, authState.accessTokenObject.token); + AuthService.saveUser( + newUserData, + authState.user.id, + authState.accessTokenObject.token + ); } }, [authState]); diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 3c01a7282..5f44f4937 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -14,8 +14,7 @@ export const SignUpPage = () => { const [name, setName] = useState(""); const [organization, setOrganization] = useState(""); const authClient = useAppContext().authClient as WebAuth; - const { passwordlessStart, completeUserSignup } = - AuthService; + const { passwordlessStart, completeUserSignup } = AuthService; const signUp = (evt: React.SyntheticEvent) => { evt.preventDefault(); diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 14f0e8fc6..7fffb8212 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -60,7 +60,7 @@ export default class AuthService { ) => { this.passwordlessLogin(authClient, email, verificationCode); // Store user sign up data, which will be saved to our backend after Auth0 success redirect - SessionCacher.setUserSignUpData({email, name, organization}); + SessionCacher.setUserSignUpData({ email, name, organization }); }; // This method initiates the log-in/sign-up process by sending a code @@ -141,7 +141,11 @@ export default class AuthService { authToken: string ) => { return new Promise((resolve, reject) => { - const response = post("/api/users", {...userSignUpData, user_external_id: userExternalId}, {"Authorization": `Bearer ${authToken}`}); + const response = post( + "/api/users", + { ...userSignUpData, user_external_id: userExternalId }, + { Authorization: `Bearer ${authToken}` } + ); response.then( (result) => { resolve(result); From 2182b67ca89c86e5d9b8be5e43fbd22bc6c43ddb Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Mon, 26 Feb 2024 14:36:59 -0800 Subject: [PATCH 16/25] 689 use config value for logout redirect param --- app/utils/AuthService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index 7fffb8212..a7108556b 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -3,6 +3,7 @@ import { defaultAuthObject } from "components/AppProvider"; import { post } from "utils/DataService"; import { SessionCacher } from "utils"; import type { AuthState, UserSignUpData } from "components/AppProvider"; +import config from "../config"; /* This class provides a set of methods that serve as an interface between our application @@ -114,7 +115,7 @@ export default class AuthService { setAuthState(defaultAuthObject); authClient.logout({ - returnTo: "http://localhost:8080", + returnTo: config.AUTH0_REDIRECT_URI, clientID: clientId, }); }; From 605e87a5d790ec6def7433b4d2e92f2f7ff5b045 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Tue, 5 Mar 2024 18:03:29 -0800 Subject: [PATCH 17/25] Add types to AppContext object --- app/utils/useAppContext.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/app/utils/useAppContext.ts b/app/utils/useAppContext.ts index f06e53735..3cb2cbb62 100644 --- a/app/utils/useAppContext.ts +++ b/app/utils/useAppContext.ts @@ -2,8 +2,27 @@ import { createContext, useContext } from "react"; import { WebAuth } from "auth0-js"; import { GeoCoordinates } from "./location"; -export const AppContext = createContext({ - userLocation: null, +interface AuthState { + isAuthenticated: boolean; + user: { + id: string; + email: string; + }; + accessTokenObject: { + expiresAt: Date; + token: string; + }; +} + +interface Context { + userLocation: GeoCoordinates | null; + authState: AuthState; + setAuthState: (state: AuthState) => void; + authClient: WebAuth | null; +} + +export const AppContext = createContext({ + userLocation: null, authState: { isAuthenticated: false, user: { @@ -15,8 +34,8 @@ export const AppContext = createContext({ token: "", }, }, - setAuthState: <(state: any) => void>(null), - authClient: null, + setAuthState: () => {}, + authClient: null, }); export const useAppContext = () => useContext(AppContext); From c4b4ba722948d92e48a812ac323587c478eeca50 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Thu, 14 Mar 2024 15:07:39 -0700 Subject: [PATCH 18/25] 689 migrate AuthService and SessionCacher from class based component to exported top-level functions. Other misc PR cleanup. --- app/components/AppProvider.tsx | 4 +- app/pages/Auth/LoginPage.tsx | 3 +- app/pages/Auth/LogoutPage.tsx | 4 +- app/pages/Auth/SignUpPage.tsx | 3 +- app/pages/HomePage/HomePage.tsx | 3 +- app/utils/AuthService.ts | 256 ++++++++++++++++---------------- app/utils/SessionCacher.ts | 61 ++++---- app/utils/index.ts | 4 +- 8 files changed, 172 insertions(+), 166 deletions(-) diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 02a72bac9..8428c6a41 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -1,7 +1,9 @@ import React, { useState, useMemo, useEffect } from "react"; import auth0, { Auth0Result } from "auth0-js"; import * as Sentry from "@sentry/browser"; -import { AppContext, GeoCoordinates, SessionCacher, AuthService } from "utils"; +import * as SessionCacher from "utils/SessionCacher"; +import * as AuthService from "utils/AuthService"; +import { AppContext, GeoCoordinates } from "utils"; import config from "../config"; export interface AuthState { diff --git a/app/pages/Auth/LoginPage.tsx b/app/pages/Auth/LoginPage.tsx index bdec46a95..68bf7cad5 100644 --- a/app/pages/Auth/LoginPage.tsx +++ b/app/pages/Auth/LoginPage.tsx @@ -2,7 +2,8 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; import { WebAuth } from "auth0-js"; import { Button } from "components/ui/inline/Button/Button"; -import { useAppContext, AuthService } from "utils"; +import * as AuthService from "utils/AuthService"; +import { useAppContext } from "utils"; import { VerificationModal } from "./VerificationModal"; diff --git a/app/pages/Auth/LogoutPage.tsx b/app/pages/Auth/LogoutPage.tsx index 711dc0d15..6ae9147f7 100644 --- a/app/pages/Auth/LogoutPage.tsx +++ b/app/pages/Auth/LogoutPage.tsx @@ -1,7 +1,9 @@ import React, { useEffect } from "react"; import { Redirect } from "react-router-dom"; import { WebAuth } from "auth0-js"; -import { useAppContext, AuthService } from "../../utils"; +import * as AuthService from "utils/AuthService"; +import { useAppContext } from "../../utils"; + import Config from "../../config"; export const LogoutPage = () => { diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 5f44f4937..5e539151b 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -2,7 +2,8 @@ import React, { useState } from "react"; import { WebAuth } from "auth0-js"; import { Link } from "react-router-dom"; import { Button } from "components/ui/inline/Button/Button"; -import { useAppContext, AuthService } from "utils"; +import * as AuthService from "utils/AuthService"; +import { useAppContext } from "utils"; import { VerificationModal } from "./VerificationModal"; diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index 2a55b8c81..1bd576e33 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -5,11 +5,12 @@ import type { WebAuth } from "auth0-js"; import { getResourceCount } from "utils/DataService"; import { Footer, NewsArticles } from "components/ui"; +import * as AuthService from "utils/AuthService"; import { Partners } from "./components/Partners/Partners"; import { SearchBar } from "./components/SearchBar/SearchBar"; import { HomePageSection } from "./components/Section/Section"; import ResourceList from "./components/ResourceList/ResourceList"; -import { whiteLabel, useAppContext, AuthService } from "../../utils"; +import { whiteLabel, useAppContext } from "../../utils"; const { showBreakingNews } = whiteLabel; diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index a7108556b..de62ae31e 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,160 +1,156 @@ import type { WebAuth, Auth0Result } from "auth0-js"; import { defaultAuthObject } from "components/AppProvider"; import { post } from "utils/DataService"; -import { SessionCacher } from "utils"; +import * as SessionCacher from "utils"; import type { AuthState, UserSignUpData } from "components/AppProvider"; import config from "../config"; -/* - This class provides a set of methods that serve as an interface between our application +/** + This file provides a set of methods that serve as an interface between our application and the Auth0 servers where the user's auth state and data is stored. */ -export default class AuthService { - static calculateSessionExpiration(secondsUntilExpiration: number) { - const currentTime = new Date(); - const expirationTime = new Date( - currentTime.getTime() + secondsUntilExpiration * 1000 - ); - - return expirationTime; - } - - static initializeUserSession( - hash: string, - authClient: WebAuth, - setAuthState: any - ) { - authClient.parseHash({ hash }, (err, authResult) => { - if (err) { - // TODO: Handle errors - } +export const calculateSessionExpiration = (secondsUntilExpiration: number) => { + const currentTime = new Date(); + const expirationTime = new Date( + currentTime.getTime() + secondsUntilExpiration * 1000 + ); - if (authResult?.accessToken) { - const { accessToken, expiresIn, idTokenPayload } = authResult; - const authObject = { - isAuthenticated: true, - user: { - email: idTokenPayload.email, - id: idTokenPayload.sub, - }, - accessTokenObject: { - token: accessToken, - expiresAt: expiresIn - ? this.calculateSessionExpiration(expiresIn) - : null, - }, - }; - - setAuthState(authObject); - } - }); - } + return expirationTime; +}; - // Invokes the passwordlessLogin method and following that stores the new user data in sessionStorage - static completeUserSignup = ( - authClient: WebAuth, - verificationCode: string, - email: string, - name: string, - organization: string | null = null - ) => { - this.passwordlessLogin(authClient, email, verificationCode); - // Store user sign up data, which will be saved to our backend after Auth0 success redirect - SessionCacher.setUserSignUpData({ email, name, organization }); - }; +export const initializeUserSession = ( + hash: string, + authClient: WebAuth, + setAuthState: any +) => { + authClient.parseHash({ hash }, (err, authResult) => { + if (err) { + // TODO: Handle errors + } - // This method initiates the log-in/sign-up process by sending a code - // to the user's inbox. - static passwordlessStart = (authClient: WebAuth, email: string) => { - return new Promise((resolve, reject) => { - authClient.passwordlessStart( - { - connection: "email", - send: "code", - email, + if (authResult?.accessToken) { + const { accessToken, expiresIn, idTokenPayload } = authResult; + const authObject = { + isAuthenticated: true, + user: { + email: idTokenPayload.email, + id: idTokenPayload.sub, + }, + accessTokenObject: { + token: accessToken, + expiresAt: expiresIn ? calculateSessionExpiration(expiresIn) : null, }, - (err) => { - if (err) { - reject(err); - return; - } + }; - resolve(true); - } - ); - }); - }; + setAuthState(authObject); + } + }); +}; - // This method passes the user's verification code to Auth0's server, which - // completes their sign-up/log-in action - static passwordlessLogin = ( - authClient: WebAuth, - email: string, - verificationCode: string - ) => { - authClient.passwordlessLogin( +// Invokes the passwordlessLogin method and following that stores the new user data in sessionStorage +export const completeUserSignup = ( + authClient: WebAuth, + verificationCode: string, + email: string, + name: string, + organization: string | null = null +) => { + passwordlessLogin(authClient, email, verificationCode); + // Store user sign up data, which will be saved to our backend after Auth0 success redirect + SessionCacher.setUserSignUpData({ email, name, organization }); +}; + +// This method initiates the log-in/sign-up process by sending a code +// to the user's inbox. +export const passwordlessStart = (authClient: WebAuth, email: string) => { + return new Promise((resolve, reject) => { + authClient.passwordlessStart( { connection: "email", + send: "code", email, - verificationCode, }, (err) => { if (err) { - // TODO: Handle errors + reject(err); + return; } + + resolve(true); } ); - }; + }); +}; + +// This method passes the user's verification code to Auth0's server, which +// completes their sign-up/log-in action +export const passwordlessLogin = ( + authClient: WebAuth, + email: string, + verificationCode: string +) => { + authClient.passwordlessLogin( + { + connection: "email", + email, + verificationCode, + }, + (err) => { + if (err) { + // TODO: Handle errors + } + } + ); +}; - static logout = ( - authClient: WebAuth, - clientId: string, - setAuthState: (state: AuthState) => void - ) => { - setAuthState(defaultAuthObject); +export const logout = ( + authClient: WebAuth, + clientId: string, + setAuthState: (state: AuthState) => void +) => { + setAuthState(defaultAuthObject); - authClient.logout({ - returnTo: config.AUTH0_REDIRECT_URI, - clientID: clientId, - }); - }; + authClient.logout({ + returnTo: config.AUTH0_REDIRECT_URI, + clientID: clientId, + }); +}; - static hasAccessTokenExpired = (tokenExpiration: Date) => { - return !tokenExpiration || new Date(tokenExpiration) < new Date(); - }; +export const hasAccessTokenExpired = (tokenExpiration: Date) => { + return !tokenExpiration || new Date(tokenExpiration) < new Date(); +}; - static refreshAccessToken = (authClient: WebAuth) => { - return new Promise((resolve, reject) => { - authClient.checkSession({}, (err, authResult: Auth0Result) => { - if (err) { - reject(err); - } else { - resolve(authResult); - } - }); +export const refreshAccessToken = (authClient: WebAuth) => { + return new Promise((resolve, reject) => { + authClient.checkSession({}, (err, authResult: Auth0Result) => { + if (err) { + reject(err); + } else { + resolve(authResult); + } }); - }; + }); +}; - static saveUser = ( - userSignUpData: UserSignUpData, - userExternalId: string, - authToken: string - ) => { - return new Promise((resolve, reject) => { - const response = post( - "/api/users", - { ...userSignUpData, user_external_id: userExternalId }, - { Authorization: `Bearer ${authToken}` } - ); - response.then( - (result) => { - resolve(result); - }, - (error) => { - reject(error); - } - ); - }); - }; -} +export const saveUser = ( + userSignUpData: UserSignUpData, + userExternalId: string, + authToken: string +) => { + return new Promise((resolve, reject) => { + const response = post( + "/api/users", + { ...userSignUpData, user_external_id: userExternalId }, + { Authorization: `Bearer ${authToken}` } + ); + response.then( + (result) => { + resolve(result); + }, + (error) => { + reject(error); + } + ); + }); +}; diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index d3429eb81..9e930eee7 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -1,35 +1,38 @@ import type { AuthState, UserSignUpData } from "components/AppProvider"; -/* - This class exists to sync a user's auth state, which is managed by the AppProvider +/** + This file provides methods to sync a user's auth state, which is managed by the AppProvider component, with the browser's sessionStorage; this enables the app to get the user's auth data upon refreshing the page, etc., and to then reset the auth state. */ -export default class SessionCacher { - static getAuthObject(): AuthState { - const object = sessionStorage.getItem("authObject"); - return object ? JSON.parse(object) : null; - } - - static setAuthObject(authObject: AuthState) { - sessionStorage.setItem("authObject", JSON.stringify(authObject)); - } - - static clearSession() { - sessionStorage.removeItem("authObject"); - } - - static setUserSignUpData(userData: UserSignUpData) { - sessionStorage.setItem("userSignUpData", JSON.stringify(userData)); - } - - static getUserSignUpData(): UserSignUpData { - const object = sessionStorage.getItem("userSignUpData"); - return object ? JSON.parse(object) : null; - } - - static clearUserSignUpData() { - sessionStorage.removeItem("userSignUpData"); - } -} +export const getAuthObject = (): AuthState | null => { + const object = sessionStorage.getItem("authObject"); + return object ? JSON.parse(object) : null; +}; + +export const setAuthObject = (authObject: AuthState) => { + sessionStorage.setItem("authObject", JSON.stringify(authObject)); +}; + +export const clearSession = () => { + sessionStorage.removeItem("authObject"); +}; + +export const setUserSignUpData = (userData: UserSignUpData) => { + sessionStorage.setItem("userSignUpData", JSON.stringify(userData)); +}; + +export const hasUserSignupData = (): boolean => { + const object = sessionStorage.getItem("userSignUpData"); + return !!object; +}; + +export const getUserSignUpData = (): UserSignUpData | null => { + const object = sessionStorage.getItem("userSignUpData"); + return object ? JSON.parse(object) : null; +}; + +export const clearUserSignUpData = () => { + sessionStorage.removeItem("userSignUpData"); +}; diff --git a/app/utils/index.ts b/app/utils/index.ts index 5b12e9608..650951a31 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -2,6 +2,6 @@ export * from "./location"; export * from "./numbers"; export * from "./time"; export * from "./useAppContext"; -export { default as AuthService } from "./AuthService"; -export { default as SessionCacher } from "./SessionCacher"; +export * from "./SessionCacher"; +export * from "./AuthService"; export { default as whiteLabel } from "./whitelabel"; From b169d0c5329cd6cf0bb60f9f8c734788f6e91ee2 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Thu, 14 Mar 2024 16:40:20 -0700 Subject: [PATCH 19/25] More misc PR updates --- app/components/AppProvider.tsx | 23 +++------------- app/pages/HomePage/HomePage.tsx | 2 +- app/utils/AuthService.ts | 47 ++++++++++++++++----------------- app/utils/SessionCacher.ts | 3 ++- app/utils/useAppContext.ts | 2 +- 5 files changed, 31 insertions(+), 46 deletions(-) diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 8428c6a41..b406ab2b9 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -3,21 +3,9 @@ import auth0, { Auth0Result } from "auth0-js"; import * as Sentry from "@sentry/browser"; import * as SessionCacher from "utils/SessionCacher"; import * as AuthService from "utils/AuthService"; -import { AppContext, GeoCoordinates } from "utils"; +import { AuthState, AppContext, GeoCoordinates } from "utils"; import config from "../config"; -export interface AuthState { - isAuthenticated: boolean; - user: { - id: string; - email: string; - }; - accessTokenObject: { - token: string; - expiresAt: Date; - }; -} - export interface UserSignUpData { email: string; name: string; @@ -90,12 +78,9 @@ export const AppProvider = ({ ) ) { AuthService.refreshAccessToken(contextValue.authClient) - .then((result: unknown) => { - const authResult = result as Auth0Result; - if ( - authResult.accessToken && - typeof authResult.expiresIn !== "undefined" - ) { + .then((result: Auth0Result) => { + const authResult = result; + if (authResult.accessToken && authResult.expiresIn !== undefined) { setAuthState({ ...authState, accessTokenObject: { diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index 1bd576e33..4b26b1489 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -84,7 +84,7 @@ export const HomePage = () => { useEffect(() => { getResourceCount().then((count: number) => setResourceCount(count)); - }); + }, []); useEffect(() => { // TODO: This effect should be moved to the case worker UI homepage when that page is created diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index de62ae31e..a962c933b 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,8 +1,9 @@ import type { WebAuth, Auth0Result } from "auth0-js"; -import { defaultAuthObject } from "components/AppProvider"; import { post } from "utils/DataService"; import * as SessionCacher from "utils"; -import type { AuthState, UserSignUpData } from "components/AppProvider"; +import { defaultAuthObject } from "components/AppProvider"; +import type { UserSignUpData } from "components/AppProvider"; +import type { AuthState } from "utils"; import config from "../config"; /** @@ -22,7 +23,7 @@ export const calculateSessionExpiration = (secondsUntilExpiration: number) => { export const initializeUserSession = ( hash: string, authClient: WebAuth, - setAuthState: any + setAuthState: (a: AuthState) => void ) => { authClient.parseHash({ hash }, (err, authResult) => { if (err) { @@ -39,7 +40,9 @@ export const initializeUserSession = ( }, accessTokenObject: { token: accessToken, - expiresAt: expiresIn ? calculateSessionExpiration(expiresIn) : null, + expiresAt: expiresIn + ? calculateSessionExpiration(expiresIn) + : new Date(1970, 0, 1), }, }; @@ -61,8 +64,9 @@ export const completeUserSignup = ( SessionCacher.setUserSignUpData({ email, name, organization }); }; -// This method initiates the log-in/sign-up process by sending a code -// to the user's inbox. +/** This method initiates the log-in/sign-up process by sending a code + to the user's inbox. +*/ export const passwordlessStart = (authClient: WebAuth, email: string) => { return new Promise((resolve, reject) => { authClient.passwordlessStart( @@ -83,8 +87,11 @@ export const passwordlessStart = (authClient: WebAuth, email: string) => { }); }; -// This method passes the user's verification code to Auth0's server, which -// completes their sign-up/log-in action +/** This method passes the user's verification code to Auth0's server, which + completes their sign-up/log-in action. Upon success, the Auth0 library + will initiate a redirect, which is why this function doesn't have a + meaningful return value. +*/ export const passwordlessLogin = ( authClient: WebAuth, email: string, @@ -121,7 +128,9 @@ export const hasAccessTokenExpired = (tokenExpiration: Date) => { return !tokenExpiration || new Date(tokenExpiration) < new Date(); }; -export const refreshAccessToken = (authClient: WebAuth) => { +export const refreshAccessToken = ( + authClient: WebAuth +): Promise => { return new Promise((resolve, reject) => { authClient.checkSession({}, (err, authResult: Auth0Result) => { if (err) { @@ -138,19 +147,9 @@ export const saveUser = ( userExternalId: string, authToken: string ) => { - return new Promise((resolve, reject) => { - const response = post( - "/api/users", - { ...userSignUpData, user_external_id: userExternalId }, - { Authorization: `Bearer ${authToken}` } - ); - response.then( - (result) => { - resolve(result); - }, - (error) => { - reject(error); - } - ); - }); + return post( + "/api/users", + { ...userSignUpData, user_external_id: userExternalId }, + { Authorization: `Bearer ${authToken}` } + ); }; diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index 9e930eee7..fe14e58bc 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -1,4 +1,5 @@ -import type { AuthState, UserSignUpData } from "components/AppProvider"; +import type { UserSignUpData } from "components/AppProvider"; +import type { AuthState } from "utils"; /** This file provides methods to sync a user's auth state, which is managed by the AppProvider diff --git a/app/utils/useAppContext.ts b/app/utils/useAppContext.ts index 3cb2cbb62..021bd1f90 100644 --- a/app/utils/useAppContext.ts +++ b/app/utils/useAppContext.ts @@ -2,7 +2,7 @@ import { createContext, useContext } from "react"; import { WebAuth } from "auth0-js"; import { GeoCoordinates } from "./location"; -interface AuthState { +export interface AuthState { isAuthenticated: boolean; user: { id: string; From 7c8ae8096f034109a515ec1a71b52cfa55e3d667 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Tue, 19 Mar 2024 15:51:59 -0700 Subject: [PATCH 20/25] 689 combine useAppContext and AppProvider files. Remove isAuthenticated bool for authState interface. Set authState object to null if user is logged out rather than use isAuthenticated bool. --- app/App.tsx | 3 +- app/components/ui/Navigation.tsx | 2 +- app/pages/Auth/LoginPage.tsx | 4 +- app/utils/AuthService.ts | 11 +-- app/utils/SessionCacher.ts | 3 +- app/utils/useAppContext.ts | 41 -------- .../useAppContext.tsx} | 96 +++++++++++-------- 7 files changed, 66 insertions(+), 94 deletions(-) delete mode 100644 app/utils/useAppContext.ts rename app/{components/AppProvider.tsx => utils/useAppContext.tsx} (63%) diff --git a/app/App.tsx b/app/App.tsx index 53b97a05c..a79f3d8de 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -10,7 +10,7 @@ import Intercom from "react-intercom"; import { Helmet } from "react-helmet-async"; import { Redirect, Route, Switch, useHistory } from "react-router-dom"; -import { GeoCoordinates, getLocation, whiteLabel } from "./utils"; +import { GeoCoordinates, getLocation, whiteLabel, AppProvider } from "./utils"; import { Banner, HamburgerMenu, @@ -19,7 +19,6 @@ import { PopupMessageProp, UserWay, } from "./components/ui"; -import { AppProvider } from "./components/AppProvider"; import config from "./config"; import MetaImage from "./assets/img/sfsg-preview.png"; diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index b02586092..b04b1616e 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -99,7 +99,7 @@ const SiteLinks = () => { The designs are still forthcoming. For now, it serves as a basic log-out functionality for the purposes of development and testing. */} - {authState.isAuthenticated && ( + {authState && (
    • Log Out
    • diff --git a/app/pages/Auth/LoginPage.tsx b/app/pages/Auth/LoginPage.tsx index 68bf7cad5..40c0e444c 100644 --- a/app/pages/Auth/LoginPage.tsx +++ b/app/pages/Auth/LoginPage.tsx @@ -2,8 +2,7 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; import { WebAuth } from "auth0-js"; import { Button } from "components/ui/inline/Button/Button"; -import * as AuthService from "utils/AuthService"; -import { useAppContext } from "utils"; +import { useAppContext, passwordlessLogin, passwordlessStart } from "utils"; import { VerificationModal } from "./VerificationModal"; @@ -13,7 +12,6 @@ export const LoginPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); const authClient = useAppContext().authClient as WebAuth; - const { passwordlessStart, passwordlessLogin } = AuthService; const logIn = (evt: React.SyntheticEvent) => { evt.preventDefault(); diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index a962c933b..b5cfd6ed6 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,9 +1,7 @@ import type { WebAuth, Auth0Result } from "auth0-js"; import { post } from "utils/DataService"; -import * as SessionCacher from "utils"; -import { defaultAuthObject } from "components/AppProvider"; -import type { UserSignUpData } from "components/AppProvider"; -import type { AuthState } from "utils"; +import type { AuthState, UserSignUpData } from "utils"; +import { setUserSignUpData } from "utils"; import config from "../config"; /** @@ -33,7 +31,6 @@ export const initializeUserSession = ( if (authResult?.accessToken) { const { accessToken, expiresIn, idTokenPayload } = authResult; const authObject = { - isAuthenticated: true, user: { email: idTokenPayload.email, id: idTokenPayload.sub, @@ -61,7 +58,7 @@ export const completeUserSignup = ( ) => { passwordlessLogin(authClient, email, verificationCode); // Store user sign up data, which will be saved to our backend after Auth0 success redirect - SessionCacher.setUserSignUpData({ email, name, organization }); + setUserSignUpData({ email, name, organization }); }; /** This method initiates the log-in/sign-up process by sending a code @@ -116,7 +113,7 @@ export const logout = ( clientId: string, setAuthState: (state: AuthState) => void ) => { - setAuthState(defaultAuthObject); + setAuthState(null); authClient.logout({ returnTo: config.AUTH0_REDIRECT_URI, diff --git a/app/utils/SessionCacher.ts b/app/utils/SessionCacher.ts index fe14e58bc..859e831f0 100644 --- a/app/utils/SessionCacher.ts +++ b/app/utils/SessionCacher.ts @@ -1,5 +1,4 @@ -import type { UserSignUpData } from "components/AppProvider"; -import type { AuthState } from "utils"; +import type { AuthState, UserSignUpData } from "utils"; /** This file provides methods to sync a user's auth state, which is managed by the AppProvider diff --git a/app/utils/useAppContext.ts b/app/utils/useAppContext.ts deleted file mode 100644 index 021bd1f90..000000000 --- a/app/utils/useAppContext.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createContext, useContext } from "react"; -import { WebAuth } from "auth0-js"; -import { GeoCoordinates } from "./location"; - -export interface AuthState { - isAuthenticated: boolean; - user: { - id: string; - email: string; - }; - accessTokenObject: { - expiresAt: Date; - token: string; - }; -} - -interface Context { - userLocation: GeoCoordinates | null; - authState: AuthState; - setAuthState: (state: AuthState) => void; - authClient: WebAuth | null; -} - -export const AppContext = createContext({ - userLocation: null, - authState: { - isAuthenticated: false, - user: { - id: "", - email: "", - }, - accessTokenObject: { - expiresAt: new Date(1970, 0, 1), - token: "", - }, - }, - setAuthState: () => {}, - authClient: null, -}); - -export const useAppContext = () => useContext(AppContext); diff --git a/app/components/AppProvider.tsx b/app/utils/useAppContext.tsx similarity index 63% rename from app/components/AppProvider.tsx rename to app/utils/useAppContext.tsx index b406ab2b9..d9399f949 100644 --- a/app/components/AppProvider.tsx +++ b/app/utils/useAppContext.tsx @@ -1,9 +1,15 @@ -import React, { useState, useMemo, useEffect } from "react"; -import auth0, { Auth0Result } from "auth0-js"; +import React, { + useState, + useMemo, + useEffect, + createContext, + useContext, +} from "react"; +import auth0, { Auth0Result, WebAuth } from "auth0-js"; import * as Sentry from "@sentry/browser"; import * as SessionCacher from "utils/SessionCacher"; import * as AuthService from "utils/AuthService"; -import { AuthState, AppContext, GeoCoordinates } from "utils"; +import { GeoCoordinates } from "utils"; import config from "../config"; export interface UserSignUpData { @@ -12,17 +18,40 @@ export interface UserSignUpData { organization: string | null; } -export const defaultAuthObject: AuthState = { - isAuthenticated: false, +export type AuthState = { user: { - id: "", - email: "", - }, + id: string; + email: string; + }; accessTokenObject: { - token: "", - expiresAt: new Date(1970, 0, 1), - }, -}; + expiresAt: Date; + token: string; + }; +} | null; + +interface Context { + userLocation: GeoCoordinates | null; + authState: AuthState; + setAuthState: (state: AuthState) => void; + authClient: WebAuth | null; +} + +export const AppContext = createContext({ + userLocation: null, + authState: null, + setAuthState: () => {}, + authClient: null, +}); + +export const useAppContext = () => useContext(AppContext); + +const authClient = new auth0.WebAuth({ + audience: config.AUTH0_AUDIENCE, + clientID: config.AUTH0_CLIENT_ID, + domain: config.AUTH0_DOMAIN, + redirectUri: config.AUTH0_REDIRECT_URI, + responseType: "token id_token", +}); export const AppProvider = ({ children, @@ -31,8 +60,17 @@ export const AppProvider = ({ children: React.ReactNode; userLocation: GeoCoordinates | null; }) => { - const authObject = SessionCacher.getAuthObject() ?? defaultAuthObject; - const [authState, setAuthState] = useState(authObject); + const authObject = SessionCacher.getAuthObject(); + const [authState, setAuthState] = useState(authObject); + const contextValue = useMemo(() => { + return { + userLocation, + authState, + setAuthState, + authClient, + }; + }, [authState, userLocation]); + useEffect(() => { // This effect runs after any changes to the AppContext's authState and syncs the changes // to the authObject in sessionStorage. @@ -40,10 +78,9 @@ export const AppProvider = ({ // If the SessionCacher has userSignUpData object, that means a user has just been created // in Auth0 and a redirect has occurred. Therefore, we save the new user data to our database. - // The authState must also have propagated or else we won't have the token yet; thus the - // `authState.isAuthenticated` check + // The authState must also have propagated or else we won't have the token yet. const newUserData = SessionCacher.getUserSignUpData(); - if (newUserData && authState.isAuthenticated) { + if (newUserData && authState) { SessionCacher.clearUserSignUpData(); AuthService.saveUser( newUserData, @@ -53,25 +90,8 @@ export const AppProvider = ({ } }, [authState]); - const contextValue = useMemo(() => { - const authClient = new auth0.WebAuth({ - audience: config.AUTH0_AUDIENCE, - clientID: config.AUTH0_CLIENT_ID, - domain: config.AUTH0_DOMAIN, - redirectUri: config.AUTH0_REDIRECT_URI, - responseType: "token id_token", - }); - - return { - userLocation, - authState, - setAuthState, - authClient, - }; - }, [authState, userLocation]); - if ( - authObject.isAuthenticated && + authObject && authObject.accessTokenObject.expiresAt && AuthService.hasAccessTokenExpired( new Date(authObject.accessTokenObject.expiresAt) @@ -80,15 +100,15 @@ export const AppProvider = ({ AuthService.refreshAccessToken(contextValue.authClient) .then((result: Auth0Result) => { const authResult = result; - if (authResult.accessToken && authResult.expiresIn !== undefined) { + if (authState && authResult.accessToken && authResult.expiresIn !== undefined) { setAuthState({ ...authState, accessTokenObject: { token: authResult.accessToken, expiresAt: AuthService.calculateSessionExpiration( authResult.expiresIn - ), - }, + ), + }, }); } else { throw new Error("Token does not exist or is unexpected token"); From bf686b44f169248f9041364d011d7bbfc95c46a2 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Tue, 19 Mar 2024 16:00:26 -0700 Subject: [PATCH 21/25] Prettier --- app/utils/useAppContext.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/utils/useAppContext.tsx b/app/utils/useAppContext.tsx index d9399f949..292d1b2b3 100644 --- a/app/utils/useAppContext.tsx +++ b/app/utils/useAppContext.tsx @@ -92,7 +92,6 @@ export const AppProvider = ({ if ( authObject && - authObject.accessTokenObject.expiresAt && AuthService.hasAccessTokenExpired( new Date(authObject.accessTokenObject.expiresAt) ) @@ -100,15 +99,19 @@ export const AppProvider = ({ AuthService.refreshAccessToken(contextValue.authClient) .then((result: Auth0Result) => { const authResult = result; - if (authState && authResult.accessToken && authResult.expiresIn !== undefined) { + if ( + authState && + authResult.accessToken && + authResult.expiresIn !== undefined + ) { setAuthState({ ...authState, accessTokenObject: { token: authResult.accessToken, expiresAt: AuthService.calculateSessionExpiration( authResult.expiresIn - ), - }, + ), + }, }); } else { throw new Error("Token does not exist or is unexpected token"); From e3631cfcbaf5fe80fa50644d333e0bd40074cd29 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Thu, 21 Mar 2024 12:48:49 -0700 Subject: [PATCH 22/25] 689 moar PR cleanup --- app/components/ui/Navigation.tsx | 3 +-- app/pages/Auth/LoginPage.tsx | 3 +-- app/pages/Auth/LogoutPage.tsx | 3 +-- app/pages/Auth/SignUpPage.tsx | 3 +-- app/pages/HomePage/HomePage.tsx | 2 +- app/utils/useAppContext.tsx | 20 ++++++++++---------- 6 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index b04b1616e..c9263a65f 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -90,8 +90,7 @@ const SiteLogo = () => ); const SiteLinks = () => { - const context = useAppContext(); - const { authState } = context; + const { authState } = useAppContext(); return (
        diff --git a/app/pages/Auth/LoginPage.tsx b/app/pages/Auth/LoginPage.tsx index 40c0e444c..40aad0526 100644 --- a/app/pages/Auth/LoginPage.tsx +++ b/app/pages/Auth/LoginPage.tsx @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; -import { WebAuth } from "auth0-js"; import { Button } from "components/ui/inline/Button/Button"; import { useAppContext, passwordlessLogin, passwordlessStart } from "utils"; @@ -11,7 +10,7 @@ import styles from "./Auth.module.scss"; export const LoginPage = () => { const [modalIsOpen, setModalIsOpen] = useState(false); const [email, setEmail] = useState(""); - const authClient = useAppContext().authClient as WebAuth; + const { authClient } = useAppContext(); const logIn = (evt: React.SyntheticEvent) => { evt.preventDefault(); diff --git a/app/pages/Auth/LogoutPage.tsx b/app/pages/Auth/LogoutPage.tsx index 6ae9147f7..a006582eb 100644 --- a/app/pages/Auth/LogoutPage.tsx +++ b/app/pages/Auth/LogoutPage.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from "react"; import { Redirect } from "react-router-dom"; -import { WebAuth } from "auth0-js"; import * as AuthService from "utils/AuthService"; import { useAppContext } from "../../utils"; @@ -9,7 +8,7 @@ import Config from "../../config"; export const LogoutPage = () => { const context = useAppContext(); const { setAuthState } = context; - const authClient = context.authClient as WebAuth; + const { authClient } = context; useEffect(() => { AuthService.logout(authClient, Config.AUTH0_CLIENT_ID, setAuthState); diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index 5e539151b..bac49f60c 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -1,5 +1,4 @@ import React, { useState } from "react"; -import { WebAuth } from "auth0-js"; import { Link } from "react-router-dom"; import { Button } from "components/ui/inline/Button/Button"; import * as AuthService from "utils/AuthService"; @@ -14,7 +13,7 @@ export const SignUpPage = () => { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [organization, setOrganization] = useState(""); - const authClient = useAppContext().authClient as WebAuth; + const { authClient } = useAppContext(); const { passwordlessStart, completeUserSignup } = AuthService; const signUp = (evt: React.SyntheticEvent) => { diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index 4b26b1489..cb72440a7 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -93,7 +93,7 @@ export const HomePage = () => { AuthService.initializeUserSession( window.location.hash, - authClient as WebAuth, + authClient, setAuthState ); diff --git a/app/utils/useAppContext.tsx b/app/utils/useAppContext.tsx index 292d1b2b3..206c7f47c 100644 --- a/app/utils/useAppContext.tsx +++ b/app/utils/useAppContext.tsx @@ -33,18 +33,9 @@ interface Context { userLocation: GeoCoordinates | null; authState: AuthState; setAuthState: (state: AuthState) => void; - authClient: WebAuth | null; + authClient: WebAuth; } -export const AppContext = createContext({ - userLocation: null, - authState: null, - setAuthState: () => {}, - authClient: null, -}); - -export const useAppContext = () => useContext(AppContext); - const authClient = new auth0.WebAuth({ audience: config.AUTH0_AUDIENCE, clientID: config.AUTH0_CLIENT_ID, @@ -53,6 +44,15 @@ const authClient = new auth0.WebAuth({ responseType: "token id_token", }); +export const AppContext = createContext({ + userLocation: null, + authState: null, + setAuthState: () => {}, + authClient, +}); + +export const useAppContext = () => useContext(AppContext); + export const AppProvider = ({ children, userLocation, From 8554f21de7e81caee24ef5a305de184daf972fea Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Thu, 21 Mar 2024 12:51:09 -0700 Subject: [PATCH 23/25] Lint fix --- app/pages/HomePage/HomePage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/pages/HomePage/HomePage.tsx b/app/pages/HomePage/HomePage.tsx index cb72440a7..b82ab8976 100644 --- a/app/pages/HomePage/HomePage.tsx +++ b/app/pages/HomePage/HomePage.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import qs from "qs"; -import type { WebAuth } from "auth0-js"; import { getResourceCount } from "utils/DataService"; import { Footer, NewsArticles } from "components/ui"; From 8b9be56b16eae9734eca7a52413930d0bc2be893 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Mon, 25 Mar 2024 14:59:05 -0700 Subject: [PATCH 24/25] 689 PR feedback and cleanup --- app/pages/Auth/SignUpPage.tsx | 4 +++- app/utils/AuthService.ts | 11 +++++++---- app/utils/useAppContext.tsx | 8 ++++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index bac49f60c..fa57b8f83 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import * as Sentry from "@sentry/browser"; import { Link } from "react-router-dom"; import { Button } from "components/ui/inline/Button/Button"; import * as AuthService from "utils/AuthService"; @@ -24,7 +25,8 @@ export const SignUpPage = () => { }, (error) => { if (error) { - // TODO: Handle errors + // TODO: Inform user of the error? + Sentry.captureException(err); } } ); diff --git a/app/utils/AuthService.ts b/app/utils/AuthService.ts index b5cfd6ed6..93ca00c34 100644 --- a/app/utils/AuthService.ts +++ b/app/utils/AuthService.ts @@ -1,4 +1,5 @@ import type { WebAuth, Auth0Result } from "auth0-js"; +import * as Sentry from "@sentry/browser"; import { post } from "utils/DataService"; import type { AuthState, UserSignUpData } from "utils"; import { setUserSignUpData } from "utils"; @@ -25,7 +26,8 @@ export const initializeUserSession = ( ) => { authClient.parseHash({ hash }, (err, authResult) => { if (err) { - // TODO: Handle errors + // TODO: Inform user of the error? + Sentry.captureException(err); } if (authResult?.accessToken) { @@ -39,7 +41,7 @@ export const initializeUserSession = ( token: accessToken, expiresAt: expiresIn ? calculateSessionExpiration(expiresIn) - : new Date(1970, 0, 1), + : new Date(1970, 1, 1), }, }; @@ -102,7 +104,8 @@ export const passwordlessLogin = ( }, (err) => { if (err) { - // TODO: Handle errors + // TODO: Inform user of the error? + Sentry.captureException(err); } } ); @@ -122,7 +125,7 @@ export const logout = ( }; export const hasAccessTokenExpired = (tokenExpiration: Date) => { - return !tokenExpiration || new Date(tokenExpiration) < new Date(); + return !tokenExpiration || tokenExpiration < new Date(); }; export const refreshAccessToken = ( diff --git a/app/utils/useAppContext.tsx b/app/utils/useAppContext.tsx index 206c7f47c..35418a028 100644 --- a/app/utils/useAppContext.tsx +++ b/app/utils/useAppContext.tsx @@ -62,6 +62,11 @@ export const AppProvider = ({ }) => { const authObject = SessionCacher.getAuthObject(); const [authState, setAuthState] = useState(authObject); + + // We have to use useMemo here to manage the contextValue to ensure that the user's authState + // propagates downward after authentication. I couldn't find a way to get this to work with + // useState. Moreover, we can't use a simple object to define contextValue, as the object would + // be recreated at each render and thus force all of its child components to re-render as well. const contextValue = useMemo(() => { return { userLocation, @@ -97,8 +102,7 @@ export const AppProvider = ({ ) ) { AuthService.refreshAccessToken(contextValue.authClient) - .then((result: Auth0Result) => { - const authResult = result; + .then((authResult: Auth0Result) => { if ( authState && authResult.accessToken && From 97cde91fa183f1d420e0cf4ab991e1317a31d3b4 Mon Sep 17 00:00:00 2001 From: Brian Schroer Date: Mon, 25 Mar 2024 15:03:11 -0700 Subject: [PATCH 25/25] Fix argument name --- app/pages/Auth/SignUpPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/Auth/SignUpPage.tsx b/app/pages/Auth/SignUpPage.tsx index fa57b8f83..2c93e1f05 100644 --- a/app/pages/Auth/SignUpPage.tsx +++ b/app/pages/Auth/SignUpPage.tsx @@ -26,7 +26,7 @@ export const SignUpPage = () => { (error) => { if (error) { // TODO: Inform user of the error? - Sentry.captureException(err); + Sentry.captureException(error); } } );