mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): implement webhook basic details page (#3820)
This commit is contained in:
parent
b91b0f3835
commit
6c3a5a6899
11 changed files with 460 additions and 69 deletions
|
@ -8,6 +8,10 @@ export enum ConnectorsTabs {
|
|||
Social = 'social',
|
||||
}
|
||||
|
||||
export enum WebhookDetailsTabs {
|
||||
Settings = 'settings',
|
||||
}
|
||||
|
||||
export enum SignInExperiencePage {
|
||||
BrandingTab = 'branding',
|
||||
SignUpAndSignInTab = 'sign-up-and-sign-in',
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
ConnectorsTabs,
|
||||
UserDetailsTabs,
|
||||
RoleDetailsTabs,
|
||||
WebhookDetailsTabs,
|
||||
} from '@/consts';
|
||||
import { isHookFeatureEnabled } from '@/consts/webhooks';
|
||||
import ApiResourceDetails from '@/pages/ApiResourceDetails';
|
||||
|
@ -38,6 +39,8 @@ import UserLogs from '@/pages/UserDetails/UserLogs';
|
|||
import UserRoles from '@/pages/UserDetails/UserRoles';
|
||||
import UserSettings from '@/pages/UserDetails/UserSettings';
|
||||
import Users from '@/pages/Users';
|
||||
import WebhookDetails from '@/pages/WebhookDetails';
|
||||
import WebhookSettings from '@/pages/WebhookDetails/WebhookSettings';
|
||||
import Webhooks from '@/pages/Webhooks';
|
||||
|
||||
import type { AppContentOutletContext } from '../AppContent/types';
|
||||
|
@ -90,6 +93,10 @@ function ConsoleContent() {
|
|||
<Route path="webhooks">
|
||||
<Route index element={<Webhooks />} />
|
||||
<Route path="create" element={<Webhooks />} />
|
||||
<Route path=":id" element={<WebhookDetails />}>
|
||||
<Route index element={<Navigate replace to={WebhookDetailsTabs.Settings} />} />
|
||||
<Route path={WebhookDetailsTabs.Settings} element={<WebhookSettings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
)}
|
||||
<Route path="users">
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import { type Hook } from '@logto/schemas';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import BasicWebhookForm from '@/pages/Webhooks/components/BasicWebhookForm';
|
||||
|
||||
import { type WebhookDetailsFormType, type WebhookDetailsOutletContext } from '../types';
|
||||
import { webhookDetailsParser } from '../utils';
|
||||
|
||||
function WebhookSettings() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { hook, isDeleting, onHookUpdated } = useOutletContext<WebhookDetailsOutletContext>();
|
||||
|
||||
const webhookFormData = webhookDetailsParser.toLocalForm(hook);
|
||||
const formMethods = useForm<WebhookDetailsFormType>({ defaultValues: webhookFormData });
|
||||
const api = useApi();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, isDirty },
|
||||
} = formMethods;
|
||||
|
||||
const onSubmit = handleSubmit(async (formData) => {
|
||||
const updatedHook = await api
|
||||
.patch(`api/hooks/${hook.id}`, { json: webhookDetailsParser.toRemoteModel(formData) })
|
||||
.json<Hook>();
|
||||
reset(webhookDetailsParser.toLocalForm(updatedHook));
|
||||
onHookUpdated(updatedHook);
|
||||
toast.success(t('general.saved'));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailsForm
|
||||
isDirty={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={onSubmit}
|
||||
onDiscard={reset}
|
||||
>
|
||||
<FormCard
|
||||
title="webhook_details.settings.settings"
|
||||
description="webhook_details.settings.settings_description"
|
||||
>
|
||||
<FormProvider {...formMethods}>
|
||||
<BasicWebhookForm />
|
||||
</FormProvider>
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebhookSettings;
|
50
packages/console/src/pages/WebhookDetails/index.module.scss
Normal file
50
packages/console/src/pages/WebhookDetails/index.module.scss
Normal file
|
@ -0,0 +1,50 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.header {
|
||||
padding: _.unit(6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.webhookIcon {
|
||||
margin-left: _.unit(2);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin-left: _.unit(6);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
flex: 1;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-title-1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text {
|
||||
font: var(--font-label-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.verticalBar {
|
||||
@include _.vertical-bar;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
198
packages/console/src/pages/WebhookDetails/index.tsx
Normal file
198
packages/console/src/pages/WebhookDetails/index.tsx
Normal file
|
@ -0,0 +1,198 @@
|
|||
import { type Hook } from '@logto/schemas';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Delete from '@/assets/images/delete.svg';
|
||||
import Forbidden from '@/assets/images/forbidden.svg';
|
||||
import More from '@/assets/images/more.svg';
|
||||
import Shield from '@/assets/images/shield.svg';
|
||||
import WebhookDark from '@/assets/images/webhook-dark.svg';
|
||||
import Webhook from '@/assets/images/webhook.svg';
|
||||
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
|
||||
import Card from '@/components/Card';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
|
||||
import DetailsPage from '@/components/DetailsPage';
|
||||
import DynamicT from '@/components/DynamicT';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import Status from '@/components/Status';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import { WebhookDetailsTabs } from '@/consts';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import useTheme from '@/hooks/use-theme';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import { type WebhookDetailsOutletContext } from './types';
|
||||
|
||||
function WebhookDetails() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { data, error, mutate } = useSWR<Hook, RequestError>(id && `api/hooks/${id}`);
|
||||
const isLoading = !data && !error;
|
||||
const api = useApi();
|
||||
|
||||
const theme = useTheme();
|
||||
const WebhookIcon = theme === 'light' ? Webhook : WebhookDark;
|
||||
|
||||
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDisableFormOpen, setIsDisableFormOpen] = useState(false);
|
||||
const [isUpdatingEnableState, setIsUpdatingEnableState] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(data?.enabled ?? true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDeleteFormOpen(false);
|
||||
setIsDisableFormOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!data || isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await api.delete(`api/hooks/${data.id}`);
|
||||
toast.success(t('webhook_details.deleted', { name: data.name }));
|
||||
navigate('/webhooks');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleEnableState = async () => {
|
||||
if (!data || isUpdatingEnableState) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingEnableState(true);
|
||||
|
||||
try {
|
||||
const { enabled } = await api
|
||||
.patch(`api/hooks/${data.id}`, { json: { enabled: !isEnabled } })
|
||||
.json<Hook>();
|
||||
setIsEnabled(enabled);
|
||||
setIsDisableFormOpen(false);
|
||||
toast.success(
|
||||
t(enabled ? 'webhook_details.webhook_reactivated' : 'webhook_details.webhook_disabled')
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingEnableState(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailsPage
|
||||
backLink="/webhooks"
|
||||
backLinkTitle="webhook_details.back_to_webhooks"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={mutate}
|
||||
>
|
||||
<PageMeta titleKey="webhook_details.page_title" />
|
||||
{data && (
|
||||
<>
|
||||
<Card className={styles.header}>
|
||||
<WebhookIcon className={styles.webhookIcon} />
|
||||
<div className={styles.metadata}>
|
||||
<div className={styles.title}>{data.name}</div>
|
||||
<div>
|
||||
{isEnabled ? (
|
||||
<div>Success Rate (WIP)</div>
|
||||
) : (
|
||||
<Status status="disabled" variant="outlined">
|
||||
<DynamicT forKey="webhook_details.not_in_use" />
|
||||
</Status>
|
||||
)}
|
||||
<div className={styles.verticalBar} />
|
||||
<div className={styles.text}>ID</div>
|
||||
<CopyToClipboard size="small" value={data.id} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ActionMenu
|
||||
buttonProps={{ icon: <More className={styles.icon} />, size: 'large' }}
|
||||
title={t('general.more_options')}
|
||||
>
|
||||
<ActionMenuItem
|
||||
icon={isEnabled ? <Forbidden /> : <Shield />}
|
||||
iconClassName={styles.icon}
|
||||
onClick={async () => {
|
||||
if (isEnabled) {
|
||||
setIsDisableFormOpen(true);
|
||||
return;
|
||||
}
|
||||
await onToggleEnableState();
|
||||
}}
|
||||
>
|
||||
<DynamicT
|
||||
forKey={
|
||||
isEnabled
|
||||
? 'webhook_details.disable_webhook'
|
||||
: 'webhook_details.reactivate_webhook'
|
||||
}
|
||||
/>
|
||||
</ActionMenuItem>
|
||||
<ActionMenuItem
|
||||
icon={<Delete />}
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
setIsDeleteFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<DynamicT forKey="webhook_details.delete_webhook" />
|
||||
</ActionMenuItem>
|
||||
</ActionMenu>
|
||||
<DeleteConfirmModal
|
||||
isOpen={isDeleteFormOpen}
|
||||
isLoading={isDeleting}
|
||||
onCancel={() => {
|
||||
setIsDeleteFormOpen(true);
|
||||
}}
|
||||
onConfirm={onDelete}
|
||||
>
|
||||
<div>{t('webhook_details.deletion_reminder')}</div>
|
||||
</DeleteConfirmModal>
|
||||
<ConfirmModal
|
||||
isOpen={isDisableFormOpen}
|
||||
isLoading={isUpdatingEnableState}
|
||||
confirmButtonText="webhook_details.disable_webhook"
|
||||
onCancel={async () => {
|
||||
setIsDisableFormOpen(false);
|
||||
}}
|
||||
onConfirm={onToggleEnableState}
|
||||
>
|
||||
<DynamicT forKey="webhook_details.disable_reminder" />
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
</Card>
|
||||
<TabNav>
|
||||
<TabNavItem href={`/webhooks/${data.id}/${WebhookDetailsTabs.Settings}`}>
|
||||
<DynamicT forKey="webhook_details.settings_tab" />
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<Outlet
|
||||
context={
|
||||
{
|
||||
hook: data,
|
||||
isDeleting,
|
||||
onHookUpdated: (hook) => {
|
||||
void mutate(hook);
|
||||
},
|
||||
} satisfies WebhookDetailsOutletContext
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DetailsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebhookDetails;
|
11
packages/console/src/pages/WebhookDetails/types.ts
Normal file
11
packages/console/src/pages/WebhookDetails/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { type HookConfig, type Hook } from '@logto/schemas';
|
||||
|
||||
import { type BasicWebhookFormType } from '../Webhooks/types';
|
||||
|
||||
export type WebhookDetailsOutletContext = {
|
||||
hook: Hook;
|
||||
isDeleting: boolean;
|
||||
onHookUpdated: (hook?: Hook) => void;
|
||||
};
|
||||
|
||||
export type WebhookDetailsFormType = BasicWebhookFormType & { headers: HookConfig['headers'] };
|
34
packages/console/src/pages/WebhookDetails/utils.ts
Normal file
34
packages/console/src/pages/WebhookDetails/utils.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { type Hook } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import { type WebhookDetailsFormType } from './types';
|
||||
|
||||
export const webhookDetailsParser = {
|
||||
toLocalForm: (data: Hook): WebhookDetailsFormType => {
|
||||
const {
|
||||
event,
|
||||
events,
|
||||
name,
|
||||
config: { url, headers },
|
||||
} = data;
|
||||
|
||||
return {
|
||||
events: conditional(events.length > 0 && events) ?? (event ? [event] : []),
|
||||
name,
|
||||
url,
|
||||
headers,
|
||||
};
|
||||
},
|
||||
toRemoteModel: (formData: WebhookDetailsFormType): Partial<Hook> => {
|
||||
const { name, events, url, headers } = formData;
|
||||
|
||||
return {
|
||||
name,
|
||||
events,
|
||||
config: {
|
||||
url,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
|
@ -0,0 +1,78 @@
|
|||
import { HookEvent } from '@logto/schemas';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CheckboxGroup } from '@/components/Checkbox';
|
||||
import FormField from '@/components/FormField';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import { hookEventLabel } from '@/consts/webhooks';
|
||||
import { uriValidator } from '@/utils/validator';
|
||||
|
||||
import { type BasicWebhookFormType } from '../../types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const hookEventOptions = Object.values(HookEvent).map((event) => ({
|
||||
title: hookEventLabel[event],
|
||||
value: event,
|
||||
}));
|
||||
|
||||
function BasicWebhookForm() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<BasicWebhookFormType>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField title="webhooks.create_form.events">
|
||||
<div className={styles.formFieldDescription}>
|
||||
{t('webhooks.create_form.events_description')}
|
||||
</div>
|
||||
<Controller
|
||||
name="events"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
value.length === 0 ? t('webhooks.create_form.missing_event_error') : true,
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CheckboxGroup options={hookEventOptions} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
{errors.events && <div className={styles.errorMessage}>{errors.events.message}</div>}
|
||||
</FormField>
|
||||
<FormField isRequired title="webhooks.create_form.name">
|
||||
<TextInput
|
||||
{...register('name', { required: true })}
|
||||
placeholder={t('webhooks.create_form.name_placeholder')}
|
||||
error={Boolean(errors.name)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="webhooks.create_form.endpoint_url"
|
||||
tip={t('webhooks.create_form.endpoint_url_tip')}
|
||||
>
|
||||
<TextInput
|
||||
{...register('url', {
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (!uriValidator(value)) {
|
||||
return t('errors.invalid_uri_format');
|
||||
}
|
||||
return value.startsWith('https://') || t('webhooks.create_form.https_format_error');
|
||||
},
|
||||
})}
|
||||
placeholder={t('webhooks.create_form.endpoint_url_placeholder')}
|
||||
error={errors.url?.type === 'required' ? true : errors.url?.message}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BasicWebhookForm;
|
|
@ -1,27 +1,17 @@
|
|||
import { type Hook, type CreateHook, HookEvent, type HookConfig } from '@logto/schemas';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type Hook, type CreateHook, type HookEvent, type HookConfig } from '@logto/schemas';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import { CheckboxGroup } from '@/components/Checkbox';
|
||||
import FormField from '@/components/FormField';
|
||||
import ModalLayout from '@/components/ModalLayout';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import { hookEventLabel } from '@/consts/webhooks';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { uriValidator } from '@/utils/validator';
|
||||
|
||||
import * as styles from './CreateForm.module.scss';
|
||||
import { type BasicWebhookFormType } from '../../types';
|
||||
import BasicWebhookForm from '../BasicWebhookForm';
|
||||
|
||||
type Props = {
|
||||
onClose: (createdHook?: Hook) => void;
|
||||
};
|
||||
|
||||
type FormData = Pick<CreateHook, 'name'> & {
|
||||
events: HookEvent[];
|
||||
url: HookConfig['url'];
|
||||
};
|
||||
|
||||
type CreateHookPayload = Pick<CreateHook, 'name'> & {
|
||||
events: HookEvent[];
|
||||
config: {
|
||||
|
@ -29,20 +19,12 @@ type CreateHookPayload = Pick<CreateHook, 'name'> & {
|
|||
};
|
||||
};
|
||||
|
||||
const hookEventOptions = Object.values(HookEvent).map((event) => ({
|
||||
title: hookEventLabel[event],
|
||||
value: event,
|
||||
}));
|
||||
|
||||
function CreateForm({ onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const formMethods = useForm<BasicWebhookFormType>();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<FormData>();
|
||||
formState: { isSubmitting },
|
||||
} = formMethods;
|
||||
|
||||
const api = useApi();
|
||||
|
||||
|
@ -76,50 +58,9 @@ function CreateForm({ onClose }: Props) {
|
|||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField title="webhooks.create_form.events">
|
||||
<div className={styles.formFieldDescription}>
|
||||
{t('webhooks.create_form.events_description')}
|
||||
</div>
|
||||
<Controller
|
||||
name="events"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
value.length === 0 ? t('webhooks.create_form.missing_event_error') : true,
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CheckboxGroup options={hookEventOptions} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
{errors.events && <div className={styles.errorMessage}>{errors.events.message}</div>}
|
||||
</FormField>
|
||||
<FormField isRequired title="webhooks.create_form.name">
|
||||
<TextInput
|
||||
{...register('name', { required: true })}
|
||||
placeholder={t('webhooks.create_form.name_placeholder')}
|
||||
error={Boolean(errors.name)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="webhooks.create_form.endpoint_url"
|
||||
tip={t('webhooks.create_form.endpoint_url_tip')}
|
||||
>
|
||||
<TextInput
|
||||
{...register('url', {
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (!uriValidator(value)) {
|
||||
return t('errors.invalid_uri_format');
|
||||
}
|
||||
return value.startsWith('https://') || t('webhooks.create_form.https_format_error');
|
||||
},
|
||||
})}
|
||||
placeholder={t('webhooks.create_form.endpoint_url_placeholder')}
|
||||
error={errors.url?.type === 'required' ? true : errors.url?.message}
|
||||
/>
|
||||
</FormField>
|
||||
<FormProvider {...formMethods}>
|
||||
<BasicWebhookForm />
|
||||
</FormProvider>
|
||||
</ModalLayout>
|
||||
);
|
||||
}
|
||||
|
|
7
packages/console/src/pages/Webhooks/types.ts
Normal file
7
packages/console/src/pages/Webhooks/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas';
|
||||
|
||||
export type BasicWebhookFormType = {
|
||||
name: Hook['name'];
|
||||
events: HookEvent[];
|
||||
url: HookConfig['url'];
|
||||
};
|
Loading…
Reference in a new issue