feat(settings): connected services disconnect flow

Closes #5101.
This commit is contained in:
Jared Hirsch 2020-10-30 15:38:35 -07:00
Родитель b5c99456dc
Коммит 0dba8fe52c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: E5976F3EF6DCC8C8
10 изменённых файлов: 618 добавлений и 263 удалений

8
.vscode/launch.json поставляемый
Просмотреть файл

@ -65,6 +65,14 @@
"skipFiles": ["<node_internals>/**"],
"cwd": "${workspaceFolder}",
"port": 9150
},
{
"name": "Attach to graphql-api server",
"type": "node",
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"cwd": "${workspaceFolder}",
"port": 9200
}
]
}

Просмотреть файл

@ -9,6 +9,7 @@ import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import AppErrorDialog from 'fxa-react/components/AppErrorDialog';
import * as Metrics from '../../lib/metrics';
import { Account } from '../../models';
import { ACCOUNT_FIELDS } from '../../models/Account';
import { Router } from '@reach/router';
import FlowContainer from '../FlowContainer';
import PageSettings from '../PageSettings';
@ -24,49 +25,7 @@ import { HomePath } from '../../constants';
export const GET_INITIAL_STATE = gql`
query GetInitialState {
account {
uid
displayName
avatarUrl
accountCreated
passwordCreated
recoveryKey
primaryEmail @client
emails {
email
isPrimary
verified
}
attachedClients {
clientId
isCurrentSession
userAgent
deviceType
deviceId
name
lastAccessTime
lastAccessTimeFormatted
approximateLastAccessTime
approximateLastAccessTimeFormatted
userAgent
location {
city
country
state
stateCode
}
os
}
totp {
exists
verified
}
subscriptions {
created
productName
}
alertTextExternal @client
}
${ACCOUNT_FIELDS}
session {
verified
}

Просмотреть файл

@ -1,4 +1,4 @@
export const MOCK_SERVICES = [
export const DESKTOP_SYNC_MOCKS = [
{
clientId: 'a8c528140153d234',
deviceId: 'baldjlkdsajflkas',
@ -23,109 +23,6 @@ export const MOCK_SERVICES = [
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
{
clientId: 'a8c528140153d1c6',
deviceId: 'dsalfjsajflkds',
sessionTokenId: null,
refreshTokenId:
'f0b7dae0043cb07cdb0f1ff160367a0b3214a91f037621e892060d9a146f2d8e',
isCurrentSession: false,
deviceType: null,
name: 'Firefox Private Network',
createdTime: 1571412069000,
lastAccessTime: 1571412069000,
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
{
clientId: '802d56ef2a9af9fa',
deviceId: 'djflkajlkfsjd',
sessionTokenId: null,
refreshTokenId:
'f0b7dae0043cb07cdb0f1ff160367a0b3214a91f037621e892060d9a146f2d8e',
isCurrentSession: false,
deviceType: null,
name: 'Firefox Monitor',
createdTime: 1570736983000,
lastAccessTime: 1570736983000,
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
{
clientId: 'a2270f727f45f648',
deviceId: 'dsfjlksajfoiew',
sessionTokenId: null,
refreshTokenId:
'f0b7dae0043cb07cdb0f1ff160367a0b3214a91f037621e892060d9a146f2d8e',
isCurrentSession: false,
deviceType: null,
name: 'Firefox Sync',
createdTime: 1571151370000,
lastAccessTime: 1571151370000,
scope: [
'https://identity.mozilla.com/apps/oldsync',
'https://identity.mozilla.com/tokens/session',
'profile',
],
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
{
clientId: 'e7ce535d93522896',
deviceId: 'f84f1ae16db91422f8c808ce3c5f3a04',
sessionTokenId: null,
refreshTokenId:
'b72fbbe94cd79b26c4ec0a8c3de237ca0c6beeea318874d041ade2c6c482c3a5',
isCurrentSession: false,
deviceType: 'mobile',
name: 'Firefox Lockwise',
createdTime: 1570221396000,
lastAccessTime: 1570221396619,
scope: ['https://identity.mozilla.com/apps/oldsync', 'profile'],
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
{
clientId: 'dsjflksajflkjlkhd74398984',
deviceId: 'bf7c2ae1fa0ebd146f6478ac68bd8d63',
@ -152,17 +49,72 @@ export const MOCK_SERVICES = [
approximateLastAccessTimeFormatted: null,
},
{
clientId: '3c49430b43dfba77',
deviceId: '9b16949ac8d5019ea08fdc0b82731a97',
clientId: 'a2270f727f45f648',
deviceId: 'dsfjlksajfoiew',
sessionTokenId: null,
refreshTokenId:
'081ada3fb68fc41c97a60358bf6443c13c55a2ea5a85b2cac0c4c2970e2d22f7',
'f0b7dae0043cb07cdb0f1ff160367a0b3214a91f037621e892060d9a146f2d8e',
isCurrentSession: false,
deviceType: 'mobile',
name: 'A-C Logins Sync Sample',
createdTime: 1570038359000,
lastAccessTime: 1570038359594,
scope: ['https://identity.mozilla.com/apps/oldsync', 'profile'],
deviceType: null,
name: 'Firefox Sync',
createdTime: 1571151370000,
lastAccessTime: 1571151370000,
scope: [
'https://identity.mozilla.com/apps/oldsync',
'https://identity.mozilla.com/tokens/session',
'profile',
],
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
];
const OAUTH_SERVICE_MOCKS = [
{
clientId: 'a8c528140153d1c6',
deviceId: 'dsalfjsajflkds',
sessionTokenId: null,
refreshTokenId:
'f0b7dae0043cb07cdb0f1ff160367a0b3214a91f037621e892060d9a146f2d8e',
isCurrentSession: false,
deviceType: null,
name: 'Firefox Private Network',
createdTime: 1571412069000,
lastAccessTime: 1571412069000,
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
{
clientId: '802d56ef2a9af9fa',
deviceId: 'djflkajlkfsjd',
sessionTokenId: null,
refreshTokenId:
'f0b7dae0043cb07cdb0f1ff160367a0b3214a91f037621e892060d9a146f2d8e',
isCurrentSession: false,
deviceType: null,
name: 'Firefox Monitor',
createdTime: 1570736983000,
lastAccessTime: 1570736983000,
location: {
city: null,
country: null,
@ -226,3 +178,62 @@ export const MOCK_SERVICES = [
approximateLastAccessTimeFormatted: null,
},
];
const MOBILE_SYNC_SERVICE_MOCKS = [
{
clientId: 'e7ce535d93522896',
deviceId: 'f84f1ae16db91422f8c808ce3c5f3a04',
sessionTokenId: null,
refreshTokenId:
'b72fbbe94cd79b26c4ec0a8c3de237ca0c6beeea318874d041ade2c6c482c3a5',
isCurrentSession: false,
deviceType: 'mobile',
name: 'Firefox Lockwise',
createdTime: 1570221396000,
lastAccessTime: 1570221396619,
scope: ['https://identity.mozilla.com/apps/oldsync', 'profile'],
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
{
clientId: '3c49430b43dfba77',
deviceId: '9b16949ac8d5019ea08fdc0b82731a97',
sessionTokenId: null,
refreshTokenId:
'081ada3fb68fc41c97a60358bf6443c13c55a2ea5a85b2cac0c4c2970e2d22f7',
isCurrentSession: false,
deviceType: 'mobile',
name: 'A-C Logins Sync Sample',
createdTime: 1570038359000,
lastAccessTime: 1570038359594,
scope: ['https://identity.mozilla.com/apps/oldsync', 'profile'],
location: {
city: null,
country: null,
state: null,
stateCode: null,
},
userAgent: '',
os: null,
createdTimeFormatted: 'a month ago',
lastAccessTimeFormatted: 'a month ago',
approximateLastAccessTime: null,
approximateLastAccessTimeFormatted: null,
},
];
export const MOCK_SERVICES = [
...DESKTOP_SYNC_MOCKS,
...OAUTH_SERVICE_MOCKS,
...MOBILE_SYNC_SERVICE_MOCKS,
];

Просмотреть файл

@ -5,14 +5,8 @@
import React from 'react';
import { LinkExternal } from 'fxa-react/components/LinkExternal';
import { useBooleanState } from 'fxa-react/lib/hooks';
import { Checkbox } from '../Checkbox';
import { DeviceLocation } from '../../models/Account';
import { Icon } from './Icon';
import { Modal } from '../Modal';
import { useAlertBar } from '../../lib/hooks';
import { VerifiedSessionGuard } from '../VerifiedSessionGuard';
export function Service({
name,
@ -20,18 +14,18 @@ export function Service({
location,
lastAccessTimeFormatted,
canSignOut,
handleSignOut,
}: {
name: string;
deviceType: string | null;
location: DeviceLocation;
lastAccessTimeFormatted: string;
canSignOut: boolean;
handleSignOut: () => void;
}) {
const alertBar = useAlertBar();
const { city, stateCode, country } = location;
const locationProvided = Boolean(city && stateCode && country);
let serviceLink, iconName;
const [modalRevealed, revealModal, hideModal] = useBooleanState();
switch (name) {
case 'Pocket':
@ -111,69 +105,12 @@ export function Service({
<button
className="cta-neutral cta-base disabled:cursor-wait whitespace-no-wrap"
data-testid="connected-service-sign-out"
onClick={revealModal}
onClick={handleSignOut}
>
Sign out
</button>
)}
</div>
{modalRevealed && (
<VerifiedSessionGuard
onDismiss={hideModal}
onError={(error) => {
hideModal();
alertBar.error(
'Sorry, there was a problem verifying your session',
error
);
}}
>
<Modal
onDismiss={hideModal}
onConfirm={hideModal} /* to be implemented in a later issue */
confirmBtnClassName="cta-primary"
confirmText="Sign Out"
headerId="connected-services-sign-out-header"
descId="connected-services-sign-out-description"
>
<h2
id="connected-services-sign-out-header"
className="font-bold text-xl text-center mb-2"
data-testid="connected-services-modal-header"
>
Disconnect from Sync
</h2>
<p id="sign-out-desc" className="my-4 text-center">
Your browsing data will remain on your device ({name}), but it
will no longer sync with your account.
</p>
<p className="my-4 text-center">
What's the main reason for disconnecting this device?
</p>
<ul className="my-4 ltr:text-left rtl:text-right">
The device is:
<li className="my-2">
<Checkbox {...{ label: 'Suspicious' }} />
</li>
<li className="my-2">
<Checkbox {...{ label: 'Lost or Stolen' }} />
</li>
<li className="my-2">
<Checkbox {...{ label: 'Old or replaced' }} />
</li>
<li className="my-2">
<Checkbox {...{ label: 'Duplicate' }} />
</li>
<li className="my-2">
<Checkbox {...{ label: 'Rather not say' }} />
</li>
</ul>
</Modal>
</VerifiedSessionGuard>
)}
</div>
</div>
);

Просмотреть файл

@ -3,11 +3,19 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { act, fireEvent, screen, wait } from '@testing-library/react';
import ConnectedServices, { sortAndFilterConnectedClients } from '.';
import { renderWithRouter, MockedCache } from '../../models/_mocks';
import { act, fireEvent, screen, wait, waitFor } from '@testing-library/react';
import ConnectedServices, {
sortAndFilterConnectedClients,
ATTACHED_CLIENT_DISCONNECT_MUTATION,
} from '.';
import {
createCache,
renderWithRouter,
MockedCache,
} from '../../models/_mocks';
import { GET_ACCOUNT } from '../../models';
import { isMobileDevice } from '../../lib/utilities';
import { MOCK_SERVICES } from './MOCK_SERVICES';
import { DESKTOP_SYNC_MOCKS, MOCK_SERVICES } from './MOCK_SERVICES';
const SERVICES_NON_MOBILE = MOCK_SERVICES.filter((d) => !isMobileDevice(d));
@ -25,6 +33,58 @@ const getIconAndServiceLink = async (name: string, testId: string) => {
};
};
const firstSyncMock = DESKTOP_SYNC_MOCKS[0];
const mocks = [
{
request: {
query: ATTACHED_CLIENT_DISCONNECT_MUTATION,
variables: {
input: {
clientId: firstSyncMock.clientId,
deviceId: firstSyncMock.deviceId,
refreshTokenId: firstSyncMock.refreshTokenId,
sessionTokenId: firstSyncMock.sessionTokenId,
},
},
},
result: {
data: {},
},
},
];
const clickFirstSignOutButton = async () => {
await act(async () => {
const signOutButtons = await screen.findAllByTestId(
'connected-service-sign-out'
);
fireEvent.click(signOutButtons[0]);
});
await wait();
};
const chooseRadioByLabel = async (label: string) => {
await act(async () => {
const radio = await screen.findByLabelText(label);
fireEvent.click(radio);
});
await wait();
};
const clickConfirmDisconnectButton = async () => {
await act(async () => {
const confirmButton = await screen.findByTestId('modal-confirm');
fireEvent.click(confirmButton);
});
await wait();
};
const expectDisconnectModalHeader = async () => {
expect(
await screen.queryByTestId('connected-services-modal-header')
).toBeInTheDocument();
};
describe('Connected Services', () => {
it('renders "fresh load" <ConnectedServices/> with correct content', async () => {
renderWithRouter(
@ -154,17 +214,52 @@ describe('Connected Services', () => {
<ConnectedServices />
</MockedCache>
);
await clickFirstSignOutButton();
await expectDisconnectModalHeader();
});
await act(async () => {
const signOutButtons = await screen.findAllByTestId(
'connected-service-sign-out'
);
fireEvent.click(signOutButtons[0]);
});
await wait();
it('renders "lost" modal when user has selected "lost" option', async () => {
renderWithRouter(
<MockedCache account={{ attachedClients: MOCK_SERVICES }} mocks={mocks}>
<ConnectedServices />
</MockedCache>
);
await clickFirstSignOutButton();
await expectDisconnectModalHeader();
await chooseRadioByLabel('Lost or Stolen');
await clickConfirmDisconnectButton();
expect(await screen.queryByTestId('lost-device-desc')).toBeInTheDocument();
});
it('renders "suspicious" modal when user has selected "suspicious" option in survey modal', async () => {
renderWithRouter(
<MockedCache account={{ attachedClients: MOCK_SERVICES }} mocks={mocks}>
<ConnectedServices />
</MockedCache>
);
await clickFirstSignOutButton();
await expectDisconnectModalHeader();
await chooseRadioByLabel('Suspicious');
await clickConfirmDisconnectButton();
expect(
screen.queryByTestId('connected-services-modal-header')
await screen.queryByTestId('suspicious-device-desc')
).toBeInTheDocument();
});
it('after a service is disconnected, removes the row from the UI', async () => {
renderWithRouter(
<MockedCache account={{ attachedClients: MOCK_SERVICES }} mocks={mocks}>
<ConnectedServices />
</MockedCache>
);
const initialCount = (
await screen.findAllByTestId('settings-connected-service')
).length;
await clickFirstSignOutButton();
await clickConfirmDisconnectButton();
const finalCount = (
await screen.findAllByTestId('settings-connected-service')
).length;
expect(finalCount === initialCount - 1).toBeTruthy;
});
});

Просмотреть файл

@ -2,22 +2,46 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useState } from 'react';
import React from 'react';
import { gql, ApolloError } from '@apollo/client';
import groupBy from 'lodash.groupby';
import { LinkExternal } from 'fxa-react/components/LinkExternal';
import { useAlertBar } from '../../lib/hooks';
import { logViewEvent } from '../../lib/metrics';
import { useBooleanState } from 'fxa-react/lib/hooks';
import { useAlertBar, useMutation } from '../../lib/hooks';
import { Modal } from '../Modal';
import { isMobileDevice } from '../../lib/utilities';
import { AttachedClient, useAccount, useLazyAccount } from '../../models';
import { AlertBar } from '../AlertBar';
import { ButtonIconReload } from '../ButtonIcon';
import { ConnectAnotherDevicePromo } from '../ConnectAnotherDevicePromo';
import { Service } from './Service';
import { VerifiedSessionGuard } from '../VerifiedSessionGuard';
import { clearSignedInAccountUid } from '../../lib/cache';
const UTM_PARAMS =
'?utm_source=accounts.firefox.com&utm_medium=referral&utm_campaign=fxa-devices';
const DEVICES_SUPPORT_URL =
'https://support.mozilla.org/kb/fxa-managing-devices' + UTM_PARAMS;
interface DisconnectingState {
reason: string | null;
client: AttachedClient | null;
}
let DS: DisconnectingState = {
reason: null,
client: null,
};
export const ATTACHED_CLIENT_DISCONNECT_MUTATION = gql`
mutation attachedClientDisconnect($input: AttachedClientDisconnectInput!) {
attachedClientDisconnect(input: $input) {
clientMutationId
}
}
`;
export function sortAndFilterConnectedClients(
attachedClients: Array<AttachedClient>
) {
@ -50,14 +74,131 @@ export const ConnectedServices = () => {
const sortedAndUniqueClients = sortAndFilterConnectedClients([
...attachedClients,
]);
const [errorText, setErrorText] = useState<string>();
const [getAccount, { accountLoading }] = useLazyAccount((error) => {
setErrorText('Sorry, there was a problem refreshing the recovery key.');
alertBar.show();
alertBar.error(
'Sorry, there was a problem refreshing the list of connected services.'
);
});
const showMobilePromo = !sortedAndUniqueClients.filter(isMobileDevice).length;
// The Confirm Disconnect modal is shown when a user clicks 'Sign Out' on a sync service.
// It asks the user to confirm they want to disconnect, and answer a survey question explaining
// why they are disconnecting.
const [
confirmDisconnectModalRevealed,
revealConfirmDisconnectModal,
hideConfirmDisconnectModal,
] = useBooleanState();
// After the user confirms they want to disconnect from sync in Confirm Disconnect modal,
// if their reason was a lost/stolen device, or a suspicious device, then we show them
// an informative modal with some advice on next steps to take.
const [
adviceModalRevealed,
revealAdviceModal,
hideAdviceModal,
] = useBooleanState();
const clearDisconnectingState = (
errorMessage?: string,
error?: ApolloError
) => {
hideConfirmDisconnectModal();
DS.client = null;
DS.reason = null;
if (errorMessage) {
alertBar.error(errorMessage, error);
}
};
const onConfirmDisconnect = (evt?: MouseEvent) => {
if (evt) {
const t = evt.target as Element;
const modalEl = t.closest('#modal');
const selected = modalEl!.querySelector(
'input[type="radio"]:checked'
) as HTMLInputElement;
if (selected) {
DS.reason = selected.value;
}
}
if (!DS.client) {
return clearDisconnectingState('Client not found, unable to disconnect');
}
logViewEvent('settings.clients.disconnect', `submit.${DS.reason!}`);
deleteConnectedService({
variables: {
input: {
clientId: DS.client.clientId,
deviceId: DS.client.deviceId,
sessionTokenId: DS.client.sessionTokenId,
refreshTokenId: DS.client.refreshTokenId,
},
},
});
};
const onSignOutClick = (client: AttachedClient) => {
DS.client = client;
// If it's a sync client, we show the disconnect survey modal.
// Only sync clients have a deviceId.
if (client.deviceId) {
revealConfirmDisconnectModal();
} else {
onConfirmDisconnect();
}
};
const onCloseAdviceModal = () => {
clearDisconnectingState();
hideAdviceModal();
};
const [deleteConnectedService] = useMutation(
ATTACHED_CLIENT_DISCONNECT_MUTATION,
{
onCompleted: () => {
// TODO: Add `timing.clients.disconnect` flow timing event as seen in
// old-settings? #6903
if (DS.client!.isCurrentSession) {
clearSignedInAccountUid();
window.location.assign(`${window.location.origin}/signin`);
} else if (DS.reason === 'suspicious' || DS.reason === 'lost') {
// Wait to clear disconnecting state till the advice modal has been shown
hideConfirmDisconnectModal();
revealAdviceModal();
} else {
const name = DS.client!.name;
alertBar.success(`Logged out of ${name}.`);
clearDisconnectingState();
}
},
onError: (error: ApolloError) =>
clearDisconnectingState(undefined, error),
ignoreResults: true,
update: (cache) => {
cache.modify({
fields: {
account: (existing: Account) => {
return {
...existing,
attachedClients: attachedClients.filter(
// TODO: should this also go into the AttachedClient model?
(client) =>
client.lastAccessTime !== DS.client!.lastAccessTime &&
client.name !== DS.client!.name
),
};
},
},
});
},
}
);
return (
<section
className="mt-11"
@ -88,7 +229,18 @@ export const ConnectedServices = () => {
deviceType: client.deviceType,
location: client.location,
lastAccessTimeFormatted: client.lastAccessTimeFormatted,
canSignOut: true,
// TODO: move this into the AttachedClient model, following the
// approach used by old-settings / content-server?
// If the client has a deviceId, we know it's a Sync client.
// If the client does not have deviceId or clientId, then we
// know it's a web session. The user can sign out of either.
canSignOut:
!!client.deviceId || (!client.deviceId && !client.clientId),
isCurrentSession: client.isCurrentSession,
clientId: client.clientId,
handleSignOut: () => {
onSignOutClick(client);
},
}}
/>
))}
@ -112,22 +264,166 @@ export const ConnectedServices = () => {
</>
)}
{alertBar.visible && (
<AlertBar
onDismiss={alertBar.hide}
type={errorText ? 'error' : 'success'}
>
{errorText ? (
<p data-testid="delete-recovery-key-error">
Error text TBD. {errorText}
</p>
) : (
<p data-testid="delete-recovery-key-success">
Account recovery key removed
</p>
)}
{alertBar.visible && alertBar.content && (
<AlertBar onDismiss={alertBar.hide} type={alertBar.type}>
<p
data-testid={`connected-services-alert-bar-message-${alertBar.type}`}
>
{alertBar.content}
</p>
</AlertBar>
)}
{confirmDisconnectModalRevealed && (
<VerifiedSessionGuard
onDismiss={clearDisconnectingState}
onError={(error: ApolloError) =>
clearDisconnectingState(undefined, error)
}
>
<Modal
onDismiss={hideConfirmDisconnectModal}
onConfirm={onConfirmDisconnect}
confirmBtnClassName="cta-primary"
confirmText="Sign Out"
headerId="connected-services-sign-out-header"
descId="connected-services-sign-out-description"
>
<h2
id="connected-services-sign-out-header"
className="font-bold text-xl text-center mb-2"
data-testid="connected-services-modal-header"
>
Disconnect from Sync
</h2>
<p
id="connected-devices-sign-out-description"
className="my-4 text-center"
>
Your browsing data will remain on your device ({DS.client!.name}
), but it will no longer sync with your account.
</p>
<p className="my-4 text-center">
What's the main reason for disconnecting this device?
</p>
<ul className="my-4 ltr:text-left rtl:text-right">
The device is:
<li>
<label>
<input
type="radio"
className="ltr:mr-2 rtl:ml-2 -mt-1 align-middle"
value="suspicious"
name="reason"
/>
Suspicious
</label>
</li>
<li>
<label>
<input
type="radio"
className="ltr:mr-2 rtl:ml-2 -mt-1 align-middle"
value="lost"
name="reason"
/>
Lost or Stolen
</label>
</li>
<li>
<label>
<input
type="radio"
className="ltr:mr-2 rtl:ml-2 -mt-1 align-middle"
value="old"
name="reason"
/>
Old or Replaced
</label>
</li>
<li>
<label>
<input
type="radio"
className="ltr:mr-2 rtl:ml-2 -mt-1 align-middle"
value="duplicate"
name="reason"
/>
Duplicate
</label>
</li>
<li>
<label>
<input
type="radio"
className="ltr:mr-2 rtl:ml-2 -mt-1 align-middle"
value="no"
name="reason"
/>
Rather not say
</label>
</li>
</ul>
</Modal>
</VerifiedSessionGuard>
)}
{adviceModalRevealed && (
<Modal
onDismiss={onCloseAdviceModal}
onConfirm={onCloseAdviceModal}
confirmBtnClassName="cta-primary"
hasCancelButton={false}
confirmText="Okay, got it"
headerId="connected-services-advice-modal-header"
descId="connected-services-advice-modal-description"
>
{DS.reason === 'lost' ? (
<>
<h2
id="connected-services-advice-modal-header"
className="font-bold text-xl text-center mb-2"
data-testid="connected-services-lost-device-modal-header"
>
Lost or stolen device disconnected
</h2>
<p
id="connected-services-advice-modal-description"
data-testid="lost-device-desc"
className="my-4 text-center"
>
Since your device was lost or stolen, to keep your information
safe, you should change your Firefox account password in your
account settings. You should also look for information from
your device manufacturer about erasing your data remotely.
</p>
</>
) : (
<>
<h2
id="connected-services-advice-modal-header"
className="font-bold text-xl text-center mb-2"
data-testid="connected-services-suspicious-device-modal-header"
>
Suspicious device disconnected
</h2>
<p
id="connected-services-advice-modal-description"
data-testid="suspicious-device-desc"
className="my-4 text-center"
>
If the disconnected device is indeed suspicious, to keep your
information safe, you should change your Firefox account
password in your account settings. You should also change any
other passwords you saved in Firefox by typing about:logins
into the address bar.
</p>
</>
)}
</Modal>
)}
</div>
</section>
);

Просмотреть файл

@ -44,6 +44,26 @@ storiesOf('Components|Modal', module)
)
}
</ModalToggle>
))
.add('with confirm and no cancel button', () => (
<ModalToggle>
{({ modalRevealed, hideModal }) =>
modalRevealed && (
<Modal
headerId="some-id"
descId="some-description"
onConfirm={hideModal as () => void}
onDismiss={hideModal}
hasCancelButton={false}
>
<h2 id="some-id">Header goes here.</h2>
<p id="some-description">
This is a modal with a confirm button, but no cancel button.
</p>
</Modal>
)
}
</ModalToggle>
));
type ModalToggleChildrenProps = {

Просмотреть файл

@ -39,6 +39,22 @@ it('renders confirm button as a link if route is passed', () => {
);
});
it('does not render the cancel button if hasCancelButton is set to false', () => {
const onDismiss = jest.fn();
renderWithRouter(
<Modal
headerId="some-header"
descId="some-description"
{...{ hasCancelButton: false, onDismiss }}
>
<div data-testid="children">Hi mom</div>
</Modal>
);
expect(document.activeElement).toBe(screen.getByTestId('modal-tab-fence'));
expect(screen.queryByTestId('children')).toBeInTheDocument();
expect(screen.queryByTestId('modal-cancel')).not.toBeInTheDocument();
});
it('accepts an alternate className', () => {
const onDismiss = jest.fn();
renderWithRouter(

Просмотреть файл

@ -16,6 +16,7 @@ type ModalProps = {
onConfirm?: () => void;
children: ReactNode;
hasButtons?: boolean;
hasCancelButton?: boolean;
headerId: string;
descId: string;
route?: string;
@ -30,6 +31,7 @@ export const Modal = ({
onConfirm,
children,
hasButtons = true,
hasCancelButton = true,
headerId,
descId,
route,
@ -76,13 +78,15 @@ export const Modal = ({
<div>{children}</div>
{hasButtons && (
<div className="flex justify-center mx-auto mt-6 max-w-64">
<button
className="cta-neutral mx-2 flex-1"
data-testid="modal-cancel"
onClick={onDismiss}
>
Cancel
</button>
{hasCancelButton && (
<button
className="cta-neutral mx-2 flex-1"
data-testid="modal-cancel"
onClick={onDismiss}
>
Cancel
</button>
)}
{route && (
<Link

Просмотреть файл

@ -19,6 +19,7 @@ export interface Email {
verified: boolean;
}
// TODO: why doesn't this match fxa-graphql-api/src/lib/resolvers/types/attachedClient.ts?
export interface AttachedClient {
clientId: string;
isCurrentSession: boolean;
@ -32,6 +33,8 @@ export interface AttachedClient {
approximateLastAccessTimeFormatted: string | null;
location: DeviceLocation;
os: string | null;
sessionTokenId: string | null;
refreshTokenId: string | null;
}
export interface Account {
@ -55,8 +58,7 @@ export interface Account {
alertTextExternal: string | null;
}
export const GET_ACCOUNT = gql`
query GetAccount {
export const ACCOUNT_FIELDS = `
account {
uid
displayName
@ -88,6 +90,8 @@ export const GET_ACCOUNT = gql`
stateCode
}
os
sessionTokenId
refreshTokenId
}
totp {
exists
@ -99,6 +103,11 @@ export const GET_ACCOUNT = gql`
}
alertTextExternal @client
}
`;
export const GET_ACCOUNT = gql`
query GetAccount {
${ACCOUNT_FIELDS}
}
`;