mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
console: Gt 652 email and slack alerts for schema registry
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9764 GitOrigin-RevId: 4b42eb2066c5903b114ddcee3dadaa7ca7afc244
This commit is contained in:
parent
7120ae7a44
commit
7a3b3aa59b
@ -88,6 +88,8 @@ type CloudServerEnv = {
|
||||
userRole: CloudUserRole;
|
||||
neonOAuthClientId?: string;
|
||||
neonRootDomain?: string;
|
||||
slackOAuthClientId?: string;
|
||||
slackRootDomain?: string;
|
||||
allowedLuxFeatures?: LuxFeature[];
|
||||
userId?: string;
|
||||
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
|
||||
@ -160,6 +162,8 @@ export type EnvVars = {
|
||||
userRole?: string;
|
||||
neonOAuthClientId?: string;
|
||||
neonRootDomain?: string;
|
||||
slackOAuthClientId?: string;
|
||||
slackRootDomain?: string;
|
||||
allowedLuxFeatures?: LuxFeature[];
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
@ -216,6 +220,8 @@ const globals = {
|
||||
hasuraCloudProjectName: window.__env?.projectName,
|
||||
neonOAuthClientId: window.__env?.neonOAuthClientId,
|
||||
neonRootDomain: window.__env?.neonRootDomain,
|
||||
slackOAuthClientId: window.__env?.slackOAuthClientId,
|
||||
slackRootDomain: window.__env?.slackRootDomain,
|
||||
allowedLuxFeatures: window.__env?.allowedLuxFeatures || [],
|
||||
luxDataHost: window.__env?.luxDataHost
|
||||
? stripTrailingSlash(window.__env.luxDataHost)
|
||||
|
@ -1,109 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Tabs } from '../../../new-components/Tabs';
|
||||
import { EmailAlerts } from './EmailAlerts';
|
||||
import { SlackAlerts } from './SlackAlerts';
|
||||
import { Dialog } from '../../../new-components/Dialog';
|
||||
import { Checkbox } from '../../../new-components/Form';
|
||||
import { FaClock, FaBan, FaBell } from 'react-icons/fa';
|
||||
import { useGetAlertConfig } from '../hooks/useGetAlertConfig';
|
||||
import { CapitalizeFirstLetter } from '../utils';
|
||||
import globals from '../../../Globals';
|
||||
import { AlertHeader } from './AlertsHeader';
|
||||
import { CustomDialogFooter } from './CustomDialogFooter';
|
||||
import { ConfigKey } from '../types';
|
||||
|
||||
type DialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const configKeys: ConfigKey[] = ['breaking', 'dangerous', 'safe'];
|
||||
|
||||
const defaultAlertConfig: Record<string, boolean> = {
|
||||
safe: true,
|
||||
breaking: true,
|
||||
dangerous: true,
|
||||
};
|
||||
|
||||
export const AlertsDialog: React.FC<DialogProps> = ({ onClose }) => {
|
||||
const projectID = globals.hasuraCloudProjectId || '';
|
||||
const fetchAlertConfigResponse = useGetAlertConfig(projectID);
|
||||
|
||||
const [config, setConfig] =
|
||||
React.useState<Record<ConfigKey, boolean>>(defaultAlertConfig);
|
||||
|
||||
const { kind } = fetchAlertConfigResponse;
|
||||
const alertConfig =
|
||||
kind === 'success' &&
|
||||
fetchAlertConfigResponse.response.schema_registry_alerts.length
|
||||
? fetchAlertConfigResponse.response.schema_registry_alerts[0].config
|
||||
: defaultAlertConfig;
|
||||
|
||||
// initialise checkboxes
|
||||
React.useEffect(() => {
|
||||
if (kind === 'success') {
|
||||
setConfig(alertConfig);
|
||||
}
|
||||
}, [alertConfig]);
|
||||
const [tabState, setTabState] = React.useState('email');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
hasBackdrop
|
||||
onClose={kind === 'error' ? onClose : undefined}
|
||||
footer={
|
||||
kind === 'success' ? (
|
||||
<CustomDialogFooter onClose={onClose} alertConfig={config} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{kind === 'loading' ? (
|
||||
<div className="flex items-top p-md">
|
||||
<AlertHeader
|
||||
icon={<FaClock className="w-9 h-9 mt-sm mr-md fill-current" />}
|
||||
title="Loading..."
|
||||
/>
|
||||
</div>
|
||||
) : kind === 'error' ? (
|
||||
<div className="flex items-top p-md">
|
||||
<AlertHeader
|
||||
icon={<FaBan className="w-9 h-9 mt-sm mr-md fill-red-500" />}
|
||||
title="Error"
|
||||
description={fetchAlertConfigResponse.message}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-top p-md">
|
||||
<div className="text-yellow-500">
|
||||
<FaBell className="w-9 h-9 mt-sm mr-md fill-current" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Email Alerts</p>
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-14rem)]">
|
||||
<p className="m-0">
|
||||
Select the change categories for which an email should be
|
||||
sent!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-8">
|
||||
{configKeys.map(c => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center mb-xs cursor-pointer w-auto"
|
||||
role="checkbox"
|
||||
onClick={() => {
|
||||
setConfig(prevConfig => ({
|
||||
...prevConfig,
|
||||
[c]: !prevConfig[c],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={config[c]} />
|
||||
<p>{CapitalizeFirstLetter(c)} changes</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Dialog hasBackdrop onClose={tabState === 'slack' ? onClose : undefined}>
|
||||
<div className="h-full ml-4">
|
||||
<Tabs
|
||||
value={tabState}
|
||||
onValueChange={state => setTabState(state)}
|
||||
headerTabBackgroundColor="white"
|
||||
items={[
|
||||
{
|
||||
value: 'email',
|
||||
label: 'Email',
|
||||
content: <EmailAlerts onClose={onClose} />,
|
||||
},
|
||||
{
|
||||
value: 'slack',
|
||||
label: 'Slack',
|
||||
content: <SlackAlerts onClose={onClose} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
@ -12,16 +12,16 @@ export const AlertHeader: React.FC<AlertHeaderProps> = ({
|
||||
description,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-top p-md">
|
||||
<div className="text-yellow-500">{icon}</div>
|
||||
<div>
|
||||
<p className="font-semibold">{title}</p>
|
||||
<p className="text-lg font-semibold">{title}</p>
|
||||
{description && (
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-14rem)]">
|
||||
<p className="m-0">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,26 +1,18 @@
|
||||
import React from 'react';
|
||||
import globals from '../../../Globals';
|
||||
import { Button } from '../../../new-components/Button';
|
||||
import { useSetEmailAlertConfig } from '../hooks/useSetAlertConfig';
|
||||
import { ConfigKey } from '../types';
|
||||
import { Analytics } from '../../Analytics';
|
||||
|
||||
type CustomDialogFooterProps = {
|
||||
onSet: () => void;
|
||||
onClose: () => void;
|
||||
alertConfig: Record<ConfigKey, boolean>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const CustomDialogFooter: React.FC<CustomDialogFooterProps> = ({
|
||||
onSet,
|
||||
onClose,
|
||||
alertConfig,
|
||||
isLoading,
|
||||
}) => {
|
||||
const projectID = globals.hasuraCloudProjectId || '';
|
||||
const { setEmailAlertMutation } = useSetEmailAlertConfig(onClose);
|
||||
|
||||
const onSet = React.useCallback(() => {
|
||||
setEmailAlertMutation.mutate({ projectId: projectID, config: alertConfig });
|
||||
}, [alertConfig]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between border-t border-gray-300 bg-white p-sm">
|
||||
<div>
|
||||
@ -38,8 +30,8 @@ export const CustomDialogFooter: React.FC<CustomDialogFooterProps> = ({
|
||||
e.preventDefault();
|
||||
onSet();
|
||||
}}
|
||||
isLoading={setEmailAlertMutation.isLoading}
|
||||
disabled={setEmailAlertMutation.isLoading}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
|
@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from '../../../new-components/Form';
|
||||
import { FaClock, FaBan, FaBell } from 'react-icons/fa';
|
||||
import { useGetAlertConfig } from '../hooks/useGetAlertConfig';
|
||||
import { CapitalizeFirstLetter } from '../utils';
|
||||
import globals from '../../../Globals';
|
||||
import { AlertHeader } from './AlertsHeader';
|
||||
import { useSetEmailAlertConfig } from '../hooks/useSetAlertConfig';
|
||||
import { CustomDialogFooter } from './CustomDialogFooter';
|
||||
import { ConfigKey } from '../types';
|
||||
|
||||
type EmailAlertsProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const configKeys: ConfigKey[] = ['breaking', 'dangerous', 'safe'];
|
||||
|
||||
const defaultAlertConfig: Record<string, boolean> = {
|
||||
safe: false,
|
||||
breaking: false,
|
||||
dangerous: false,
|
||||
};
|
||||
|
||||
export const EmailAlerts: React.FC<EmailAlertsProps> = ({ onClose }) => {
|
||||
const projectID = globals.hasuraCloudProjectId || '';
|
||||
const fetchAlertConfigResponse = useGetAlertConfig(projectID, 'mail');
|
||||
|
||||
const [config, setConfig] =
|
||||
React.useState<Record<ConfigKey, boolean>>(defaultAlertConfig);
|
||||
|
||||
const { kind } = fetchAlertConfigResponse;
|
||||
const alertConfig =
|
||||
kind === 'success' &&
|
||||
fetchAlertConfigResponse.response.alert_config_service.length
|
||||
? fetchAlertConfigResponse.response.alert_config_service[0].rules
|
||||
: defaultAlertConfig;
|
||||
|
||||
const { setEmailAlertMutation } = useSetEmailAlertConfig(onClose);
|
||||
|
||||
const onSet = React.useCallback(() => {
|
||||
setEmailAlertMutation.mutate({ projectId: projectID, rules: config });
|
||||
}, [config]);
|
||||
|
||||
// initialise checkboxes
|
||||
React.useEffect(() => {
|
||||
if (kind === 'success') {
|
||||
setConfig(alertConfig);
|
||||
}
|
||||
}, [alertConfig]);
|
||||
|
||||
return (
|
||||
<div className="ml-[-14px]">
|
||||
{kind === 'loading' ? (
|
||||
<AlertHeader
|
||||
icon={<FaClock className="w-9 h-9 mt-sm mr-md fill-current" />}
|
||||
title="Loading..."
|
||||
/>
|
||||
) : kind === 'error' ? (
|
||||
<AlertHeader
|
||||
icon={<FaBan className="w-9 h-9 mt-sm mr-md fill-red-500" />}
|
||||
title="Error"
|
||||
description={fetchAlertConfigResponse.message}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AlertHeader
|
||||
icon={<FaBell className="w-9 h-9 mt-sm mr-md fill-current" />}
|
||||
title="Email Alerts"
|
||||
description="Select the change categories for which an email should be
|
||||
sent!"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col ml-8">
|
||||
{configKeys.map(c => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center mb-xs cursor-pointer w-auto"
|
||||
role="checkbox"
|
||||
onClick={() => {
|
||||
setConfig(prevConfig => ({
|
||||
...prevConfig,
|
||||
[c]: !prevConfig[c],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={config[c]} />
|
||||
<p>{CapitalizeFirstLetter(c)} changes</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CustomDialogFooter
|
||||
onSet={onSet}
|
||||
onClose={onClose}
|
||||
isLoading={setEmailAlertMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,252 @@
|
||||
import React from 'react';
|
||||
import { Button } from './../../../new-components/Button';
|
||||
import { AlertHeader } from './AlertsHeader';
|
||||
import { FaBell, FaTimes } from 'react-icons/fa';
|
||||
import Tooltip from '../../../components/Common/Tooltip/Tooltip';
|
||||
import {
|
||||
useSlackIntegration,
|
||||
SlackOauthStatus,
|
||||
} from '../hooks/useSlackIntegration';
|
||||
import globals from '../../../Globals';
|
||||
import { useGetSlackState } from '../hooks/useGetSlackState';
|
||||
import { SlackButtonSVG } from './SlackButtonSvg';
|
||||
import { FaExclamationTriangle, FaCheck, FaTrash } from 'react-icons/fa';
|
||||
import { SlackDeleteConfirmationDialog } from './SlackDeleteConfirmationDialog';
|
||||
import { Analytics } from '../../Analytics';
|
||||
|
||||
type TileProp = {
|
||||
statusIcon: React.ReactNode;
|
||||
actionName: string;
|
||||
actionIcon: React.ReactNode;
|
||||
message: React.ReactNode;
|
||||
};
|
||||
|
||||
const getSlackIntegrationStatusTileProps = (
|
||||
slackStatus: SlackOauthStatus,
|
||||
onAction: () => void
|
||||
): TileProp | null => {
|
||||
switch (slackStatus.status) {
|
||||
case 'error': {
|
||||
return {
|
||||
statusIcon: (
|
||||
<FaExclamationTriangle className="fill-current h-4 w-4 mr-8 shrink-0 text-gray-500" />
|
||||
),
|
||||
actionName: 'dismiss-slack-integration-error',
|
||||
actionIcon: (
|
||||
<Tooltip message="Dismiss">
|
||||
<FaTimes
|
||||
className="fill-current cursor-pointer text-muted hover:text-gray-800 ml-2"
|
||||
onClick={onAction}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
message: <p>{slackStatus.error.message}</p>,
|
||||
};
|
||||
}
|
||||
case 'authenticated': {
|
||||
return {
|
||||
statusIcon: (
|
||||
<FaCheck className="fill-current mt-[3px] h-4 w-4 mr-8 shrink-0 text-emerald-500" />
|
||||
),
|
||||
actionName: 'delete-slack-integration',
|
||||
actionIcon: (
|
||||
<Tooltip message="Remove Slack Integration">
|
||||
<FaTrash
|
||||
className="fill-current cursor-pointer text-muted hover:text-gray-800 ml-2"
|
||||
onClick={onAction}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
message: (
|
||||
<div>
|
||||
Slack Integration for alerts on this project has been configured for
|
||||
the channel <b>{slackStatus.channelName}</b> for{' '}
|
||||
<b>{slackStatus.teamName}</b> workspace.
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type SlackIntegrationStatusTileProps = {
|
||||
status: SlackOauthStatus;
|
||||
onAction: () => void;
|
||||
};
|
||||
|
||||
export const SlackIntegrationStatusTile: React.FC<
|
||||
SlackIntegrationStatusTileProps
|
||||
> = props => {
|
||||
const { status, onAction } = props;
|
||||
|
||||
const slackIntegrationTileProps = getSlackIntegrationStatusTileProps(
|
||||
status,
|
||||
onAction
|
||||
);
|
||||
|
||||
if (!slackIntegrationTileProps) return null;
|
||||
|
||||
const { statusIcon, actionName, actionIcon, message } =
|
||||
slackIntegrationTileProps;
|
||||
return (
|
||||
<div className="flex justify-between border rounded mx-6 p-2 mb-4 text-gray-500">
|
||||
<div className="flex justify-start items-center">
|
||||
{statusIcon}
|
||||
{message}
|
||||
</div>
|
||||
<Analytics name={`settings-schema-registry-slack-alerts-${actionName}`}>
|
||||
<div className="flex">{actionIcon}</div>
|
||||
</Analytics>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SlackIntegrationStateProps = {
|
||||
slackIntegrationStatus: SlackOauthStatus;
|
||||
onIntegrationSlack: () => void;
|
||||
onDelete: () => void;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export const SlackIntegrationState = (props: SlackIntegrationStateProps) => {
|
||||
const { slackIntegrationStatus, onIntegrationSlack, onDelete, onDismiss } =
|
||||
props;
|
||||
const isButtonDisabled = slackIntegrationStatus.status === 'authenticating';
|
||||
|
||||
switch (slackIntegrationStatus.status) {
|
||||
case 'idle':
|
||||
case 'authenticating':
|
||||
return (
|
||||
<div className="flex justify-center mb-4">
|
||||
<Analytics name="settings-schema-registry-add-to-slack-btn">
|
||||
<Button
|
||||
onClick={onIntegrationSlack}
|
||||
mode="default"
|
||||
data-testid="onboarding-wizard-neon-connect-db-button"
|
||||
isLoading={isButtonDisabled}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<SlackButtonSVG />
|
||||
<p className="ml-2">Add to Slack</p>
|
||||
</div>
|
||||
</Button>
|
||||
</Analytics>
|
||||
</div>
|
||||
);
|
||||
case 'authenticated': {
|
||||
return (
|
||||
<SlackIntegrationStatusTile
|
||||
status={slackIntegrationStatus}
|
||||
onAction={onDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'error':
|
||||
return (
|
||||
<SlackIntegrationStatusTile
|
||||
status={slackIntegrationStatus}
|
||||
onAction={onDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type SlackAlertsProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const SlackAlerts: React.FC<SlackAlertsProps> = ({ onClose }) => {
|
||||
const projectID = globals.hasuraCloudProjectId || '';
|
||||
|
||||
const slackState = useGetSlackState(projectID);
|
||||
|
||||
const doesSlackIntegrationExist =
|
||||
slackState.kind === 'success' && slackState.response.slack_config.length;
|
||||
|
||||
const slackChannel = doesSlackIntegrationExist
|
||||
? slackState.response.slack_config[0].channel_name
|
||||
: '';
|
||||
const slackTeam = doesSlackIntegrationExist
|
||||
? slackState.response.slack_config[0].team_name
|
||||
: '';
|
||||
|
||||
const [
|
||||
isSlackDeleteConfirmationModalOpen,
|
||||
setIsSlackDeleteConfirmationModalOpen,
|
||||
] = React.useState(false);
|
||||
|
||||
const onCloseDeleteConfirmation = () => {
|
||||
setIsSlackDeleteConfirmationModalOpen(false);
|
||||
};
|
||||
|
||||
const { slackOauthStatus, startSlackOAuth, deleteSlackApp } =
|
||||
useSlackIntegration(onCloseDeleteConfirmation);
|
||||
|
||||
const [slackIntegrationStatus, setSlackIntegrationStatus] =
|
||||
React.useState<SlackOauthStatus>(slackOauthStatus);
|
||||
|
||||
const onIntegrateSlack = () => {
|
||||
startSlackOAuth();
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
setIsSlackDeleteConfirmationModalOpen(true);
|
||||
};
|
||||
|
||||
const onDeleteSlack = React.useCallback(() => {
|
||||
deleteSlackApp.mutate({ projectID: projectID });
|
||||
}, [projectID, deleteSlackApp]);
|
||||
|
||||
const onDismiss = React.useCallback(() => {
|
||||
if (slackIntegrationStatus.status === 'error') {
|
||||
setSlackIntegrationStatus({ status: 'idle' });
|
||||
}
|
||||
}, [slackIntegrationStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSlackIntegrationStatus(slackOauthStatus);
|
||||
if (
|
||||
!doesSlackIntegrationExist &&
|
||||
slackOauthStatus.status === 'authenticated'
|
||||
)
|
||||
setSlackIntegrationStatus({ status: 'idle' });
|
||||
}, [slackOauthStatus, doesSlackIntegrationExist]);
|
||||
|
||||
return (
|
||||
<div className="ml-[-14px]">
|
||||
<AlertHeader
|
||||
icon={<FaBell className="w-9 h-9 mr-md mt-xs fill-current" />}
|
||||
title="Slack Alerts"
|
||||
description="Integrate with a Slack channel to enable alerts."
|
||||
/>
|
||||
|
||||
{doesSlackIntegrationExist ? (
|
||||
<SlackIntegrationStatusTile
|
||||
status={{
|
||||
status: 'authenticated',
|
||||
channelName: slackChannel,
|
||||
teamName: slackTeam,
|
||||
}}
|
||||
onAction={onDelete}
|
||||
/>
|
||||
) : (
|
||||
<SlackIntegrationState
|
||||
slackIntegrationStatus={slackIntegrationStatus}
|
||||
onIntegrationSlack={onIntegrateSlack}
|
||||
onDelete={onDelete}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
)}
|
||||
{isSlackDeleteConfirmationModalOpen && (
|
||||
<SlackDeleteConfirmationDialog
|
||||
onClose={() => setIsSlackDeleteConfirmationModalOpen(false)}
|
||||
onSubmit={onDeleteSlack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SlackButtonSVG: React.FC = () => {
|
||||
return (
|
||||
<svg
|
||||
height="16"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="height:16px;width:16px;margin-right:12px"
|
||||
viewBox="0 0 122.8 122.8"
|
||||
>
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#e01e5a"
|
||||
></path>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36c5f0"
|
||||
></path>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2eb67d"
|
||||
></path>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ecb22e"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Dialog } from '../../../new-components/Dialog';
|
||||
|
||||
type SlackDeleteConfirmationDialogProps = {
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const SlackDeleteConfirmationDialog: React.FC<
|
||||
SlackDeleteConfirmationDialogProps
|
||||
> = ({ onClose, onSubmit }) => {
|
||||
return (
|
||||
<Dialog hasBackdrop>
|
||||
<>
|
||||
<p className="font-bold text-lg ml-4 my-4">
|
||||
Are you sure you want to disable Slack alerts?
|
||||
</p>
|
||||
<Dialog.Footer
|
||||
callToDeny="Cancel"
|
||||
callToAction="Yes"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onSubmitAnalyticsName="delete-schema-registry-slack-alerts-submit"
|
||||
onCancelAnalyticsName="delete-schema-registry-slack-alerts-cancel"
|
||||
/>
|
||||
</>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { persistSlackCallbackSearch } from '../utils';
|
||||
|
||||
/*
|
||||
* This component is only used for local development
|
||||
* It's used for listening to Slack's OAuth callback (/slack-integration/callback) in localdev.
|
||||
* In production, Slack's OAuth callback is handled by cloud dashboard
|
||||
*/
|
||||
export function SlackCallbackHandler() {
|
||||
React.useEffect(() => {
|
||||
// set the value for slack callback search in local storage
|
||||
persistSlackCallbackSearch(window.location.search);
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
return <>Please wait...</>;
|
||||
}
|
@ -3,6 +3,7 @@ export const FETCH_REGISTRY_SCHEMAS_QUERY_NAME =
|
||||
export const FETCH_REGISTRY_SCHEMA_QUERY_NAME =
|
||||
'FETCH_REGISTRY_SCHEMA_QUERY_NAME';
|
||||
export const FETCH_ALERT_CONFIG_QUERY_NAME = 'FETCH_ALERT_CONFIG_QUERY_NAME';
|
||||
export const FETCH_SLACK_STATE_QUERY_NAME = 'FETCH_SLACK_STATE_QUERY_NAME';
|
||||
|
||||
// 5 minutes as default stale time
|
||||
export const SCHEMA_REGISTRY_REFRESH_TIME = 5 * 60 * 1000;
|
||||
|
@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { DELETE_SLACK_APP } from '../queries';
|
||||
import { DeleteSlackAppMutationResponseWithError } from '../types';
|
||||
import { FETCH_SLACK_STATE_QUERY_NAME } from '../constants';
|
||||
import { hasuraToast } from '../../../new-components/Toasts';
|
||||
import { controlPlaneClient } from '../../ControlPlane';
|
||||
|
||||
type DeleteSlackAppMutationFnArgs = {
|
||||
projectID: string;
|
||||
};
|
||||
|
||||
export const useDeleteSlackApp = (onClose: () => void) => {
|
||||
const deleteSlackAppMutationFn = (variables: { projectID: string }) => {
|
||||
return controlPlaneClient.query<
|
||||
DeleteSlackAppMutationResponseWithError,
|
||||
{ projectID: string }
|
||||
>(DELETE_SLACK_APP, variables);
|
||||
};
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteSlackApp = useMutation(
|
||||
(args: DeleteSlackAppMutationFnArgs) => deleteSlackAppMutationFn(args),
|
||||
{
|
||||
onSuccess: response => {
|
||||
if (response.errors && response.errors.length > 0) {
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message:
|
||||
'Something unexpected happened while deleting Slack Alerts!',
|
||||
});
|
||||
} else {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Slack Integration deleted successfully',
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
queryClient.invalidateQueries(FETCH_SLACK_STATE_QUERY_NAME);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message:
|
||||
'Something went wrong while deleting the tag for Schema Registry',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
deleteSlackApp,
|
||||
};
|
||||
};
|
@ -1,9 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { FETCH_ALERT_CONFIG } from '../queries';
|
||||
import { schemaRegsitryControlPlaneClient } from '../utils';
|
||||
import { GetAlertConfigResponseWithError } from '../types';
|
||||
import { GetAlertConfigResponseWithError, AlertType } from '../types';
|
||||
import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants';
|
||||
import { controlPlaneClient } from '../../ControlPlane';
|
||||
|
||||
type FetchAlertResponse =
|
||||
| {
|
||||
@ -18,13 +18,17 @@ type FetchAlertResponse =
|
||||
response: NonNullable<GetAlertConfigResponseWithError['data']>;
|
||||
};
|
||||
|
||||
export const useGetAlertConfig = (projectId: string): FetchAlertResponse => {
|
||||
export const useGetAlertConfig = (
|
||||
projectId: string,
|
||||
type: AlertType
|
||||
): FetchAlertResponse => {
|
||||
const fetchAlertConfigQueryFn = (projectId: string) => {
|
||||
return schemaRegsitryControlPlaneClient.query<
|
||||
return controlPlaneClient.query<
|
||||
GetAlertConfigResponseWithError,
|
||||
{ projectId: string }
|
||||
{ projectId: string; type: AlertType }
|
||||
>(FETCH_ALERT_CONFIG, {
|
||||
projectId: projectId,
|
||||
type: type,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { FETCH_SLACK_STATE } from '../queries';
|
||||
import { GetSlackStateResponseWithError } from '../types';
|
||||
import { FETCH_SLACK_STATE_QUERY_NAME } from '../constants';
|
||||
import { controlPlaneClient } from '../../ControlPlane';
|
||||
|
||||
type FetchSlackStateResponse =
|
||||
| {
|
||||
kind: 'loading';
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: 'success';
|
||||
response: NonNullable<GetSlackStateResponseWithError['data']>;
|
||||
};
|
||||
|
||||
export const useGetSlackState = (
|
||||
projectId: string
|
||||
): FetchSlackStateResponse => {
|
||||
const fetchSlackStateQueryFn = (projectId: string) => {
|
||||
return controlPlaneClient.query<
|
||||
GetSlackStateResponseWithError,
|
||||
{ projectId: string }
|
||||
>(FETCH_SLACK_STATE, {
|
||||
projectId: projectId,
|
||||
});
|
||||
};
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: FETCH_SLACK_STATE_QUERY_NAME,
|
||||
queryFn: () => fetchSlackStateQueryFn(projectId),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return {
|
||||
kind: 'loading',
|
||||
};
|
||||
}
|
||||
|
||||
if (error || !data || !!data.errors || !data.data) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'success',
|
||||
response: data.data,
|
||||
};
|
||||
};
|
@ -1,25 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { SET_ALERT_CONFIG } from '../queries';
|
||||
import { schemaRegsitryControlPlaneClient } from '../utils';
|
||||
import { SetAlertConfigResponseWithError } from '../types';
|
||||
import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants';
|
||||
import { reactQueryClient } from '../../../lib/reactQuery';
|
||||
import { hasuraToast } from '../../../new-components/Toasts';
|
||||
import { controlPlaneClient } from '../../ControlPlane';
|
||||
|
||||
type setEmailAlertMutationFnArgs = {
|
||||
projectId: string;
|
||||
config: Record<string, boolean>;
|
||||
rules: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export const useSetEmailAlertConfig = (onSuccess: VoidFunction) => {
|
||||
const setSchemaRegistryEmailAlertMutationFn = (variables: {
|
||||
projectId: string;
|
||||
config: Record<string, boolean>;
|
||||
rules: Record<string, boolean>;
|
||||
}) => {
|
||||
return schemaRegsitryControlPlaneClient.query<
|
||||
return controlPlaneClient.query<
|
||||
SetAlertConfigResponseWithError,
|
||||
{ projectId: string; config: Record<string, boolean> }
|
||||
{ projectId: string; rules: Record<string, boolean> }
|
||||
>(SET_ALERT_CONFIG, variables);
|
||||
};
|
||||
|
||||
@ -27,14 +27,22 @@ export const useSetEmailAlertConfig = (onSuccess: VoidFunction) => {
|
||||
(args: setEmailAlertMutationFnArgs) =>
|
||||
setSchemaRegistryEmailAlertMutationFn(args),
|
||||
{
|
||||
onSuccess: () => {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Email configuration set successfully!',
|
||||
});
|
||||
onSuccess: data => {
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message: 'Something unexpected happened!',
|
||||
});
|
||||
} else {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Email configuration set successfully!',
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
if (data.data) onSuccess();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
hasuraToast({
|
||||
|
@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { useDeleteSlackApp } from './useDeleteSlackApp';
|
||||
import { useSlackOAuth } from './useSlackOAuth';
|
||||
|
||||
export { SlackOauthStatus } from './useSlackOAuth';
|
||||
|
||||
export const useSlackIntegration = (
|
||||
onDeleteClose: () => void,
|
||||
oauthString?: string
|
||||
) => {
|
||||
const { slackOauthStatus, startSlackOAuth } = useSlackOAuth(oauthString);
|
||||
const { deleteSlackApp } = useDeleteSlackApp(onDeleteClose);
|
||||
|
||||
return { slackOauthStatus, startSlackOAuth, deleteSlackApp };
|
||||
};
|
@ -0,0 +1,316 @@
|
||||
import * as React from 'react';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import type { GraphQLError } from 'graphql';
|
||||
import { ExchangeTokenResponse, useSlackOAuth } from './useSlackOAuth';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
import { SLACK_CALLBACK_SEARCH } from '../utils';
|
||||
|
||||
function TestComponent() {
|
||||
const { startSlackOAuth, slackOauthStatus, oauth2State } = useSlackOAuth();
|
||||
return (
|
||||
<div>
|
||||
<p data-testid="status">{slackOauthStatus.status}</p>
|
||||
<p data-testid="error">
|
||||
{slackOauthStatus.status === 'error'
|
||||
? slackOauthStatus.error.message
|
||||
: ''}
|
||||
</p>
|
||||
<p data-testid="channelName">
|
||||
{slackOauthStatus.status === 'authenticated'
|
||||
? slackOauthStatus.channelName
|
||||
: ''}
|
||||
</p>
|
||||
<p data-testid="oauth2-state">{oauth2State}</p>
|
||||
<button data-testid="start" onClick={startSlackOAuth}>
|
||||
start
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// LOCALSTORAGE MOCK
|
||||
// --------------------------------------------------
|
||||
const getMockLocalStorage = () => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem(key: string): string | undefined {
|
||||
return store[key];
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
clear() {
|
||||
store = {};
|
||||
},
|
||||
removeItem(key: string) {
|
||||
delete store[key];
|
||||
},
|
||||
};
|
||||
};
|
||||
const mockLocalStorage = getMockLocalStorage();
|
||||
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });
|
||||
|
||||
// --------------------------------------------------
|
||||
// POPUP MOCK
|
||||
// --------------------------------------------------
|
||||
const getMockPopupImpl = () => {
|
||||
const popup = {
|
||||
closed: false,
|
||||
};
|
||||
const openPopup = () => {
|
||||
popup.closed = false;
|
||||
return popup;
|
||||
};
|
||||
const closePopup = () => {
|
||||
popup.closed = true;
|
||||
return popup;
|
||||
};
|
||||
return {
|
||||
openPopup,
|
||||
closePopup,
|
||||
};
|
||||
};
|
||||
const mockPopupImpl = getMockPopupImpl();
|
||||
Object.defineProperty(window, 'open', { value: mockPopupImpl.openPopup });
|
||||
|
||||
// --------------------------------------------------
|
||||
// NETWORK MOCK
|
||||
// --------------------------------------------------
|
||||
const server = setupServer();
|
||||
server.events.on('response:mocked', () => {
|
||||
//
|
||||
jest.advanceTimersByTime(10);
|
||||
jest.runAllTicks();
|
||||
});
|
||||
|
||||
const mockHTTPResponse = (status = 200, returnBody: any) => {
|
||||
server.use(
|
||||
rest.all('*', (req, res, context) => {
|
||||
return res(context.json(returnBody), context.status(status));
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: Props) => {
|
||||
// const reactQueryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// --------------------------------------------------
|
||||
// TEST UTILS
|
||||
// --------------------------------------------------
|
||||
// use fake timers because the hook uses setTimeout and setInterval
|
||||
|
||||
function closeFakePopup() {
|
||||
act(() => {
|
||||
mockPopupImpl.closePopup();
|
||||
jest.advanceTimersByTime(4000);
|
||||
jest.runAllTicks();
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// TESTS
|
||||
// --------------------------------------------------
|
||||
describe('Slack', () => {
|
||||
// reset test state after each test
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockLocalStorage.clear();
|
||||
mockPopupImpl.closePopup();
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.clearAllTimers();
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('Happy path', async () => {
|
||||
// Arrange
|
||||
const { waitForValueToChange, result } = renderHook(() => useSlackOAuth(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.slackOauthStatus.status).toEqual('idle');
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.startSlackOAuth();
|
||||
});
|
||||
// Wait for the loading state to be triggered
|
||||
expect(result.current.slackOauthStatus.status).toEqual('authenticating');
|
||||
|
||||
// --------------------------------------------------
|
||||
// CONTROLLING TEST MOCKS
|
||||
// set search params in local storage and close popup
|
||||
// mock success exchange of token
|
||||
const response: ExchangeTokenResponse = {
|
||||
data: {
|
||||
slackExchangeOAuthToken: {
|
||||
channel_name: '#test-channel',
|
||||
team_name: 'test-team',
|
||||
},
|
||||
},
|
||||
};
|
||||
mockHTTPResponse(200, response);
|
||||
|
||||
const oauth2State = result.current.oauth2State;
|
||||
mockLocalStorage.setItem(
|
||||
SLACK_CALLBACK_SEARCH,
|
||||
`code=test_code&state=${oauth2State}`
|
||||
);
|
||||
|
||||
closeFakePopup();
|
||||
jest.runAllTicks();
|
||||
// --------------------------------------------------
|
||||
|
||||
// --------------------------------------------------
|
||||
|
||||
// ALl good until here
|
||||
// Assert
|
||||
await waitForValueToChange(() => result.current.slackOauthStatus);
|
||||
|
||||
expect(result.current.slackOauthStatus.status).toEqual('authenticated');
|
||||
|
||||
// @ts-expect-error we know better than typescript here
|
||||
expect(result.current.slackOauthStatus?.channelName).toEqual(
|
||||
response.data?.slackExchangeOAuthToken.channel_name
|
||||
);
|
||||
});
|
||||
|
||||
it('Renders idle state correctly', () => {
|
||||
// Arrange
|
||||
const { result } = renderHook(() => useSlackOAuth(), { wrapper });
|
||||
// Assert
|
||||
expect(result.current.slackOauthStatus.status).toEqual('idle');
|
||||
});
|
||||
|
||||
it('throws unexpected error when the popup is closed before the parameters are stored in localstorage', () => {
|
||||
// Arrange
|
||||
render(<TestComponent />, { wrapper });
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('start'));
|
||||
|
||||
// --------------------------------------------------
|
||||
// CONTROLLING TEST MOCKS
|
||||
closeFakePopup();
|
||||
// --------------------------------------------------
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(
|
||||
'Slack integration closed unexpectedly. Please try again.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws OAuth error when the OAuth code does not exist in search params in local storage', () => {
|
||||
// Arrange
|
||||
render(<TestComponent />, { wrapper });
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('start'));
|
||||
|
||||
// --------------------------------------------------
|
||||
// CONTROLLING TEST MOCKS
|
||||
// set search params in localstorage and close popup
|
||||
// code is not set in search params
|
||||
// results in authentication error
|
||||
mockLocalStorage.setItem(SLACK_CALLBACK_SEARCH, 'state=test_state');
|
||||
closeFakePopup();
|
||||
// --------------------------------------------------
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(
|
||||
'Error authenticating with Slack. Please try again.'
|
||||
);
|
||||
});
|
||||
|
||||
it('Throw forgery error when the OAuth state mismatch', () => {
|
||||
// Arrange
|
||||
render(<TestComponent />, { wrapper });
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('start'));
|
||||
|
||||
// --------------------------------------------------
|
||||
// CONTROLLING TEST MOCKS
|
||||
// set search params localstorage and close popup
|
||||
// results in state mismatch error
|
||||
mockLocalStorage.setItem(
|
||||
SLACK_CALLBACK_SEARCH,
|
||||
'code=test_code&state=test_state'
|
||||
);
|
||||
closeFakePopup();
|
||||
// --------------------------------------------------
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(
|
||||
'Invalid OAuth session state. Please try again.'
|
||||
);
|
||||
});
|
||||
|
||||
it('Renders oauth error when there is an error exchanging the token', async () => {
|
||||
// Arrange
|
||||
const { waitForValueToChange, result } = renderHook(() => useSlackOAuth(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.startSlackOAuth();
|
||||
});
|
||||
|
||||
// --------------------------------------------------
|
||||
// CONTROLLING TEST MOCKS
|
||||
// set the right in local storage along with a code
|
||||
const oauth2State = result.current.oauth2State;
|
||||
mockLocalStorage.setItem(
|
||||
SLACK_CALLBACK_SEARCH,
|
||||
`code=test_code&state=${oauth2State}`
|
||||
);
|
||||
|
||||
const response: { errors: GraphQLError[] } = {
|
||||
// @ts-expect-error The error format seems not to match the declared type returned by
|
||||
// controlPlaneDataApiClient, we should fix it
|
||||
errors: [{ message: 'Something went wrong while integrating Slack.' }],
|
||||
};
|
||||
|
||||
// mock HTTP response for exchanging token to return an exchange error
|
||||
mockHTTPResponse(200, response);
|
||||
|
||||
closeFakePopup();
|
||||
// --------------------------------------------------
|
||||
|
||||
// Assert
|
||||
|
||||
await waitForValueToChange(() => result.current.slackOauthStatus);
|
||||
console.log(result.current.slackOauthStatus);
|
||||
|
||||
expect(result.current.slackOauthStatus.status).toEqual('error');
|
||||
|
||||
// @ts-expect-error we know better than typescript here
|
||||
expect(result.current.slackOauthStatus?.error?.message).toEqual(
|
||||
'Something went wrong while integrating Slack.'
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,238 @@
|
||||
import * as React from 'react';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { FETCH_SLACK_STATE_QUERY_NAME } from '../constants';
|
||||
import globals from '../../../Globals';
|
||||
import { useIsUnmounted, generateRandomString } from '../utils';
|
||||
import {
|
||||
getPersistedSlackCallbackSearch,
|
||||
clearPersistedSlackCallbackSearch,
|
||||
} from '../utils';
|
||||
import { controlPlaneClient } from '../../ControlPlane';
|
||||
import { SLACK_TOKEN_EXCHANGE_QUERY } from '../queries';
|
||||
|
||||
export type ExchangeTokenResponse = {
|
||||
data?: {
|
||||
slackExchangeOAuthToken: {
|
||||
channel_name: string;
|
||||
team_name: string;
|
||||
};
|
||||
};
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
|
||||
export type SlackOauthStatus =
|
||||
| {
|
||||
status: 'idle';
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
error: Error;
|
||||
}
|
||||
| {
|
||||
status: 'authenticating';
|
||||
}
|
||||
| {
|
||||
status: 'authenticated';
|
||||
channelName: string;
|
||||
teamName: string;
|
||||
};
|
||||
|
||||
export const useSlackOAuth = (oauthString?: string) => {
|
||||
const isUnmounted = useIsUnmounted();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [status, setStatus] = useState<SlackOauthStatus>({
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
const oauth2State = useMemo(
|
||||
() => oauthString || generateRandomString(),
|
||||
[oauthString]
|
||||
);
|
||||
|
||||
/*
|
||||
* Handle the redirect params to get the oauth session:
|
||||
* */
|
||||
const startFetchingControlPlaneData = useCallback(
|
||||
async (callbackSearch: string) => {
|
||||
const params = new URLSearchParams(callbackSearch);
|
||||
|
||||
// if grant code is absent, the redirect was unsuccessful
|
||||
if (!params.get('code')) {
|
||||
setStatus({
|
||||
status: 'error',
|
||||
error: new Error(
|
||||
'Error authenticating with Slack. Please try again.'
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* if the state in params does not match the local state,
|
||||
* the request was probably tampered with
|
||||
* */
|
||||
if (params.get('state') !== oauth2State) {
|
||||
setStatus({
|
||||
status: 'error',
|
||||
error: new Error('Invalid OAuth session state. Please try again.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const slackTokenExchangeResponse = await controlPlaneClient.query<
|
||||
ExchangeTokenResponse,
|
||||
{ code: string; projectId: string }
|
||||
>(SLACK_TOKEN_EXCHANGE_QUERY, {
|
||||
code: params.get('code') || '',
|
||||
projectId: globals.hasuraCloudProjectId || '',
|
||||
});
|
||||
|
||||
if (isUnmounted()) return;
|
||||
|
||||
if (typeof slackTokenExchangeResponse === 'string') {
|
||||
setStatus({
|
||||
status: 'error',
|
||||
error: new Error(slackTokenExchangeResponse),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
slackTokenExchangeResponse.errors &&
|
||||
slackTokenExchangeResponse.errors.length > 0
|
||||
) {
|
||||
throw Error(`Something went wrong while integrating Slack.`);
|
||||
}
|
||||
|
||||
if (slackTokenExchangeResponse?.data) {
|
||||
queryClient.invalidateQueries(FETCH_SLACK_STATE_QUERY_NAME);
|
||||
setStatus({
|
||||
status: 'authenticated',
|
||||
channelName:
|
||||
slackTokenExchangeResponse.data.slackExchangeOAuthToken
|
||||
.channel_name,
|
||||
teamName:
|
||||
slackTokenExchangeResponse.data.slackExchangeOAuthToken.team_name,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
setStatus({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
});
|
||||
}
|
||||
},
|
||||
[oauth2State]
|
||||
);
|
||||
|
||||
// function to start the oauth process
|
||||
const startSlackOAuth = React.useCallback(() => {
|
||||
setStatus({ status: 'authenticating' });
|
||||
|
||||
const searchParams = generateUrlSearchParams(
|
||||
globals.slackOAuthClientId ?? '',
|
||||
oauth2State
|
||||
);
|
||||
|
||||
// open Slack auth page in a popup
|
||||
// Sample URL should look like this: https://slack.com/oauth/v2/authorize?scope=incoming-webhook&client_id=33336676.569200954261
|
||||
const popup = window.open(
|
||||
`https://${
|
||||
globals.slackRootDomain
|
||||
}/oauth/v2/authorize?${searchParams.toString()}`,
|
||||
'slack-oauth2',
|
||||
'menubar=no,toolbar=no,location=no,width=800,height=600'
|
||||
);
|
||||
|
||||
// Usually means that a popup blocker blocked the popup
|
||||
if (!popup) {
|
||||
setStatus({
|
||||
status: 'error',
|
||||
error: new Error(
|
||||
'Could not open popup for logging in with Slack. Please disable your popup blocker and try again.'
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* After OAuth success, slack redirects the user to
|
||||
* /slack-integration/callback path of Hasura Cloud.
|
||||
* The Callback component passes the search params of the redirect
|
||||
* to the current context through localstorage.
|
||||
* We read the the localstorage and clear the redirect params.
|
||||
* */
|
||||
const intervalId = setInterval(() => {
|
||||
if (isUnmounted()) return;
|
||||
|
||||
if (!popup.closed) return;
|
||||
clearInterval(intervalId);
|
||||
|
||||
/*
|
||||
* Once the popup is closed,
|
||||
* the redirect params are expected to be available in localstorage. Check ../components/TempCallback
|
||||
* Here we read the params from localstorage and store them in local state.
|
||||
* These params are handled in another effect.
|
||||
* */
|
||||
setTimeout(() => {
|
||||
if (isUnmounted()) return;
|
||||
|
||||
const search = getPersistedSlackCallbackSearch();
|
||||
if (!search) {
|
||||
setStatus({
|
||||
status: 'error',
|
||||
error: new Error(
|
||||
'Slack integration closed unexpectedly. Please try again.'
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startFetchingControlPlaneData(search);
|
||||
clearPersistedSlackCallbackSearch();
|
||||
}, 500);
|
||||
}, 500);
|
||||
}, [oauth2State, isUnmounted]);
|
||||
|
||||
return {
|
||||
oauth2State,
|
||||
startSlackOAuth,
|
||||
slackOauthStatus: status,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
// Sample Slack pop-up window URL
|
||||
https://slack.com/oauth?
|
||||
client_id=254593026723.5374035727812&
|
||||
scope=incoming-webhook&
|
||||
user_scope=&
|
||||
redirect_uri=&
|
||||
state=&
|
||||
granular_bot_scope=1&
|
||||
single_channel=0&
|
||||
install_redirect=&
|
||||
tracked=1&
|
||||
team=
|
||||
*/
|
||||
|
||||
function generateUrlSearchParams(
|
||||
slackOAuthClientId: string,
|
||||
oauth2State: string
|
||||
) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('client_id', slackOAuthClientId);
|
||||
searchParams.set('scope', 'incoming-webhook');
|
||||
searchParams.set('user_scope', '');
|
||||
searchParams.set('state', oauth2State);
|
||||
|
||||
return searchParams;
|
||||
}
|
@ -65,27 +65,22 @@ query fetchRegistrySchema ($schemaId: uuid!) {
|
||||
`);
|
||||
|
||||
export const FETCH_ALERT_CONFIG = gql(`
|
||||
query QueryAlerts($projectId: uuid!) {
|
||||
schema_registry_alerts(where: {project_id: {_eq: $projectId}}) {
|
||||
config
|
||||
alert_type
|
||||
id
|
||||
meta
|
||||
query fetchAlertConfig($projectId: uuid!, $type: alert_service_type_enum!) {
|
||||
alert_config_service(where: {project_id: {_eq: $projectId}, type: {_eq: $type}}) {
|
||||
project_id
|
||||
slack_webhook
|
||||
type
|
||||
metadata
|
||||
rules
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const SET_ALERT_CONFIG = gql(`
|
||||
mutation UpsertEmailAlertConfig($projectId: uuid, $config: jsonb) {
|
||||
insert_schema_registry_alerts_one(object: {alert_type: email, config: $config, project_id: $projectId}, on_conflict: {constraint: schema_registry_alerts_project_id_alert_type_key, update_columns: config}) {
|
||||
id
|
||||
project_id
|
||||
alert_type
|
||||
config
|
||||
mutation UpsertAlertConfig($projectId: uuid, $rules: jsonb) {
|
||||
insert_alert_config(objects: {alert_types: {data: {type: "SchemaRegistryUpdates"}, on_conflict: {constraint: alert_config_alert_type_pkey, update_columns: type}}, project_id: $projectId, enabled: true, alert_config_services: {data: {metadata: "", rules: $rules, type: mail}, on_conflict: {constraint: alert_config_service_pkey, update_columns: rules}}}, on_conflict: {constraint: alert_config_pkey, update_columns: enabled}) {
|
||||
affected_rows
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const ADD_SCHEMA_TAG = gql(`
|
||||
@ -105,3 +100,38 @@ mutation DeleteSchemaTag($ID: uuid!) {
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const DELETE_SLACK_APP = gql(`
|
||||
mutation DeleteSlackApp($projectID: uuid!) {
|
||||
deleteSlackApp(args: {projectID: $projectID}) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const FETCH_SLACK_STATE = gql(`
|
||||
query FetchSlackState($projectId: uuid!) {
|
||||
slack_config(where: {project_id: {_eq: $projectId}}) {
|
||||
channel_name
|
||||
webhook_url
|
||||
project_id
|
||||
channel_id
|
||||
slack_team_id
|
||||
team_name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const SLACK_TOKEN_EXCHANGE_QUERY = gql(`
|
||||
mutation slackTokenExchange (
|
||||
$code: String!
|
||||
$projectId: uuid!
|
||||
) {
|
||||
slackExchangeOAuthToken (
|
||||
code: $code
|
||||
projectId: $projectId
|
||||
) {
|
||||
channel_name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
@ -95,7 +95,19 @@ export type GetAlertConfigResponseWithError = {
|
||||
};
|
||||
|
||||
export type GetAlertConfigQueryResponse = {
|
||||
schema_registry_alerts: SchemaRegistryAlert[];
|
||||
alert_config_service: AlertConfig[];
|
||||
};
|
||||
|
||||
export type AlertConfig = {
|
||||
project_id: string;
|
||||
type: AlertType;
|
||||
metadata: Record<string, any>;
|
||||
rules: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type SetAlertConfigResponseWithError = {
|
||||
data?: SetAlertConfigQueryResponse;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
|
||||
export type SchemaRegistryAlert = {
|
||||
@ -107,11 +119,6 @@ export type SchemaRegistryAlert = {
|
||||
meta: Record<string, any>;
|
||||
};
|
||||
|
||||
export type SetAlertConfigResponseWithError = {
|
||||
data?: SetAlertConfigQueryResponse;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
|
||||
export type SetAlertConfigQueryResponse = {
|
||||
schema_registry_alerts: SchemaRegistryAlert;
|
||||
};
|
||||
@ -161,3 +168,37 @@ export type DeleteSchemaRegistryTagMutationResponse = {
|
||||
export type DeletedTagID = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type MailAlertType = 'mail';
|
||||
type SlackAlertType = 'slack';
|
||||
|
||||
export type AlertType = MailAlertType | SlackAlertType;
|
||||
|
||||
export type DeleteSlackAppMutationResponseWithError = {
|
||||
data?: DeleteSlackAppMutationResponse;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
|
||||
export type DeleteSlackAppMutationResponse = {
|
||||
deleteSlackApp: {
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetSlackStateResponseWithError = {
|
||||
data?: GetSlackStateQueryResponse;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
|
||||
export type GetSlackStateQueryResponse = {
|
||||
slack_config: SlackConfig[];
|
||||
};
|
||||
|
||||
export type SlackConfig = {
|
||||
channel_name: string;
|
||||
webhook_url: string;
|
||||
project_id: string;
|
||||
channel_id: string;
|
||||
slack_team_id: string;
|
||||
team_name: string;
|
||||
};
|
||||
|
@ -9,6 +9,13 @@ import {
|
||||
SiblingSchema,
|
||||
GetRegistrySchemaResponseWithError,
|
||||
} from './types';
|
||||
import {
|
||||
getLSItem,
|
||||
LS_KEYS,
|
||||
removeLSItem,
|
||||
setLSItem,
|
||||
} from '../../utils/localStorage';
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
export const CapitalizeFirstLetter = (str: string) => {
|
||||
@ -119,6 +126,45 @@ export const getPublishTime = (isoStringTs: string) => {
|
||||
return published.format('DD/MM/YYYY HH:mm:ss');
|
||||
};
|
||||
|
||||
export const SLACK_CALLBACK_SEARCH = LS_KEYS.slackCallbackSearch;
|
||||
|
||||
export const persistSlackCallbackSearch = (value: string) => {
|
||||
setLSItem(SLACK_CALLBACK_SEARCH, value);
|
||||
};
|
||||
|
||||
export const getPersistedSlackCallbackSearch = () => {
|
||||
return getLSItem(SLACK_CALLBACK_SEARCH);
|
||||
};
|
||||
|
||||
export const clearPersistedSlackCallbackSearch = () => {
|
||||
removeLSItem(SLACK_CALLBACK_SEARCH);
|
||||
};
|
||||
|
||||
export function useIsUnmounted() {
|
||||
const rIsUnmounted = useRef<'mounting' | 'mounted' | 'unmounted'>('mounting');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
rIsUnmounted.current = 'mounted';
|
||||
return () => {
|
||||
rIsUnmounted.current = 'unmounted';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(() => rIsUnmounted.current !== 'mounted', []);
|
||||
}
|
||||
|
||||
export const generateRandomString = (stringLength = 16) => {
|
||||
const allChars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let str = '';
|
||||
|
||||
for (let i = 0; i < stringLength; i++) {
|
||||
const randomNum = Math.floor(Math.random() * allChars.length);
|
||||
str += allChars.charAt(randomNum);
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
export const hexToRGB = (hex: string, alpha: number) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16),
|
||||
g = parseInt(hex.slice(3, 5), 16),
|
||||
|
@ -29,6 +29,7 @@ import HelpPage from './components/Services/Support/HelpPage';
|
||||
import FormRestView from './components/Services/ApiExplorer/Rest/Form';
|
||||
import { HerokuCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Heroku/TempCallback';
|
||||
import { NeonCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Neon/TempCallback';
|
||||
import { SlackCallbackHandler } from './features/SchemaRegistry/components/TempSlackCallback';
|
||||
import InsecureDomains from './components/Services/Settings/InsercureDomains/AllowInsecureDomains';
|
||||
import AuthContainer from './components/Services/Auth/AuthContainer';
|
||||
import { FeatureFlags } from './features/FeatureFlags';
|
||||
@ -100,6 +101,10 @@ const routes = store => {
|
||||
<Route path="login" component={generatedLoginConnector(connect)} />
|
||||
<Route path="heroku-callback" component={HerokuCallbackHandler} />
|
||||
<Route path="neon-integration/callback" component={NeonCallbackHandler} />
|
||||
<Route
|
||||
path="slack-integration/callback"
|
||||
component={SlackCallbackHandler}
|
||||
/>
|
||||
<Route path="" component={AuthContainer}>
|
||||
<Route
|
||||
path=""
|
||||
|
@ -119,6 +119,7 @@ export const LS_KEYS = {
|
||||
permissionConfirmationModalStatus:
|
||||
'console:permissionConfirmationModalStatus',
|
||||
neonCallbackSearch: 'neon:authCallbackSearch',
|
||||
slackCallbackSearch: 'slack:authCallbackSearch',
|
||||
herokuCallbackSearch: 'HEROKU_CALLBACK_SEARCH',
|
||||
consolePersonalAccessToken: 'PERSONAL_ACCESS_TOKEN',
|
||||
notificationsData: 'notifications:data',
|
||||
|
Loading…
Reference in New Issue
Block a user