[APP-615] COPPA-compliant signup (#570)

* Rework account creation to be COPPA compliant

* Fix lint

* Switch android datepicker to use the spinner mode

* Fix type signatures & usages
zio/stable
Paul Frazee 2023-05-08 17:25:57 -05:00 committed by GitHub
parent cdfb1c7abf
commit 7a176b3fdf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 254 additions and 52 deletions

View File

@ -38,6 +38,7 @@
"@react-native-camera-roll/camera-roll": "^5.2.2", "@react-native-camera-roll/camera-roll": "^5.2.2",
"@react-native-clipboard/clipboard": "^1.10.0", "@react-native-clipboard/clipboard": "^1.10.0",
"@react-native-community/blur": "^4.3.0", "@react-native-community/blur": "^4.3.0",
"@react-native-community/datetimepicker": "6.7.3",
"@react-navigation/bottom-tabs": "^6.5.7", "@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2", "@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6", "@react-navigation/native": "^6.1.6",

View File

@ -39,3 +39,13 @@ export function niceDate(date: number | string | Date) {
minute: '2-digit', minute: '2-digit',
})}` })}`
} }
export function getAge(birthDate: Date): number {
var today = new Date()
var age = today.getFullYear() - birthDate.getFullYear()
var m = today.getMonth() - birthDate.getMonth()
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
age--
}
return age
}

View File

@ -6,6 +6,9 @@ import {ComAtprotoServerCreateAccount} from '@atproto/api'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {createFullHandle} from 'lib/strings/handles' import {createFullHandle} from 'lib/strings/handles'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {getAge} from 'lib/strings/time'
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
export class CreateAccountModel { export class CreateAccountModel {
step: number = 1 step: number = 1
@ -21,7 +24,7 @@ export class CreateAccountModel {
email = '' email = ''
password = '' password = ''
handle = '' handle = ''
is13 = false birthDate = DEFAULT_DATE
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {}, {autoBind: true}) makeAutoObservable(this, {}, {autoBind: true})
@ -32,6 +35,13 @@ export class CreateAccountModel {
next() { next() {
this.error = '' this.error = ''
if (this.step === 2) {
if (getAge(this.birthDate) < 13) {
this.error =
'Unfortunately, you do not meet the requirements to create an account.'
return
}
}
this.step++ this.step++
} }
@ -124,8 +134,7 @@ export class CreateAccountModel {
return ( return (
(!this.isInviteCodeRequired || this.inviteCode) && (!this.isInviteCodeRequired || this.inviteCode) &&
!!this.email && !!this.email &&
!!this.password && !!this.password
this.is13
) )
} }
return !!this.handle return !!this.handle
@ -186,7 +195,7 @@ export class CreateAccountModel {
this.handle = v this.handle = v
} }
setIs13(v: boolean) { setBirthDate(v: Date) {
this.is13 = v this.birthDate = v
} }
} }

View File

@ -1,14 +1,9 @@
import React from 'react' import React from 'react'
import { import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CreateAccountModel} from 'state/models/ui/create-account' import {CreateAccountModel} from 'state/models/ui/create-account'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader' import {StepHeader} from './StepHeader'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -104,26 +99,20 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
<Text <Text
type="md-medium" type="md-medium"
style={[pal.text, s.mb2]} style={[pal.text, s.mb2]}
nativeID="legalCheck"> nativeID="birthDate">
Legal check Your birth date
</Text> </Text>
<TouchableOpacity <DateInput
testID="is13Input" testID="birthdayInput"
style={[styles.toggleBtn, pal.border]} value={model.birthDate}
onPress={() => model.setIs13(!model.is13)} onChange={model.setBirthDate}
accessibilityRole="checkbox" buttonType="default-light"
accessibilityLabel="Verify age" buttonStyle={[pal.border, styles.dateInputButton]}
accessibilityHint="Verifies that I am at least 13 years of age" buttonLabelType="lg"
accessibilityLabelledBy="legalCheck"> accessibilityLabel="Birthday"
<View style={[pal.borderDark, styles.checkbox]}> accessibilityHint="Enter your birth date"
{model.is13 && ( accessibilityLabelledBy="birthDate"
<FontAwesomeIcon icon="check" style={s.blue3} size={16} /> />
)}
</View>
<Text type="md" style={[pal.text, styles.toggleBtnLabel]}>
I am 13 years old or older
</Text>
</TouchableOpacity>
</View> </View>
{model.serviceDescription && ( {model.serviceDescription && (
@ -144,26 +133,9 @@ const styles = StyleSheet.create({
marginTop: 10, marginTop: 10,
}, },
toggleBtn: { dateInputButton: {
flexDirection: 'row',
flex: 1,
alignItems: 'center',
borderWidth: 1, borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 10,
borderRadius: 6, borderRadius: 6,
}, paddingVertical: 14,
toggleBtnLabel: {
flex: 1,
paddingHorizontal: 10,
},
checkbox: {
borderWidth: 1,
borderRadius: 2,
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
}, },
}) })

