Skip to content

Commit 98a9c12

Browse files
authored
Merge pull request #20127 from mozilla/fxa-13170
fix(fxa-settings): Handle missing account data in localStorage
2 parents f4c4f28 + 99d11d7 commit 98a9c12

File tree

5 files changed

+133
-16
lines changed

5 files changed

+133
-16
lines changed

packages/fxa-settings/src/components/Settings/index.test.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,43 @@ describe('Settings App', () => {
192192
});
193193
});
194194

195+
it('redirects to root when account data is unavailable in localStorage', async () => {
196+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
197+
198+
// Create an account object where accessing primaryEmail throws,
199+
// simulating the Account.data getter failure when localStorage has no data
200+
const brokenAccount = {
201+
...MOCK_ACCOUNT,
202+
} as unknown as Account;
203+
Object.defineProperty(brokenAccount, 'primaryEmail', {
204+
get() {
205+
throw new Error('Account data not loaded from localStorage');
206+
},
207+
});
208+
209+
renderWithRouter(
210+
<AppContext.Provider
211+
value={mockAppContext({ account: brokenAccount })}
212+
>
213+
<Subject />
214+
</AppContext.Provider>,
215+
{ route: SETTINGS_PATH }
216+
);
217+
218+
await waitFor(() => {
219+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/');
220+
});
221+
expect(warnSpy).toHaveBeenCalledWith(
222+
'Account data unavailable, redirecting to sign-in'
223+
);
224+
warnSpy.mockRestore();
225+
});
226+
195227
it('redirects to root if account is not verified', async () => {
196228
// this warning is expected, so we don't want to see it in the test output
197229
const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => {
198230
if (
199-
msg === 'Account or email verification is require to access /settings!'
231+
msg === 'Account or email verification is required to access /settings!'
200232
)
201233
return;
202234
});
@@ -230,7 +262,7 @@ describe('Settings App', () => {
230262
// this warning is expected, so we don't want to see it in the test output
231263
const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => {
232264
if (
233-
msg === 'Account or email verification is require to access /settings!'
265+
msg === 'Account or email verification is required to access /settings!'
234266
)
235267
return;
236268
});
@@ -261,7 +293,7 @@ describe('Settings App', () => {
261293
// this warning is expected, so we don't want to see it in the test output
262294
const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => {
263295
if (
264-
msg === 'Account or email verification is require to access /settings!'
296+
msg === 'Account or email verification is required to access /settings!'
265297
)
266298
return;
267299
});

packages/fxa-settings/src/components/Settings/index.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,19 @@ export const Settings = ({
148148
return <AppErrorDialog data-testid="error-dialog" />;
149149
}
150150

151-
// If the account email isn't verified or the user is an unverified session state,
152-
// kick back to root to prompt for verification. This should only happen if the user
153-
// tries to access /settings directly without entering a confirmation code on
154-
// confirm_signup_code page or signin_token_code page.
155-
if (account.primaryEmail.verified === false || sessionVerified === false) {
156-
console.warn(
157-
'Account or email verification is require to access /settings!'
158-
);
151+
// Redirect to root if the account or session is unverified. The try-catch
152+
// handles the case where account data may be missing from localStorage
153+
// (e.g. WKWebView storage eviction on iOS).
154+
try {
155+
if (account.primaryEmail.verified === false || sessionVerified === false) {
156+
console.warn(
157+
'Account or email verification is required to access /settings!'
158+
);
159+
navigateWithQuery('/');
160+
return <LoadingSpinner fullScreen />;
161+
}
162+
} catch {
163+
console.warn('Account data unavailable, redirecting to sign-in');
159164
navigateWithQuery('/');
160165
return <LoadingSpinner fullScreen />;
161166
}
@@ -178,6 +183,8 @@ export const Settings = ({
178183
return <LoadingSpinner fullScreen />;
179184
}
180185

186+
const hasPassword = account.hasPassword;
187+
181188
return (
182189
<SettingsLayout>
183190
<Head />
@@ -191,7 +198,7 @@ export const Settings = ({
191198
<MfaGuardPage2faSetup path="/two_step_authentication" />
192199
<MfaGuardPage2faChange path="/two_step_authentication/change" />
193200
<MfaGuardPage2faReplaceBackupCodes path="/two_step_authentication/replace_codes" />
194-
{account.hasPassword ? (
201+
{hasPassword ? (
195202
<>
196203
<MfaGuardPageRecoveryKeyCreate path="/account_recovery" />
197204
<MfaGuardedPageChangePassword path="/change_password" />

packages/fxa-settings/src/lib/account-storage.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,30 @@ export function getCurrentAccountUid(): string | null {
162162
return storage().get('currentAccountUid') || null;
163163
}
164164

165+
/**
166+
* Find a valid account UID when currentAccountUid is missing.
167+
* Returns the most recently logged-in account with a session token, or null.
168+
* Used as a recovery path when WKWebView storage eviction loses the UID
169+
* but account data still exists in the accounts map.
170+
*/
171+
export function findLastActiveAccountUid(): string | null {
172+
const accounts = storage().get('accounts') || {};
173+
let bestUid: string | null = null;
174+
let bestLastLogin = -1;
175+
176+
for (const uid of Object.keys(accounts)) {
177+
const account = accounts[uid];
178+
if (account?.sessionToken && account.uid) {
179+
const lastLogin = account.lastLogin ?? 0;
180+
if (lastLogin > bestLastLogin) {
181+
bestLastLogin = lastLogin;
182+
bestUid = uid;
183+
}
184+
}
185+
}
186+
return bestUid;
187+
}
188+
165189
export function getAccountData(uid?: string): UnifiedAccountData | null {
166190
const accountUid = uid || getCurrentAccountUid();
167191
if (!accountUid) return null;

packages/fxa-settings/src/models/Account.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import {
3232
} from '../lib/interfaces';
3333
import { createSaltV2 } from 'fxa-auth-client/lib/salt';
3434
import { getHandledError } from '../lib/error-utils';
35+
import * as Sentry from '@sentry/browser';
3536
import {
3637
getFullAccountData,
38+
getCurrentAccountUid,
3739
updateExtendedAccountState,
3840
updateBasicAccountData,
3941
ExtendedAccountState,
@@ -308,7 +310,18 @@ export class Account implements AccountData {
308310
securityEvents: [],
309311
} as AccountData;
310312
}
311-
throw new Error('Account data not loaded from localStorage');
313+
const error = new Error('Account data not loaded from localStorage');
314+
Sentry.captureException(error, {
315+
tags: { isIOS: /FxiOS/.test(navigator.userAgent) },
316+
extra: {
317+
currentAccountUid: getCurrentAccountUid(),
318+
hasAccountsKey: !!localStorage.getItem('__fxa_storage.accounts'),
319+
pathname: window.location.pathname,
320+
search: window.location.search,
321+
entrypoint: new URLSearchParams(window.location.search).get('entrypoint'),
322+
},
323+
});
324+
throw error;
312325
}
313326

314327
return {

packages/fxa-settings/src/models/contexts/AccountStateContext.tsx

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ import {
2121
SecurityEvent,
2222
} from '../Account';
2323
import { useLocalStorageSync } from '../../lib/hooks/useLocalStorageSync';
24+
import * as Sentry from '@sentry/browser';
2425
import {
2526
getAccountData,
2627
updateAccountData,
2728
clearExtendedAccountState,
2829
getCurrentAccountUid,
30+
setCurrentAccountUid,
31+
findLastActiveAccountUid,
2932
UnifiedAccountData,
3033
} from '../../lib/account-storage';
3134

@@ -168,14 +171,36 @@ function unifiedToAccountState(
168171
};
169172
}
170173

174+
/**
175+
* Recover the current account UID when it is missing from localStorage.
176+
* This primarily affects Firefox iOS where WKWebView storage eviction can
177+
* lose currentAccountUid while account data still exists.
178+
*
179+
* Recovery order: URL ?uid= param, then fallback UID, then scan for an
180+
* active session in accounts storage.
181+
*/
182+
function recoverAccountUid(fallbackUid?: string | null): string | null {
183+
const urlUid = new URLSearchParams(window.location.search).get('uid');
184+
const recovered = urlUid || fallbackUid || findLastActiveAccountUid();
185+
if (recovered) {
186+
setCurrentAccountUid(recovered);
187+
}
188+
return recovered;
189+
}
190+
171191
export function AccountStateProvider({
172192
children,
173193
initialState,
174194
}: AccountStateProviderProps) {
175195
// useLocalStorageSync triggers re-renders on storage changes;
176196
// we read from getAccountData() rather than the return value directly
177197
useLocalStorageSync('accounts');
178-
const currentAccountUid = useLocalStorageSync('currentAccountUid') as string | undefined;
198+
let currentAccountUid = useLocalStorageSync('currentAccountUid') as string | undefined;
199+
200+
// Recover UID when missing (iOS WKWebView storage eviction)
201+
if (!currentAccountUid) {
202+
currentAccountUid = recoverAccountUid() ?? undefined;
203+
}
179204

180205
const accountState = unifiedToAccountState(
181206
getAccountData(currentAccountUid),
@@ -185,8 +210,24 @@ export function AccountStateProvider({
185210
// Serialize AccountState -> UnifiedAccountData for localStorage:
186211
// Set -> Array, Error -> {message,name}, exclude derived 'primaryEmail'
187212
const setAccountDataCallback = useCallback((data: Partial<AccountState>) => {
188-
const uid = getCurrentAccountUid();
189-
if (!uid) return;
213+
let uid = getCurrentAccountUid();
214+
215+
// Recover UID when currentAccountUid is missing
216+
if (!uid) {
217+
uid = recoverAccountUid(data.uid);
218+
}
219+
220+
if (!uid) {
221+
Sentry.captureMessage('setAccountData called with no currentAccountUid', {
222+
level: 'warning',
223+
extra: {
224+
pathname: window.location.pathname,
225+
search: window.location.search,
226+
hasAccountsKey: !!localStorage.getItem('__fxa_storage.accounts'),
227+
},
228+
});
229+
return;
230+
}
190231

191232
const { uid: dataUid, email: dataEmail, primaryEmail, ...rest } = data;
192233
const storageData: Partial<UnifiedAccountData> = {

0 commit comments

Comments
 (0)