зеркало из https://github.com/mozilla/fxa.git
Родитель
b5c99456dc
Коммит
0dba8fe52c
|
@ -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}
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче