Merge pull request #14410 from mozilla/FXA-5253

refactor(ts(x),css): Admin panel component restructure and cleanup
This commit is contained in:
Lauren Zugai 2022-11-07 11:07:58 -06:00 коммит произвёл GitHub
Родитель 70a784eb9e d61563b869
Коммит 7930740165
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
35 изменённых файлов: 1633 добавлений и 1577 удалений

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

@ -4,3 +4,4 @@
export { USER_GROUP_HEADER, USER_EMAIL_HEADER } from 'fxa-shared/guards';
export const SERVER_CONFIG_PLACEHOLDER = '__SERVER_CONFIG__';
export const HIDE_ROW = 'N/A';

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

@ -3,7 +3,7 @@
"version": "1.244.2",
"description": "FxA Admin Panel",
"scripts": {
"build-css": "tailwindcss -i ./src/styles/tailwind.css -o ./src/styles/tailwind.out.css",
"build-css": "tailwindcss -i ./src/styles/tailwind.css -o ./src/styles/tailwind.out.css --postcss",
"build:client": "tsc --build ../fxa-react && tsc --build && NODE_ENV=production npm run build-css && SKIP_PREFLIGHT_CHECK=true PUBLIC_URL=/ INLINE_RUNTIME_CHUNK=false CI=false rescripts build",
"build:server": "tsc -p server/tsconfig.json",
"build": "npm-run-all build:client build:server",
@ -64,6 +64,7 @@
"@types/helmet": "4.0.0",
"@types/jsdom": "^16.2.11",
"@types/on-headers": "^1.0.0",
"@types/postcss-import": "^14",
"@types/react": "^17.0.14",
"@types/serve-static": "1.13.9",
"@types/supertest": "^2.0.11",
@ -80,6 +81,7 @@
"jest-watch-typeahead": "0.6.5",
"pm2": "^5.2.2",
"postcss": "^8.4.14",
"postcss-import": "^15.0.0",
"prettier": "^2.3.1",
"supertest": "^6.3.0",
"tailwindcss": "^3.2.0",

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

@ -1,5 +1,7 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},

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

@ -7,8 +7,8 @@ import { UserContext } from './hooks/UserContext';
import { GuardContext } from './hooks/GuardContext';
import { Route, Routes, BrowserRouter, Navigate } from 'react-router-dom';
import AppLayout from './components/AppLayout';
import AccountSearch from './components/AccountSearch';
import Permissions from './components/Permissions';
import AccountSearch from './components/PageAccountSearch';
import { PagePermissions } from './components/PagePermissions';
import { IClientConfig, IUserInfo } from '../interfaces';
import { AdminPanelFeature, AdminPanelGuard } from 'fxa-shared/guards';
import PageRelyingParties from './components/PageRelyingParties';
@ -34,7 +34,7 @@ const App = ({ config }: { config: IClientConfig }) => {
element={<PageRelyingParties />}
/>
)}
<Route path="/permissions" element={<Permissions />} />
<Route path="/permissions" element={<PagePermissions />} />
</Routes>
</AppLayout>
</UserContext.Provider>

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

@ -1,70 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
const undetermined = [
`The recipient's email provider sent a bounce message. The bounce message didn't contain enough information for Amazon SES to determine the reason for the bounce. The bounce email, which was sent to the address in the Return-Path header of the email that resulted in the bounce, might contain additional information about the issue that caused the email to bounce.`];
const permanentGeneral = [
`The recipient's email provider sent a hard bounce message, but didn't specify the reason for the hard bounce.`,
'⚠️ Important:',
`When you receive this type of bounce notification, you should immediately remove the recipient's email address from your mailing list. Sending messages to addresses that produce hard bounces can have a negative impact on your reputation as a sender. If you continue sending email to addresses that produce hard bounces, we might pause your ability to send additional email.`
];
const permanentNoEmail = [
`The intended recipient's email provider sent a bounce message indicating that the email address doesn't exist.`,
'⚠️ Important:',
`When you receive this type of bounce notification, you should immediately remove the recipient's email address from your mailing list. Sending messages to addresses that don't exist can have a negative impact on your reputation as a sender. If you continue sending email to addresses that don't exist, we might pause your ability to send additional email.`,
];
const permanentSuppressed = [
`The recipient's email address is on the Amazon SES suppression list because it has a recent history of producing hard bounces. To override the global suppression list, see Using the Amazon SES account-level suppression list (https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html).`];
const permanentOnAccountSuppressionList = ['Amazon SES has suppressed sending to this address because it is on the account-level suppression list (https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html). This does not count toward your bounce rate metric.'];
const transientGeneral = [
`The recipient's email provider sent a general bounce message. You might be able to send a message to the same recipient in the future if the issue that caused the message to bounce is resolved.`,
' Note:',
`If you send an email to a recipient who has an active automatic response rule (such as an "out of the office" message), you might receive this type of notification. Even though the response has a notification type of Bounce, Amazon SES doesn't count automatic responses when it calculates the bounce rate for your account.`,
];
const transientMailboxFull = [`The recipient's email provider sent a bounce message because the recipient's inbox was full. You might be able to send to the same recipient in the future when the mailbox is no longer full.`];
const transientMessageTooLarge = [`The recipient's email provider sent a bounce message because message you sent was too large. You might be able to send a message to the same recipient if you reduce the size of the message.`];
const transientContentRejected = [`The recipient's email provider sent a bounce message because the message you sent contains content that the provider doesn't allow. You might be able to send a message to the same recipient if you change the content of the message.`];
const transientAttachmentRejected = [`The recipient's email provider sent a bounce message because the message contained an unacceptable attachment. For example, some email providers may reject messages with attachments of a certain file type, or messages with very large attachments. You might be able to send a message to the same recipient if you remove or change the content of the attachment.`];
const complaintAbuse = ['Indicates unsolicited email or some other kind of email abuse.'];
const complaintAuthFailure = ['Email authentication failure report.'];
const complaintFraud = ['Indicates some kind of fraud or phishing activity.'];
const complaintNotSpam = ['Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.'];
const complaintOther = ['Indicates any other feedback that does not fit into other registered types.'];
const complaintVirus = ['Reports that a virus is found in the originating message.'];
const BOUNCE_DESCRIPTIONS = {
undetermined,
permanentGeneral,
permanentNoEmail,
permanentOnAccountSuppressionList,
permanentSuppressed,
transientGeneral,
transientMailboxFull,
transientMessageTooLarge,
transientContentRejected,
transientAttachmentRejected,
complaintAbuse,
complaintAuthFailure,
complaintFraud,
complaintNotSpam,
complaintOther,
complaintVirus,
}
export default BOUNCE_DESCRIPTIONS;

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -1,175 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 dateFormat from 'dateformat';
import { AttachedClient, Location } from 'fxa-admin-server/src/graphql';
import React from 'react';
import { DATE_FORMAT, ResultTableRow } from '../Account';
type Nullable<T> = T | null;
export const NUMBER_OF_SERVICES_TO_SHOW = 3;
export const ConnectedServices = ({
services,
}: {
services?: Nullable<AttachedClient[]>;
}) => {
if (services && services.length > 0) {
if (services.length > NUMBER_OF_SERVICES_TO_SHOW) {
return (
<details>
<summary className="hover:cursor-pointer text-violet-900 font-semibold mb-4">
Toggle viewing {services.length} connected services
</summary>
{services.map((service) => (
<ConnectedService
key={`${service.name}-${service.createdTime}`}
{...service}
/>
))}
</details>
);
}
return (
<>
{services.map((service) => (
<ConnectedService
key={`${service.name}-${service.createdTime}`}
{...service}
/>
))}
</>
);
}
return (
<li className="account-li account-border-info">
This account has no connected services.
</li>
);
};
const ConnectedService = ({
clientId,
createdTime,
createdTimeFormatted,
deviceId,
deviceType,
lastAccessTime,
lastAccessTimeFormatted,
location,
name,
os,
userAgent,
sessionTokenId,
refreshTokenId,
}: AttachedClient) => {
const testId = (id: string) => `connected-service-${id}`;
return (
<div className="account-li account-border-info">
<table className="pt-1" aria-label="simple table">
<tbody>
<ResultTableRow
label="Client"
value={format.client(name, clientId)}
testId={testId('client')}
/>
<ResultTableRow
label="Device Type"
value={deviceType}
testId={testId('device-type')}
/>
<ResultTableRow
label="User Agent"
value={userAgent}
testId={testId('user-agent')}
/>
<ResultTableRow
label="Operating System"
value={os}
testId={testId('os')}
/>
<ResultTableRow
label="Created At"
value={format.time(createdTime, createdTimeFormatted)}
testId={testId('created-at')}
/>
<ResultTableRow
label="Last Used"
value={format.time(lastAccessTime, lastAccessTimeFormatted)}
testId={testId('last-accessed-at')}
/>
<ResultTableRow
label="Location"
value={format.location(location)}
testId={testId('location')}
/>
<ResultTableRow
label="Client ID"
value={clientId || 'N/A'}
testId={testId('client-id')}
/>
<ResultTableRow
label="Device ID"
value={deviceId || 'N/A'}
testId={testId('device-id')}
/>
<ResultTableRow
label="Session Token ID"
value={sessionTokenId || 'N/A'}
testId={testId('session-token-id')}
/>
<ResultTableRow
label="Refresh Token ID"
value={refreshTokenId || 'N/A'}
testId={testId('refresh-token-id')}
/>
</tbody>
</table>
</div>
);
};
const format = {
location(location?: Nullable<Location>) {
if (
!location ||
(!location.city &&
!location.state &&
!location.stateCode &&
!location.country &&
!location.countryCode)
) {
return null;
}
return (
<>
{[
location.city || <i>Unknown City</i>,
location.state || location.stateCode || '',
location.country || location.country || <i>Unknown Country</i>,
].join(', ')}
</>
);
},
time(raw?: Nullable<number>, formatted?: Nullable<string>) {
if (!raw || raw < 1) return null;
return (
<>
{dateFormat(new Date(raw), DATE_FORMAT)}
{formatted ? <i> ({formatted})</i> : <></>}
</>
);
},
client(name?: Nullable<string>, clientId?: Nullable<string>) {
return (
<>
{name} {clientId && <i>[{clientId}]</i>}
</>
);
},
};

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