View File

@ -35,6 +35,9 @@ export function Button({
onPress, onPress,
children, children,
testID, testID,
accessibilityLabel,
accessibilityHint,
accessibilityLabelledBy,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
type?: ButtonType type?: ButtonType
label?: string label?: string
@ -42,6 +45,9 @@ export function Button({
labelStyle?: StyleProp<TextStyle> labelStyle?: StyleProp<TextStyle>
onPress?: () => void onPress?: () => void
testID?: string testID?: string
accessibilityLabel?: string
accessibilityHint?: string
accessibilityLabelledBy?: string
}>) { }>) {
const theme = useTheme() const theme = useTheme()
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@ -133,7 +139,10 @@ export function Button({
style={[typeOuterStyle, styles.outer, style]} style={[typeOuterStyle, styles.outer, style]}
onPress={onPressWrapped} onPress={onPressWrapped}
testID={testID} testID={testID}
accessibilityRole="button"> accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityLabelledBy={accessibilityLabelledBy}>
{label ? ( {label ? (
<Text type="button" style={[typeLabelStyle, labelStyle]}> <Text type="button" style={[typeLabelStyle, labelStyle]}>
{label} {label}

View File

@ -0,0 +1,96 @@
import React, {useState, useCallback} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import DateTimePicker, {
DateTimePickerEvent,
} from '@react-native-community/datetimepicker'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {isIOS, isAndroid} from 'platform/detection'
import {Button, ButtonType} from './Button'
import {Text} from '../text/Text'
import {TypographyVariant} from 'lib/ThemeContext'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
interface Props {
testID?: string
value: Date
onChange: (date: Date) => void
buttonType?: ButtonType
buttonStyle?: StyleProp<ViewStyle>
buttonLabelType?: TypographyVariant
buttonLabelStyle?: StyleProp<TextStyle>
accessibilityLabel: string
accessibilityHint: string
accessibilityLabelledBy?: string
}
export function DateInput(props: Props) {
const [show, setShow] = useState(false)
const theme = useTheme()
const pal = usePalette('default')
const onChangeInternal = useCallback(
(event: DateTimePickerEvent, date: Date | undefined) => {
setShow(false)
if (date) {
props.onChange(date)
}
},
[setShow, props],
)
const onPress = useCallback(() => {
setShow(true)
}, [setShow])
return (
<View>
{isAndroid && (
<Button
type={props.buttonType}
style={props.buttonStyle}
onPress={onPress}
accessibilityLabel={props.accessibilityLabel}
accessibilityHint={props.accessibilityHint}
accessibilityLabelledBy={props.accessibilityLabelledBy}>
<View style={styles.button}>
<FontAwesomeIcon
icon={['far', 'calendar']}
style={pal.textLight as FontAwesomeIconStyle}
/>
<Text
type={props.buttonLabelType}
style={[pal.text, props.buttonLabelStyle]}>
{props.value.toLocaleDateString()}
</Text>
</View>
</Button>
)}
{(isIOS || show) && (
<DateTimePicker
testID={props.testID ? `${props.testID}-datepicker` : undefined}
mode="date"
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={theme.colorScheme}
value={props.value}
onChange={onChangeInternal}
accessibilityLabel={props.accessibilityLabel}
accessibilityHint={props.accessibilityHint}
accessibilityLabelledBy={props.accessibilityLabelledBy}
/>
)}
</View>
)
}
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
})

View File

@ -0,0 +1,92 @@
import React, {useState, useCallback} from 'react'
import {
StyleProp,
StyleSheet,
TextInput as RNTextInput,
TextStyle,
View,
ViewStyle,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
interface Props {
testID?: string
value: Date
onChange: (date: Date) => void
buttonType?: string
buttonStyle?: StyleProp<ViewStyle>
buttonLabelType?: string
buttonLabelStyle?: StyleProp<TextStyle>
accessibilityLabel: string
accessibilityHint: string
accessibilityLabelledBy?: string
}
export function DateInput(props: Props) {
const theme = useTheme()
const pal = usePalette('default')
const palError = usePalette('error')
const [value, setValue] = useState(props.value.toLocaleDateString())
const [isValid, setIsValid] = useState(true)
const onChangeInternal = useCallback(
(v: string) => {
setValue(v)
const d = new Date(v)
if (!isNaN(Number(d))) {
setIsValid(true)
props.onChange(d)
} else {
setIsValid(false)
}
},
[setValue, setIsValid, props],
)
return (
<View style={[isValid ? pal.border : palError.border, styles.container]}>
<FontAwesomeIcon
icon={['far', 'calendar']}
style={[pal.textLight, styles.icon]}
/>
<RNTextInput
testID={props.testID}
style={[pal.text, styles.textInput]}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
onChangeText={v => onChangeInternal(v)}
value={value}
accessibilityLabel={props.accessibilityLabel}
accessibilityHint={props.accessibilityHint}
accessibilityLabelledBy={props.accessibilityLabelledBy}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
borderWidth: 1,
borderRadius: 6,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 4,
},
icon: {
marginLeft: 10,
},
textInput: {
flex: 1,
width: '100%',
paddingVertical: 10,
paddingHorizontal: 10,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
})

View File

@ -20,6 +20,7 @@ import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell' import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark' import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark'
import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark' import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark'
import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera' import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera'
import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck' import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck'
import {faCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' import {faCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
@ -97,6 +98,7 @@ export function setup() {
farBell, farBell,
faBookmark, faBookmark,
farBookmark, farBookmark,
farCalendar,
faCamera, faCamera,
faCheck, faCheck,
faCircleCheck, faCircleCheck,

View File

@ -440,6 +440,7 @@ export const SettingsScreen = withAuthRequired(
function AccountDropdownBtn({handle}: {handle: string}) { function AccountDropdownBtn({handle}: {handle: string}) {
const store = useStores() const store = useStores()
const pal = usePalette('default')
const items = [ const items = [
{ {
label: 'Remove account', label: 'Remove account',
@ -452,7 +453,10 @@ function AccountDropdownBtn({handle}: {handle: string}) {
return ( return (
<View style={s.pl10}> <View style={s.pl10}>
<DropdownButton type="bare" items={items}> <DropdownButton type="bare" items={items}>
<FontAwesomeIcon icon="ellipsis-h" /> <FontAwesomeIcon
icon="ellipsis-h"
style={pal.textLight as FontAwesomeIconStyle}
/>
</DropdownButton> </DropdownButton>
</View> </View>
) )

View File

@ -2949,6 +2949,13 @@
prompts "^2.4.0" prompts "^2.4.0"
semver "^6.3.0" semver "^6.3.0"
"@react-native-community/datetimepicker@6.7.3":
version "6.7.3"
resolved "https://registry.yarnpkg.com/@react-native-community/datetimepicker/-/datetimepicker-6.7.3.tgz#e6d75a42729265d8404d1d668c86926564abca2f"
integrity sha512-fXWbEdHMLW/e8cts3snEsbOTbnFXfUHeO2pkiDFX3fWpFoDtUrRWvn50xbY13IJUUKHDhoJ+mj24nMRVIXfX1A==
dependencies:
invariant "^2.2.4"
"@react-native-community/eslint-config@^3.0.0": "@react-native-community/eslint-config@^3.0.0":
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz#42f677d5fff385bccf1be1d3b8faa8c086cf998d" resolved "https://registry.yarnpkg.com/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz#42f677d5fff385bccf1be1d3b8faa8c086cf998d"