0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(console): sign up and sign in change alert (#2273)

This commit is contained in:
Xiao Yijun 2022-11-02 11:39:27 +08:00 committed by GitHub
parent de870d6def
commit bf6bd75e0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 407 additions and 36 deletions

View file

@ -36,6 +36,7 @@
"@silverhand/ts-config-react": "1.2.1",
"@tsconfig/docusaurus": "^1.0.5",
"@types/color": "^3.0.3",
"@types/lodash.get": "^4.4.7",
"@types/lodash.kebabcase": "^4.1.6",
"@types/mdx": "^2.0.1",
"@types/mdx-js__react": "^1.5.5",
@ -48,6 +49,7 @@
"cross-env": "^7.0.3",
"csstype": "^3.0.11",
"dayjs": "^1.10.5",
"deep-object-diff": "^1.1.7",
"deepmerge": "^4.2.2",
"dnd-core": "^16.0.0",
"eslint": "^8.21.0",
@ -56,6 +58,7 @@
"i18next-browser-languagedetector": "^6.1.4",
"ky": "^0.31.0",
"lint-staged": "^13.0.0",
"lodash.get": "^4.4.2",
"lodash.kebabcase": "^4.1.1",
"nanoid": "^3.1.23",
"parcel": "2.7.0",

View file

@ -1,28 +1,33 @@
@use '@/scss/underscore' as _;
.container {
min-width: 552px;
}
.description {
font: var(--font-body-medium);
}
.content {
margin-top: _.unit(6);
display: flex;
flex-direction: row;
margin-top: _.unit(3);
border-radius: 8px;
padding: _.unit(5);
background: var(--color-layer-2);
font: var(--font-body-medium);
justify-content: space-between;
align-items: stretch;
column-gap: _.unit(3);
.section {
&:not(:first-child) {
margin-top: _.unit(3);
}
flex: 1;
background: var(--color-layer-2);
border-radius: 8px;
padding: _.unit(5);
color: var(--color-text);
.title {
font: var(--font-subhead-2);
font: var(--font-title-medium);
margin: _.unit(1) 0;
}
.connector {
margin-left: _.unit(1);
}
}
}

View file

@ -1,8 +1,8 @@
import type { SignInExperience } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import SignUpAndSignInDiffSection from '../tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection';
import * as styles from './SignInMethodsChangePreview.module.scss';
import SignInMethodsPreview from './SignInMethodsPreview';
type Props = {
before: SignInExperience;
@ -13,16 +13,16 @@ const SignInMethodsChangePreview = ({ before, after }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div>
<div className={styles.container}>
<div className={styles.description}>{t('sign_in_exp.save_alert.description')}</div>
<div className={styles.content}>
<div className={styles.section}>
<div className={styles.title}>{t('sign_in_exp.save_alert.before')}</div>
<SignInMethodsPreview data={before} />
<SignUpAndSignInDiffSection before={before} after={after} />
</div>
<div className={styles.section}>
<div className={styles.title}>{t('sign_in_exp.save_alert.after')}</div>
<SignInMethodsPreview data={after} />
<SignUpAndSignInDiffSection isAfter before={before} after={after} />
</div>
</div>
</div>

View file

@ -28,7 +28,7 @@ import BrandingTab from './tabs/BrandingTab';
import OthersTab from './tabs/OthersTab';
import SignUpAndSignInTab from './tabs/SignUpAndSignInTab';
import type { SignInExperienceForm } from './types';
import { compareSignInMethods, signInExperienceParser } from './utilities';
import { compareSignUpAndSignInConfigs, signInExperienceParser } from './utilities';
const SignInExperience = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -84,7 +84,7 @@ const SignInExperience = () => {
const formatted = signInExperienceParser.toRemoteModel(formData);
// Sign-in methods changed, need to show confirm modal first.
if (!compareSignInMethods(data, formatted)) {
if (!compareSignUpAndSignInConfigs(data, formatted)) {
setDataToCompare(formatted);
return;

View file

@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import * as styles from './index.module.scss';
type Props = {
children: ReactNode;
hasChanged: boolean;
isAfter?: boolean;
};
const DiffSegment = ({ children, hasChanged, isAfter = false }: Props) => {
if (!hasChanged) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
}
return <span className={isAfter ? styles.green : styles.red}>{children}</span>;
};
export default DiffSegment;

View file

@ -0,0 +1,88 @@
import type { SignInIdentifier } from '@logto/schemas';
import { detailedDiff } from 'deep-object-diff';
import get from 'lodash.get';
import { useTranslation } from 'react-i18next';
import type { SignInMethod } from '../SignInMethodEditBox/types';
import DiffSegment from './DiffSegment';
import * as styles from './index.module.scss';
import type { SignInMethodsObject } from './types';
import { convertToSignInMethodsObject } from './utilities';
type Props = {
before: SignInMethod[];
after: SignInMethod[];
isAfter?: boolean;
};
const SignInDiffSection = ({ before, after, isAfter = false }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const beforeSignInMethodsObject = convertToSignInMethodsObject(before);
const afterSignInMethodsObject = convertToSignInMethodsObject(after);
const signInDiff = isAfter
? detailedDiff(beforeSignInMethodsObject, afterSignInMethodsObject)
: detailedDiff(afterSignInMethodsObject, beforeSignInMethodsObject);
const displaySignInMethodsObject = isAfter ? afterSignInMethodsObject : beforeSignInMethodsObject;
const hasIdentifierChanged = (identifierKey: SignInIdentifier) =>
get(signInDiff, `added.${identifierKey.toLocaleLowerCase()}`) !== undefined;
const hasAuthenticationChanged = (
identifierKey: SignInIdentifier,
authenticationKey: keyof SignInMethodsObject[SignInIdentifier]
) =>
get(signInDiff, `updated.${identifierKey.toLocaleLowerCase()}.${authenticationKey}`) !==
undefined;
return (
<div>
<div className={styles.title}>{t('sign_in_exp.save_alert.sign_in')}</div>
<ul className={styles.list}>
{
// eslint-disable-next-line no-restricted-syntax
(Object.keys(displaySignInMethodsObject).slice().sort() as SignInIdentifier[]).map(
(identifierKey) => {
const { password, verificationCode } = displaySignInMethodsObject[identifierKey];
const hasAuthentication = password || verificationCode;
const needDisjunction = password && verificationCode;
return (
<li key={identifierKey}>
<DiffSegment hasChanged={hasIdentifierChanged(identifierKey)} isAfter={isAfter}>
{t('sign_in_exp.sign_up_and_sign_in.identifiers', {
context: identifierKey.toLocaleLowerCase(),
})}
{hasAuthentication && ' ('}
{password && (
<DiffSegment
hasChanged={hasAuthenticationChanged(identifierKey, 'password')}
isAfter={isAfter}
>
{t('sign_in_exp.sign_up_and_sign_in.sign_in.password_auth')}
</DiffSegment>
)}
{needDisjunction && ` ${String(t('sign_in_exp.sign_up_and_sign_in.or'))} `}
{verificationCode && (
<DiffSegment
hasChanged={hasAuthenticationChanged(identifierKey, 'verificationCode')}
isAfter={isAfter}
>
{t('sign_in_exp.sign_up_and_sign_in.sign_in.verification_code_auth')}
</DiffSegment>
)}
{hasAuthentication && ')'}
</DiffSegment>
</li>
);
}
)
}
</ul>
</div>
);
};
export default SignInDiffSection;

View file

@ -0,0 +1,54 @@
import type { SignUp } from '@logto/schemas';
import { diff } from 'deep-object-diff';
import get from 'lodash.get';
import { useTranslation } from 'react-i18next';
import DiffSegment from './DiffSegment';
import * as styles from './index.module.scss';
type Props = {
before: SignUp;
after: SignUp;
isAfter?: boolean;
};
const SignUpDiffSection = ({ before, after, isAfter = false }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const signUpDiff = isAfter ? diff(before, after) : diff(after, before);
const signUp = isAfter ? after : before;
const hasChanged = (path: keyof SignUp) => get(signUpDiff, path) !== undefined;
const { identifier, password, verify } = signUp;
const hasAuthentication = password || verify;
const needConjunction = password && verify;
return (
<div>
<div className={styles.title}>{t('sign_in_exp.save_alert.sign_up')}</div>
<ul className={styles.list}>
<li>
<DiffSegment hasChanged={hasChanged('identifier')} isAfter={isAfter}>
{t('sign_in_exp.sign_up_and_sign_in.identifiers', {
context: identifier.toLowerCase(),
})}
</DiffSegment>
{hasAuthentication && ' ('}
{password && (
<DiffSegment hasChanged={hasChanged('password')} isAfter={isAfter}>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.set_a_password_option')}
</DiffSegment>
)}
{needConjunction && ` ${String(t('sign_in_exp.sign_up_and_sign_in.and'))} `}
{verify && (
<DiffSegment hasChanged={hasChanged('verify')} isAfter={isAfter}>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.verify_at_sign_up_option')}
</DiffSegment>
)}
{hasAuthentication && ')'}
</li>
</ul>
</div>
);
};
export default SignUpDiffSection;

View file

@ -0,0 +1,63 @@
import { isLanguageTag } from '@logto/language-kit';
import { conditional } from '@silverhand/essentials';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
import useConnectorGroups from '@/hooks/use-connector-groups';
import DiffSegment from './DiffSegment';
import * as styles from './index.module.scss';
type Props = {
before: string[];
after: string[];
isAfter?: boolean;
};
const SocialTargetsDiffSection = ({ before, after, isAfter = false }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: groups, error } = useConnectorGroups();
const { language } = i18next;
const sortedBeforeTargets = before.slice().sort();
const sortedAfterTargets = after.slice().sort();
const displayTargets = isAfter ? sortedAfterTargets : sortedBeforeTargets;
const hasChanged = (target: string) => !(before.includes(target) && after.includes(target));
if (!groups) {
return null;
}
if (error) {
return null;
}
return (
<div>
<div className={styles.title}>{t('sign_in_exp.save_alert.social')}</div>
<ul className={styles.list}>
{displayTargets.map((target) => {
const connectorDetail = groups.find(
({ target: connectorTarget }) => connectorTarget === target
);
if (!connectorDetail) {
return null;
}
return (
<li key={target}>
<DiffSegment hasChanged={hasChanged(target)} isAfter={isAfter}>
{conditional(isLanguageTag(language) && connectorDetail.name[language]) ??
connectorDetail.name.en}
</DiffSegment>
</li>
);
})}
</ul>
</div>
);
};
export default SocialTargetsDiffSection;

View file

@ -0,0 +1,17 @@
@use '@/scss/underscore' as _;
.title {
font: var(--font-title-small);
}
.list {
padding-left: _.unit(6);
}
.red {
background-color: rgba(221, 55, 48, 30%);
}
.green {
background-color: rgb(104, 190, 108, 40%);
}

View file

@ -0,0 +1,45 @@
import type { SignInExperience } from '@logto/schemas';
import SignInDiffSection from './SignInDiffSection';
import SignUpDiffSection from './SignUpDiffSection';
import SocialTargetsDiffSection from './SocialTargetsDiffSection';
import { isSignInMethodsDifferent, isSignUpDifferent, isSocialTargetsDifferent } from './utilities';
type Props = {
before: SignInExperience;
after: SignInExperience;
isAfter?: boolean;
};
const SignUpAndSignInDiffSection = ({ before, after, isAfter = false }: Props) => {
const showSignUpDiff = isSignUpDifferent(before.signUp, after.signUp);
const showSignInDiff = isSignInMethodsDifferent(before.signIn.methods, after.signIn.methods);
const showSocialDiff = isSocialTargetsDifferent(
before.socialSignInConnectorTargets,
after.socialSignInConnectorTargets
);
return (
<>
{showSignUpDiff && (
<SignUpDiffSection before={before.signUp} after={after.signUp} isAfter={isAfter} />
)}
{showSignInDiff && (
<SignInDiffSection
before={before.signIn.methods}
after={after.signIn.methods}
isAfter={isAfter}
/>
)}
{showSocialDiff && (
<SocialTargetsDiffSection
before={before.socialSignInConnectorTargets}
after={after.socialSignInConnectorTargets}
isAfter={isAfter}
/>
)}
</>
);
};
export default SignUpAndSignInDiffSection;

View file

@ -0,0 +1,6 @@
import type { SignInIdentifier } from '@logto/schemas';
export type SignInMethodsObject = Record<
SignInIdentifier,
{ password: boolean; verificationCode: boolean }
>;

View file

@ -0,0 +1,27 @@
import type { SignInExperience } from '@logto/schemas';
import { diff } from 'deep-object-diff';
import type { SignInMethod } from '../SignInMethodEditBox/types';
import type { SignInMethodsObject } from './types';
export const isSignUpDifferent = (
before: SignInExperience['signUp'],
after: SignInExperience['signUp']
) => Object.keys(diff(before, after)).length > 0;
export const convertToSignInMethodsObject = (signInMethods: SignInMethod[]): SignInMethodsObject =>
signInMethods.reduce<SignInMethodsObject>(
(methodsObject, { identifier, password, verificationCode }) => ({
...methodsObject,
[identifier]: { password, verificationCode },
}),
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, no-restricted-syntax
{} as SignInMethodsObject
);
export const isSignInMethodsDifferent = (before: SignInMethod[], after: SignInMethod[]) =>
Object.keys(diff(convertToSignInMethodsObject(before), convertToSignInMethodsObject(after)))
.length > 0;
export const isSocialTargetsDifferent = (before: string[], after: string[]) =>
Object.keys(diff(before.slice().sort(), after.slice().sort())).length > 0;

View file

@ -3,6 +3,11 @@ import type { SignInExperience, SignInMethods, Translation } from '@logto/schema
import { SignUpIdentifier, SignInMethodKey, SignInMethodState, SignInMode } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import {
isSignInMethodsDifferent,
isSignUpDifferent,
isSocialTargetsDifferent,
} from './tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/utilities';
import type { SignInExperienceForm } from './types';
const findMethodState = (
@ -78,26 +83,18 @@ export const signInExperienceParser = {
},
};
export const compareSignInMethods = (
export const compareSignUpAndSignInConfigs = (
before: SignInExperience,
after: SignInExperience
): boolean => {
if (before.socialSignInConnectorTargets.length !== after.socialSignInConnectorTargets.length) {
return false;
}
if (
before.socialSignInConnectorTargets.some(
(target) => !after.socialSignInConnectorTargets.includes(target)
return (
!isSignUpDifferent(before.signUp, after.signUp) &&
!isSignInMethodsDifferent(before.signIn.methods, after.signIn.methods) &&
!isSocialTargetsDifferent(
before.socialSignInConnectorTargets,
after.socialSignInConnectorTargets
)
) {
return false;
}
const { signInMethods: beforeMethods } = before;
const { signInMethods: afterMethods } = after;
return Object.values(SignInMethodKey).every((key) => beforeMethods[key] === afterMethods[key]);
);
};
export const flattenTranslation = (

View file

@ -45,6 +45,8 @@ const sign_in_exp = {
identifiers_username: 'Username',
identifiers_email_or_sms: 'Email address or phone number',
identifiers_none: 'None',
and: 'and',
or: 'or',
sign_up: {
title: 'SIGN UP',
sign_up_identifier: 'Sign up identifier',
@ -162,6 +164,9 @@ const sign_in_exp = {
'You are changing sign-in methods. This will impact some of your users. Are you sure you want to do that?',
before: 'Before',
after: 'After',
sign_up: 'Sign up',
sign_in: 'Sign in',
social: 'Social',
},
preview: {
title: 'Sign-in preview',

View file

@ -47,6 +47,8 @@ const sign_in_exp = {
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
@ -164,6 +166,9 @@ const sign_in_exp = {
'Vous changez de méthode de connexion. Cela aura un impact sur certains de vos utilisateurs. Êtes-vous sûr de vouloir faire cela ?',
before: 'Avant',
after: 'Après',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {
title: "Aperçu de l'expérience de connexion",

View file

@ -42,6 +42,8 @@ const sign_in_exp = {
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
@ -159,6 +161,9 @@ const sign_in_exp = {
'로그인 방법이 수정되었어요. 일부 사용자에게 영향을 미칠 수 있어요. 정말로 진행할까요?',
before: '이전',
after: '이후',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {
title: '로그인 화면 미리보기',

View file

@ -45,6 +45,8 @@ const sign_in_exp = {
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
@ -162,6 +164,9 @@ const sign_in_exp = {
'Está alterando os métodos de login. Isso afetará alguns dos seus utilizadoress. Tem a certeza que deseja fazer isso?',
before: 'Antes',
after: 'Depois',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {
title: 'Pre-visualização do login',

View file

@ -46,6 +46,8 @@ const sign_in_exp = {
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
@ -163,6 +165,9 @@ const sign_in_exp = {
'Oturum açma yöntemlerini değiştiriyorsunuz. Bu, bazı kullanıcılarınızı etkileyecektir. Bunu yapmak istediğine emin misin?',
before: 'Önce',
after: 'Sonra',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {
title: 'Oturum Açma Önizlemesi',

View file

@ -43,6 +43,8 @@ const sign_in_exp = {
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
@ -152,6 +154,9 @@ const sign_in_exp = {
description: '你正在修改登录方式,这可能会影响部分用户。是否继续保存修改?',
before: '修改前',
after: '修改后',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {
title: '登录预览',

22
pnpm-lock.yaml generated
View file

@ -123,6 +123,7 @@ importers:
'@silverhand/ts-config-react': 1.2.1
'@tsconfig/docusaurus': ^1.0.5
'@types/color': ^3.0.3
'@types/lodash.get': ^4.4.7
'@types/lodash.kebabcase': ^4.1.6
'@types/mdx': ^2.0.1
'@types/mdx-js__react': ^1.5.5
@ -135,6 +136,7 @@ importers:
cross-env: ^7.0.3
csstype: ^3.0.11
dayjs: ^1.10.5
deep-object-diff: ^1.1.7
deepmerge: ^4.2.2
dnd-core: ^16.0.0
eslint: ^8.21.0
@ -143,6 +145,7 @@ importers:
i18next-browser-languagedetector: ^6.1.4
ky: ^0.31.0
lint-staged: ^13.0.0
lodash.get: ^4.4.2
lodash.kebabcase: ^4.1.1
nanoid: ^3.1.23
parcel: 2.7.0
@ -191,6 +194,7 @@ importers:
'@silverhand/ts-config-react': 1.2.1_typescript@4.7.4
'@tsconfig/docusaurus': 1.0.5
'@types/color': 3.0.3
'@types/lodash.get': 4.4.7
'@types/lodash.kebabcase': 4.1.6
'@types/mdx': 2.0.1
'@types/mdx-js__react': 1.5.5
@ -203,6 +207,7 @@ importers:
cross-env: 7.0.3
csstype: 3.0.11
dayjs: 1.10.7
deep-object-diff: 1.1.7
deepmerge: 4.2.2
dnd-core: 16.0.0
eslint: 8.21.0
@ -211,6 +216,7 @@ importers:
i18next-browser-languagedetector: 6.1.4
ky: 0.31.0
lint-staged: 13.0.0
lodash.get: 4.4.2
lodash.kebabcase: 4.1.1
nanoid: 3.3.1
parcel: 2.7.0_postcss@8.4.6
@ -4055,6 +4061,12 @@ packages:
'@types/node': 17.0.23
dev: true
/@types/lodash.get/4.4.7:
resolution: {integrity: sha512-af34Mj+KdDeuzsJBxc/XeTtOx0SZHZNLd+hdrn+PcKGQs0EG2TJTzQAOTCZTgDJCArahlCzLWSy8c2w59JRz7Q==}
dependencies:
'@types/lodash': 4.14.178
dev: true
/@types/lodash.kebabcase/4.1.6:
resolution: {integrity: sha512-+RAD9pCAa8kuVyCYTeDNiwBXwD/0u0p+hos3NSqD+tXTjJextbfF3farfYB+ssAKgEssoewXEtBsfwBpsI7gsA==}
dependencies:
@ -5843,6 +5855,10 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
/deep-object-diff/1.1.7:
resolution: {integrity: sha512-QkgBca0mL08P6HiOjoqvmm6xOAl2W6CT2+34Ljhg0OeFan8cwlcdq8jrLKsBBuUFAZLsN5b6y491KdKEoSo9lg==}
dev: true
/deepmerge/4.2.2:
resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
engines: {node: '>=0.10.0'}
@ -5950,8 +5966,8 @@ packages:
engines: {node: '>=0.3.1'}
dev: true
/diff/5.0.0:
resolution: {integrity: sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==}
/diff/5.1.0:
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
engines: {node: '>=0.3.1'}
dev: true
@ -13945,7 +13961,7 @@ packages:
hasBin: true
dependencies:
dequal: 2.0.2
diff: 5.0.0
diff: 5.1.0
kleur: 4.1.4
sade: 1.8.1
dev: true