зеркало из https://github.com/mozilla/fxa.git
Merge pull request #14410 from mozilla/FXA-5253
refactor(ts(x),css): Admin panel component restructure and cleanup
This commit is contained in:
Коммит
7930740165
|
@ -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
|
||||
|
|
Загрузка…
Ссылка в новой задаче