Mirror — see github.com/blacksky-algorithms/blacksky.community
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix birthdate updates for OAuth users via gatekeeper

OAuth sessions lack full-access scope, so the PDS blocks reading and
writing personalDetailsPref. Route Blacksky PDS OAuth users through the
gatekeeper which creates a full-access internal session.

The dialog now asks for the account password first, fetches preferences
with full access via /gate/get-preferences (new endpoint), shows the
actual birthdate, and saves via /gate/put-preferences. Non-Blacksky PDS
OAuth users are redirected to their PDS account page. App passwords are
detected and rejected with a clear error message.

+372 -64
+326 -63
src/components/dialogs/BirthDateSettings.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {Linking, View} from 'react-native' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 + import {useQueryClient} from '@tanstack/react-query' 5 6 7 + import {gateGetPreferences, gatePutPreferences} from '#/lib/api/gatekeeper' 6 8 import {useCleanError} from '#/lib/hooks/useCleanError' 9 + import {useIsBlackskyPds} from '#/lib/hooks/useIsBlackskyPds' 7 10 import {isAppPassword} from '#/lib/jwt' 8 11 import {getAge, getDateAgo} from '#/lib/strings/time' 9 12 import {logger} from '#/logger' 10 13 import { 14 + preferencesQueryKey, 11 15 usePreferencesQuery, 12 16 type UsePreferencesQueryResponse, 13 17 } from '#/state/queries/preferences' ··· 18 22 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 23 import * as Dialog from '#/components/Dialog' 20 24 import {DateField} from '#/components/forms/DateField' 25 + import * as TextField from '#/components/forms/TextField' 21 26 import {SimpleInlineLinkText} from '#/components/Link' 22 27 import {Loader} from '#/components/Loader' 23 - import {Span, Text} from '#/components/Typography' 28 + import {Text} from '#/components/Typography' 24 29 import {IS_IOS, IS_WEB} from '#/env' 25 30 31 + function isAppPasswordFormat(pw: string): boolean { 32 + return /^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i.test(pw) 33 + } 34 + 35 + // Types accepted by PDS putPreferences 36 + const ALLOWED_PREF_TYPES = new Set([ 37 + 'app.bsky.actor.defs#adultContentPref', 38 + 'app.bsky.actor.defs#contentLabelPref', 39 + 'app.bsky.actor.defs#savedFeedsPref', 40 + 'app.bsky.actor.defs#savedFeedsPrefV2', 41 + 'app.bsky.actor.defs#personalDetailsPref', 42 + 'app.bsky.actor.defs#feedViewPref', 43 + 'app.bsky.actor.defs#threadViewPref', 44 + 'app.bsky.actor.defs#interestsPref', 45 + 'app.bsky.actor.defs#mutedWordsPref', 46 + 'app.bsky.actor.defs#hiddenPostsPref', 47 + 'app.bsky.actor.defs#bskyAppStatePref', 48 + 'app.bsky.actor.defs#labelersPref', 49 + 'app.bsky.actor.defs#postInteractionSettingsPref', 50 + 'app.bsky.actor.defs#verificationPrefs', 51 + 'app.bsky.actor.defs#liveEventPreferences', 52 + ]) 53 + 26 54 export function BirthDateSettingsDialog({ 27 55 control, 28 56 }: { ··· 33 61 const {isLoading, error, data: preferences} = usePreferencesQuery() 34 62 const {currentAccount} = useSession() 35 63 const isUsingAppPassword = isAppPassword(currentAccount?.accessJwt || '') 64 + const isOauth = currentAccount?.isOauthSession === true 65 + const isBskyPds = useIsBlackskyPds() 66 + const useGatekeeper = isOauth && isBskyPds 36 67 37 68 return ( 38 69 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 39 70 <Dialog.Handle /> 40 - {( 41 - <Dialog.ScrollableInner 42 - label={_(msg`My Birthdate`)} 43 - style={web({maxWidth: 400})}> 44 - <View style={[a.gap_md]}> 45 - <Text style={[a.text_xl, a.font_semi_bold]}> 46 - <Trans>My Birthdate</Trans> 47 - </Text> 48 - <Text 49 - style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 71 + <Dialog.ScrollableInner 72 + label={_(msg`My Birthdate`)} 73 + style={web({maxWidth: 400})}> 74 + <View style={[a.gap_md]}> 75 + <Text style={[a.text_xl, a.font_semi_bold]}> 76 + <Trans>My Birthdate</Trans> 77 + </Text> 78 + <Text 79 + style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 80 + <Trans> 81 + This information is private and not shared with other users. 82 + </Trans> 83 + </Text> 84 + 85 + {isLoading ? ( 86 + <Loader size="xl" /> 87 + ) : error || !preferences ? ( 88 + <ErrorMessage 89 + message={ 90 + error?.toString() || 91 + _( 92 + msg`We were unable to load your birthdate preferences. Please try again.`, 93 + ) 94 + } 95 + style={[a.rounded_sm]} 96 + /> 97 + ) : isUsingAppPassword ? ( 98 + <Admonition type="info"> 99 + <Trans> 100 + To update your birthdate, please sign in with your main account 101 + password instead of an app password. 102 + </Trans> 103 + </Admonition> 104 + ) : isOauth && !isBskyPds ? ( 105 + <OAuthExternalPdsMessage serviceUrl={currentAccount?.service} /> 106 + ) : useGatekeeper ? ( 107 + <GatekeeperBirthdayFlow control={control} /> 108 + ) : ( 109 + <DirectBirthdayFlow control={control} preferences={preferences} /> 110 + )} 111 + </View> 112 + 113 + <Dialog.Close /> 114 + </Dialog.ScrollableInner> 115 + </Dialog.Outer> 116 + ) 117 + } 118 + 119 + function OAuthExternalPdsMessage({serviceUrl}: {serviceUrl?: string}) { 120 + const {_} = useLingui() 121 + const pdsAccountUrl = serviceUrl ? `${serviceUrl}/account` : undefined 122 + 123 + return ( 124 + <View style={a.gap_lg}> 125 + <Admonition type="info"> 126 + <Trans> 127 + Birthdate updates are not available when signed in with OAuth. Please 128 + manage your birthdate through your hosting provider's website. 129 + </Trans> 130 + </Admonition> 131 + 132 + {pdsAccountUrl && ( 133 + <Button 134 + label={_(msg`Open account settings`)} 135 + size="large" 136 + variant="solid" 137 + color="primary" 138 + onPress={() => Linking.openURL(pdsAccountUrl)}> 139 + <ButtonText> 140 + <Trans>Open Account Settings</Trans> 141 + </ButtonText> 142 + </Button> 143 + )} 144 + </View> 145 + ) 146 + } 147 + 148 + function GatekeeperBirthdayFlow({ 149 + control, 150 + }: { 151 + control: Dialog.DialogControlProps 152 + }) { 153 + const {_} = useLingui() 154 + const cleanError = useCleanError() 155 + const {currentAccount} = useSession() 156 + const queryClient = useQueryClient() 157 + 158 + const [password, setPassword] = React.useState('') 159 + const [fullPrefs, setFullPrefs] = React.useState<any[] | null>(null) 160 + const [date, setDate] = React.useState<Date>(getDateAgo(18)) 161 + const [originalDate, setOriginalDate] = React.useState<Date | null>(null) 162 + const [isPending, setIsPending] = React.useState(false) 163 + const [error, setError] = React.useState<string | null>(null) 164 + 165 + const loadPreferences = React.useCallback(async () => { 166 + if (!currentAccount || !password) return 167 + setIsPending(true) 168 + setError(null) 169 + try { 170 + const result = await gateGetPreferences({ 171 + serviceUrl: currentAccount.service, 172 + did: currentAccount.did, 173 + password, 174 + }) 175 + setFullPrefs(result.preferences) 176 + const personalPref = result.preferences.find( 177 + (p: any) => p.$type === 'app.bsky.actor.defs#personalDetailsPref', 178 + ) 179 + if (personalPref?.birthDate) { 180 + const bd = new Date(personalPref.birthDate) 181 + setDate(bd) 182 + setOriginalDate(bd) 183 + } 184 + } catch (e: any) { 185 + const {clean} = cleanError(e) 186 + setError(clean || e?.message || 'Failed to load preferences') 187 + } finally { 188 + setIsPending(false) 189 + } 190 + }, [currentAccount, password, cleanError]) 191 + 192 + const saveBirthDate = React.useCallback(async () => { 193 + if (!currentAccount || !fullPrefs) return 194 + setIsPending(true) 195 + setError(null) 196 + try { 197 + // Filter to PDS-accepted types, excluding read-only declaredAgePref 198 + const pdsPrefs = fullPrefs.filter((p: any) => 199 + ALLOWED_PREF_TYPES.has(p.$type), 200 + ) 201 + 202 + const personalDetailsIdx = pdsPrefs.findIndex( 203 + (p: any) => p.$type === 'app.bsky.actor.defs#personalDetailsPref', 204 + ) 205 + const personalDetails = { 206 + $type: 'app.bsky.actor.defs#personalDetailsPref', 207 + birthDate: date.toISOString(), 208 + } 209 + const updatedPrefs = 210 + personalDetailsIdx >= 0 211 + ? pdsPrefs.map((p: any, i: number) => 212 + i === personalDetailsIdx ? personalDetails : p, 213 + ) 214 + : [...pdsPrefs, personalDetails] 215 + 216 + const {status} = await gatePutPreferences({ 217 + serviceUrl: currentAccount.service, 218 + did: currentAccount.did, 219 + password, 220 + preferences: updatedPrefs, 221 + }) 222 + 223 + if (status === 'authFactorTokenRequired') { 224 + setError('Email 2FA is required but not supported for this action yet.') 225 + return 226 + } 227 + 228 + setOriginalDate(date) 229 + await queryClient.invalidateQueries({queryKey: preferencesQueryKey}) 230 + control.close() 231 + } catch (e: any) { 232 + logger.error('setBirthDate failed', {message: e?.message}) 233 + const {clean} = cleanError(e) 234 + setError(clean || e?.message || 'Failed to save birthdate') 235 + } finally { 236 + setIsPending(false) 237 + } 238 + }, [ 239 + currentAccount, 240 + fullPrefs, 241 + date, 242 + password, 243 + queryClient, 244 + control, 245 + cleanError, 246 + ]) 247 + 248 + const hasChanged = 249 + originalDate === null || date.toISOString() !== originalDate.toISOString() 250 + const age = getAge(new Date(date)) 251 + const isUnder13 = age < 13 252 + const isUnder18 = age >= 13 && age < 18 253 + const isAppPw = isAppPasswordFormat(password) 254 + 255 + if (!fullPrefs) { 256 + // Step 1: Enter password to load preferences 257 + return ( 258 + <View style={a.gap_lg} testID="birthDateSettingsDialog"> 259 + <View> 260 + <TextField.LabelText> 261 + <Trans>Account password</Trans> 262 + </TextField.LabelText> 263 + <TextField.Root> 264 + <TextField.Input 265 + testID="birthdayPasswordInput" 266 + label={_(msg`Account password`)} 267 + defaultValue={password} 268 + onChangeText={setPassword} 269 + secureTextEntry 270 + autoCapitalize="none" 271 + autoComplete="password" 272 + onSubmitEditing={loadPreferences} 273 + /> 274 + </TextField.Root> 275 + {isAppPw && ( 276 + <Admonition type="error"> 50 277 <Trans> 51 - This information is private and not shared with other users. 278 + App passwords cannot be used here. Please enter your main 279 + account password. 52 280 </Trans> 53 - </Text> 281 + </Admonition> 282 + )} 283 + </View> 54 284 55 - {isLoading ? ( 56 - <Loader size="xl" /> 57 - ) : error || !preferences ? ( 58 - <ErrorMessage 59 - message={ 60 - error?.toString() || 61 - _( 62 - msg`We were unable to load your birthdate preferences. Please try again.`, 63 - ) 64 - } 65 - style={[a.rounded_sm]} 66 - /> 67 - ) : isUsingAppPassword ? ( 68 - <Admonition type="info"> 69 - <Trans> 70 - Hmm, it looks like you're logged in with an{' '} 71 - <Span style={[a.italic]}>App Password</Span>. To set your 72 - birthdate, you'll need to log in with your main account 73 - password, or ask whomever controls this account to do so. 74 - </Trans> 75 - </Admonition> 76 - ) : ( 77 - <BirthdayInner control={control} preferences={preferences} /> 78 - )} 79 - </View> 285 + {error ? <ErrorMessage message={error} style={[a.rounded_sm]} /> : null} 80 286 81 - <Dialog.Close /> 82 - </Dialog.ScrollableInner> 287 + <View style={IS_WEB && [a.flex_row, a.justify_end]}> 288 + <Button 289 + label={_(msg`Load birthdate`)} 290 + size="large" 291 + onPress={loadPreferences} 292 + variant="solid" 293 + color="primary" 294 + disabled={!password || isAppPw || isPending}> 295 + <ButtonText> 296 + <Trans>Continue</Trans> 297 + </ButtonText> 298 + {isPending && <ButtonIcon icon={Loader} />} 299 + </Button> 300 + </View> 301 + </View> 302 + ) 303 + } 304 + 305 + // Step 2: Edit and save birthdate 306 + return ( 307 + <View style={a.gap_lg} testID="birthDateSettingsDialog"> 308 + <View style={IS_IOS && [a.w_full, a.align_center]}> 309 + <DateField 310 + testID="birthdayInput" 311 + value={date} 312 + onChangeDate={newDate => setDate(new Date(newDate))} 313 + label={_(msg`Birthdate`)} 314 + accessibilityHint={_(msg`Enter your birthdate`)} 315 + /> 316 + </View> 317 + 318 + {isUnder18 && hasChanged && ( 319 + <Admonition type="info"> 320 + <Trans> 321 + The birthdate you've entered means you are under 18 years old. 322 + Certain content and features may be unavailable to you. 323 + </Trans> 324 + </Admonition> 83 325 )} 84 - </Dialog.Outer> 326 + 327 + {isUnder13 && ( 328 + <Admonition type="error"> 329 + <Trans> 330 + You must be at least 13 years old to use Bluesky. Read our{' '} 331 + <SimpleInlineLinkText 332 + to="https://bsky.social/about/support/tos" 333 + label={_(msg`Terms of Service`)}> 334 + Terms of Service 335 + </SimpleInlineLinkText>{' '} 336 + for more information. 337 + </Trans> 338 + </Admonition> 339 + )} 340 + 341 + {error ? <ErrorMessage message={error} style={[a.rounded_sm]} /> : null} 342 + 343 + <View style={IS_WEB && [a.flex_row, a.justify_end]}> 344 + <Button 345 + label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)} 346 + size="large" 347 + onPress={hasChanged ? saveBirthDate : () => control.close()} 348 + variant="solid" 349 + color="primary" 350 + disabled={isUnder13 || isPending}> 351 + <ButtonText> 352 + {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>} 353 + </ButtonText> 354 + {isPending && <ButtonIcon icon={Loader} />} 355 + </Button> 356 + </View> 357 + </View> 85 358 ) 86 359 } 87 360 88 - function BirthdayInner({ 361 + function DirectBirthdayFlow({ 89 362 control, 90 363 preferences, 91 364 }: { ··· 100 373 const agent = useAgent() 101 374 const [isPending, setIsPending] = React.useState(false) 102 375 const [error, setError] = React.useState<Error | null>(null) 103 - const setBirthDate = React.useCallback( 104 - async ({birthDate}: {birthDate: Date}) => { 105 - setIsPending(true) 106 - setError(null) 107 - try { 108 - await agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 109 - } catch (e: any) { 110 - setError(e) 111 - throw e 112 - } finally { 113 - setIsPending(false) 114 - } 115 - }, 116 - [agent], 117 - ) 376 + 118 377 const hasChanged = date !== preferences.birthDate 119 378 const errorMessage = React.useMemo(() => { 120 379 if (error) { ··· 128 387 const isUnder18 = age >= 13 && age < 18 129 388 130 389 const onSave = React.useCallback(async () => { 390 + setIsPending(true) 391 + setError(null) 131 392 try { 132 - // skip if date is the same 133 393 if (hasChanged) { 134 - await setBirthDate({birthDate: date}) 394 + await agent.setPersonalDetails({birthDate: date.toISOString()}) 135 395 } 136 396 control.close() 137 397 } catch (e: any) { 138 - logger.error(`setBirthDate failed`, {message: e.message}) 398 + setError(e) 399 + logger.error('setBirthDate failed', {message: e.message}) 400 + } finally { 401 + setIsPending(false) 139 402 } 140 - }, [date, setBirthDate, control, hasChanged]) 403 + }, [date, agent, control, hasChanged]) 141 404 142 405 return ( 143 406 <View style={a.gap_lg} testID="birthDateSettingsDialog"> ··· 176 439 177 440 {errorMessage ? ( 178 441 <ErrorMessage message={errorMessage} style={[a.rounded_sm]} /> 179 - ) : undefined} 442 + ) : null} 180 443 181 444 <View style={IS_WEB && [a.flex_row, a.justify_end]}> 182 445 <Button
+41
src/lib/api/gatekeeper.ts
··· 141 141 name: params.name, 142 142 }) 143 143 } 144 + 145 + export async function gateGetPreferences(params: { 146 + serviceUrl: string 147 + did: string 148 + password: string 149 + }): Promise<{preferences: any[]}> { 150 + return await gatekeeperPost(params.serviceUrl, '/gate/get-preferences', { 151 + did: params.did, 152 + password: params.password, 153 + }) 154 + } 155 + 156 + export async function gatePutPreferences(params: { 157 + serviceUrl: string 158 + did: string 159 + password: string 160 + preferences: unknown 161 + authFactorToken?: string 162 + }): Promise<{status: 'success' | 'authFactorTokenRequired'}> { 163 + const body: Record<string, unknown> = { 164 + did: params.did, 165 + password: params.password, 166 + preferences: params.preferences, 167 + } 168 + if (params.authFactorToken) { 169 + body.authFactorToken = params.authFactorToken 170 + } 171 + 172 + try { 173 + await gatekeeperPost(params.serviceUrl, '/gate/put-preferences', body) 174 + return {status: 'success'} 175 + } catch (e) { 176 + if ( 177 + e instanceof GatekeeperError && 178 + e.errorType === 'AuthFactorTokenRequired' 179 + ) { 180 + return {status: 'authFactorTokenRequired'} 181 + } 182 + throw e 183 + } 184 + }
+5 -1
src/screens/Settings/AccountSettings.tsx
··· 158 158 </SettingsList.ItemText> 159 159 <SettingsList.BadgeButton 160 160 label={_(msg`Edit`)} 161 - onPress={() => birthdayControl.open()} 161 + onPress={() => 162 + isOauth && !isBskyPds 163 + ? openPdsAccountPage() 164 + : birthdayControl.open() 165 + } 162 166 /> 163 167 </SettingsList.Item> 164 168 <SettingsList.Divider />