1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: Slack App addon (#4238)

https://linear.app/unleash/issue/2-1232/implement-first-iteration-of-the-new-slack-app-addon

This PR implements the first iteration of the new Slack App addon.
Unlike the old Slack addon, this one uses a Slack App (bot) that is
installed to Slack workspaces in order to post messages. This uses
`@slack/web-api`, which internally uses the latest Slack API endpoints
like `postMessage`.

This is currently behind a flag: `slackAppAddon`.

The current flow is that the Unleash Slack App is installed from
whatever source:
 - Unleash addons page;
 - Direct link;
 - https://unleash-slack-app.vercel.app/ (temporary URL);
 - Slack App Directory (in the future);
 - Etc;

After installed, we resolve the authorization to an `access_token` that
the user can paste into the Unleash Slack App addon configuration form.


https://github.com/Unleash/unleash/assets/14320932/6a6621b9-5b8a-4921-a279-30668be6d46c

Co-authored by: @daveleek

---------

Co-authored-by: David Leek <david@getunleash.io>
This commit is contained in:
Nuno Góis 2023-07-14 09:49:34 +01:00 committed by GitHub
parent fc9cacfb6d
commit 383e522127
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 562 additions and 42 deletions

View File

@ -12,6 +12,12 @@ export const StyledFormSection = styled('section')({
marginBottom: '36px',
});
export const StyledAlerts = styled(StyledFormSection)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}));
export const StyledHelpText = styled('p')({
marginBottom: '0.5rem',
});

View File

