85f6031708
Device assurance can't be verified server-side (the OS gives only age bounds, no signed attestation), so it's persisted client-side only. Bind each cached grant to the region it was captured in so a TX grant can't unlock another region. - Add AgeAssuranceDeviceSignals (signals + originRegion); store the region-tagged record in the persisted cache instead of the raw response - getAssuredAgeFromDeviceSignals now requires the current region to match the capture region - Gate the native age request to native platforms (web returns a misleading default); web/new-device/declined falls back to KWS - TX allows ['device', 'kws'] so the fallback path is real Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
import {useCallback, useEffect} from 'react'
|
||
import {ScrollView, View} from 'react-native'
|
||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||
import {msg} from '@lingui/core/macro'
|
||
import {useLingui} from '@lingui/react'
|
||
import {Trans} from '@lingui/react/macro'
|
||
|
||
import {
|
||
SupportCode,
|
||
useCreateSupportLink,
|
||
} from '#/lib/hooks/useCreateSupportLink'
|
||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||
import {useSession, useSessionApi} from '#/state/session'
|
||
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
|
||
import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog'
|
||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||
import {Admonition} from '#/components/Admonition'
|
||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||
import * as Dialog from '#/components/Dialog'
|
||
import {useDialogControl} from '#/components/Dialog'
|
||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||
import {Full as Logo} from '#/components/icons/Logo'
|
||
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||
import * as Toast from '#/components/Toast'
|
||
import {Text} from '#/components/Typography'
|
||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||
import {useAgeAssurance} from '#/ageAssurance'
|
||
import {
|
||
getDeviceSignals,
|
||
setDeviceSignalsForDid,
|
||
useAgeAssuranceServerDataContext,
|
||
} from '#/ageAssurance/data'
|
||
import {logger} from '#/ageAssurance/logger'
|
||
import {type AgeAssuranceDeviceSignals} from '#/ageAssurance/types'
|
||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||
import {
|
||
getAssuredAgeFromDeviceSignals,
|
||
isLegacyBirthdateBug,
|
||
regionAllowsDeviceVerification,
|
||
useAgeAssuranceRegionConfig,
|
||
} from '#/ageAssurance/util'
|
||
import {useAnalytics} from '#/analytics'
|
||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||
import {useDeviceGeolocationApi} from '#/geolocation'
|
||
|
||
const textStyles = [a.text_md, a.leading_snug]
|
||
|
||
export function NoAccessScreen() {
|
||
const t = useTheme()
|
||
const {_} = useLingui()
|
||
const ax = useAnalytics()
|
||
const {gtPhone} = useBreakpoints()
|
||
const insets = useSafeAreaInsets()
|
||
const birthdateControl = useDialogControl()
|
||
const deactivateAccountControl = useDialogControl()
|
||
const deleteAccountControl = useDialogControl()
|
||
const {metadata} = useAgeAssuranceServerDataContext()
|
||
const region = useAgeAssuranceRegionConfig()
|
||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||
const {logoutCurrentAccount} = useSessionApi()
|
||
const createSupportLink = useCreateSupportLink()
|
||
|
||
const aa = useAgeAssurance()
|
||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||
const isAARegion = !!region
|
||
const hasDeclaredAge = metadata?.declaredAge !== undefined
|
||
const canUpdateBirthday =
|
||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
|
||
|
||
useEffect(() => {
|
||
// just counting overall hits here
|
||
ax.metric(`blockedGeoOverlay:shown`, {})
|
||
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
||
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
|
||
isAARegion,
|
||
hasDeclaredAge,
|
||
canUpdateBirthday,
|
||
})
|
||
// TODO This can be cleaned up with useEffectEvent once we're on 19.2
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const onPressLogout = useCallback(() => {
|
||
if (IS_WEB) {
|
||
// We're switching accounts, which remounts the entire app.
|
||
// On mobile, this gets us Home, but on the web we also need reset the URL.
|
||
// We can't change the URL via a navigate() call because the navigator
|
||
// itself is about to unmount, and it calls pushState() too late.
|
||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||
history.pushState(null, '', '/')
|
||
}
|
||
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
|
||
}, [logoutCurrentAccount])
|
||
|
||
const orgAdmonition = (
|
||
<Admonition type="tip">
|
||
<Trans>
|
||
For organizational accounts, use the birthdate of the person who is
|
||
responsible for the account.
|
||
</Trans>
|
||
</Admonition>
|
||
)
|
||
|
||
const birthdateUpdateText = canUpdateBirthday ? (
|
||
<>
|
||
<Text style={[textStyles]}>
|
||
<Trans>
|
||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||
<SimpleInlineLinkText
|
||
label={_(msg`Click here to update your birthdate`)}
|
||
style={[textStyles]}
|
||
{...createStaticClick(() => {
|
||
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
|
||
birthdateControl.open()
|
||
})}>
|
||
clicking here
|
||
</SimpleInlineLinkText>
|
||
.
|
||
</Trans>
|
||
</Text>
|
||
|
||
{orgAdmonition}
|
||
</>
|
||
) : (
|
||
<Text style={[textStyles]}>
|
||
<Trans>
|
||
If you believe your birthdate is incorrect, please{' '}
|
||
<SimpleInlineLinkText
|
||
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
||
label={_(msg`Click here to contact our support team`)}
|
||
style={[textStyles]}>
|
||
contact our support team
|
||
</SimpleInlineLinkText>
|
||
.
|
||
</Trans>
|
||
</Text>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
<View style={[a.util_screen_outer, a.flex_1]}>
|
||
<ScrollView
|
||
contentContainerStyle={[
|
||
a.px_2xl,
|
||
{
|
||
paddingTop: IS_WEB
|
||
? a.p_5xl.padding
|
||
: insets.top + a.p_2xl.padding,
|
||
paddingBottom: 100,
|
||
},
|
||
]}>
|
||
<View
|
||
style={[
|
||
a.mx_auto,
|
||
a.w_full,
|
||
web({
|
||
maxWidth: 380,
|
||
paddingTop: gtPhone ? '8vh' : undefined,
|
||
}),
|
||
{
|
||
gap: 32,
|
||
},
|
||
]}>
|
||
<View style={[a.align_start]}>
|
||
<AgeAssuranceBadge />
|
||
</View>
|
||
|
||
{hasDeclaredAge ? (
|
||
<>
|
||
{isAARegion ? (
|
||
<>
|
||
<View style={[a.gap_lg]}>
|
||
<Text style={[textStyles]}>
|
||
<Trans>Hey there!</Trans>
|
||
</Text>
|
||
<Text style={[textStyles]}>
|
||
<Trans>
|
||
You are accessing Bluesky from a region that legally
|
||
requires us to verify your age before allowing you to
|
||
access the app.
|
||
</Trans>
|
||
</Text>
|
||
|
||
{!aa.flags.isOverRegionMinAccessAge && (
|
||
<Text style={[textStyles]}>
|
||
<Trans>
|
||
Unfortunately, your declared age indicates that you
|
||
are not old enough to access Bluesky in your region.
|
||
</Trans>
|
||
</Text>
|
||
)}
|
||
|
||
{!isBlocked && birthdateUpdateText}
|
||
</View>
|
||
|
||
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
|
||
</>
|
||
) : (
|
||
<View style={[a.gap_lg]}>
|
||
<Text style={[textStyles]}>
|
||
<Trans>
|
||
Unfortunately, the birthdate you have saved to your
|
||
profile makes you too young to access Bluesky.
|
||
</Trans>
|
||
</Text>
|
||
|
||
{birthdateUpdateText}
|
||
</View>
|
||
)}
|
||
</>
|
||
) : (
|
||
<View style={[a.gap_lg]}>
|
||
<Text style={[textStyles]}>
|
||
<Trans>Hi there!</Trans>
|
||
</Text>
|
||
<Text style={[textStyles]}>
|
||
<Trans>
|
||
In order to provide an age-appropriate experience, we need
|
||
to know your birthdate. This is a one-time thing, and your
|
||
data will be kept private.
|
||
</Trans>
|
||
</Text>
|
||
<Text style={[textStyles]}>
|
||
<Trans>
|
||
Set your birthdate below and we'll get you back to posting
|
||
and exploring in no time!
|
||
</Trans>
|
||
</Text>
|
||
<Button
|
||
color="primary"
|
||
size="large"
|
||
label={_(msg`Click here to update your birthdate`)}
|
||
onPress={() => birthdateControl.open()}>
|
||
<ButtonText>
|
||
<Trans>Add your birthdate</Trans>
|
||
</ButtonText>
|
||
</Button>
|
||
|
||
{orgAdmonition}
|
||
</View>
|
||
)}
|
||
|
||
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
|
||
<Logo width={120} textFill={t.atoms.text.color} />
|
||
<Text
|
||
style={[
|
||
a.text_sm,
|
||
a.italic,
|
||
a.leading_snug,
|
||
t.atoms.text_contrast_medium,
|
||
]}>
|
||
<Trans>
|
||
To log out,{' '}
|
||
<SimpleInlineLinkText
|
||
label={_(msg`Click here to log out`)}
|
||
{...createStaticClick(() => {
|
||
onPressLogout()
|
||
})}
|
||
style={[a.italic]}>
|
||
click here
|
||
</SimpleInlineLinkText>
|
||
. Or if you’d prefer, you can{' '}
|
||
<SimpleInlineLinkText
|
||
label={_(msg`Click here to delete your account`)}
|
||
{...createStaticClick(() => {
|
||
ax.metric(
|
||
'ageAssurance:noAccessScreen:openDeleteAccountDialog',
|
||
{},
|
||
)
|
||
deleteAccountControl.open()
|
||
})}
|
||
style={[a.italic]}>
|
||
delete your account
|
||
</SimpleInlineLinkText>
|
||
.
|
||
</Trans>
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
|
||
<BirthDateSettingsDialog control={birthdateControl} />
|
||
<DeactivateAccountDialog control={deactivateAccountControl} />
|
||
<DeleteAccountDialog
|
||
control={deleteAccountControl}
|
||
deactivateDialogControl={deactivateAccountControl}
|
||
/>
|
||
|
||
{/*
|
||
* While this blocking overlay is up, other dialogs in the shell
|
||
* are not mounted, so it _should_ be safe to use these here
|
||
* without fear of other modals showing up.
|
||
*/}
|
||
<BottomSheetOutlet />
|
||
<PortalOutlet />
|
||
</>
|
||
)
|
||
}
|
||
|
||
function AccessSection() {
|
||
const t = useTheme()
|
||
const {_, i18n} = useLingui()
|
||
const ax = useAnalytics()
|
||
const control = useDialogControl()
|
||
const appealControl = Dialog.useDialogControl()
|
||
const locationControl = Dialog.useDialogControl()
|
||
const getTimeAgo = useGetTimeAgo()
|
||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||
const {currentAccount} = useSession()
|
||
const region = useAgeAssuranceRegionConfig()
|
||
|
||
const aa = useAgeAssurance()
|
||
const {status, lastInitiatedAt} = aa.state
|
||
const isBlocked = status === aa.Status.Blocked
|
||
const hasInitiated = !!lastInitiatedAt
|
||
const timeAgo = lastInitiatedAt
|
||
? getTimeAgo(lastInitiatedAt, new Date())
|
||
: null
|
||
const diff = lastInitiatedAt
|
||
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||
: null
|
||
|
||
const openKwsDialog = useCallback(() => {
|
||
control.open()
|
||
ax.metric('ageAssurance:initDialogOpen', {
|
||
hasInitiatedPreviously: hasInitiated,
|
||
})
|
||
}, [control, ax, hasInitiated])
|
||
|
||
const onPressVerify = useCallback(async () => {
|
||
/*
|
||
* In regions that permit on-device verification, try the native age API
|
||
* first. We tag the result with the current region (device assurance is
|
||
* region-bound — a TX grant only counts in TX) and, if it's sufficient,
|
||
* persist it client-side so the AA state recompute lifts the gate.
|
||
* Otherwise we fall back to the KWS flow below. `getDeviceSignals` handles
|
||
* its own errors and returns undefined (e.g. on web or failure), which also
|
||
* routes us to the fallback.
|
||
*/
|
||
if (region && regionAllowsDeviceVerification(region)) {
|
||
const did = currentAccount?.did
|
||
const signals = await getDeviceSignals()
|
||
if (signals && did) {
|
||
const deviceSignals: AgeAssuranceDeviceSignals = {
|
||
signals,
|
||
originRegion: {
|
||
countryCode: region.countryCode,
|
||
regionCode: region.regionCode,
|
||
},
|
||
}
|
||
const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals)
|
||
if (assuredAge !== undefined) {
|
||
// Sufficient device signals: persist and let the AA state recompute
|
||
// from the cache write unlock access. Nothing else to do here.
|
||
setDeviceSignalsForDid({did, deviceSignals})
|
||
return
|
||
}
|
||
}
|
||
logger.debug(
|
||
`onPressVerify: device signals unavailable or insufficient, falling back to KWS`,
|
||
)
|
||
}
|
||
|
||
openKwsDialog()
|
||
}, [region, currentAccount?.did, openKwsDialog])
|
||
|
||
return (
|
||
<>
|
||
<AgeAssuranceInitDialog control={control} />
|
||
<AgeAssuranceAppealDialog control={appealControl} />
|
||
|
||
<View style={[a.gap_xl]}>
|
||
{isBlocked ? (
|
||
<Admonition type="warning">
|
||
<Trans>
|
||
You are currently unable to access Bluesky's Age Assurance flow.
|
||
Please{' '}
|
||
<SimpleInlineLinkText
|
||
label={_(msg`Contact our moderation team`)}
|
||
{...createStaticClick(() => {
|
||
appealControl.open()
|
||
ax.metric('ageAssurance:appealDialogOpen', {})
|
||
})}>
|
||
contact our moderation team
|
||
</SimpleInlineLinkText>{' '}
|
||
if you believe this is an error.
|
||
</Trans>
|
||
</Admonition>
|
||
) : (
|
||
<>
|
||
<View style={[a.gap_md]}>
|
||
<Button
|
||
label={_(msg`Verify now`)}
|
||
size="large"
|
||
color={hasInitiated ? 'secondary' : 'primary'}
|
||
onPress={() => void onPressVerify()}>
|
||
<ButtonIcon icon={ShieldIcon} />
|
||
<ButtonText>
|
||
{hasInitiated ? (
|
||
<Trans>Verify again</Trans>
|
||
) : (
|
||
<Trans>Verify now</Trans>
|
||
)}
|
||
</ButtonText>
|
||
</Button>
|
||
|
||
{lastInitiatedAt && timeAgo && diff ? (
|
||
<Text
|
||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||
title={i18n.date(lastInitiatedAt, {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'medium',
|
||
})}>
|
||
{diff.value === 0 ? (
|
||
<Trans>Last initiated just now</Trans>
|
||
) : (
|
||
<Trans>Last initiated {timeAgo} ago</Trans>
|
||
)}
|
||
</Text>
|
||
) : (
|
||
<Text
|
||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||
<Trans>Age assurance only takes a few minutes</Trans>
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</>
|
||
)}
|
||
|
||
<View style={[a.gap_xs]}>
|
||
{IS_NATIVE && (
|
||
<>
|
||
<Admonition>
|
||
<Trans>
|
||
Is your location not accurate?{' '}
|
||
<SimpleInlineLinkText
|
||
label={_(msg`Update your location`)}
|
||
{...createStaticClick(() => {
|
||
locationControl.open()
|
||
})}>
|
||
Tap here to update your location with GPS.
|
||
</SimpleInlineLinkText>{' '}
|
||
</Trans>
|
||
</Admonition>
|
||
|
||
<DeviceLocationRequestDialog
|
||
control={locationControl}
|
||
onLocationAcquired={props => {
|
||
const access = computeAgeAssuranceRegionAccess(
|
||
props.geolocation,
|
||
)
|
||
if (access !== aa.Access.Full) {
|
||
props.disableDialogAction()
|
||
props.setDialogError(
|
||
_(
|
||
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
|
||
),
|
||
)
|
||
} else {
|
||
props.closeDialog(() => {
|
||
// set this after close!
|
||
setDeviceGeolocation(props.geolocation)
|
||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||
type: 'success',
|
||
})
|
||
})
|
||
}
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</>
|
||
)
|
||
}
|