Skip to content

Commit 7adb0e6

Browse files
committed
fix(settings): Fix avatar deletion failing
Because: * After an avatar upload, if a user refreshes or just goes back to Settings at a different point in time, we are hitting /v/profile/default to delete the avatar instead of sending the avatar ID, causing a 400 but giving the usual visual feedback that the avatar has been removed This commit: * Fetches v1/avatar alongside v1/profile to get the current avatar ID * Falls back to null instead of 'default' when no avatar ID is available * Adjusts the functional test to account for this closes FXA-13300
1 parent 9e880f4 commit 7adb0e6

2 files changed

Lines changed: 84 additions & 25 deletions

File tree

packages/functional-tests/tests/settings/avatar.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ test.describe('severity-1 #smoke', () => {
7373
/avatar/
7474
);
7575

76+
// Refresh to simulate returning to the page later, which causes the
77+
// avatar ID to be lost (the /v1/profile endpoint doesn't return it)
78+
await page.reload();
79+
await expect(settings.settingsHeading).toBeVisible();
80+
await expect(settings.avatar.photo).not.toHaveAttribute('src', /avatar/);
81+
7682
await settings.avatar.changeButton.click();
7783

7884
await expect(avatar.profilePictureHeading).toBeVisible();
@@ -86,6 +92,15 @@ test.describe('severity-1 #smoke', () => {
8692
'src',
8793
/avatar/
8894
);
95+
96+
// Verify removal persists after refresh
97+
await page.reload();
98+
await expect(settings.settingsHeading).toBeVisible();
99+
await expect(settings.avatar.photo).toHaveAttribute('src', /avatar/);
100+
await expect(settings.avatarDropDownMenuPhoto).toHaveAttribute(
101+
'src',
102+
/avatar/
103+
);
89104
});
90105
});
91106