@ -1,4 +1,4 @@
import React, {
import {
ChangeEventHandler,
FormEventHandler,
MouseEventHandler,
@ -6,11 +6,18 @@ import React, {
useState,
VFC,
} from 'react';
import { Button, Divider, FormControlLabel, Switch } from '@mui/material';
import {
Alert,
Button,
Divider,
FormControlLabel,
Switch,
} from '@mui/material';
import produce from 'immer';
import { trim } from 'component/common/util';
import { IAddon, IAddonProvider } from 'interfaces/addons';
import { AddonParameters } from './AddonParameters/AddonParameters';
import { AddonInstall } from './AddonInstall/AddonInstall';
import cloneDeep from 'lodash.clonedeep';
import { useNavigate } from 'react-router-dom';
import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
@ -29,6 +36,7 @@ import {
import {
StyledForm,
StyledFormSection,
StyledAlerts,
StyledHelpText,
StyledTextField,
StyledContainer,
@ -37,6 +45,7 @@ import {
} from './AddonForm.styles';
import { useTheme } from '@mui/system';
import { GO_BACK } from 'constants/navigate';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IAddonFormProps {
provider?: IAddonProvider;
@ -241,6 +250,8 @@ export const AddonForm: VFC<IAddonFormProps> = ({
name,
description,
documentationUrl = 'https://unleash.github.io/docs/addons',
installation,
alerts,
} = provider ? provider : ({} as Partial<IAddonProvider>);
return (
@ -253,6 +264,21 @@ export const AddonForm: VFC<IAddonFormProps> = ({
>
<StyledForm onSubmit={onSubmit}>
<StyledContainer>
<StyledAlerts>
{alerts?.map(({ type, text }) => (
<Alert severity={type}>{text}</Alert>
))}
</StyledAlerts>
<ConditionallyRender
condition={Boolean(installation)}
show={() => (
<AddonInstall
url={installation!.url}
title={installation!.title}
helpText={installation!.helpText}
/>
)}
/>
<StyledFormSection>
<StyledTextField
size="small"

View File

@ -0,0 +1,39 @@
import React from 'react';
import {
StyledFormSection,
StyledHelpText,
StyledTitle,
} from '../AddonForm.styles';
import { Button } from '@mui/material';
import { Link } from 'react-router-dom';
export interface IAddonInstallProps {
url: string;
title?: string;
helpText?: string;
}
export const AddonInstall = ({
url,
title = 'Install addon',
helpText = 'Click this button to install this addon.',
}: IAddonInstallProps) => {
return (
<React.Fragment>
<StyledFormSection>
<StyledTitle>{title}</StyledTitle>
<StyledHelpText>{helpText}</StyledHelpText>
<Button
type="button"
variant="outlined"
component={Link}
target="_blank"
rel="noopener noreferrer"
to={url}
>
Install
</Button>
</StyledFormSection>
</React.Fragment>
);
};

View File

@ -21,6 +21,7 @@ interface IAddonIconProps {
export const AddonIcon = ({ name }: IAddonIconProps) => {
switch (name) {
case 'slack':
case 'slack-app':
return (
<img
style={style}

View File

@ -0,0 +1,35 @@
import { styled } from '@mui/material';
import { Badge } from 'component/common/Badge/Badge';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { IAddonProvider } from 'interfaces/addons';
const StyledBadge = styled(Badge)(({ theme }) => ({
cursor: 'pointer',
marginLeft: theme.spacing(1),
}));
interface IAddonNameCellProps {
provider: Pick<
IAddonProvider,
'displayName' | 'description' | 'deprecated'
>;
}
export const AddonNameCell = ({ provider }: IAddonNameCellProps) => (
<HighlightCell
value={provider.displayName}
subtitle={provider.description}
afterTitle={
<ConditionallyRender
condition={Boolean(provider.deprecated)}
show={
<HtmlTooltip title={provider.deprecated} arrow>
<StyledBadge color="neutral">Deprecated</StyledBadge>
</HtmlTooltip>
}
/>
}
/>
);

View File

@ -11,7 +11,6 @@ import {
} from 'component/common/Table';
import { useTable, useSortBy } from 'react-table';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes';
@ -19,6 +18,8 @@ import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import { ConfigureAddonButton } from './ConfigureAddonButton/ConfigureAddonButton';
import { AddonIcon } from '../AddonIcon/AddonIcon';
import { AddonNameCell } from '../AddonNameCell/AddonNameCell';
import { IAddonInstallation } from 'interfaces/addons';
interface IProvider {
name: string;
@ -27,6 +28,8 @@ interface IProvider {
documentationUrl: string;
parameters: object[];
events: string[];
installation?: IAddonInstallation;
deprecated?: string;
}
interface IAvailableAddonsProps {
@ -46,11 +49,15 @@ export const AvailableAddons = ({
});
}
return providers.map(({ name, displayName, description }) => ({
name,
displayName,
description,
}));
return providers.map(
({ name, displayName, description, deprecated, installation }) => ({
name,
displayName,
description,
deprecated,
installation,
})
);
}, [providers, loading]);
const columns = useMemo(
@ -71,19 +78,9 @@ export const AvailableAddons = ({
Header: 'Name',
accessor: 'name',
width: '90%',
Cell: ({
row: {
original: { name, description },
},
}: any) => {
return (
<LinkCell
data-loading
title={name}
subtitle={description}
/>
);
},
Cell: ({ row: { original } }: any) => (
<AddonNameCell provider={original} />
),
sortType: 'alphanumeric',
},
{
@ -91,7 +88,7 @@ export const AvailableAddons = ({
align: 'center',
Cell: ({ row: { original } }: any) => (
<ActionCell>
<ConfigureAddonButton name={original.name} />
<ConfigureAddonButton provider={original} />
</ActionCell>
),
width: 150,

View File

@ -1,19 +1,24 @@
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { CREATE_ADDON } from 'component/providers/AccessProvider/permissions';
import { IAddonProvider } from 'interfaces/addons';
import { useNavigate } from 'react-router-dom';
interface IConfigureAddonButtonProps {
name: string;
provider: IAddonProvider;
}
export const ConfigureAddonButton = ({ name }: IConfigureAddonButtonProps) => {
export const ConfigureAddonButton = ({
provider,
}: IConfigureAddonButtonProps) => {
const navigate = useNavigate();
return (
<PermissionButton
permission={CREATE_ADDON}
variant="outlined"
onClick={() => navigate(`/addons/create/${name}`)}
onClick={() => {
navigate(`/addons/create/${provider.name}`);
}}
>
Configure
</PermissionButton>

View File

@ -8,7 +8,6 @@ import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
import { IAddon } from 'interfaces/addons';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { formatUnknownError } from 'utils/formatUnknownError';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { sortTypes } from 'utils/sortTypes';
import { useTable, useSortBy } from 'react-table';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
@ -16,9 +15,10 @@ import { SortableTableHeader, TablePlaceholder } from 'component/common/Table';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { AddonIcon } from '../AddonIcon/AddonIcon';
import { ConfiguredAddonsActionsCell } from './ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell';
import { AddonNameCell } from '../AddonNameCell/AddonNameCell';
export const ConfiguredAddons = () => {
const { refetchAddons, addons, loading } = useAddons();
const { refetchAddons, addons, providers, loading } = useAddons();
const { updateAddon, removeAddon } = useAddonsApi();
const { setToastData, setToastApiError } = useToast();
const [showDelete, setShowDelete] = useState(false);
@ -84,15 +84,18 @@ export const ConfiguredAddons = () => {
row: {
original: { provider, description },
},
}: any) => {
return (
<LinkCell
data-loading
title={provider}
subtitle={description}
/>
);
},
}: any) => (
<AddonNameCell
provider={{
...(providers.find(
({ name }) => name === provider
) || {
displayName: provider,
}),
description,
}}
/>
),
sortType: 'alphanumeric',
},
{

View File

@ -19,6 +19,21 @@ export interface IAddonProvider {
name: string;
parameters: IAddonProviderParams[];
tagTypes: ITagType[];
installation?: IAddonInstallation;
alerts?: IAddonAlert[];
deprecated?: string;
}
export interface IAddonInstallation {
url: string;
warning?: string;
title?: string;
helpText?: string;
}
export interface IAddonAlert {
type: 'success' | 'info' | 'warning' | 'error';
text: string;
}
export interface IAddonProviderParams {

View File

@ -104,6 +104,7 @@
]
},
"dependencies": {
"@slack/web-api": "^6.8.1",
"@unleash/express-openapi": "^0.3.0",
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",

View File

@ -1,12 +1,14 @@
import joi from 'joi';
import { nameType } from '../routes/util';
import { tagTypeSchema } from '../services/tag-type-schema';
import { installationDefinitionSchema } from './installation-definition-schema';
export const addonDefinitionSchema = joi.object().keys({
name: nameType,
displayName: joi.string(),
documentationUrl: joi.string().uri({ scheme: [/https?/] }),
description: joi.string().allow(''),
deprecated: joi.boolean().optional().default(false),
parameters: joi
.array()
.optional()
@ -23,4 +25,14 @@ export const addonDefinitionSchema = joi.object().keys({
),
events: joi.array().optional().items(joi.string()),
tagTypes: joi.array().optional().items(tagTypeSchema),
installation: installationDefinitionSchema.optional(),
alerts: joi
.array()
.optional()
.items(
joi.object().keys({
type: joi.string().valid('success', 'info', 'warning', 'error'),
text: joi.string().required(),
}),
),
});

View File

@ -4,6 +4,8 @@ import TeamsAddon from './teams';
import DatadogAddon from './datadog';
import Addon from './addon';
import { LogProvider } from '../logger';
import SlackAppAddon from './slack-app';
import { IFlagResolver } from '../types';
export interface IAddonProviders {
[key: string]: Addon;
@ -12,14 +14,28 @@ export interface IAddonProviders {
export const getAddons: (args: {
getLogger: LogProvider;
unleashUrl: string;
newFeatureLink?: boolean;
}) => IAddonProviders = ({ getLogger, unleashUrl }) => {
const addons = [
flagResolver: IFlagResolver;
}) => IAddonProviders = ({ getLogger, unleashUrl, flagResolver }) => {
const slackAppAddonEnabled = flagResolver.isEnabled('slackAppAddon');
const slackAddon = new SlackAddon({ getLogger, unleashUrl });
if (slackAppAddonEnabled) {
slackAddon.definition.deprecated =
'This addon is deprecated. Please try the new Slack App addon instead.';
}
const addons: Addon[] = [
new Webhook({ getLogger }),
new SlackAddon({ getLogger, unleashUrl }),
slackAddon,
new TeamsAddon({ getLogger, unleashUrl }),
new DatadogAddon({ getLogger, unleashUrl }),
];
if (slackAppAddonEnabled) {
addons.push(new SlackAppAddon({ getLogger, unleashUrl }));
}
return addons.reduce((map, addon) => {
// eslint-disable-next-line no-param-reassign
map[addon.name] = addon;

View File

@ -0,0 +1,7 @@
import joi from 'joi';
export const installationDefinitionSchema = joi.object().keys({
url: joi.string().uri({ scheme: [/https?/] }),
title: joi.string().optional(),
helpText: joi.string().optional(),
});

View File

@ -0,0 +1,77 @@
import {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_STALE_ON,
FEATURE_STALE_OFF,
FEATURE_ENVIRONMENT_ENABLED,
FEATURE_ENVIRONMENT_DISABLED,
FEATURE_STRATEGY_REMOVE,
FEATURE_STRATEGY_UPDATE,
FEATURE_STRATEGY_ADD,
FEATURE_METADATA_UPDATED,
FEATURE_PROJECT_CHANGE,
FEATURE_VARIANTS_UPDATED,
} from '../types/events';
import { IAddonDefinition } from '../types/model';
const slackAppDefinition: IAddonDefinition = {
name: 'slack-app',
displayName: 'Slack App',
description:
'The Unleash Slack App posts messages to your Slack workspace. You can decide which channels to post to by configuring your feature toggles with "slack" tags. For example, if you\'d like the bot to post messages to the #general channel, you should configure your feature toggle with the "slack:general" tag.',
documentationUrl: 'https://docs.getunleash.io/docs/addons/slack-app',
alerts: [
{
type: 'info',
text: `The Unleash Slack App bot has access to public channels by default. If you want the bot to post messages to private channels, you'll need to invite it to those channels.`,
},
{
type: 'warning',
text: `Please ensure you have the Unleash Slack App installed in your Slack workspace if you haven't installed it already.`,
},
],
installation: {
url: 'https://unleash-slack-app.vercel.app/install',
title: 'Slack App installation',
helpText:
'After installing the Unleash Slack app in your Slack workspace, paste the access token into the appropriate field below in order to configure this addon.',
},
parameters: [
{
name: 'accessToken',
displayName: 'Access token',
description: '(Required)',
type: 'text',
required: true,
sensitive: true,
},
],
events: [
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_STALE_ON,
FEATURE_STALE_OFF,
FEATURE_ENVIRONMENT_ENABLED,
FEATURE_ENVIRONMENT_DISABLED,
FEATURE_STRATEGY_REMOVE,
FEATURE_STRATEGY_UPDATE,
FEATURE_STRATEGY_ADD,
FEATURE_METADATA_UPDATED,
FEATURE_VARIANTS_UPDATED,
FEATURE_PROJECT_CHANGE,
],
tagTypes: [
{
name: 'slack',
description:
'Slack tag used by the slack-addon to specify the slack channel.',
icon: 'S',
},
],
};
export default slackAppDefinition;

View File

@ -0,0 +1,91 @@
import { WebClient } from '@slack/web-api';
import Addon from './addon';
import slackAppDefinition from './slack-app-definition';
import { IAddonConfig } from '../types/model';
import {
FeatureEventFormatter,
FeatureEventFormatterMd,
LinkStyle,
} from './feature-event-formatter-md';
import { IEvent } from '../types/events';
interface ISlackAppAddonParameters {
accessToken: string;
}
export default class SlackAppAddon extends Addon {
private msgFormatter: FeatureEventFormatter;
private slackClient?: WebClient;
constructor(args: IAddonConfig) {
super(slackAppDefinition, args);
this.msgFormatter = new FeatureEventFormatterMd(
args.unleashUrl,
LinkStyle.SLACK,
);
}
async handleEvent(
event: IEvent,
parameters: ISlackAppAddonParameters,
): Promise<void> {
const { accessToken } = parameters;
if (!accessToken) return;
if (!this.slackClient) {
this.slackClient = new WebClient(accessToken);
}
const slackChannels = await this.slackClient.conversations.list({
types: 'public_channel,private_channel',
});
const taggedChannels = this.findTaggedChannels(event);
if (slackChannels.channels?.length && taggedChannels.length) {
const slackChannelsToPostTo = slackChannels.channels.filter(
({ id, name }) => id && name && taggedChannels.includes(name),
);
const text = this.msgFormatter.format(event);
const featureLink = this.msgFormatter.featureLink(event);
const requests = slackChannelsToPostTo.map(({ id }) =>
this.slackClient!.chat.postMessage({
channel: id!,
text,
attachments: [
{
actions: [
{
name: 'featureToggle',
text: 'Open in Unleash',
type: 'button',
value: 'featureToggle',
style: 'primary',
url: featureLink,
},
],
},
],
}),
);
await Promise.all(requests);
this.logger.info(`Handled event ${event.type}.`);
}
}
findTaggedChannels({ tags }: Pick<IEvent, 'tags'>): string[] {
if (tags) {
return tags
.filter((tag) => tag.type === 'slack')
.map((t) => t.value);
}
return [];
}
}
module.exports = SlackAppAddon;

View File

@ -119,6 +119,66 @@ export const addonTypeSchema = {
'feature-project-change',
],
},
installation: {
type: 'object',
additionalProperties: false,
required: ['url'],
description: 'The installation configuration for this addon type.',
properties: {
url: {
type: 'string',
description:
'A URL to where the addon configuration should redirect to install addons of this type.',
example: 'https://unleash-slack-app.vercel.app/install',
},
title: {
type: 'string',
description:
'The title of the installation configuration. This will be displayed to the user when installing addons of this type.',
example: 'Slack App installation',
},
helpText: {
type: 'string',
description:
'The help text of the installation configuration. This will be displayed to the user when installing addons of this type.',
example:
'Clicking the Install button will send you to Slack to initiate the installation procedure for the Unleash Slack app for your workspace',
},
},
},
alerts: {
type: 'array',
description:
'A list of alerts to display to the user when installing addons of this type.',
items: {
type: 'object',
additionalProperties: false,
required: ['type', 'text'],
properties: {
type: {
type: 'string',
enum: ['success', 'info', 'warning', 'error'],
description:
'The type of alert. This determines the color of the alert.',
example: 'info',
},
text: {
type: 'string',
description:
'The text of the alert. This is what will be displayed to the user.',
example:
"Please ensure you have the Unleash Slack App installed in your Slack workspace if you haven't installed it already. If you want the Unleash Slack App bot to post messages to private channels, you'll need to invite it to those channels.",
},
},
},
},
deprecated: {
type: 'string',
description:
'This should be used to inform the user that this addon type is deprecated and should not be used. Deprecated addons will show a badge with this information on the UI.',
example:
'This addon is deprecated. Please try the new addon instead.',
},
},
components: {
schemas: {

View File

@ -49,7 +49,11 @@ export default class AddonService {
IUnleashStores,
'addonStore' | 'eventStore' | 'featureToggleStore'
>,
{ getLogger, server }: Pick<IUnleashConfig, 'getLogger' | 'server'>,
{
getLogger,
server,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'server' | 'flagResolver'>,
tagTypeService: TagTypeService,
addons?: IAddonProviders,
) {
@ -64,6 +68,7 @@ export default class AddonService {
getAddons({
getLogger,
unleashUrl: server.unleashUrl,
flagResolver,
});
this.sensitiveParams = this.loadSensitiveParams(this.addonProviders);
if (addonStore) {

View File

@ -249,9 +249,23 @@ export interface IAddonDefinition {
displayName: string;
documentationUrl: string;
description: string;
deprecated?: string;
parameters?: IAddonParameterDefinition[];
events?: string[];
tagTypes?: ITagType[];
installation?: IAddonInstallation;
alerts?: IAddonAlert[];
}
export interface IAddonInstallation {
url: string;
title?: string;
helpText?: string;
}
export interface IAddonAlert {
type: 'success' | 'info' | 'warning' | 'error';
text: string;
}
export interface IAddonConfig {

View File

@ -43,6 +43,7 @@ process.nextTick(async () => {
strategyVariant: true,
newProjectLayout: true,
emitPotentiallyStaleEvents: true,
slackAppAddon: true,
},
},
authentication: {

View File

@ -13,6 +13,7 @@ beforeAll(async () => {
experimental: {
flags: {
strictSchemaValidation: true,
slackAppAddon: true,
},
},
});
@ -32,7 +33,7 @@ test('gets all addons', async () => {
.expect(200)
.expect((res) => {
expect(res.body.addons.length).toBe(0);
expect(res.body.providers.length).toBe(4);
expect(res.body.providers.length).toBe(5);
expect(res.body.providers[0].name).toBe('webhook');
});
});

108
yarn.lock
View File

@ -1041,6 +1041,35 @@
dependencies:
"@sinonjs/commons" "^2.0.0"
"@slack/logger@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@slack/logger/-/logger-3.0.0.tgz#b736d4e1c112c22a10ffab0c2d364620aedcb714"
integrity sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==
dependencies:
"@types/node" ">=12.0.0"
"@slack/types@^2.0.0":
version "2.8.0"
resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.8.0.tgz#11ea10872262a7e6f86f54e5bcd4f91e3a41fe91"
integrity sha512-ghdfZSF0b4NC9ckBA8QnQgC9DJw2ZceDq0BIjjRSv6XAZBXJdWgxIsYz0TYnWSiqsKZGH2ZXbj9jYABZdH3OSQ==
"@slack/web-api@^6.8.1":
version "6.8.1"
resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-6.8.1.tgz#c6c1e7405c884c4d9048f8b1d3901bd138d00610"
integrity sha512-eMPk2S99S613gcu7odSw/LV+Qxr8A+RXvBD0GYW510wJuTERiTjP5TgCsH8X09+lxSumbDE88wvWbuFuvGa74g==
dependencies:
"@slack/logger" "^3.0.0"
"@slack/types" "^2.0.0"
"@types/is-stream" "^1.1.0"
"@types/node" ">=12.0.0"
axios "^0.27.2"
eventemitter3 "^3.1.0"
form-data "^2.5.0"
is-electron "2.2.0"
is-stream "^1.1.0"
p-queue "^6.6.1"
p-retry "^4.0.0"
"@swc/core-darwin-arm64@1.3.67":
version "1.3.67"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.67.tgz#8076dcd75862b92a7987a8da5a24986ab559d793"
@ -1267,6 +1296,13 @@
resolved "https://registry.yarnpkg.com/@types/hash-sum/-/hash-sum-1.0.0.tgz#838f4e8627887d42b162d05f3d96ca636c2bc504"
integrity sha512-FdLBT93h3kcZ586Aee66HPCVJ6qvxVjBlDWNmxSGSbCZe9hTsjRKdSsl4y1T+3zfujxo9auykQMnFsfyHWD7wg==
"@types/is-stream@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1"
integrity sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==
dependencies:
"@types/node" "*"
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
@ -1363,6 +1399,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.37.tgz#a1f8728e4dc30163deb41e9b7aba65d0c2d4eda1"
integrity sha512-ql+4dw4PlPFBP495k8JzUX/oMNRI2Ei4PrMHgj8oT4VhGlYUzF4EYr0qk2fW+XBVGIrq8Zzk13m4cvyXZuv4pA==
"@types/node@>=12.0.0":
version "20.4.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.1.tgz#a6033a8718653c50ac4962977e14d0f984d9527d"
integrity sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==
"@types/nodemailer@6.4.8":
version "6.4.8"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.8.tgz#f06c661e9b201fc2acc3a00a0fded42ba7eaca9d"
@ -1409,6 +1450,11 @@
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a"
integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==
"@types/retry@0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
"@types/semver@7.5.0":
version "7.5.0"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.0.tgz#591c1ce3a702c45ee15f47a42ade72c2fd78978a"
@ -3174,6 +3220,16 @@ event-stream@=3.3.4:
stream-combiner "~0.0.4"
through "~2.3.1"
eventemitter3@^3.1.0:
version "3.1.2"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
eventemitter3@^4.0.4:
version "4.0.7"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
execa@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
@ -3504,6 +3560,15 @@ forever-agent@~0.6.1:
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==
form-data@^2.5.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.6"
mime-types "^2.1.12"
form-data@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f"
@ -4110,6 +4175,11 @@ is-date-object@^1.0.1:
dependencies:
has-tostringtag "^1.0.0"
is-electron@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.0.tgz#8943084f09e8b731b3a7a0298a7b5d56f6b7eef0"
integrity sha512-SpMppC2XR3YdxSzczXReBjqs2zGscWQpBIKqwXYBFic0ERaxNVgwLCHwOLZeESfdJQjX0RDvrJ1lBXX2ij+G1Q==
is-extendable@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
@ -4221,6 +4291,11 @@ is-shared-array-buffer@^1.0.2:
dependencies:
call-bind "^1.0.2"
is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==
is-stream@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
@ -5724,6 +5799,11 @@ owasp-password-strength-test@^1.3.0:
resolved "https://registry.yarnpkg.com/owasp-password-strength-test/-/owasp-password-strength-test-1.3.0.tgz#4f629e42903e8f6d279b230d657ab61e58e44b12"
integrity sha512-33/Z+vyjlFaVZsT7aAFe3SkQZdU6su59XNkYdU5o2Fssz0D9dt6uiFaMm62M7dFQSKogULq8UYvdKnHkeqNB2w==
p-finally@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
@ -5766,6 +5846,29 @@ p-map@^5.5.0:
dependencies:
aggregate-error "^4.0.0"
p-queue@^6.6.1:
version "6.6.2"
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
dependencies:
eventemitter3 "^4.0.4"
p-timeout "^3.2.0"
p-retry@^4.0.0:
version "4.6.2"
resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16"
integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==
dependencies:
"@types/retry" "0.12.0"
retry "^0.13.1"
p-timeout@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==
dependencies:
p-finally "^1.0.0"
p-try@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
@ -6425,6 +6528,11 @@ retry@^0.12.0:
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
retry@^0.13.1:
version "0.13.1"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658"
integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==
reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"