Skip to content

Commit 4bb97b4

Browse files
authored
Merge pull request #18247 from mozilla/fxa-10373
Wire functionality to remove a recovery phone
2 parents ea7ad14 + b6f1d8a commit 4bb97b4

17 files changed

Lines changed: 273 additions & 33 deletions

File tree

libs/accounts/recovery-phone/src/lib/recovery-phone.errors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ export class RecoveryNumberNotSupportedError extends RecoveryPhoneError {
4444
}
4545
}
4646

47+
export class RecoveryNumberRemoveMissingBackupCodes extends RecoveryPhoneError {
48+
constructor(uid: string, cause?: Error) {
49+
super(
50+
'Unable to remove recovery phone, missing backup authentication codes.',
51+
{ uid },
52+
cause
53+
);
54+
}
55+
}
56+
4757
export class SmsSendRateLimitExceededError extends RecoveryPhoneError {
4858
constructor(
4959
uid: string,

libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
RecoveryNumberNotExistsError,
1313
RecoveryNumberNotSupportedError,
1414
RecoveryPhoneNotEnabled,
15+
RecoveryNumberRemoveMissingBackupCodes,
1516
} from './recovery-phone.errors';
1617

1718
describe('RecoveryPhoneService', () => {
@@ -46,6 +47,7 @@ describe('RecoveryPhoneService', () => {
4647

4748
beforeEach(async () => {
4849
mockSmsManager.sendSMS.mockReturnValue({ status: 'success' });
50+
mockRecoveryPhoneManager.hasRecoveryCodes.mockResolvedValue(true);
4951

5052
const module: TestingModule = await Test.createTestingModule({
5153
providers: [
@@ -280,6 +282,13 @@ describe('RecoveryPhoneService', () => {
280282
mockRecoveryPhoneManager.removePhoneNumber.mockRejectedValueOnce(error);
281283
expect(service.removePhoneNumber(uid)).rejects.toThrow(error);
282284
});
285+
286+
it('should throw if missing backup authentication codes', () => {
287+
mockRecoveryPhoneManager.hasRecoveryCodes.mockResolvedValue(false);
288+
const error = new RecoveryNumberRemoveMissingBackupCodes(uid);
289+
mockRecoveryPhoneManager.removePhoneNumber.mockRejectedValueOnce(error);
290+
expect(service.removePhoneNumber(uid)).rejects.toThrow(error);
291+
});
283292
});
284293

285294
describe('sendCode', () => {

libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
RecoveryNumberNotExistsError,
1212
RecoveryNumberNotSupportedError,
1313
RecoveryPhoneNotEnabled,
14+
RecoveryNumberRemoveMissingBackupCodes,
1415
} from './recovery-phone.errors';
1516
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message';
1617

@@ -161,16 +162,25 @@ export class RecoveryPhoneService {
161162

162163
/**
163164
* Remove phone number from an account. Each user can only have one associated
164-
* phone number.
165+
* phone number. A user must have backup codes before removing a phone number.
165166
*
166167
* @param uid An account id
167168
* @returns True if successful
168169
*/
169170
public async removePhoneNumber(uid: string) {
171+
170172
if (!this.config.enabled) {
171173
throw new RecoveryPhoneNotEnabled();
172174
}
173175

176+
const hasRecoveryCodes = await this.recoveryPhoneManager.hasRecoveryCodes(
177+
uid
178+
);
179+
180+
if (!hasRecoveryCodes) {
181+
throw new RecoveryNumberRemoveMissingBackupCodes(uid);
182+
}
183+
174184
return await this.recoveryPhoneManager.removePhoneNumber(uid);
175185
}
176186

packages/fxa-auth-server/lib/error.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,16 @@ AppError.recoveryPhoneNumberDoesNotExist = () => {
594594
});
595595
};
596596

597+
AppError.recoveryPhoneRemoveMissingRecoveryCodes = () => {
598+
return new AppError({
599+
code: 400,
600+
error: 'Bad Request',
601+
errno: ERRNO.RECOVERY_PHONE_REMOVE_MISSING_RECOVERY_CODES,
602+
message:
603+
'Unable to remove recovery phone, missing backup authentication codes.',
604+
});
605+
};
606+
597607
AppError.smsSendRateLimitExceeded = () => {
598608
return new AppError({
599609
code: 429,

packages/fxa-auth-server/lib/routes/recovery-phone.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
RecoveryNumberAlreadyExistsError,
1111
RecoveryNumberNotExistsError,
1212
SmsSendRateLimitExceededError,
13+
RecoveryNumberRemoveMissingBackupCodes,
1314
} from '@fxa/accounts/recovery-phone';
1415
import {
1516
AccountManager,
@@ -211,15 +212,24 @@ class RecoveryPhoneHandler {
211212
try {
212213
success = await this.recoveryPhoneService.removePhoneNumber(uid);
213214
} catch (error) {
215+
214216
if (error instanceof RecoveryPhoneNotEnabled) {
215217
throw AppError.featureNotEnabled();
216218
}
217219

220+
if (error instanceof RecoveryNumberNotExistsError) {
221+
throw AppError.recoveryPhoneNumberDoesNotExist();
222+
}
223+
224+
if (error instanceof RecoveryNumberRemoveMissingBackupCodes) {
225+
throw AppError.recoveryPhoneRemoveMissingRecoveryCodes();
226+
}
227+
218228
throw AppError.backendServiceFailure(
219229
'RecoveryPhoneService',
220230
'destroy',
221231
{ uid },
222-
error
232+
error,
223233
);
224234
}
225235

packages/fxa-auth-server/test/remote/recovery_phone_tests.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ describe(`#integration - recovery phone`, function () {
7979
await db.deleteFrom('accounts').execute();
8080
await db.deleteFrom('recoveryPhones').execute();
8181
await db.deleteFrom('sessionTokens').execute();
82+
await db.deleteFrom('recoveryCodes').execute();
8283
}
8384

8485
beforeEach(async function () {
@@ -92,6 +93,15 @@ describe(`#integration - recovery phone`, function () {
9293
version: 'V2',
9394
}
9495
);
96+
97+
// Add totp to account
98+
client.totpAuthenticator = new otplib.authenticator.Authenticator();
99+
const totpResult = await client.createTotpToken();
100+
client.totpAuthenticator.options = {
101+
secret: totpResult.secret,
102+
crypto: crypto,
103+
};
104+
await client.verifyTotpCode(client.totpAuthenticator.generate());
95105
});
96106

97107
afterEach(async function () {
@@ -123,15 +133,6 @@ describe(`#integration - recovery phone`, function () {
123133
this.skip('Invalid twilio accountSid or authToken. Check env / config!');
124134
}
125135

126-
// Add totp to account
127-
client.totpAuthenticator = new otplib.authenticator.Authenticator();
128-
const totpResult = await client.createTotpToken();
129-
client.totpAuthenticator.options = {
130-
secret: totpResult.secret,
131-
crypto: crypto,
132-
};
133-
await client.verifyTotpCode(client.totpAuthenticator.generate());
134-
135136
// Add recovery phone
136137
await client.recoveryPhoneNumberCreate(phoneNumber);
137138
await client.recoveryPhoneConfirmSetup(

packages/fxa-settings/src/components/Banner/index.stories.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,34 @@ export const Variation3cHeadingDescriptionDismiss = () => (
133133
</AppLayout>
134134
);
135135

136+
export const Variation4aIconStart = () => (
137+
<AppLayout>
138+
<Banner
139+
type="error"
140+
content={{
141+
localizedHeading: sampleHeading,
142+
localizedDescription: sampleDescription,
143+
}}
144+
iconAlign={'start'}
145+
dismissButton={{ action: () => alert('Dismiss clicked') }}
146+
/>
147+
</AppLayout>
148+
);
149+
150+
export const Variation4bIconCenter = () => (
151+
<AppLayout>
152+
<Banner
153+
type="error"
154+
content={{
155+
localizedHeading: sampleHeading,
156+
localizedDescription: sampleDescription,
157+
}}
158+
iconAlign={'center'}
159+
dismissButton={{ action: () => alert('Dismiss clicked') }}
160+
/>
161+
</AppLayout>
162+
);
163+
136164
export const TypeError = () => (
137165
<AppLayout>
138166
<Banner

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export const Banner = ({
2727
link,
2828
isFancy,
2929
bannerId,
30+
iconAlign = 'center',
3031
}: BannerProps) => {
32+
const iconClassName = `shrink-0 self-${iconAlign}`;
3133
return (
3234
<div
3335
id={bannerId || ''}
@@ -48,22 +50,22 @@ export const Banner = ({
4850
tabIndex={-1}
4951
>
5052
{/* Icon fills use 'currentColor' (from text color) for better accessibility in HCM mode */}
51-
{type === 'error' && <ErrorIcon className="shrink-0" ariaHidden />}
53+
{type === 'error' && <ErrorIcon className={iconClassName} aria-hidden />}
5254
{type === 'info' && !isFancy && (
53-
<InfoIcon className="shrink-0" ariaHidden />
55+
<InfoIcon className={iconClassName} aria-hidden />
5456
)}
5557
{type === 'info' && isFancy && (
56-
<InformationOutlineBlueIcon className="shrink-0" ariaHidden />
58+
<InformationOutlineBlueIcon className={iconClassName} aria-hidden />
5759
)}
5860
{type === 'success' && (
5961
<CheckmarkCircleOutlineCurrentIcon
60-
className="shrink-0"
62+
className={iconClassName}
6163
mode="success"
6264
ariaHidden
6365
/>
6466
)}
6567
{type === 'warning' && (
66-
<WarningIcon className="shrink-0" mode="attention" ariaHidden />
68+
<WarningIcon className={iconClassName} mode="attention" ariaHidden />
6769
)}
6870

6971
<div className="flex flex-col grow">

packages/fxa-settings/src/components/Banner/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type BannerProps = {
2525
link?: BannerLinkProps;
2626
isFancy?: boolean;
2727
bannerId?: string;
28+
iconAlign?: 'start' | 'center';
2829
};
2930

3031
export type Animation = {

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type FlowContainerProps = {
1414
onBackButtonClick?: (
1515
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
1616
) => void;
17+
hideBackButton?: boolean;
1718
localizedBackButtonTitle?: string;
1819
children?: React.ReactNode;
1920
};
@@ -22,6 +23,7 @@ export const FlowContainer = ({
2223
title,
2324
subtitle,
2425
onBackButtonClick = () => window.history.back(),
26+
hideBackButton = false,
2527
localizedBackButtonTitle,
2628
children,
2729
}: FlowContainerProps & RouteComponentProps) => {
@@ -38,12 +40,14 @@ export const FlowContainer = ({
3840
<Head title={title} />
3941

4042
<div className="relative flex items-center">
41-
<ButtonBack
42-
onClick={onBackButtonClick}
43-
dataTestId="flow-container-back-btn"
44-
localizedTitle={backButtonTitle}
45-
localizedAriaLabel={backButtonTitle}
46-
/>
43+
{!hideBackButton && (
44+
<ButtonBack
45+
onClick={onBackButtonClick}
46+
dataTestId="flow-container-back-btn"
47+
localizedTitle={backButtonTitle}
48+
localizedAriaLabel={backButtonTitle}
49+
/>
50+
)}
4751
<h1 className="font-header text-md text-grey-400">{title}</h1>
4852
</div>
4953
{subtitle && (

0 commit comments

Comments
 (0)