Skip to content

Commit 8592f15

Browse files
committed
Fix OAuth scope errors and handle change crash for non-Blacksky PDS users
- Add granular OAuth scopes (identity:handle, account:email?action=manage, account:status?action=manage) for proper permissions on newer PDSs - Add isOauth && !isBskyPds guards for Update Email, Deactivate, and Delete which are hardcoded to reject OAuth at the PDS level - Route Blacksky PDS OAuth users through gatekeeper for email update, deactivate, and delete (password-required operations) - Fix ChangeHandleDialog crash: OauthBskyAppAgent extends Agent (not AtpAgent) so resumeSession does not exist, causing TypeError in onSuccess callback that TanStack Query catches and surfaces as a mutation error despite HTTP 200 - Add ScopeMissingError and OAuth error handling in error strings and useCleanError as safety net
1 parent 2b76c35 commit 8592f15

9 files changed

Lines changed: 350 additions & 43 deletions

File tree

bskyweb/static/oauth-client-metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"redirect_uris": [
66
"https://blacksky.community/auth/web/callback"
77
],
8-
"scope": "atproto transition:generic transition:email transition:chat.bsky",
8+
"scope": "atproto transition:generic transition:email transition:chat.bsky identity:handle account:email?action=manage account:status?action=manage",
99
"token_endpoint_auth_method": "none",
1010
"response_types": [
1111
"code"

src/components/dialogs/EmailDialog/screens/Update.tsx

Lines changed: 116 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {useReducer} from 'react'
2-
import {View} from 'react-native'
2+
import {Linking, View} from 'react-native'
33
import {msg, Trans} from '@lingui/macro'
44
import {useLingui} from '@lingui/react'
55
import {validate as validateEmail} from 'email-validator'
66

7+
import {gateUpdateEmail} from '#/lib/api/gatekeeper'
78
import {wait} from '#/lib/async/wait'
89
import {useCleanError} from '#/lib/hooks/useCleanError'
10+
import {useIsBlackskyPds} from '#/lib/hooks/useIsBlackskyPds'
911
import {logger} from '#/logger'
1012
import {useSession} from '#/state/session'
1113
import {atoms as a, useTheme} from '#/alf'
@@ -27,6 +29,7 @@ import {Divider} from '#/components/Divider'
2729
import * as TextField from '#/components/forms/TextField'
2830
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
2931
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
32+
import {Lock_Stroke2_Corner2_Rounded as Lock} from '#/components/icons/Lock'
3033
import {Loader} from '#/components/Loader'
3134
import {Text} from '#/components/Typography'
3235

@@ -37,6 +40,7 @@ type State = {
3740
emailValid: boolean
3841
email: string
3942
token: string
43+
password: string
4044
}
4145

4246
type Action =
@@ -60,6 +64,10 @@ type Action =
6064
type: 'setToken'
6165
value: string
6266
}
67+
| {
68+
type: 'setPassword'
69+
value: string
70+
}
6371

6472
function reducer(state: State, action: Action): State {
6573
switch (action.type) {
@@ -100,6 +108,12 @@ function reducer(state: State, action: Action): State {
100108
token: action.value,
101109
}
102110
}
111+
case 'setPassword': {
112+
return {
113+
...state,
114+
password: action.value,
115+
}
116+
}
103117
}
104118
}
105119

@@ -115,12 +129,51 @@ export function Update(_props: ScreenProps<ScreenID.Update>) {
115129
email: '',
116130
emailValid: true,
117131
token: '',
132+
password: '',
118133
})
119134

120135
const {mutateAsync: updateEmail} = useUpdateEmail()
121136
const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
122137
const {mutateAsync: requestEmailVerification} = useRequestEmailVerification()
123138