packages/fxa-settings/src/lib/hooks/useAccountData.ts

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import {
1111
RecoveryPhoneStatus,
1212
useAccountState,
1313
} from '../../models/contexts/AccountStateContext';
14-
import { Email, LinkedAccount, SecurityEvent, mapAttachedClient } from '../../models/Account';
14+
import {
15+
Email,
16+
LinkedAccount,
17+
SecurityEvent,
18+
mapAttachedClient,
19+
} from '../../models/Account';
1520
import { AccountTotp, AccountBackupCodes, AccountAvatar } from '../interfaces';
1621
import config from '../config';
1722
import { ERRNO } from '@fxa/accounts/errors';
@@ -60,21 +65,40 @@ interface AccountDataResult {
6065
/** Shape returned by the consolidated /account auth-server endpoint. */
6166
interface AccountResponse {
6267
emails?: Array<{ email: string; isPrimary: boolean; verified: boolean }>;
63-
linkedAccounts?: Array<{ providerId: number; authAt: number; enabled: boolean }>;
64-
subscriptions?: Array<{ created?: number; createdAt?: number; productName?: string; product_name?: string }>;
68+
linkedAccounts?: Array<{
69+
providerId: number;
70+
authAt: number;
71+
enabled: boolean;
72+
}>;
73+
subscriptions?: Array<{
74+
created?: number;
75+
createdAt?: number;
76+
productName?: string;
77+
product_name?: string;
78+
}>;
6579
totp?: { exists?: boolean; verified?: boolean };
6680
backupCodes?: { hasBackupCodes?: boolean; count?: number };
6781
recoveryKey?: { exists?: boolean; estimatedSyncDeviceCount?: number };
68-
recoveryPhone?: { exists?: boolean; phoneNumber?: string; available?: boolean };
69-
securityEvents?: Array<{ name: string; createdAt: number; verified?: boolean }>;
82+
recoveryPhone?: {
83+
exists?: boolean;
84+
phoneNumber?: string;
85+
available?: boolean;
86+
};
87+
securityEvents?: Array<{
88+
name: string;
89+
createdAt: number;
90+
verified?: boolean;
91+
}>;
7092
metricsOptOutAt?: number | null;
7193
createdAt?: number;
7294
passwordCreatedAt?: number;
7395
hasPassword?: boolean;
7496
}
7597

7698
/** Transform the consolidated /account endpoint response to AccountState format. */
77-
function transformAccountResponse(response: AccountResponse): Partial<AccountState> {
99+
function transformAccountResponse(
100+
response: AccountResponse
101+
): Partial<AccountState> {
78102
const emails: Email[] = (response.emails || []).map((e) => ({
79103
email: e.email,
80104
isPrimary: e.isPrimary,
@@ -152,19 +176,22 @@ async function fetchProfileData(
152176
{ scope: 'profile', ttl: PROFILE_OAUTH_TOKEN_TTL_SECONDS }
153177
);
154178

155-
const response = await fetch(`${config.servers.profile.url}/v1/profile`, {
156-
headers: { Authorization: `Bearer ${access_token}` },
157-
});
179+
const headers = { Authorization: `Bearer ${access_token}` };
180+
const [profileResponse, avatarResponse] = await Promise.all([
181+
fetch(`${config.servers.profile.url}/v1/profile`, { headers }),
182+
fetch(`${config.servers.profile.url}/v1/avatar`, { headers }),
183+
]);
158184

159-
if (!response.ok) {
185+
if (!profileResponse.ok) {
160186
return { displayName: null, avatar: null };
161187
}
162188

163-
const profile = await response.json();
189+
const profile = await profileResponse.json();
190+
const avatarData = avatarResponse.ok ? await avatarResponse.json() : null;
164191
return {
165192
displayName: profile.displayName || null,
166193
avatar: profile.avatar
167-
? { id: profile.avatarId || 'default', url: profile.avatar }
194+
? { id: avatarData?.id || null, url: profile.avatar }
168195
: null,
169196
};
170197
} catch (error) {
@@ -192,10 +219,7 @@ export function useAccountData({
192219
authClientRef.current = authClient;
193220

194221
const accountState = useAccountState();
195-
const {
196-
setAccountData,
197-
...stateData
198-
} = accountState;
222+
const { setAccountData, ...stateData } = accountState;
199223

200224
const [localLoading, setLocalLoading] = useState(true);
201225
const [localError, setLocalError] = useState<Error | null>(null);
@@ -222,32 +246,45 @@ export function useAccountData({
222246
]);
223247

224248
// Check for invalid token errors - user needs to sign in again
225-
const rejectedResults = [accountResult, profileResult, attachedClientsResult]
226-
.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
249+
const rejectedResults = [
250+
accountResult,
251+
profileResult,
252+
attachedClientsResult,
253+
].filter((r): r is PromiseRejectedResult => r.status === 'rejected');
227254
if (rejectedResults.some((r) => isInvalidTokenError(r.reason))) {
228255
throw new InvalidTokenError();
229256
}
230257

231258
let accountData: Partial<AccountState> = {};
232259

233260
if (accountResult.status === 'fulfilled') {
234-
accountData = { ...accountData, ...transformAccountResponse(accountResult.value) };
261+
accountData = {
262+
...accountData,
263+
...transformAccountResponse(accountResult.value),
264+
};
235265
} else {
236-
Sentry.captureMessage(`Failed to fetch account: ${accountResult.reason}`);
266+
Sentry.captureMessage(
267+
`Failed to fetch account: ${accountResult.reason}`
268+
);
237269
}
238270

239271
if (profileResult.status === 'fulfilled') {
240272
const { displayName, avatar } = profileResult.value;
241273
if (displayName !== null) accountData.displayName = displayName;
242274
if (avatar !== null) accountData.avatar = avatar;
243275
} else {
244-
Sentry.captureMessage(`Failed to fetch profile: ${profileResult.reason}`);
276+
Sentry.captureMessage(
277+
`Failed to fetch profile: ${profileResult.reason}`
278+
);
245279
}
246280

247281
if (attachedClientsResult.status === 'fulfilled') {
248-
accountData.attachedClients = attachedClientsResult.value.map(mapAttachedClient);
282+
accountData.attachedClients =
283+
attachedClientsResult.value.map(mapAttachedClient);
249284
} else {
250-
Sentry.captureMessage(`Failed to fetch attached clients: ${attachedClientsResult.reason}`);
285+
Sentry.captureMessage(
286+
`Failed to fetch attached clients: ${attachedClientsResult.reason}`
287+
);
251288
accountData.attachedClients = [];
252289
}
253290

@@ -278,7 +315,10 @@ export function useAccountData({
278315
}
279316
case 'displayName':
280317
case 'avatar': {
281-
const { displayName, avatar } = await fetchProfileData(client, token);
318+
const { displayName, avatar } = await fetchProfileData(
319+
client,
320+
token
321+
);
282322
if (displayName !== null) fieldData.displayName = displayName;
283323
if (avatar !== null) fieldData.avatar = avatar;
284324
break;
@@ -303,7 +343,11 @@ export function useAccountData({
303343
}, [fetchAccountData]);
304344

305345
return {
306-
data: { ...stateData, isLoading: localLoading, error: localError } as AccountState,
346+
data: {
347+
...stateData,
348+
isLoading: localLoading,
349+
error: localError,
350+
} as AccountState,
307351
isLoading: localLoading,
308352
error: localError,
309353
refetch: fetchAccountData,

0 commit comments

Comments
 (0)