@ -1,84 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 dateFormat from 'dateformat';
import { MozSubscription } from 'fxa-admin-server/src/graphql';
import LinkExternal from 'fxa-react/components/LinkExternal';
import { ReactComponent as IconExternalLink } from '../../../images/icon-external-link.svg';
import { DATE_FORMAT } from '../Account';
const Subscription = ({
created,
currentPeriodEnd,
currentPeriodStart,
cancelAtPeriodEnd,
endedAt,
latestInvoice,
manageSubscriptionLink,
planId,
productName,
productId,
status,
subscriptionId,
}: MozSubscription) => {
return (
<ul className="account-border-info">
<li className="account-li">
Product name: <span>{productName}</span>
</li>
<li className="account-li">
Status: <span>{status}</span>
</li>
<li className="account-li">
Created at: <span>{dateFormat(new Date(created), DATE_FORMAT)}</span>
</li>
{endedAt != null && (
<li className="account-li">
Ended at: <span>{dateFormat(new Date(endedAt), DATE_FORMAT)}</span>
</li>
)}
<li className="account-li">
Current period start:{' '}
<span>{dateFormat(new Date(currentPeriodStart), DATE_FORMAT)}</span>
</li>
<li className="account-li">
Current period end:{' '}
<span>{dateFormat(new Date(currentPeriodEnd), DATE_FORMAT)}</span>
</li>
<li className="account-li">
Cancel at period end? <span>{cancelAtPeriodEnd ? 'Yes' : 'No'}</span>
</li>
<li className="account-li mt-2">
Subscription ID: <span>{subscriptionId}</span>
</li>
<li className="account-li">
Product ID: <span>{productId}</span>
</li>
<li className="account-li">
Plan ID: <span>{planId}</span>
</li>
{!!latestInvoice && (
<li className="account-li mt-2">
<LinkExternal href={latestInvoice} className="underline">
Latest invoice
<IconExternalLink className="ml-2 w-4 inline-block icon-dark" />
</LinkExternal>
</li>
)}
{!!manageSubscriptionLink && (
<li className="account-li mt-2">
<LinkExternal href={manageSubscriptionLink} className="underline">
Manage Subscription
<IconExternalLink className="ml-2 w-4 inline-block icon-dark" />
</LinkExternal>
</li>
)}
</ul>
);
};
export default Subscription;

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