139+
const isOauth = currentAccount?.isOauthSession === true
140+
const isBskyPds = useIsBlackskyPds()
141+
const useGatekeeper = isOauth && isBskyPds
142+
143+
if (isOauth && !isBskyPds) {
144+
const pdsAccountUrl = currentAccount?.service
145+
? `${currentAccount.service}/account`
146+
: undefined
147+
148+
return (
149+
<View style={[a.gap_lg]}>
150+
<Text style={[a.text_xl, a.font_bold]}>
151+
<Trans>Update your email</Trans>
152+
</Text>
153+
154+
<Admonition type="info">
155+
<Trans>
156+
Email updates are not available when signed in with OAuth. Please
157+
manage your email through your hosting provider's website.
158+
</Trans>
159+
</Admonition>
160+
161+
{pdsAccountUrl && (
162+
<Button
163+
label={_(msg`Open account settings`)}
164+
size="large"
165+
variant="solid"
166+
color="primary"
167+
onPress={() => Linking.openURL(pdsAccountUrl)}>
168+
<ButtonText>
169+
<Trans>Open Account Settings</Trans>
170+
</ButtonText>
171+
</Button>
172+
)}
173+
</View>
174+
)
175+
}
176+
124177
const handleEmailChange = (email: string) => {
125178
dispatch({
126179
type: 'setEmail',
@@ -159,33 +212,44 @@ export function Update(_props: ScreenProps<ScreenID.Update>) {
159212
}
160213

161214
try {
162-
const {status} = await wait(
163-
1000,
164-
updateEmail({
165-
email: state.email,
166-
token: state.token,
167-
}),
168-
)
215+
if (useGatekeeper) {
216+
const {status} = await wait(
217+
1000,
218+
gateUpdateEmail({
219+
serviceUrl: currentAccount.service,
220+
did: currentAccount.did,
221+
password: state.password,
222+
email: state.email,
223+
token: state.token || undefined,
224+
}),
225+
)
169226

170-
if (status === 'tokenRequired') {
171-
dispatch({
172-
type: 'setStep',
173-
step: 'token',
174-
})
175-
dispatch({
176-
type: 'setMutationStatus',
177-
status: 'default',
178-
})
179-
} else if (status === 'success') {
180-
dispatch({
181-
type: 'setMutationStatus',
182-
status: 'success',
183-
})
227+
if (status === 'tokenRequired') {
228+
dispatch({type: 'setStep', step: 'token'})
229+
dispatch({type: 'setMutationStatus', status: 'default'})
230+
} else if (status === 'success') {
231+
dispatch({type: 'setMutationStatus', status: 'success'})
232+
}
233+
} else {
234+
const {status} = await wait(
235+
1000,
236+
updateEmail({
237+
email: state.email,
238+
token: state.token,
239+
}),
240+
)
184241

185-
try {
186-
// fire off a confirmation email immediately
187-
await requestEmailVerification()
188-
} catch {}
242+
if (status === 'tokenRequired') {
243+
dispatch({type: 'setStep', step: 'token'})
244+
dispatch({type: 'setMutationStatus', status: 'default'})
245+
} else if (status === 'success') {
246+
dispatch({type: 'setMutationStatus', status: 'success'})
247+
248+
try {
249+
// fire off a confirmation email immediately
250+
await requestEmailVerification()
251+
} catch {}
252+
}
189253
}
190254
} catch (e) {
191255
logger.error('EmailDialog: update email failed', {safeMessage: e})
@@ -235,6 +299,31 @@ export function Update(_props: ScreenProps<ScreenID.Update>) {
235299
</TextField.Root>
236300
</View>
237301

302+
{useGatekeeper && (
303+
<View>
304+
<Text
305+
style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
306+
<Trans>Please enter your account password.</Trans>
307+
</Text>
308+
<TextField.Root>
309+
<TextField.Icon icon={Lock} />
310+
<TextField.Input
311+
label={_(msg`Password`)}
312+
placeholder={_(msg`Password`)}
313+
defaultValue={state.password}
314+
onChangeText={
315+
state.mutationStatus === 'success'
316+
? undefined
317+
: (value: string) => dispatch({type: 'setPassword', value})
318+
}
319+
secureTextEntry
320+
autoComplete="password"
321+
autoCapitalize="none"
322+
/>
323+
</TextField.Root>
324+
</View>
325+
)}
326+
238327
{state.step === 'token' && (
239328
<>
240329
<Divider />
@@ -304,6 +393,7 @@ export function Update(_props: ScreenProps<ScreenID.Update>) {
304393
onPress={handleUpdateEmail}
305394
disabled={
306395
!state.email ||
396+
(useGatekeeper && !state.password) ||
307397
(state.step === 'token' &&
308398
(!state.token || state.token.length !== 11)) ||
309399
state.mutationStatus === 'pending'

src/lib/hooks/useCleanError.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export function useCleanError() {
6666
}
6767
}
6868

69+
if (raw.includes('OAuth credentials are not supported')) {
70+
return {
71+
raw,
72+
clean: _(
73+
msg`This feature is not available when signed in with OAuth. Please manage your account through your hosting provider's website.`,
74+
),
75+
}
76+
}
77+
6978
if (raw.includes('Rate Limit Exceeded')) {
7079
return {
7180
raw,

src/lib/strings/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export function cleanError(str: any): string {
3030
if (str.includes('Bad token scope') || str.includes('Bad token method')) {
3131
return t`This feature is not available while using an App Password. Please sign in with your main password.`
3232
}
33+
if (str.includes('OAuth credentials are not supported')) {
34+
return t`This feature is not available when signed in with OAuth. Please manage your account through your hosting provider's website.`
35+
}
36+
if (
37+
str.includes('ScopeMissingError') ||
38+
str.includes('Missing required scope')
39+
) {
40+
return t`This feature is not available with your current session. Please manage your account through your hosting provider's website, or sign out and sign back in to refresh your permissions.`
41+
}
3342
if (str.includes('Account has been suspended')) {
3443
return t`Account has been suspended`
3544
}

src/screens/Settings/AccountSettings.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import {Linking} from 'react-native'
12
import {msg, Trans} from '@lingui/macro'
23
import {useLingui} from '@lingui/react'
34
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
45

6+
import {useIsBlackskyPds} from '#/lib/hooks/useIsBlackskyPds'
57
import {type CommonNavigatorParams} from '#/lib/routes/types'
68
import {useModalControls} from '#/state/modals'
79
import {useSession} from '#/state/session'
@@ -41,6 +43,18 @@ export function AccountSettingsScreen({}: Props) {
4143
const exportCarControl = useDialogControl()
4244
const deactivateAccountControl = useDialogControl()
4345

46+
const isOauth = currentAccount?.isOauthSession === true
47+
const isBskyPds = useIsBlackskyPds()
48+
const pdsAccountUrl = currentAccount?.service
49+
? `${currentAccount.service}/account`
50+
: undefined
51+
52+
const openPdsAccountPage = () => {
53+
if (pdsAccountUrl) {
54+
void Linking.openURL(pdsAccountUrl)
55+
}
56+
}
57+
4458
return (
4559
<Layout.Screen>
4660
<Layout.Header.Outer>
@@ -105,9 +119,11 @@ export function AccountSettingsScreen({}: Props) {
105119
<SettingsList.PressableItem
106120
label={_(msg`Update email`)}
107121
onPress={() =>
108-
emailDialogControl.open({
109-
id: EmailDialogScreenID.Update,
110-
})
122+
isOauth && !isBskyPds
123+
? openPdsAccountPage()
124+
: emailDialogControl.open({
125+
id: EmailDialogScreenID.Update,
126+
})
111127
}>
112128
<SettingsList.ItemIcon icon={PencilIcon} />
113129
<SettingsList.ItemText>
@@ -157,7 +173,11 @@ export function AccountSettingsScreen({}: Props) {
157173
</SettingsList.PressableItem>
158174
<SettingsList.PressableItem
159175
label={_(msg`Deactivate account`)}
160-
onPress={() => deactivateAccountControl.open()}
176+
onPress={() =>
177+
isOauth && !isBskyPds
178+
? openPdsAccountPage()
179+
: deactivateAccountControl.open()
180+
}
161181
destructive>
162182
<SettingsList.ItemIcon icon={FreezeIcon} />
163183
<SettingsList.ItemText>
@@ -167,7 +187,11 @@ export function AccountSettingsScreen({}: Props) {
167187
</SettingsList.PressableItem>
168188
<SettingsList.PressableItem
169189
label={_(msg`Delete account`)}
170-
onPress={() => openModal({name: 'delete-account'})}
190+
onPress={() =>
191+
isOauth && !isBskyPds
192+
? openPdsAccountPage()
193+
: openModal({name: 'delete-account'})
194+
}
171195
destructive>
172196
<SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} />
173197
<SettingsList.ItemText>

src/screens/Settings/components/ChangeHandleDialog.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
1717

1818
import {HITSLOP_10, urls} from '#/lib/constants'
1919
import {cleanError} from '#/lib/strings/errors'
20-
import {createFullHandle, validateServiceHandle} from '#/lib/strings/handles'
21-
import {sanitizeHandle} from '#/lib/strings/handles'
20+
import {
21+
createFullHandle,
22+
sanitizeHandle,
23+
validateServiceHandle,
24+
} from '#/lib/strings/handles'
2225
import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
2326
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
2427
import {useServiceQuery} from '#/state/queries/service'
@@ -173,7 +176,11 @@ function ProvidedHandlePage({
173176
queryKey: RQKEY_PROFILE(currentAccount.did),
174177
})
175178
}
176-
agent.resumeSession(agent.session!).then(() => control.close())
179+
if ('resumeSession' in agent && agent.session) {
180+
agent.resumeSession(agent.session).then(() => control.close())
181+
} else {
182+
control.close()
183+
}
177184
},
178185
})
179186

@@ -401,7 +408,11 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
401408
queryKey: RQKEY_PROFILE(currentAccount.did),
402409
})
403410
}
404-
agent.resumeSession(agent.session!).then(() => control.close())
411+
if ('resumeSession' in agent && agent.session) {
412+
agent.resumeSession(agent.session).then(() => control.close())
413+
} else {
414+
control.close()
415+
}
405416
},
406417
})
407418

0 commit comments

Comments
 (0)