From 998bb3802f0800f57154af81789ee52dd1d5fa11 Mon Sep 17 00:00:00 2001 From: Nafis Zaman Date: Thu, 25 Jan 2024 12:45:46 -0600 Subject: [PATCH] Initial work for EULA support --- config.js | 15 + configs/webpack/common.js | 4 +- express.js | 107 +++ firebaseAuth.js | 56 ++ package.json | 2 + src/App.tsx | 27 +- src/components/ParentalConsent/index.tsx | 20 + .../ParentalConsent/parental-consent.html.ejs | 36 + src/components/interface/Form.tsx | 11 + src/consent/LegalAcceptance.tsx | 39 + src/consent/UserConsent.tsx | 8 + src/db/Selector.ts | 6 + src/db/constants.ts | 1 + src/firebase/firebase.ts | 5 +- src/pages/LoginPage.tsx | 337 +++++++- src/pages/ParentalConsentPage.tsx | 67 ++ src/util/Validator.ts | 22 +- static/sample.pdf | Bin 0 -> 3028 bytes yarn.lock | 772 +++++++++++++++++- 19 files changed, 1468 insertions(+), 67 deletions(-) create mode 100644 firebaseAuth.js create mode 100644 src/components/ParentalConsent/index.tsx create mode 100644 src/components/ParentalConsent/parental-consent.html.ejs create mode 100644 src/consent/LegalAcceptance.tsx create mode 100644 src/consent/UserConsent.tsx create mode 100644 src/pages/ParentalConsentPage.tsx create mode 100755 static/sample.pdf diff --git a/config.js b/config.js index 042b8889..03710005 100644 --- a/config.js +++ b/config.js @@ -11,6 +11,18 @@ try { console.error(e); } +const serviceAccountKeyString = process.env.FIREBASE_SERVICE_ACCOUNT_KEY_STRING; +const serviceAccountKeyFile = process.env.FIREBASE_SERVICE_ACCOUNT_KEY_FILE; + +let serviceAccountKey; +if (serviceAccountKeyString) { + serviceAccountKey = JSON.parse(serviceAccountKeyString); +} else if (serviceAccountKeyFile) { + serviceAccountKey = JSON.parse(fs.readFileSync(serviceAccountKeyFile, 'utf8')); +} else { + throw new Error('FIREBASE_SERVICE_ACCOUNT_KEY_STRING or FIREBASE_SERVICE_ACCOUNT_KEY_FILE must be set'); +} + module.exports = { get: () => { return { @@ -23,6 +35,9 @@ module.exports = { staticMaxAge: getEnvVarOrDefault('CACHING_STATIC_MAX_AGE', 60 * 60 * 1000), }, dbUrl: getEnvVarOrDefault('API_URL', 'https://db-prerelease.botballacademy.org'), + firebase: { + serviceAccountKey, + }, }; }, }; diff --git a/configs/webpack/common.js b/configs/webpack/common.js index 09450745..75478927 100644 --- a/configs/webpack/common.js +++ b/configs/webpack/common.js @@ -37,6 +37,7 @@ module.exports = { entry: { app: './index.tsx', login: './components/Login/index.tsx', + parentalConsent: './components/ParentalConsent/index.tsx', 'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js', 'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker.js', }, @@ -135,8 +136,9 @@ module.exports = { ], }, plugins: [ - new HtmlWebpackPlugin({ template: 'index.html.ejs', excludeChunks: ['login'] }), + new HtmlWebpackPlugin({ template: 'index.html.ejs', excludeChunks: ['login', 'parentalConsent'] }), new HtmlWebpackPlugin({ template: 'components/Login/login.html.ejs', filename: 'login.html', chunks: ['login'] }), + new HtmlWebpackPlugin({ template: 'components/ParentalConsent/parental-consent.html.ejs', filename: 'parental-consent.html', chunks: ['parentalConsent'] }), new DefinePlugin({ SIMULATOR_VERSION: JSON.stringify(require('../../package.json').version), SIMULATOR_GIT_HASH: JSON.stringify(commitHash), diff --git a/express.js b/express.js index 05621562..f2fdbb98 100644 --- a/express.js +++ b/express.js @@ -11,6 +11,8 @@ const sourceDir = 'dist'; const { get: getConfig } = require('./config'); const { WebhookClient } = require('discord.js'); const proxy = require('express-http-proxy'); +const axios = require('axios').default; +const { initializeAuth, getCustomToken, getIdTokenFromCustomToken } = require('./firebaseAuth'); let config; @@ -21,6 +23,17 @@ try { throw e; } +initializeAuth(config.firebase.serviceAccountKey); + +// TODO: acquire/refresh token as needed instead of once +let firebaseIdToken; +getCustomToken() + .then(customToken => { + return getIdTokenFromCustomToken(customToken); + }).then(idToken => { + firebaseIdToken = idToken; + }); + app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); @@ -200,6 +213,51 @@ app.post('/feedback', (req, res) => { }); }); +// API TO GRANT PARENTAL CONSENT TO USER +app.post('/parental-consent/:userId', (req, res) => { + const userId = req.params['userId']; + + getParentalConsent(userId, firebaseIdToken) + .then(currentConsent => { + if (currentConsent?.legalAcceptance?.state !== 'awaiting-parental-consent') { + console.error('Current state is not awaiting parental consent'); + res.status(400).send(); + return; + } + + const consentRequestSentAt = currentConsent?.legalAcceptance?.sentAt; + const consentRequestSentAtMs = consentRequestSentAt ? Date.parse(consentRequestSentAt) : NaN; + if (isNaN(consentRequestSentAtMs)) { + console.error('Sent at time is not valid'); + res.status(500).send(); + return; + } + + const currMs = new Date().getTime(); + if (currMs - consentRequestSentAtMs > 48 * 60 * 60 * 1000) { + console.error('Consent was requested too long ago'); + res.status(400).send(); + return; + } + + // TODO: save parental consent PDF and get URI + const parentalConsentUri = ''; + + setParentalConsentObtained(userId, currentConsent, parentalConsentUri, firebaseIdToken) + .then(setConsentResult => { + res.status(200).send(); + }) + .catch(setConsentError => { + console.error('Failed to set consent', setConsentError); + res.status(400).send(); + }); + }) + .catch(getConsentError => { + console.error('Failed to get current consent state', getConsentError); + res.status(400).send(); + }); +}); + app.use('/static', express.static(`${__dirname}/static`, { maxAge: config.caching.staticMaxAge, })); @@ -233,6 +291,10 @@ app.get('/login', (req, res) => { res.sendFile(`${__dirname}/${sourceDir}/login.html`); }); +app.get('/parental-consent/*', (req, res) => { + res.sendFile(`${__dirname}/${sourceDir}/parental-consent.html`); +}); + app.use('*', (req, res) => { setCrossOriginIsolationHeaders(res); res.sendFile(`${__dirname}/${sourceDir}/index.html`); @@ -249,4 +311,49 @@ app.listen(config.server.port, () => { function setCrossOriginIsolationHeaders(res) { res.header("Cross-Origin-Opener-Policy", "same-origin"); res.header("Cross-Origin-Embedder-Policy", "require-corp"); +} + +function getParentalConsent(userId, token) { + const requestConfig = { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }; + + return axios.get(`${config.dbUrl}/user/${userId}`, requestConfig) + .then(response => { + return response.data; + }) + .catch(error => { + if (error.response && error.response.status === 404) { + return null; + } + + console.error('ERROR:', error) + throw error; + }); +} + +function setParentalConsentObtained(userId, currUserConsent, parentalConsentUri, token) { + const nextUserConsent = { + ...currUserConsent, + legalAcceptance: { + state: 'obtained-parental-consent', + version: 1, + receivedAt: new Date().toISOString(), + parentEmailAddress: currUserConsent.parentEmailAddress, + parentalConsentUri: parentalConsentUri, + }, + }; + + const requestConfig = { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }; + + return axios.post(`${config.dbUrl}/user/${userId}`, nextUserConsent, requestConfig) + .then(response => { + return response.data; + }); } \ No newline at end of file diff --git a/firebaseAuth.js b/firebaseAuth.js new file mode 100644 index 00000000..3b3b9a50 --- /dev/null +++ b/firebaseAuth.js @@ -0,0 +1,56 @@ +const { cert, initializeApp } = require('firebase-admin/app'); +const { getAuth } = require('firebase-admin/auth'); +const axios = require('axios').default; + +let firebaseAuth = null; + +function initializeAuth(serviceAccountKey) { + const firebaseApp = initializeApp({ + credential: cert(serviceAccountKey), + }); + + firebaseAuth = getAuth(firebaseApp); +} + +// Create a custom token with specific claims +// Used to exchange for an ID token from firebase +function getCustomToken() { + if (!firebaseAuth) { + throw new Error('Firebase auth not initizlied'); + } + + return firebaseAuth.createCustomToken('simulator', { 'sim_backend': true }); +} + +// Get an ID token from firebase using a previously created custom token +function getIdTokenFromCustomToken(customToken) { + if (!firebaseAuth) { + throw new Error('Firebase auth not initizlied'); + } + + // TODO: move into config somewhere + const apiKey = 'AIzaSyBiVC6umtYRy-aQqDUBv8Nn1txWLssix04'; + + // Send request to auth emulator if using + const urlPrefix = process.env.FIREBASE_AUTH_EMULATOR_HOST ? `http://${process.env.FIREBASE_AUTH_EMULATOR_HOST}/` : 'https://'; + const url = `${urlPrefix}identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`; + + return axios.post(url, { + token: customToken, + returnSecureToken: true, + }) + .then(response => { + const responseBody = response.data; + return responseBody.idToken; + }) + .catch(error => { + console.error('FAILED TO GET ID TOKEN', error?.response?.data?.error); + return null; + }); +} + +module.exports = { + initializeAuth, + getCustomToken, + getIdTokenFromCustomToken, +}; \ No newline at end of file diff --git a/package.json b/package.json index 38e326c5..4172268f 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", "@types/react": "^16.9.25", + "axios": "^1.6.6", "babylonjs-gltf2interface": "6.18.0", "body-parser": "^1.19.0", "colorjs.io": "^0.4.2", @@ -71,6 +72,7 @@ "dotenv-expand": "^5.1.0", "express-http-proxy": "^1.6.3", "firebase": "^9.0.1", + "firebase-admin": "^12.0.0", "form-data": "^4.0.0", "history": "^4.7.2", "image-loader": "^0.0.1", diff --git a/src/App.tsx b/src/App.tsx index 193f1964..64153014 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,11 @@ import Root from './pages/Root'; import ChallengeRoot from './pages/ChallengeRoot'; import DocumentationWindow from './components/documentation/DocumentationWindow'; import { DARK } from './components/constants/theme'; +import db from './db'; +import Selector from './db/Selector'; +import DbError from './db/Error'; +import UserConsent from './consent/UserConsent'; +import LegalAcceptance from './consent/LegalAcceptance'; export interface AppPublicProps { @@ -68,7 +73,27 @@ class App extends React.Component { componentDidMount() { this.onAuthStateChangedSubscription_ = auth.onAuthStateChanged(user => { if (user) { - this.setState({ loading: false }); + + // Ensure user has obtained consent before continuing + db.get(Selector.user(user.uid)) + .then(userConsent => { + if (LegalAcceptance.isConsentObtained(userConsent?.legalAcceptance)) { + console.log('Consent verified'); + this.setState({ loading: false }); + } else { + console.log('Consent not obtained'); + this.props.login(); + } + }) + .catch(error => { + if (DbError.is(error) && error.code === DbError.CODE_NOT_FOUND) { + console.log('Consent info does not exist'); + this.props.login(); + } + + // TODO: show user an error + console.error('Failed to read user consent from DB'); + }); } else { this.props.login(); } diff --git a/src/components/ParentalConsent/index.tsx b/src/components/ParentalConsent/index.tsx new file mode 100644 index 00000000..5d215b34 --- /dev/null +++ b/src/components/ParentalConsent/index.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import * as ReactDom from 'react-dom'; +import { DARK } from '../constants/theme'; + +import { Provider as StyletronProvider } from "styletron-react"; +import { Client as Styletron } from "styletron-engine-atomic"; +import ParentalConsentPage from '../../pages/ParentalConsentPage'; + +const reactRoot = document.getElementById('reactRoot'); + +const engine = new Styletron({ prefix: 'style' }); + +const userId = window.location.pathname.split('/').pop() + +ReactDom.render( + + + , + reactRoot + ); \ No newline at end of file diff --git a/src/components/ParentalConsent/parental-consent.html.ejs b/src/components/ParentalConsent/parental-consent.html.ejs new file mode 100644 index 00000000..e7d456b0 --- /dev/null +++ b/src/components/ParentalConsent/parental-consent.html.ejs @@ -0,0 +1,36 @@ + + + + + + + + + Parental Consent for Simulator + + + + + + +
+ + \ No newline at end of file diff --git a/src/components/interface/Form.tsx b/src/components/interface/Form.tsx index 2d0356d4..8956d6d3 100644 --- a/src/components/interface/Form.tsx +++ b/src/components/interface/Form.tsx @@ -221,6 +221,7 @@ namespace Form { export const IDENTITY_FINALIZER = (value: string) => value; export const EMAIL_VALIDATOR = (value: string) => Validators.validate(value, Validators.Types.Email); export const PASSWORD_VALIDATOR = (value: string) => Validators.validatePassword(value); + export const DATE_VALIDATOR = (value: string) => Validators.validate(value, Validators.Types.Date); export const NON_EMPTY_VALIDATOR = (value: string) => Validators.validate(value, Validators.Types.Length, 1); @@ -245,6 +246,16 @@ namespace Form { assistText, }); + export const dob = (id: string, text: string, tooltip?: string, assist?: () => void, assistText?: string): Item => ({ + id, + text, + tooltip, + validator: DATE_VALIDATOR, + finalizer: IDENTITY_FINALIZER, + assist, + assistText, + }); + export const verifier = (id: string, text: string, validType: Validators.Types, tooltip?: string): Item => ({ id, text, diff --git a/src/consent/LegalAcceptance.tsx b/src/consent/LegalAcceptance.tsx new file mode 100644 index 00000000..45ac86bf --- /dev/null +++ b/src/consent/LegalAcceptance.tsx @@ -0,0 +1,39 @@ +namespace LegalAcceptance { + export enum State { + AwaitingParentalConsent = 'awaiting-parental-consent', + ObtainedParentalConsent = 'obtained-parental-consent', + ObtainedUserConsent = 'obtained-user-consent', + } + + export interface AwaitingParentalConsent { + state: State.AwaitingParentalConsent; + version: number; + sentAt: string; + parentEmailAddress: string; + noAutoDelete: boolean; + } + + export interface ObtainedParentalConsent { + state: State.ObtainedParentalConsent; + version: number; + receivedAt: string; + parentEmailAddress: string; + parentalConsentUri: string; + } + + export interface ObtainedUserConsent { + state: State.ObtainedUserConsent; + version: number; + } + + export const isConsentObtained = (legalAcceptance: LegalAcceptance): boolean => { + if (!legalAcceptance) return false; + + return legalAcceptance.state === State.ObtainedUserConsent + || legalAcceptance.state === State.ObtainedParentalConsent; + }; +} + +type LegalAcceptance = LegalAcceptance.AwaitingParentalConsent | LegalAcceptance.ObtainedParentalConsent | LegalAcceptance.ObtainedUserConsent; + +export default LegalAcceptance; \ No newline at end of file diff --git a/src/consent/UserConsent.tsx b/src/consent/UserConsent.tsx new file mode 100644 index 00000000..98484416 --- /dev/null +++ b/src/consent/UserConsent.tsx @@ -0,0 +1,8 @@ +import LegalAcceptance from "./LegalAcceptance"; + +interface UserConsent { + dateOfBirth: string; + legalAcceptance: LegalAcceptance; +} + +export default UserConsent; \ No newline at end of file diff --git a/src/db/Selector.ts b/src/db/Selector.ts index 3db36fb1..b13e4feb 100644 --- a/src/db/Selector.ts +++ b/src/db/Selector.ts @@ -1,4 +1,5 @@ import { + USER_COLLECTION, SCENE_COLLECTION, CHALLENGE_COLLECTION, CHALLENGE_COMPLETION_COLLECTION, @@ -10,6 +11,11 @@ interface Selector { } namespace Selector { + export const user = (id: string): Selector => ({ + collection: USER_COLLECTION, + id, + }); + export const scene = (id: string): Selector => ({ collection: SCENE_COLLECTION, id, diff --git a/src/db/constants.ts b/src/db/constants.ts index 5c457840..e2249b72 100644 --- a/src/db/constants.ts +++ b/src/db/constants.ts @@ -1,3 +1,4 @@ +export const USER_COLLECTION = 'user'; export const SCENE_COLLECTION = 'scene'; export const CHALLENGE_COLLECTION = 'challenge'; export const CHALLENGE_COMPLETION_COLLECTION = 'challenge_completion'; \ No newline at end of file diff --git a/src/firebase/firebase.ts b/src/firebase/firebase.ts index 5427639e..10df4b31 100644 --- a/src/firebase/firebase.ts +++ b/src/firebase/firebase.ts @@ -1,5 +1,5 @@ import { initializeApp } from 'firebase/app'; -import { GoogleAuthProvider, getAuth } from 'firebase/auth'; +import { GoogleAuthProvider, connectAuthEmulator, getAuth } from 'firebase/auth'; import config from './config'; const firebase = initializeApp(config.firebase); @@ -11,4 +11,7 @@ export const Providers = { export const auth = getAuth(); +// Uncomment if using the Firebase local emulator +// connectAuthEmulator(auth, "http://127.0.0.1:9099"); + export default firebase; \ No newline at end of file diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 08299e1c..01cd0135 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -8,7 +8,7 @@ import { createUserWithEmail, forgotPassword } from '../firebase/modules/auth'; -import { AuthProvider, getRedirectResult, signInWithPopup } from 'firebase/auth'; +import { AuthProvider, getRedirectResult, signInWithPopup, signOut } from 'firebase/auth'; import Form from '../components/interface/Form'; import { TabBar } from '../components/Layout/TabBar'; @@ -22,6 +22,11 @@ import { Validators } from '../util/Validator'; import { faSignInAlt, faUnlock, faUserPlus } from '@fortawesome/free-solid-svg-icons'; import { faGoogle } from '@fortawesome/free-brands-svg-icons'; import qs from 'qs'; +import db from '../db'; +import Selector from '../db/Selector'; +import DbError from '../db/Error'; +import UserConsent from '../consent/UserConsent'; +import LegalAcceptance from '../consent/LegalAcceptance'; export interface LoginPagePublicProps extends ThemeProps, StyleProps { externalIndex?: number; @@ -34,6 +39,7 @@ interface LoginPageState { initialAuthLoaded: boolean, authenticating: boolean, loggedIn: boolean; + userConsent: UserConsent; index: number; forgotPassword: boolean; logInFailedMessage: string; @@ -140,6 +146,7 @@ class LoginPage extends React.Component { initialAuthLoaded: false, authenticating: false, loggedIn: auth.currentUser !== null, + userConsent: undefined, index: this.props.externalIndex !== undefined ? this.props.externalIndex : 0, forgotPassword: false, logInFailedMessage: null, @@ -157,13 +164,126 @@ class LoginPage extends React.Component { } auth.onAuthStateChanged((user) => { if (user) { - this.setState({ loggedIn: true, initialAuthLoaded: true }); + // User signed in, now check their consent state + console.log('onAuthStateChanged with user; getting user from db'); + db.get(Selector.user(user.uid)) + .then(userConsentFromDb => { + console.log('got user from db', userConsentFromDb); + switch (userConsentFromDb.legalAcceptance.state) { + case LegalAcceptance.State.AwaitingParentalConsent: + case LegalAcceptance.State.ObtainedParentalConsent: + case LegalAcceptance.State.ObtainedUserConsent: + this.setState({ loggedIn: true, initialAuthLoaded: true, authenticating: false, userConsent: userConsentFromDb }); + break; + default: + const exhaustive: never = userConsentFromDb.legalAcceptance; + console.error('Unknown acceptance state', exhaustive); + signOut(auth).then(() => { + this.setState({ loggedIn: false, initialAuthLoaded: true, authenticating: false, userConsent: undefined, logInFailedMessage: 'Something went wrong' }); + }); + break; + } + }) + .catch(error => { + if (DbError.is(error) && error.code === DbError.CODE_NOT_FOUND) { + // Consent info doesn't exist yet for this user + this.setState({ loggedIn: true, initialAuthLoaded: true, authenticating: false, userConsent: undefined }); + } else { + // TODO: show user an error + console.error('failed to get user from db', error); + signOut(auth).then(() => { + this.setState({ loggedIn: false, initialAuthLoaded: true, authenticating: false, userConsent: undefined, logInFailedMessage: 'Something went wrong' }); + }); + } + }); } else { - this.setState({ loggedIn: false, initialAuthLoaded: true }); + console.log('onAuthStateChanged without user'); + this.setState({ loggedIn: false, authenticating: false, initialAuthLoaded: true }); } }); }; + private getAge = (dob: Date) => { + const today = new Date(); + let age = today.getFullYear() - dob.getFullYear(); + + const m = today.getMonth() - dob.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) { + age--; + } + + return age; + }; + + private onFinalizeDateOfBirth_ = (values: { [id: string]: string }) => { + if (this.state.userConsent?.dateOfBirth) return; + + const [mm, dd, yyyy] = values['dob'].split('/'); + const dob = `${yyyy}-${mm}-${dd}`; + + this.setState({ + userConsent: { + ...this.state.userConsent, + dateOfBirth: dob, + }, + }); + }; + + private onFinalizeParentEmail_ = (values: { [id: string]: string }) => { + if (this.state.userConsent?.legalAcceptance) return; + + const parentEmailAddress = values['parentEmail']; + const currentTimeStr = new Date().toISOString(); + + const nextUserConsent: UserConsent = { + ...this.state.userConsent, + legalAcceptance: { + state: LegalAcceptance.State.AwaitingParentalConsent, + version: 1, + sentAt: currentTimeStr, + parentEmailAddress, + noAutoDelete: false, + }, + }; + + const userId = auth.currentUser.uid; + + this.setState({ authenticating: true }, () => { + db.set(Selector.user(userId), nextUserConsent) + .then(() => { + this.setState({ authenticating: false, userConsent: nextUserConsent }); + }) + .catch((error) => { + console.error('Setting user consent failed', error); + this.setState({ authenticating: false }); + }); + }); + }; + + private onFinalizeTerms_ = (values: { [id: string]: string }) => { + const nextUserConsent: UserConsent = { + ...this.state.userConsent, + legalAcceptance: { + state: LegalAcceptance.State.ObtainedUserConsent, + version: 1, + }, + }; + + const userId = auth.currentUser.uid; + + this.setState({ authenticating: true }, () => { + db.set(Selector.user(userId), nextUserConsent) + .then(() => { + this.setState({ authenticating: false, userConsent: nextUserConsent }); + }) + .catch((error) => { + console.error('Setting user consent failed', error); + this.setState({ authenticating: false }); + }); + }); + }; + + private onFinalize_ = (values: { [id: string]: string }) => { if (this.state.forgotPassword) { forgotPassword(values.email); @@ -253,7 +373,7 @@ class LoginPage extends React.Component { render() { const { props, state } = this; const { className, style } = props; - const { initialAuthLoaded, index, authenticating, loggedIn, forgotPassword, logInFailedMessage } = state; + const { initialAuthLoaded, index, authenticating, loggedIn, userConsent, forgotPassword, logInFailedMessage } = state; const theme = DARK; if (!initialAuthLoaded) { @@ -261,38 +381,6 @@ class LoginPage extends React.Component { return null; } - if (loggedIn) { - setTimeout(() => { - const { search } = window.location; - const q = qs.parse(search.length > 0 ? search.substring(1) : ''); - const { from } = q; - window.location.href = from ? from.toString() : '/'; - }); - return null; - } - - const googleButtonItems = [ - StyledText.component ({ - component: StyledToolIcon, - props: { - icon: faGoogle, - brand: true, - theme, - } - }), - StyledText.text ({ - text: 'Continue with Google', - style: { - fontWeight: 400, - fontSize: '0.9em', - textAlign: 'center', - color: theme.color, - marginLeft: '8px', - marginRight: '8px', - } - }) - ]; - let kiprLogo: JSX.Element; switch (theme.foreground) { case 'white': { @@ -305,6 +393,161 @@ class LoginPage extends React.Component { } } + if (loggedIn) { + if (!userConsent || !userConsent.dateOfBirth) { + // Don't know user's DoB yet. Collect it + + const DOB_FORM_ITEMS: Form.Item[] = [ + Form.dob('dob', 'Date of birth'), + ]; + + const DOB_FORM_VERIFIERS: Form.Item[] = [ + Form.verifier('dob', 'A valid date of birth is required (MM/DD/YYYY)', Validators.Types.Date), + ]; + + return ( + + + {kiprLogo} + +
Additional info
+ + + + +
+
+ ); + } + + if (!userConsent.legalAcceptance) { + const dobDate = new Date(userConsent.dateOfBirth); + if (this.getAge(dobDate) < 16) { + // Under 16, so collect parental email + const PARENT_EMAIL_FORM_ITEMS: Form.Item[] = [ + Form.email('parentEmail', 'Parent email'), + ]; + + const PARENT_EMAIL_FORM_VERIFIERS: Form.Item[] = [ + Form.verifier('parentEmail', 'A valid parent email is required', Validators.Types.Email), + ]; + + return ( + + + {kiprLogo} + +
Additional info
+ + + + +
+
+ ); + } else { + return + + {kiprLogo} + +
Terms of use
+ + + + {/* TODO: replace with actual PDF */} +