@ -34,7 +34,7 @@ export const AppLayout = ({ children }: AppLayoutProps) => {
<Nav />
<main className="flex-4">
<div className="p-4 rounded-md bg-white border border-grey-100 text-grey-900">
<div className="px-6 pb-6 pt-5 rounded-md bg-white border border-grey-100 text-grey-900">
{children}
</div>
</main>

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

@ -17,7 +17,7 @@ const getNavLinkClassName = (isActive: boolean) =>
export const Nav = () => (
<nav className="mb-4 desktop:mr-5 desktop:flex-1 desktop:mb-0">
<div className="p-4 rounded-md bg-white border border-grey-100">
<div className="p-3 rounded-md bg-white border border-grey-100">
<h2 className="mb-3 uppercase text-sm tracking-wide font-normal text-grey-500">
Navigation
</h2>

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

@ -100,7 +100,7 @@ it('displays the account', async () => {
expect(getByTestId('account-section')).toBeInTheDocument();
expect(getByTestId('sign-up-email')).toHaveTextContent(accountResponse.email);
expect(getByTestId('primary-verified')).toHaveTextContent('confirmed');
expect(getByTestId('primary-verified')).toHaveTextContent('Yes');
expect(getByTestId('primary-email')).toHaveTextContent(
accountResponse.emails![0].email
);
@ -146,14 +146,14 @@ it('displays when account is locked', async () => {
);
});
it('displays the unverified account', async () => {
it('displays the unconfirmed account', async () => {
accountResponse.emails![0].isVerified = false;
const { getByTestId } = render(
<MockedProvider>
<Account {...accountResponse} />
</MockedProvider>
);
expect(getByTestId('primary-verified')).toHaveTextContent('not confirmed');
expect(getByTestId('primary-verified')).toHaveTextContent('No');
});
it('displays the bounce type description', async () => {
@ -205,7 +205,7 @@ it('displays secondary emails', async () => {
expect(getByTestId('secondary-email')).toHaveTextContent(
'ohdeceiver@gmail.com'
);
expect(getByTestId('secondary-verified')).toHaveTextContent('not confirmed');
expect(getByTestId('secondary-verified')).toHaveTextContent('No');
});
it('displays the locale', async () => {

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

@ -0,0 +1,388 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { gql, useMutation } from '@apollo/client';
import {
Account as AccountType,
SecurityEvents as SecurityEventsType,
Totp as TotpType,
RecoveryKeys as RecoveryKeysType,
LinkedAccount as LinkedAccountType,
} from 'fxa-admin-server/src/graphql';
import { AdminPanelFeature } from 'fxa-shared/guards';
import Guard from '../../Guard';
import Subscription from '../Subscription';
import { ConnectedServices } from '../ConnectedServices';
import { TableRowYHeader, TableYHeaders } from '../../TableYHeaders';
import { TableRowXHeader, TableXHeaders } from '../../TableXHeaders';
import EmailBounces from '../EmailBounces';
import { getFormattedDate } from '../../../lib/utils';
import DangerZone from '../DangerZone';
import ResultBoolean from '../../ResultBoolean';
import { HIDE_ROW } from '../../../../constants';
export type AccountProps = AccountType & {
onCleared: () => void;
query: string;
};
export const RECORD_ADMIN_SECURITY_EVENT = gql`
mutation recordAdminSecurityEvent($uid: String!, $name: String!) {
recordAdminSecurityEvent(uid: $uid, name: $name)
}
`;
export const EDIT_LOCALE = gql`
mutation editLocale($uid: String!, $locale: String!) {
editLocale(uid: $uid, locale: $locale)
}
`;
export const UNLINK_ACCOUNT = gql`
mutation unlinkAccount($uid: String!) {
unlinkAccount(uid: $uid)
}
`;
export const LinkedAccount = ({
uid,
authAt,
providerId,
onCleared,
}: {
uid: string;
authAt: number;
providerId: string;
onCleared: () => void;
}) => {
const [unlinkAccount] = useMutation(UNLINK_ACCOUNT, {
onCompleted: () => {
window.alert('The linked account has been removed.');
},
onError: () => {
window.alert('Error unlinking account');
},
});
const handleUnlinkAccount = async () => {
if (!window.confirm('Are you sure? This cannot be undone.')) {
return;
}
await unlinkAccount({ variables: { uid } });
onCleared();
};
return (
<TableRowXHeader>
<>{providerId}</>
<>{getFormattedDate(authAt)}</>
<button
className="p-1 text-red-700 border-2 rounded border-grey-100 bg-grey-10 hover:border-grey-10 hover:bg-grey-50 hover:text-red-700"
type="button"
onClick={handleUnlinkAccount}
>
Unlink
</button>
</TableRowXHeader>
);
};
export const Account = ({
uid,
email,
emails,
createdAt,
disabledAt,
locale,
lockedAt,
emailBounces,
totp: totps,
recoveryKeys,
attachedClients,
subscriptions,
onCleared,
query,
securityEvents,
linkedAccounts,
}: AccountProps) => {
const createdAtDate = getFormattedDate(createdAt);
const disabledAtDate = getFormattedDate(disabledAt);
const lockedAtDate = getFormattedDate(lockedAt);
const primaryEmail = emails!.find((email) => email.isPrimary)!;
const secondaryEmails = emails!.filter((email) => !email.isPrimary);
const [editLocale] = useMutation(EDIT_LOCALE, {});
const handleEditLocale = async () => {
try {
const newLocale = window.prompt('Enter a new locale.');
if (!newLocale) {
return;
}
const res = await editLocale({
variables: {
uid,
locale: newLocale,
},
});
if (res.data?.editLocale) {
onCleared();
} else {
window.alert(`Edit unsuccessful.`);
}
} catch (err) {
window.alert(`An unexpected error was encountered. Edit unsuccessful.`);
}
};
function highlight(val: string) {
return query === val ? 'bg-yellow-100' : undefined;
}
return (
<>
<hr />
<section data-testid="account-section">
<TableYHeaders header="Account Details">
<TableRowYHeader
header="Sign-up Email"
children={<span className={highlight(email)}>{email}</span>}
testId="sign-up-email"
/>
<TableRowYHeader
header="uid"
children={<span className={highlight(uid)}>{uid}</span>}
testId="account-uid"
/>
<TableRowYHeader
header="Created At"
children={`${createdAtDate} (${createdAt})`}
testId="account-created-at"
/>
<TableRowYHeader
header="Locale"
children={
<>
{locale}
<Guard features={[AdminPanelFeature.EditLocale]}>
<button
className="bg-grey-10 border-2 border-grey-100 font-small leading-6 ml-2 rounded text-red-700 w-10 hover:border-2 hover:border-grey-10 hover:bg-grey-50"
type="button"
onClick={handleEditLocale}
data-testid="edit-account-locale"
>
Edit
</button>
</Guard>
</>
}
testId="account-locale"
/>
<>
{lockedAt != null && (
<TableRowYHeader
header="Locked At"
className="bg-yellow-100"
children={`${lockedAtDate} (${lockedAt})`}
testId="account-locked-at"
/>
)}
</>
<>
{disabledAt != null && (
<TableRowYHeader
header="Disabled At"
className="bg-yellow-100"
children={`${disabledAtDate} (${disabledAt})`}
testId="account-disabled-at"
/>
)}
</>
</TableYHeaders>
<TableXHeaders
header="Primary Email"
rowHeaders={['Email', 'Confirmed']}
>
<TableRowXHeader>
<span
data-testid="primary-email"
className={highlight(primaryEmail.email)}
>
{primaryEmail.email}
</span>
<ResultBoolean
isTruthy={primaryEmail.isVerified}
testId="primary-verified"
/>
</TableRowXHeader>
</TableXHeaders>
<h3 className="header-lg">Secondary Emails</h3>
{secondaryEmails.length > 0 ? (
<TableXHeaders
rowHeaders={['Email', 'Confirmed']}
testId="secondary-section"
>
{secondaryEmails.map((secondaryEmail) => (
<TableRowXHeader key={secondaryEmail.createdAt}>
<span
data-testid="secondary-email"
className={highlight(secondaryEmail.email)}
>
{secondaryEmail.email}
</span>
<ResultBoolean
isTruthy={secondaryEmail.isVerified}
testId="secondary-verified"
/>
</TableRowXHeader>
))}
</TableXHeaders>
) : (
<p className="result-none">
This account doesn't have any secondary emails.
</p>
)}
<EmailBounces {...{ emailBounces, uid, emails, onCleared }} />
<h3 className="header-lg">
2FA / TOTP (Time-Based One-Time Passwords)
</h3>
{totps && totps.length > 0 ? (
<TableXHeaders rowHeaders={['Created At', 'Enabled', 'Confirmed']}>
{totps.map((totp: TotpType) => (
<TableRowXHeader key={totp.createdAt}>
<td data-testid="totp-created-at">
{getFormattedDate(totp.createdAt)}
</td>
<ResultBoolean isTruthy={totp.enabled} testId="totp-enabled" />
<ResultBoolean
isTruthy={totp.verified}
testId="totp-verified"
/>
</TableRowXHeader>
))}
</TableXHeaders>
) : (
<p className="result-none">
This account hasn't started 2FA / TOTP setup.
</p>
)}
<h3 className="header-lg">Account Recovery Key</h3>
{recoveryKeys && recoveryKeys.length > 0 ? (
<>
{recoveryKeys.map((recoveryKey: RecoveryKeysType) => (
<TableYHeaders key={createdAt}>
<TableRowYHeader
header="Created At"
children={getFormattedDate(recoveryKey.createdAt)}
testId="recovery-keys-created-at"
/>
<TableRowYHeader
header="Enabled"
children={<ResultBoolean isTruthy={!!recoveryKey.enabled} />}
testId="recovery-keys-enabled"
/>
<TableRowYHeader
header="Confirmed"
children={
<ResultBoolean isTruthy={!!recoveryKey.verifiedAt} />
}
testId="recovery-keys-verified"
/>
<TableRowYHeader
header="Confirmed At"
children={
recoveryKey.verifiedAt
? getFormattedDate(recoveryKey.verifiedAt)
: HIDE_ROW
}
/>
</TableYHeaders>
))}
</>
) : (
<p className="result-none">
This account doesn't have an account recovery key created.
</p>
)}
<h3 className="header-lg">Subscriptions</h3>
{subscriptions && subscriptions.length > 0 ? (
<>
{subscriptions.map((subscription) => (
<Subscription
key={subscription.subscriptionId}
{...subscription}
/>
))}
</>
) : (
<p className="result-none">
This account doesn't have any subscriptions.
</p>
)}
<Guard features={[AdminPanelFeature.ConnectedServices]}>
<h3 className="header-lg">Connected Services</h3>
<ConnectedServices services={attachedClients} />
</Guard>
<h3 className="header-lg">Account History</h3>
{securityEvents && securityEvents.length > 0 ? (
<TableXHeaders rowHeaders={['Event', 'Timestamp']}>
{securityEvents.map((securityEvent: SecurityEventsType) => (
<TableRowXHeader key={securityEvent.uid}>
<>{securityEvent.name}</>
<>{getFormattedDate(securityEvent.createdAt)}</>
</TableRowXHeader>
))}
</TableXHeaders>
) : (
<p data-testid="account-security-events" className="result-none">
This account doesn't have any account history.
</p>
)}
<h3 className="header-lg">Linked Accounts</h3>
{linkedAccounts && linkedAccounts.length > 0 ? (
<TableXHeaders rowHeaders={['Event', 'Timestamp', 'Action']}>
{linkedAccounts.map((linkedAccount: LinkedAccountType) => (
<LinkedAccount
{...{
uid,
providerId: linkedAccount.providerId,
authAt: linkedAccount.authAt,
onCleared: onCleared,
}}
/>
))}
</TableXHeaders>
) : (
<p className="result-none">
This account doesn't have any linked accounts.
</p>
)}
</section>
<DangerZone
{...{
uid,
disabledAt: disabledAt!,
email: primaryEmail, // only the primary for now
onCleared: onCleared,
unsubscribeToken: '<USER_TOKEN>',
}}
/>
</>
);
};
export default Account;

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

@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { MockedResponse } from '@apollo/client/testing';
import { UNSUBSCRIBE_FROM_MAILING_LISTS } from '.';
import { UNSUBSCRIBE_FROM_MAILING_LISTS } from '../DangerZone';
export const mockUnsubscribe = (success: boolean): MockedResponse => {
const request = {

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

@ -0,0 +1,165 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 from 'react';
import { AttachedClient, Location } from 'fxa-admin-server/src/graphql';
import { TableRowYHeader, TableYHeaders } from '../../TableYHeaders';
import { getFormattedDate } from '../../../lib/utils';
import { HIDE_ROW } from '../../../../constants';
export const NUMBER_OF_SERVICES_TO_SHOW = 3;
export const ConnectedServices = ({
services,
}: {
services?: Nullable<AttachedClient[]>;
}) => {
if (services && services.length > 0) {
const connectedServicesTables = (
<>
{services.map((service) => (
<ConnectedService
key={`${service.name}-${service.createdTime}`}
{...service}
/>
))}
</>
);
if (services.length > NUMBER_OF_SERVICES_TO_SHOW) {
return (
<details>
<summary className="hover:cursor-pointer text-violet-900 font-semibold mb-4">
Toggle viewing {services.length} connected services
</summary>
{connectedServicesTables}
</details>
);
}
return connectedServicesTables;
}
return <p className="result-none">This account has no connected services.</p>;
};
const ConnectedService = ({
clientId,
createdTime,
createdTimeFormatted,
deviceId,
deviceType,
lastAccessTime,
lastAccessTimeFormatted,
location,
name,
os,
userAgent,
sessionTokenId,
refreshTokenId,
}: AttachedClient) => {
const testId = (id: string) => `connected-service-${id}`;
return (
<TableYHeaders>
<TableRowYHeader
header="Client"
children={format.client(name, clientId)}
testId={testId('client')}
/>
<TableRowYHeader
header="Device Type"
children={deviceType || HIDE_ROW}
testId={testId('device-type')}
/>
<TableRowYHeader
header="User Agent"
children={userAgent || HIDE_ROW}
testId={testId('user-agent')}
/>
<TableRowYHeader
header="Operating System"
children={os || HIDE_ROW}
testId={testId('os')}
/>
<TableRowYHeader
header="Created At"
children={format.time(createdTime, createdTimeFormatted) || HIDE_ROW}
testId={testId('created-at')}
/>
<TableRowYHeader
header="Last Used"
children={
format.time(lastAccessTime, lastAccessTimeFormatted) || HIDE_ROW
}
testId={testId('last-accessed-at')}
/>
<TableRowYHeader
header="Location"
children={format.location(location) || HIDE_ROW}
testId={testId('location')}
/>
<TableRowYHeader
header="Client ID"
children={clientId || HIDE_ROW}
testId={testId('client-id')}
/>
<TableRowYHeader
header="Device ID"
children={deviceId || HIDE_ROW}
testId={testId('device-id')}
/>
<TableRowYHeader
header="Session Token ID"
children={sessionTokenId || HIDE_ROW}
testId={testId('session-token-id')}
/>
<TableRowYHeader
header="Refresh Token ID"
children={refreshTokenId || HIDE_ROW}
testId={testId('refresh-token-id')}
/>
</TableYHeaders>
);
};
const format = {
location(location?: Nullable<Location>) {
if (
!location ||
(!location.city &&
!location.state &&
!location.stateCode &&
!location.country &&
!location.countryCode)
) {
return null;
}
return (
<>
{[
location.city || <i>Unknown City</i>,
location.state || location.stateCode || '',
location.country || location.country || <i>Unknown Country</i>,
].join(', ')}
</>
);
},
time(raw?: Nullable<number>, formatted?: Nullable<string>) {
if (!raw || raw < 1) return null;
return (
<>
{getFormattedDate(raw)}
{formatted ? <i> ({formatted})</i> : <></>}
</>
);
},
client(name?: Nullable<string>, clientId?: Nullable<string>) {
return (
<>
{name} {clientId && <i>[{clientId}]</i>}
</>
);
},
};

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

@ -0,0 +1,274 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { gql, useMutation } from '@apollo/client';
import { Email } from 'fxa-admin-server/src/graphql';
import { RECORD_ADMIN_SECURITY_EVENT } from '../Account';
import { AdminPanelFeature } from 'fxa-shared/guards';
import Guard from '../../Guard';
import { getFormattedDate } from '../../../lib/utils';
import { ReactElement } from 'react';
type DangerZoneProps = {
uid: string;
email: Email;
disabledAt: number | null;
onCleared: Function;
};
export const UNVERIFY_EMAIL = gql`
mutation unverify($email: String!) {
unverifyEmail(email: $email)
}
`;
export const DISABLE_ACCOUNT = gql`
mutation disableAccount($uid: String!) {
disableAccount(uid: $uid)
}
`;
export const ENABLE_ACCOUNT = gql`
mutation enableAccount($uid: String!) {
enableAccount(uid: $uid)
}
`;
export const SEND_PASSWORD_RESET_EMAIL = gql`
mutation sendPasswordResetEmail($email: String!) {
sendPasswordResetEmail(email: $email)
}
`;
export const UNSUBSCRIBE_FROM_MAILING_LISTS = gql`
mutation unsubscribeFromMailingLists($uid: String!) {
unsubscribeFromMailingLists(uid: $uid)
}
`;
const DangerZoneAction = ({
header,
description,
buttonHandler,
buttonTestId,
buttonText,
unverifyMessage,
hideButton = false,
hiddenButtonContent,
}: {
header: string;
description: string | ReactElement;
buttonHandler: () => void;
buttonTestId?: string;
buttonText: string;
unverifyMessage?: string;
hideButton?: boolean;
hiddenButtonContent?: string;
}) => (
<>
<h4 className="header-lg">{header}</h4>
<div className="border-l-2 border-red-600 mb-4 pl-4">
<p>{description}</p>
{hideButton && hiddenButtonContent ? (
<p className="mt-2">{hiddenButtonContent}</p>
) : (
<button
className="bg-grey-10 border-2 border-grey-100 font-medium h-12 mt-4 rounded text-red-700 w-40 hover:border-2 hover:border-grey-10 hover:bg-grey-50 hover:text-red-700"
onClick={buttonHandler}
data-testid={buttonTestId}
>
{buttonText}
</button>
)}
{unverifyMessage && <p className="mt-4">{unverifyMessage}</p>}
</div>
</>
);
export const DangerZone = ({
uid,
email,
disabledAt,
onCleared,
}: DangerZoneProps) => {
const [unverify, { loading: unverifyLoading }] = useMutation(UNVERIFY_EMAIL, {
onCompleted: () => {
window.alert("The user's email has been unconfirmed.");
onCleared();
},
onError: () => {
window.alert('Error in unconfirming email');
},
});
const handleUnverify = () => {
if (!window.confirm('Are you sure? This cannot be undone.')) {
return;
}
unverify({ variables: { email: email.email } });
};
const [unsubscribeFromMailingLists] = useMutation(
UNSUBSCRIBE_FROM_MAILING_LISTS,
{
onCompleted: (data) => {
if (data.unsubscribeFromMailingLists) {
window.alert(
"The user's email has been unsubscribed from mozilla mailing lists."
);
} else {
window.alert('Unsubscribing was not successful.');
}
},
onError: () => {
window.alert('Unexpected error encountered!');
},
}
);
const handleUnsubscribeFromMailingLists = () => {
if (!window.confirm('Are you sure? This cannot be undone.')) {
return;
}
unsubscribeFromMailingLists({ variables: { uid } });
};
const [disableAccount] = useMutation(DISABLE_ACCOUNT, {
onCompleted: () => {
window.alert('The account has been disabled.');
onCleared();
},
onError: () => {
window.alert('Error disabling account');
},
});
const [enableAccount] = useMutation(ENABLE_ACCOUNT, {
onCompleted: () => {
window.alert('The account has been enabled.');
onCleared();
},
onError: () => {
window.alert('Error enabling account');
},
});
const [sendPasswordResetEmail] = useMutation(SEND_PASSWORD_RESET_EMAIL, {
onCompleted: () => {
window.alert(`Password reset email sent to ${email.email}`);
onCleared();
},
onError: () => {
window.alert('Error sending password reset email.');
},
});
const [recordAdminSecurityEvent] = useMutation(RECORD_ADMIN_SECURITY_EVENT);
const handleDisable = () => {
if (!window.confirm('Are you sure?')) {
return;
}
disableAccount({ variables: { uid } });
recordAdminSecurityEvent({ variables: { uid, name: 'account.disable' } });
};
const handleEnable = () => {
if (!window.confirm('Are you sure?')) {
return;
}
enableAccount({ variables: { uid } });
recordAdminSecurityEvent({ variables: { uid, name: 'account.enable' } });
};
const handleSendPasswordReset = () => {
if (!window.confirm('Are you sure?')) {
return;
}
sendPasswordResetEmail({ variables: { email: email.email } });
};
// define loading messages
const loadingMessage = 'Please wait a moment...';
let unverifyMessage = '';
if (unverifyLoading) unverifyMessage = loadingMessage;
return (
<section className="mt-8">
<Guard
features={[
AdminPanelFeature.UnverifyEmail,
AdminPanelFeature.DisableAccount,
AdminPanelFeature.EnableAccount,
AdminPanelFeature.UnsubscribeFromMailingLists,
]}
>
<h3 className="mt-0 mb-1 bg-red-600 font-medium h-8 pb-8 pl-2 pt-1 rounded-sm text-lg text-white">
Danger Zone
</h3>
<p className="my-4">
Please run these commands with caution some actions are
irreversible.
</p>
</Guard>
<Guard features={[AdminPanelFeature.UnverifyEmail]}>
<DangerZoneAction
header="Email Confirmation"
description="Reset email confirmation. User needs to re-confirm on next login."
buttonHandler={handleUnverify}
buttonText="Unconfirm Email"
{...{ unverifyMessage }}
/>
</Guard>
<Guard features={[AdminPanelFeature.DisableAccount]}>
<DangerZoneAction
header="Disable Login"
description="Stops this account from logging in."
buttonText="Disable"
hideButton={!!disabledAt}
hiddenButtonContent={`This account was disabled at: ${getFormattedDate(
disabledAt
)}`}
buttonHandler={handleDisable}
/>
</Guard>
{disabledAt && (
<Guard features={[AdminPanelFeature.EnableAccount]}>
<DangerZoneAction
header="Enable Login"
description="Allows this account to log in."
buttonHandler={handleEnable}
buttonText="Enable"
/>
</Guard>
)}
<Guard features={[AdminPanelFeature.SendPasswordResetEmail]}>
<DangerZoneAction
header="Send Password Reset Email"
description="Send the user a password reset email to all verified emails. For
Sync users this will also reset their encryption key so make sure
they have a backup of Sync data."
buttonHandler={handleSendPasswordReset}
buttonText="Password Reset"
buttonTestId="password-reset-button"
/>
</Guard>
<Guard features={[AdminPanelFeature.UnsubscribeFromMailingLists]}>
<DangerZoneAction
header="Unsubscribe From Mailing Lists"
description={
<>
Unsubscribe user from <b>all</b> Mozilla mailing lists.
</>
}
buttonHandler={handleUnsubscribeFromMailingLists}
buttonText="Unsubscribe"
buttonTestId="unsubscribe-from-mailing-lists"
/>
</Guard>
</section>
);
};
export default DangerZone;

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

@ -0,0 +1,163 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { BounceType, BounceSubType } from 'fxa-admin-server/src/graphql';
import { HIDE_ROW } from '../../../../constants';
const undetermined = [
`The recipient's email provider sent a bounce message. The bounce message didn't contain enough information for Amazon SES to determine the reason for the bounce. The bounce email, which was sent to the address in the Return-Path header of the email that resulted in the bounce, might contain additional information about the issue that caused the email to bounce.`,
];
const permanentGeneral = [
`The recipient's email provider sent a hard bounce message, but didn't specify the reason for the hard bounce.`,
'⚠️ Important:',
`When you receive this type of bounce notification, you should immediately remove the recipient's email address from your mailing list. Sending messages to addresses that produce hard bounces can have a negative impact on your reputation as a sender. If you continue sending email to addresses that produce hard bounces, we might pause your ability to send additional email.`,
];
const permanentNoEmail = [
`The intended recipient's email provider sent a bounce message indicating that the email address doesn't exist.`,
'⚠️ Important:',
`When you receive this type of bounce notification, you should immediately remove the recipient's email address from your mailing list. Sending messages to addresses that don't exist can have a negative impact on your reputation as a sender. If you continue sending email to addresses that don't exist, we might pause your ability to send additional email.`,
];
const permanentSuppressed = [
`The recipient's email address is on the Amazon SES suppression list because it has a recent history of producing hard bounces. To override the global suppression list, see Using the Amazon SES account-level suppression list (https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html).`,
];
const permanentOnAccountSuppressionList = [
'Amazon SES has suppressed sending to this address because it is on the account-level suppression list (https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html). This does not count toward your bounce rate metric.',
];
const transientGeneral = [
`The recipient's email provider sent a general bounce message. You might be able to send a message to the same recipient in the future if the issue that caused the message to bounce is resolved.`,
' Note:',
`If you send an email to a recipient who has an active automatic response rule (such as an "out of the office" message), you might receive this type of notification. Even though the response has a notification type of Bounce, Amazon SES doesn't count automatic responses when it calculates the bounce rate for your account.`,
];
const transientMailboxFull = [
`The recipient's email provider sent a bounce message because the recipient's inbox was full. You might be able to send to the same recipient in the future when the mailbox is no longer full.`,
];
const transientMessageTooLarge = [
`The recipient's email provider sent a bounce message because message you sent was too large. You might be able to send a message to the same recipient if you reduce the size of the message.`,
];
const transientContentRejected = [
`The recipient's email provider sent a bounce message because the message you sent contains content that the provider doesn't allow. You might be able to send a message to the same recipient if you change the content of the message.`,
];
const transientAttachmentRejected = [
`The recipient's email provider sent a bounce message because the message contained an unacceptable attachment. For example, some email providers may reject messages with attachments of a certain file type, or messages with very large attachments. You might be able to send a message to the same recipient if you remove or change the content of the attachment.`,
];
const complaintAbuse = [
'Indicates unsolicited email or some other kind of email abuse.',
];
const complaintAuthFailure = ['Email authentication failure report.'];
const complaintFraud = ['Indicates some kind of fraud or phishing activity.'];
const complaintNotSpam = [
'Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.',
];
const complaintOther = [
'Indicates any other feedback that does not fit into other registered types.',
];
const complaintVirus = [
'Reports that a virus is found in the originating message.',
];
const BOUNCE_DESCRIPTIONS = {
undetermined,
permanentGeneral,
permanentNoEmail,
permanentOnAccountSuppressionList,
permanentSuppressed,
transientGeneral,
transientMailboxFull,
transientMessageTooLarge,
transientContentRejected,
transientAttachmentRejected,
complaintAbuse,
complaintAuthFailure,
complaintFraud,
complaintNotSpam,
complaintOther,
complaintVirus,
};
const getEmailBounceDescription = (
bounceType: string,
bounceSubType: string
) => {
let description: string[] | string = HIDE_ROW;
switch (bounceType) {
case BounceType.Undetermined: {
if (bounceSubType === BounceSubType.Undetermined) {
description = BOUNCE_DESCRIPTIONS.undetermined;
}
break;
}
case BounceType.Permanent: {
if (bounceSubType === BounceSubType.General) {
description = BOUNCE_DESCRIPTIONS.permanentGeneral;
} else if (bounceSubType === BounceSubType.NoEmail) {
description = BOUNCE_DESCRIPTIONS.permanentNoEmail;
} else if (bounceSubType === BounceSubType.Suppressed) {
description = BOUNCE_DESCRIPTIONS.permanentSuppressed;
} else if (bounceSubType === BounceSubType.OnAccountSuppressionList) {
description = BOUNCE_DESCRIPTIONS.permanentOnAccountSuppressionList;
}
break;
}
case BounceType.Transient: {
if (bounceSubType === BounceSubType.General) {
description = BOUNCE_DESCRIPTIONS.transientGeneral;
} else if (bounceSubType === BounceSubType.MailboxFull) {
description = BOUNCE_DESCRIPTIONS.transientMailboxFull;
} else if (bounceSubType === BounceSubType.MessageTooLarge) {
description = BOUNCE_DESCRIPTIONS.transientMessageTooLarge;
} else if (bounceSubType === BounceSubType.ContentRejected) {
description = BOUNCE_DESCRIPTIONS.transientContentRejected;
} else if (bounceSubType === BounceSubType.AttachmentRejected) {
description = BOUNCE_DESCRIPTIONS.transientAttachmentRejected;
}
break;
}
case BounceType.Complaint: {
if (bounceSubType === BounceSubType.Abuse) {
description = BOUNCE_DESCRIPTIONS.complaintAbuse;
} else if (bounceSubType === BounceSubType.AuthFailure) {
description = BOUNCE_DESCRIPTIONS.complaintAuthFailure;
} else if (bounceSubType === BounceSubType.Fraud) {
description = BOUNCE_DESCRIPTIONS.complaintFraud;
} else if (bounceSubType === BounceSubType.NotSpam) {
description = BOUNCE_DESCRIPTIONS.complaintNotSpam;
} else if (bounceSubType === BounceSubType.Other) {
description = BOUNCE_DESCRIPTIONS.complaintOther;
} else if (bounceSubType === BounceSubType.Virus) {
description = BOUNCE_DESCRIPTIONS.complaintVirus;
}
break;
}
default: {
}
}
if (Array.isArray(description)) {
return description.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
));
}
return description;
};
export default getEmailBounceDescription;

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

@ -0,0 +1,154 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { AdminPanelFeature } from 'fxa-shared/guards';
import Guard from '../../Guard';
import { gql, useMutation } from '@apollo/client';
import { RECORD_ADMIN_SECURITY_EVENT } from '../Account';
import {
EmailBounce as EmailBounceType,
Email as EmailType,
} from 'fxa-admin-server/src/graphql';
import { TableRowYHeader, TableYHeaders } from '../../TableYHeaders';
import getEmailBounceDescription from './getBounceDescription';
import { getFormattedDate } from '../../../lib/utils';
import { HIDE_ROW } from '../../../../constants';
export const CLEAR_BOUNCES_BY_EMAIL = gql`
mutation clearBouncesByEmail($email: String!) {
clearEmailBounce(email: $email)
}
`;
const ClearButton = ({
emails,
onCleared,
uid,
}: {
emails: string[];
onCleared: Function;
uid: string;
}) => {
const [clearBounces] = useMutation(CLEAR_BOUNCES_BY_EMAIL);
const [recordAdminSecurityEvent] = useMutation(RECORD_ADMIN_SECURITY_EVENT);
const handleClear = () => {
if (!window.confirm('Are you sure? This cannot be undone.')) {
return;
}
// This could be improved to clear bounces for individual email
// addresses, but for now it seems satisfactory to clear all bounces
// for all emails, since they own all of the addresses
emails.forEach((email) => clearBounces({ variables: { email } }));
recordAdminSecurityEvent({
variables: { uid: uid, name: 'emails.clearBounces' },
});
onCleared();
};
return (
<>
<Guard features={[AdminPanelFeature.ClearEmailBounces]}>
<button
data-testid="clear-button"
className="bg-red-600 border-0 rounded-md text-base mx-0 mb-6 px-4 py-3 text-white transition duration-200 hover:bg-red-700"
onClick={handleClear}
>
Clear all bounces
</button>
</Guard>
</>
);
};
const EmailBounce = ({
email,
templateName,
createdAt,
bounceType,
bounceSubType,
diagnosticCode,
}: EmailBounceType) => {
const date = getFormattedDate(createdAt);
const bounceDescription = getEmailBounceDescription(
bounceType,
bounceSubType
);
return (
<TableYHeaders testId="bounce-group">
<TableRowYHeader
header="email"
children={email}
testId={'bounce-email'}
/>
<TableRowYHeader
header="template"
children={templateName}
testId={'bounce-template'}
/>
<TableRowYHeader
header="created at"
children={`${createdAt} (${date})`}
testId={'bounce-createdAt'}
/>
<TableRowYHeader
header="bounce type"
children={bounceType}
testId={'bounce-type'}
/>
<TableRowYHeader
header="bounce subtype"
children={bounceSubType}
testId={'bounce-subtype'}
/>
<TableRowYHeader
header="bounce description"
children={bounceDescription}
testId={'bounce-description'}
/>
<TableRowYHeader
header="diagnostic code"
children={diagnosticCode?.length ? diagnosticCode : HIDE_ROW}
testId={'bounce-diagnostic-code'}
/>
</TableYHeaders>
);
};
export const EmailBounces = ({
emailBounces,
uid,
emails,
onCleared,
}: {
emailBounces?: Nullable<EmailBounceType[]>;
uid: string;
emails?: Nullable<EmailType[]>;
onCleared: Function;
}) => (
<>
<h3 className="header-lg">Email Bounces</h3>
{emailBounces && emailBounces.length > 0 ? (
<>
<ClearButton
{...{
uid,
emails: emails!.map((emails) => emails.email),
onCleared,
}}
/>
{emailBounces.map((emailBounce: EmailBounceType) => (
<EmailBounce key={emailBounce.createdAt} {...emailBounce} />
))}
</>
) : (
<p data-testid="no-bounces-message" className="result-none">
This account doesn't have any bounced emails.
</p>
)}
</>
);
export default EmailBounces;

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

@ -0,0 +1,78 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { MozSubscription } from 'fxa-admin-server/src/graphql';
import LinkExternal from 'fxa-react/components/LinkExternal';
import { HIDE_ROW } from '../../../../constants';
import { ReactComponent as IconExternalLink } from '../../../images/icon-external-link.svg';
import { getFormattedDate } from '../../../lib/utils';
import ResultBoolean from '../../ResultBoolean';
import { TableRowYHeader, TableYHeaders } from '../../TableYHeaders';
const Subscription = ({
created,
currentPeriodEnd,
currentPeriodStart,
cancelAtPeriodEnd,
endedAt,
latestInvoice,
manageSubscriptionLink,
planId,
productName,
productId,
status,
subscriptionId,
}: MozSubscription) => (
<TableYHeaders>
<TableRowYHeader header="Product name" children={productName} />
<TableRowYHeader header="Status" children={status} />
<TableRowYHeader header="Created at" children={getFormattedDate(created)} />
<TableRowYHeader
header="Ended at"
children={endedAt ? getFormattedDate(endedAt) : HIDE_ROW}
/>
<TableRowYHeader
header="Current period start"
children={getFormattedDate(currentPeriodStart)}
/>
<TableRowYHeader
header="Current period end"
children={getFormattedDate(currentPeriodEnd)}
/>
<TableRowYHeader
header="Cancel at period end"
children={<ResultBoolean isTruthy={cancelAtPeriodEnd} format={false} />}
/>
<TableRowYHeader header="Subscription ID" children={subscriptionId} />
<TableRowYHeader header="Product ID" children={productId} />
<TableRowYHeader header="Plan ID" children={planId} />
<TableRowYHeader
header="Links"
children={
<>
{!!latestInvoice && (
<LinkExternal href={latestInvoice} className="underline block">
Latest invoice
<IconExternalLink className="ml-2 w-4 inline-block icon-dark" />
</LinkExternal>
)}
{!!manageSubscriptionLink && (
<LinkExternal
href={manageSubscriptionLink}
className="underline block"
>
Manage Subscription
<IconExternalLink className="ml-2 w-4 inline-block icon-dark" />
</LinkExternal>
)}
</>
}
/>
</TableYHeaders>
);
export default Subscription;

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

@ -6,18 +6,15 @@ import React from 'react';
import Chance from 'chance';
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import {
CLEAR_BOUNCES_BY_EMAIL,
EDIT_LOCALE,
RECORD_ADMIN_SECURITY_EVENT,
UNSUBSCRIBE_FROM_MAILING_LISTS,
} from './Account/index';
import { EDIT_LOCALE, RECORD_ADMIN_SECURITY_EVENT } from './Account/index';
import { GET_ACCOUNT_BY_EMAIL, AccountSearch, GET_EMAILS_LIKE } from './index';
import {
AdminPanelEnv,
AdminPanelGroup,
AdminPanelGuard,
} from 'fxa-shared/guards';
import { UNSUBSCRIBE_FROM_MAILING_LISTS } from './DangerZone';
import { CLEAR_BOUNCES_BY_EMAIL } from './EmailBounces';
const chance = new Chance();

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

@ -212,8 +212,8 @@ export const AccountSearch = () => {
};
return (
<div className="text-grey-900" data-testid="account-search">
<h2 className="text-lg font-semibold mb-2">Account Search</h2>
<div data-testid="account-search">
<h2 className="header-page">Account Search</h2>
<p className="mb-1">
Search for a Firefox user account by email or UID and view its details,
including: secondary emails, email bounces, time-based one-time
@ -308,9 +308,12 @@ const AccountSearchResult = ({
}) => {
if (loading)
return (
<p data-testid="loading-message" className="mt-2">
Loading...
</p>
<>
<hr />
<p data-testid="loading-message" className="mt-2">
Loading...
</p>
</>
);
if (error) {
return <ErrorAlert {...{ error }}></ErrorAlert>;
@ -324,9 +327,10 @@ const AccountSearchResult = ({
return <Account {...{ query, onCleared }} {...data.accountByUid} />;
}
return (
<p data-testid="no-account-message" className="mt-2">
Account not found.
</p>
<>
<hr />
<p data-testid="no-account-message">Account not found.</p>
</>
);
};

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

@ -5,7 +5,7 @@
import React from 'react';
import { render, RenderResult } from '@testing-library/react';
import { IClientConfig } from '../../../interfaces';
import { Permissions } from './index';
import { PagePermissions } from './index';
import {
AdminPanelEnv,
AdminPanelGroup,
@ -38,7 +38,7 @@ describe('Permissions', () => {
let renderResult: RenderResult;
beforeEach(() => {
renderResult = render(<Permissions />);
renderResult = render(<PagePermissions />);
});
function getByTestId(id: string) {
@ -46,13 +46,13 @@ describe('Permissions', () => {
}
it('has user email', () => {
expect(getByTestId('permissions-user-email-val').textContent).toEqual(
expect(getByTestId('permissions-user-email').textContent).toEqual(
mockConfig.user.email
);
});
it('has user group', () => {
expect(getByTestId('permissions-user-group-val').textContent).toEqual(
expect(getByTestId('permissions-user-group').textContent).toEqual(
mockConfig.user.group.name
);
});

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

@ -0,0 +1,74 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 from 'react';
import { IFeatureFlag } from 'fxa-shared/guards';
import { useUserContext } from '../../hooks/UserContext';
import { useGuardContext } from '../../hooks/GuardContext';
import { TableRowYHeader, TableYHeaders } from '../TableYHeaders';
import { TableRowXHeader, TableXHeaders } from '../TableXHeaders';
export const PermissionsTable = ({
featureFlags,
}: {
featureFlags: IFeatureFlag[];
}) => {
return featureFlags.length === 0 ? (
<></>
) : (
<TableXHeaders
rowHeaders={['Feature', 'Enabled']}
className="table-x-headers"
>
<>
{featureFlags.map((flag) => {
const testId = `permissions-row-${flag.id}`;
return (
<TableRowXHeader key={flag.id} {...{ testId }}>
<td data-testid={`${testId}-label`}>{flag.name}</td>
<td data-testid={`${testId}-val`} className="text-center">
{flag.enabled ? '✅' : '❌'}
</td>
</TableRowXHeader>
);
})}
</>
</TableXHeaders>
);
};
export const PagePermissions = () => {
const { user } = useUserContext();
const { guard } = useGuardContext();
const featureFlags: IFeatureFlag[] = guard.getFeatureFlags(user.group);
return (
<div>
<h2 className="header-page">Permissions</h2>
<p>
This page displays your current user, group, and associated permissions.
</p>
<hr />
<TableYHeaders className="table-y-headers">
<TableRowYHeader
header="Signed In As"
children={user.email}
testId="permissions-user-email"
/>
<TableRowYHeader
header="Your Group"
children={user.group.name}
testId="permissions-user-group"
/>
</TableYHeaders>
<PermissionsTable {...{ featureFlags }} />
</div>
);
};
export default PagePermissions;

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

@ -77,7 +77,7 @@ it('renders as expected with a relying party containing all fields', async () =>
screen.getByText(MOCK_RP_ALL_FIELDS.redirectUri);
screen.getByText(MOCK_RP_ALL_FIELDS.allowedScopes!);
screen.getByText('1970', { exact: false });
expect(screen.getAllByText('Yes')).toHaveLength(3);
expect(screen.getAllByText('true')).toHaveLength(3);
});
it('updates notes', async () => {
@ -145,7 +145,7 @@ it('renders as expected with a relying party containing falsy fields', async ()
<PageRelyingParties />
</MockedProvider>
);
expect(await screen.findAllByText('No')).toHaveLength(3);
expect(await screen.findAllByText('false')).toHaveLength(3);
screen.getByText('(empty string)');
screen.getByText('NULL');
});

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

@ -6,11 +6,11 @@ import React, { useState } from 'react';
import { ApolloError, gql, useMutation, useQuery } from '@apollo/client';
import LinkExternal from 'fxa-react/components/LinkExternal';
import { RelyingParty } from 'fxa-admin-server/src/graphql';
import { DATE_FORMAT } from '../AccountSearch/Account';
import dateFormat from 'dateformat';
import ErrorAlert from '../ErrorAlert';
import { AdminPanelFeature } from '../../../../fxa-shared/guards';
import { Guard } from '../Guard';
import { getFormattedDate } from '../../lib/utils';
import { TableRowYHeader, TableYHeaders } from '../TableYHeaders';
const RELYING_PARTIES_SCHEMA = `
relyingParties {
@ -91,7 +91,7 @@ const Notes = ({ id, notes }: { id: string; notes: string }) => {
const saveButtonClass = () => {
const base =
'bg-grey-10 border-2 p-1 border-grey-100 font-small leading-6 ml-2 rounded mt';
'bg-grey-10 border-2 p-1 border-grey-100 font-small leading-6 rounded';
const active =
'text-red-700 hover:text-red-700 hover:border-2 hover:border-grey-10 hover:bg-grey-50';
const inactive = 'text-grey-700 cursor-not-allowed';
@ -102,10 +102,10 @@ const Notes = ({ id, notes }: { id: string; notes: string }) => {
};
const statusClass = () => {
if (error) {
return `p-2 text-red-700 visible`;
return `text-red-700 visible`;
}
if (status) {
return `p-2 text-gray-800 visible`;
return `text-gray-800 visible`;
}
return `collapsed`;
};
@ -118,10 +118,10 @@ const Notes = ({ id, notes }: { id: string; notes: string }) => {
};
return (
<div className="notes ">
<>
<textarea
data-testid={`notes-${id}`}
className="w-full mt-4 mb-2 border border-grey-100"
className="w-96 mb-2 border border-grey-100 block"
onChange={handleNotesChange}
defaultValue={notes}
/>
@ -134,11 +134,14 @@ const Notes = ({ id, notes }: { id: string; notes: string }) => {
>
Save
</button>
<div className={statusClass()} data-testid={`notes-status-${id}`}>
<p
className={`pl-3 inline-block ${statusClass()}`}
data-testid={`notes-status-${id}`}
>
{statusText()}
</div>
</p>
</Guard>
</div>
</>
);
};
@ -159,7 +162,7 @@ const Result = ({
}
if (data && data.relyingParties.length > 0) {
return (
<>
<section>
{data.relyingParties.map(
({
id,
@ -173,62 +176,53 @@ const Result = ({
allowedScopes,
notes,
}) => (
<div key={id}>
<h3 className="account-header">{name}</h3>
<table className="account-border-info">
<tbody>
<tr>
<th>ID</th>
<td>{id}</td>
</tr>
<tr>
<th>Created At</th>
<td>{dateFormat(new Date(createdAt), DATE_FORMAT)}</td>
</tr>
<tr>
<th>Redirect URI</th>
<td>{redirectUri}</td>
</tr>
<tr>
<th className="align-top">Allowed Scopes</th>
<td>
<AllowedScopes {...{ allowedScopes }} />
</td>
</tr>
<tr>
<th>Trusted?</th>
<td>{trusted ? 'Yes' : 'No'}</td>
</tr>
<tr>
<th>Can Grant?</th>
<td>{canGrant ? 'Yes' : 'No'}</td>
</tr>
<tr>
<th>Public Client?</th>
<td>{publicClient ? 'Yes' : 'No'}</td>
</tr>
<tr>
<th>Image URI</th>
<td>
{imageUri ? (
imageUri
) : (
<span className="result-grey">(empty string)</span>
)}
</td>
</tr>
<tr>
<th>Notes</th>
<td>
<Notes {...{ id, notes: notes || '' }} />
</td>
</tr>
</tbody>
</table>
</div>
<TableYHeaders key={id} header={name} className="table-y-headers">
<TableRowYHeader header="ID" children={id} />
<TableRowYHeader
header="Created At"
children={getFormattedDate(createdAt)}
/>
<TableRowYHeader
header="Redirect URI"
children={
redirectUri ? (
redirectUri
) : (
<span className="result-grey">(empty string)</span>
)
}
/>
<TableRowYHeader
header="Allowed Scopes"
children={<AllowedScopes {...{ allowedScopes }} />}
/>
<TableRowYHeader header="Trusted" children={trusted.toString()} />
<TableRowYHeader
header="Can Grant"
children={canGrant.toString()}
/>
<TableRowYHeader
header="Public Client"
children={publicClient.toString()}
/>
<TableRowYHeader
header="Image URI"
children={
imageUri ? (
imageUri
) : (
<span className="result-grey">(empty string)</span>
)
}
/>
<TableRowYHeader
header="Notes"
children={<Notes {...{ id, notes: notes || '' }} />}
/>
</TableYHeaders>
)
)}
</>
</section>
);
}
@ -269,13 +263,13 @@ export const PageRelyingParties = () => {
return (
<>
<h2 className="text-lg font-semibold mb-2">Relying Parties</h2>
<h2 className="header-page">Relying Parties</h2>
<p className="mb-2">
This page displays all FxA and SubPlat relying parties (RPs).
</p>
<p className="mb-6">
<p>
Firefox accounts integrates with Mozilla groups on request via OAuth,
OpenID, and webhooks, allowing them to offer users authentication and/or
authorization with their Firefox account. These groups assume an RP

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

@ -1,107 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 from 'react';
import { IFeatureFlag } from 'fxa-shared/guards';
import { useUserContext } from '../../hooks/UserContext';
import { useGuardContext } from '../../hooks/GuardContext';
const styleClasses = {
label: 'px-4 py-2',
val: 'font-medium text-violet-900 px-4 py-2',
};
export const LabelValRow = ({
label,
val,
testId,
}: {
label: string;
val: string;
testId: string;
}) => (
<tr key={testId}>
<td className={styleClasses.label} data-testid={`${testId}-label`}>
{label}
</td>
<td className={styleClasses.val} data-testid={`${testId}-val`}>
{val}
</td>
</tr>
);
export const PermissionRow = ({ flag }: { flag: IFeatureFlag }) => {
const testId = `permissions-row-${flag.id}`;
return (
<LabelValRow
{...{
testId,
label: flag.name,
val: flag.enabled ? '✅' : '❌',
}}
></LabelValRow>
);
};
export const PermissionsTable = ({
featureFlags,
}: {
featureFlags: IFeatureFlag[];
}) => {
return featureFlags.length === 0 ? (
<></>
) : (
<table className="table-auto" aria-label="permissions table">
<thead>
<tr>
<th className="text-left pl-4">Feature</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
{featureFlags.map((flag) => {
return <PermissionRow key={flag.id} {...{ flag }} />;
})}
</tbody>
</table>
);
};
export const Permissions = () => {
const { user } = useUserContext();
const { guard } = useGuardContext();
const featureFlags: IFeatureFlag[] = guard.getFeatureFlags(user.group);
return (
<div className="text-grey-900">
<h2 className="text-lg font-semibold mb-2">Permissions</h2>
<p className="mb-2">
This page displays your current user, group, and associated permissions.
</p>
<table className="table-auto">
<tbody>
<LabelValRow
{...{
testId: 'permissions-user-email',
label: 'Signed In As:',
val: user.email,
}}
/>
<LabelValRow
{...{
testId: 'permissions-user-group',
label: 'Your Group:',
val: user.group.name,
}}
/>
</tbody>
</table>
<br />
<PermissionsTable {...{ featureFlags }} />
</div>
);
};
export default Permissions;

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

@ -0,0 +1,24 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
export const ResultBoolean = ({
isTruthy,
testId,
format = true,
}: {
isTruthy: boolean;
testId?: string;
format?: boolean;
}) => {
const className = format
? `font-semibold ${isTruthy ? 'text-green-900' : 'text-red-700'}`
: undefined;
return (
<span {...{ className }} data-testid={testId}>
{isTruthy ? 'Yes' : 'No'}
</span>
);
};
export default ResultBoolean;

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

@ -0,0 +1,86 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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, { Children, ReactElement } from 'react';
interface TableXHeadersProps {
header?: string;
rowHeaders: string[];
testId?: string;
className?: string;
children:
| ReactElement<TableRowXHeaderProps>
| ReactElement<TableRowXHeaderProps>[];
}
interface TableRowXHeaderProps {
children: ReactElement | ReactElement[];
testId?: string;
}
/**
* Every child must be a valid React element, including but not limited to fragments.
* If a child element is anything but a `td`, the element is placed inside a `td` with classes.
* If the element is a `td`, this takes the children and props of that `td` and places them
* inside a new `td` with classes which is useful when you don't want to generate a new DOM
* element inside a `td` unnecessarily.
*/
export const TableRowXHeader = ({ children, testId }: TableRowXHeaderProps) => {
const arrayElements = Children.toArray(children);
const tableTdClasses = 'table-td border-r border-b';
return (
<tr data-testid={testId}>
{arrayElements.map((element, i) => {
if (React.isValidElement(element) && element.type === 'td') {
const {
className: elementClassNames,
children: elementChildren,
...props
} = element.props;
return (
<td
className={`${elementClassNames} ${tableTdClasses}`}
key={i}
{...props}
>
{elementChildren}
</td>
);
}
return (
<td className={tableTdClasses} key={i}>
{element}
</td>
);
})}
</tr>
);
};
export const TableXHeaders = ({
header,
rowHeaders,
children,
testId,
className = 'table-x-headers border-l-thick',
}: TableXHeadersProps) => (
<>
{header && <h3 className="header-lg">{header}</h3>}
<table {...{ className }} data-testid={testId}>
<thead>
<tr>
{rowHeaders.map((rowHeader) => (
<th className="table-th" key={rowHeader}>
{rowHeader}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</>
);

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

@ -0,0 +1,56 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { ReactElement } from 'react';
import { HIDE_ROW } from '../../../constants';
interface TableYHeadersProps {
header?: string;
testId?: string;
className?: string;
children:
| ReactElement<TableRowYHeaderProps>
| ReactElement<TableRowYHeaderProps>[];
}
interface TableRowYHeaderProps {
header: string;
children?: string | ReactElement | ReactElement[];
testId?: string;
className?: string;
}
export const TableRowYHeader = ({
header,
children,
testId,
className,
}: TableRowYHeaderProps) => {
if (!children || children === HIDE_ROW) {
return null;
}
return (
<tr {...{ className }}>
<th className="table-th text-left">{header}</th>
<td data-testid={testId} className="table-td border-b">
{children}
</td>
</tr>
);
};
export const TableYHeaders = ({
header,
children,
testId,
className = 'table-y-headers border-l-thick',
}: TableYHeadersProps) => (
<>
{header && <h3 className="header-lg">{header}</h3>}
<table {...{ className }} data-testid={testId}>
<tbody>{children}</tbody>
</table>
</>
);

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

@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 dateFormat from 'dateformat';
const DATE_FORMAT = 'yyyy-mm-dd @ HH:MM:ss Z';
export const getFormattedDate = (raw: Nullable<number> | undefined) =>
dateFormat(new Date(raw || 0), DATE_FORMAT);

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

@ -1 +1,3 @@
/// <reference types="react-scripts" />
type Nullable<T> = T | null;

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

@ -0,0 +1,39 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
.table-th {
@apply font-medium text-violet-900 align-top p-1 pr-4 bg-grey-10;
}
.table-td {
@apply px-2 py-1 border-grey-10;
}
.table-xy-headers {
@apply mb-5 border border-grey-50 border-separate rounded;
&.border-l-thick {
@apply border-l-2 border-l-grey-300;
}
}
.table-y-headers {
@apply table-xy-headers;
tr:last-of-type td {
@apply border-none;
}
}
.table-x-headers {
@apply table-xy-headers;
td:last-of-type {
@apply border-r-0;
}
tr:last-of-type td {
@apply border-b-0;
}
}

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

@ -1,51 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import './tables';
.icon-dark path {
fill: #0c0c0d;
}
hr {
@apply border-grey-50 mb-4;
@apply border-grey-50 my-6;
}
th {
@apply font-medium text-violet-900 pl-4 pr-8 text-left;
.header-page {
@apply text-lg font-semibold mb-2;
}
.header-lg {
@apply text-lg mt-7 mb-2;
}
.result-none {
@apply border-l border-l-grey-300 border-dotted pl-2;
}
.result-grey {
@apply italic text-grey-500;
}
.account-header {
@apply mt-0 mb-1 text-lg;
}
.account-li {
@apply list-none pl-0;
}
.account-li span {
@apply font-medium text-violet-900;
}
.account-label {
@apply align-top w-48;
}
.notes {
@apply w-96;
}
.account-border-info {
@apply border-l-2 border-grey-500 mb-8 pl-4;
}
.account-li .account-enabled-verified {
@apply font-semibold text-green-900;
}
.account-li .account-disabled-unverified {
@apply font-semibold text-red-600;
}

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

@ -24517,6 +24517,7 @@ fsevents@~2.1.1:
"@types/helmet": 4.0.0
"@types/jsdom": ^16.2.11
"@types/on-headers": ^1.0.0
"@types/postcss-import": ^14
"@types/react": ^17.0.14
"@types/serve-static": 1.13.9
"@types/supertest": ^2.0.11
@ -24546,6 +24547,7 @@ fsevents@~2.1.1:
on-headers: ^1.0.2
pm2: ^5.2.2
postcss: ^8.4.14
postcss-import: ^15.0.0
prettier: ^2.3.1
react: ^16.13.0
react-dom: ^16.13.0