mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Move "Webhook" settings to table (#12119)
* Move 'Webhook' settings to table * Add tests
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { observable } from "mobx";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import type { Searchable } from "./interfaces/Searchable";
|
||||
import User from "./User";
|
||||
|
||||
class WebhookSubscription extends Model {
|
||||
class WebhookSubscription extends Model implements Searchable {
|
||||
static modelName = "WebhookSubscription";
|
||||
|
||||
@Field
|
||||
@@ -24,6 +27,23 @@ class WebhookSubscription extends Model {
|
||||
@Field
|
||||
@observable
|
||||
events: string[];
|
||||
|
||||
/** The user who created this webhook subscription. */
|
||||
@Relation(() => User)
|
||||
createdBy?: User;
|
||||
|
||||
/** The user ID that created this webhook subscription. */
|
||||
createdById: string;
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.name, this.url, ...(this.events ?? [])].filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchSuppressed(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default WebhookSubscription;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Text from "@shared/components/Text";
|
||||
import { s } from "@shared/styles";
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
type StatusColor = "accent" | "warning" | "danger" | "textTertiary" | "success";
|
||||
|
||||
export const Status = styled(Text).attrs({
|
||||
type: "secondary",
|
||||
size: "small",
|
||||
as: "span",
|
||||
})`
|
||||
})<{ $color?: StatusColor }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,11 +18,13 @@ export const Status = styled(Text).attrs({
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
${s("accent")} 0 33%,
|
||||
transparent 33%
|
||||
);
|
||||
${(props) => css`
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
${s(props.$color ?? "accent")} 0 33%,
|
||||
transparent 33%
|
||||
);
|
||||
`}
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,30 +1,106 @@
|
||||
import type { ColumnSort } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, WebhooksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import WebhookSubscriptionListItem from "./components/WebhookSubscriptionListItem";
|
||||
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { StickyFilters } from "~/scenes/Settings/components/StickyFilters";
|
||||
import { createWebhookSubscription } from "./actions";
|
||||
import { WebhookSubscriptionsTable } from "./components/WebhookSubscriptionsTable";
|
||||
|
||||
function Webhooks() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { webhookSubscriptions } = useStores();
|
||||
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||
const can = usePolicy(team);
|
||||
const appName = env.APP_NAME;
|
||||
const params = useQuery();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [query, setQuery] = useState(params.get("query") || "");
|
||||
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
sort: params.get("sort") || "createdAt",
|
||||
direction: (params.get("direction") || "desc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const orderedData = webhookSubscriptions.orderedData;
|
||||
const filteredWebhooks = useMemo(
|
||||
() =>
|
||||
reqParams.query
|
||||
? webhookSubscriptions.findByQuery(reqParams.query)
|
||||
: orderedData,
|
||||
[webhookSubscriptions, orderedData, reqParams.query]
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: filteredWebhooks,
|
||||
sort,
|
||||
reqFn: webhookSubscriptions.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const updateParams = useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load webhooks"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateParams]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -36,7 +112,7 @@ function Webhooks() {
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewModalOpen}
|
||||
action={createWebhookSubscription}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("New webhook")}…`}
|
||||
@@ -45,6 +121,7 @@ function Webhooks() {
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>{t("Webhooks")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
@@ -54,29 +131,25 @@ function Webhooks() {
|
||||
in near real-time.
|
||||
</Trans>
|
||||
</Text>
|
||||
<PaginatedList<WebhookSubscription>
|
||||
fetch={webhookSubscriptions.fetchPage}
|
||||
items={webhookSubscriptions.enabled}
|
||||
heading={<h2>{t("Active")}</h2>}
|
||||
renderItem={(webhook) => (
|
||||
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
|
||||
)}
|
||||
/>
|
||||
<PaginatedList<WebhookSubscription>
|
||||
items={webhookSubscriptions.disabled}
|
||||
heading={<h2>{t("Inactive")}</h2>}
|
||||
renderItem={(webhook) => (
|
||||
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("New webhook")}
|
||||
onRequestClose={handleNewModalClose}
|
||||
isOpen={newModalOpen}
|
||||
width="480px"
|
||||
>
|
||||
<WebhookSubscriptionNew onSubmit={handleNewModalClose} />
|
||||
</Modal>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<WebhookSubscriptionsTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EditIcon, PlusIcon, TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import { createAction } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
import WebhookSubscriptionDeleteDialog from "./components/WebhookSubscriptionDeleteDialog";
|
||||
import WebhookSubscriptionEdit from "./components/WebhookSubscriptionEdit";
|
||||
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
|
||||
|
||||
export const createWebhookSubscription = createAction({
|
||||
name: ({ t }) => t("New webhook"),
|
||||
analyticsName: "New webhook",
|
||||
section: SettingsSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "create",
|
||||
visible: () =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "")
|
||||
.createWebhookSubscription,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("New webhook"),
|
||||
content: (
|
||||
<WebhookSubscriptionNew onSubmit={stores.dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const editWebhookSubscriptionFactory = ({
|
||||
webhook,
|
||||
}: {
|
||||
webhook: WebhookSubscription;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Edit")}…`,
|
||||
analyticsName: "Edit webhook",
|
||||
section: SettingsSection,
|
||||
icon: <EditIcon />,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Edit webhook"),
|
||||
content: (
|
||||
<WebhookSubscriptionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
webhookSubscription={webhook}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteWebhookSubscriptionFactory = ({
|
||||
webhook,
|
||||
}: {
|
||||
webhook: WebhookSubscription;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete webhook",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "delete remove",
|
||||
dangerous: true,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete webhook"),
|
||||
content: (
|
||||
<WebhookSubscriptionDeleteDialog
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
webhook={webhook}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { EditIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import WebhookSubscriptionRevokeDialog from "./WebhookSubscriptionDeleteDialog";
|
||||
import WebhookSubscriptionEdit from "./WebhookSubscriptionEdit";
|
||||
|
||||
type Props = {
|
||||
webhook: WebhookSubscription;
|
||||
};
|
||||
|
||||
const WebhookSubscriptionListItem = ({ webhook }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const [editModalOpen, handleEditModalOpen, handleEditModalClose] =
|
||||
useBoolean();
|
||||
|
||||
const showDeletionConfirmation = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete webhook"),
|
||||
content: (
|
||||
<WebhookSubscriptionRevokeDialog
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
webhook={webhook}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, webhook]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={webhook.id}
|
||||
title={
|
||||
<>
|
||||
{webhook.name}
|
||||
{!webhook.enabled && (
|
||||
<StyledBadge yellow>{t("Disabled")}</StyledBadge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
{t("Subscribed events")}: <code>{webhook.events.join(", ")}</code>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={showDeletionConfirmation}
|
||||
icon={<TrashIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
<Button icon={<EditIcon />} onClick={handleEditModalOpen} neutral>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
<Modal
|
||||
title={t("Edit webhook")}
|
||||
onRequestClose={handleEditModalClose}
|
||||
isOpen={editModalOpen}
|
||||
width="480px"
|
||||
>
|
||||
<WebhookSubscriptionEdit
|
||||
onSubmit={handleEditModalClose}
|
||||
webhookSubscription={webhook}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledBadge = styled(Badge)`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export default WebhookSubscriptionListItem;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { useWebhookSubscriptionMenuActions } from "../hooks/useWebhookSubscriptionMenuActions";
|
||||
|
||||
type Props = {
|
||||
/** The webhook subscription to associate with the menu */
|
||||
webhook: WebhookSubscription;
|
||||
};
|
||||
|
||||
function WebhookSubscriptionMenu({ webhook }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useWebhookSubscriptionMenuActions(webhook);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("Webhook")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(WebhookSubscriptionMenu);
|
||||
@@ -0,0 +1,138 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import { Status } from "~/scenes/Settings/components/Status";
|
||||
import { FILTER_HEIGHT } from "~/scenes/Settings/components/StickyFilters";
|
||||
import { useWebhookSubscriptionMenuActions } from "../hooks/useWebhookSubscriptionMenuActions";
|
||||
import WebhookSubscriptionMenu from "./WebhookSubscriptionMenu";
|
||||
|
||||
const ROW_HEIGHT = 50;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<WebhookSubscription>, "columns" | "rowHeight">;
|
||||
|
||||
const WebhookRowContextMenu = observer(function WebhookRowContextMenu({
|
||||
webhook,
|
||||
menuLabel,
|
||||
children,
|
||||
}: {
|
||||
webhook: WebhookSubscription;
|
||||
menuLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const action = useWebhookSubscriptionMenuActions(webhook);
|
||||
return (
|
||||
<ContextMenu action={action} ariaLabel={menuLabel}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
});
|
||||
|
||||
export const WebhookSubscriptionsTable = observer(
|
||||
function WebhookSubscriptionsTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
(webhook: WebhookSubscription, rowElement: React.ReactNode) => (
|
||||
<WebhookRowContextMenu webhook={webhook} menuLabel={t("Webhook")}>
|
||||
{rowElement}
|
||||
</WebhookRowContextMenu>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumn<WebhookSubscription>[]>(
|
||||
() => [
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
header: t("Name"),
|
||||
accessor: (webhook) => webhook.name,
|
||||
component: (webhook) => (
|
||||
<HStack spacing={4} wrap>
|
||||
<Tooltip content={webhook.enabled ? t("Active") : t("Disabled")}>
|
||||
<Status $color={webhook.enabled ? "success" : "textTertiary"} />
|
||||
</Tooltip>
|
||||
<Text selectable>{webhook.name}</Text>
|
||||
</HStack>
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "url",
|
||||
header: t("URL"),
|
||||
accessor: (webhook) => webhook.url,
|
||||
component: (webhook) => (
|
||||
<UrlText type="tertiary" monospace selectable title={webhook.url}>
|
||||
{webhook.url}
|
||||
</UrlText>
|
||||
),
|
||||
width: "3fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdById",
|
||||
header: t("Created by"),
|
||||
accessor: (webhook) => webhook.createdBy?.name,
|
||||
component: (webhook) =>
|
||||
webhook.createdBy ? (
|
||||
<HStack>
|
||||
<Avatar model={webhook.createdBy} size={AvatarSize.Medium} />
|
||||
<Text selectable>{webhook.createdBy.name}</Text>
|
||||
</HStack>
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "updatedAt",
|
||||
header: t("Last updated"),
|
||||
accessor: (webhook) => webhook.updatedAt,
|
||||
component: (webhook) =>
|
||||
webhook.updatedAt ? (
|
||||
<Time dateTime={webhook.updatedAt} addSuffix shorten />
|
||||
) : null,
|
||||
width: "1.5fr",
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (webhook) => <WebhookSubscriptionMenu webhook={webhook} />,
|
||||
width: "50px",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
decorateRow={applyContextMenu}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const UrlText = styled(Text)`
|
||||
${ellipsis()}
|
||||
max-width: 100%;
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import {
|
||||
deleteWebhookSubscriptionFactory,
|
||||
editWebhookSubscriptionFactory,
|
||||
} from "../actions";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for webhook subscription operations.
|
||||
*
|
||||
* @param webhook - the webhook subscription to build actions for.
|
||||
* @returns action with children for use in menus.
|
||||
*/
|
||||
export function useWebhookSubscriptionMenuActions(
|
||||
webhook: WebhookSubscription
|
||||
) {
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
editWebhookSubscriptionFactory({ webhook }),
|
||||
deleteWebhookSubscriptionFactory({ webhook }),
|
||||
],
|
||||
[webhook]
|
||||
);
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -1,4 +1,34 @@
|
||||
import { z } from "zod";
|
||||
import { WebhookSubscription } from "@server/models";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const WebhookSubscriptionsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Webhook subscriptions sorting direction */
|
||||
direction: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||
|
||||
/** Webhook subscriptions sorting column */
|
||||
sort: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => Object.keys(WebhookSubscription.getAttributes()).includes(val),
|
||||
{
|
||||
error: "Invalid sort parameter",
|
||||
}
|
||||
)
|
||||
.prefault("createdAt"),
|
||||
|
||||
/** Search query to filter webhook subscriptions by name */
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type WebhookSubscriptionsListReq = z.infer<
|
||||
typeof WebhookSubscriptionsListSchema
|
||||
>;
|
||||
|
||||
export const WebhookSubscriptionsCreateSchema = z.object({
|
||||
body: z.object({
|
||||
|
||||
@@ -51,6 +51,78 @@ describe("#webhookSubscriptions.list", () => {
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(webhookSubscriptions.length);
|
||||
});
|
||||
|
||||
it("should filter webhook subscriptions by query", async () => {
|
||||
const user = await buildAdmin();
|
||||
await buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
name: "Production Webhook",
|
||||
});
|
||||
await buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
name: "Staging Webhook",
|
||||
});
|
||||
await buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
name: "Development Hook",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: { token: user.getJwtToken(), query: "webhook" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(
|
||||
body.data.every((webhook: { name: string }) =>
|
||||
webhook.name.toLowerCase().includes("webhook")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter webhook subscriptions by query case-insensitively", async () => {
|
||||
const user = await buildAdmin();
|
||||
await buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
name: "Production Webhook",
|
||||
});
|
||||
await buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
name: "Staging Webhook",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: { token: user.getJwtToken(), query: "PRODUCTION" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].name).toEqual("Production Webhook");
|
||||
});
|
||||
|
||||
it("should return empty array when query matches no webhook subscriptions", async () => {
|
||||
const user = await buildAdmin();
|
||||
await buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
name: "Production Webhook",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: { token: user.getJwtToken(), query: "nonexistent" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#webhookSubscriptions.create", () => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import Router from "koa-router";
|
||||
import compact from "lodash/compact";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { Op, Sequelize, type WhereOptions } from "sequelize";
|
||||
import { UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { WebhookSubscription } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import pagination from "@server/routes/api/middlewares/pagination";
|
||||
import pagination, {
|
||||
paginateQuery,
|
||||
} from "@server/routes/api/middlewares/pagination";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { AuthenticationType } from "@server/types";
|
||||
import presentWebhookSubscription from "../presenters/webhookSubscription";
|
||||
@@ -19,23 +22,57 @@ router.post(
|
||||
"webhookSubscriptions.list",
|
||||
auth({ role: UserRole.Admin }),
|
||||
pagination(),
|
||||
async (ctx: APIContext) => {
|
||||
validate(T.WebhookSubscriptionsListSchema),
|
||||
async (ctx: APIContext<T.WebhookSubscriptionsListReq>) => {
|
||||
const { sort, direction, query } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "listWebhookSubscription", user.team);
|
||||
|
||||
const webhooks = await WebhookSubscription.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
let where: WhereOptions<WebhookSubscription> = {
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
...where,
|
||||
[Op.and]: [
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER("webhook_subscription"."name")) like unaccent(LOWER(:query))`
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const replacements = { query: `%${query}%` };
|
||||
|
||||
const { results, pagination } = await paginateQuery(
|
||||
ctx,
|
||||
(opts) =>
|
||||
WebhookSubscription.findAll({
|
||||
where,
|
||||
replacements,
|
||||
include: [
|
||||
{
|
||||
association: "createdBy",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [[sort, direction]],
|
||||
offset: opts.offset,
|
||||
limit: opts.limit,
|
||||
}),
|
||||
() =>
|
||||
WebhookSubscription.count({
|
||||
where,
|
||||
// @ts-expect-error Types are incorrect for count
|
||||
replacements,
|
||||
}) as unknown as Promise<number>
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: webhooks.map(presentWebhookSubscription),
|
||||
pagination,
|
||||
data: results.map(presentWebhookSubscription),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WebhookSubscription } from "@server/models";
|
||||
import presentUser from "@server/presenters/user";
|
||||
|
||||
export default function presentWebhookSubscription(
|
||||
webhook: WebhookSubscription
|
||||
@@ -10,6 +11,8 @@ export default function presentWebhookSubscription(
|
||||
secret: webhook.secret,
|
||||
events: webhook.events,
|
||||
enabled: webhook.enabled,
|
||||
createdBy: webhook.createdBy ? presentUser(webhook.createdBy) : undefined,
|
||||
createdById: webhook.createdById,
|
||||
createdAt: webhook.createdAt,
|
||||
updatedAt: webhook.updatedAt,
|
||||
};
|
||||
|
||||
@@ -130,6 +130,63 @@ describe("#apiKeys.list", () => {
|
||||
expect(body.data.length).toEqual(2);
|
||||
});
|
||||
|
||||
it("should filter api keys by query", async () => {
|
||||
const admin = await buildAdmin();
|
||||
await buildApiKey({ userId: admin.id, name: "Production Key" });
|
||||
await buildApiKey({ userId: admin.id, name: "Staging Key" });
|
||||
await buildApiKey({ userId: admin.id, name: "Development Token" });
|
||||
|
||||
const res = await server.post("/api/apiKeys.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
query: "key",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(
|
||||
body.data.every((apiKey: { name: string }) =>
|
||||
apiKey.name.toLowerCase().includes("key")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter api keys by query case-insensitively", async () => {
|
||||
const admin = await buildAdmin();
|
||||
await buildApiKey({ userId: admin.id, name: "Production Key" });
|
||||
await buildApiKey({ userId: admin.id, name: "Staging Key" });
|
||||
|
||||
const res = await server.post("/api/apiKeys.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
query: "PRODUCTION",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].name).toEqual("Production Key");
|
||||
});
|
||||
|
||||
it("should return empty array when query matches no api keys", async () => {
|
||||
const admin = await buildAdmin();
|
||||
await buildApiKey({ userId: admin.id, name: "Production Key" });
|
||||
|
||||
const res = await server.post("/api/apiKeys.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
query: "nonexistent",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/apiKeys.list");
|
||||
expect(res.status).toEqual(401);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Router from "koa-router";
|
||||
import type { WhereOptions } from "sequelize";
|
||||
import { Op, Sequelize, type WhereOptions } from "sequelize";
|
||||
import { UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
@@ -53,17 +53,17 @@ router.post(
|
||||
pagination(),
|
||||
validate(T.APIKeysListSchema),
|
||||
async (ctx: APIContext<T.APIKeysListReq>) => {
|
||||
const { userId, sort, direction } = ctx.input.body;
|
||||
const { userId, query, sort, direction } = ctx.input.body;
|
||||
const { pagination } = ctx.state;
|
||||
const actor = ctx.state.auth.user;
|
||||
|
||||
let where: WhereOptions<User> = {
|
||||
let userWhere: WhereOptions<User> = {
|
||||
teamId: actor.teamId,
|
||||
};
|
||||
|
||||
if (cannot(actor, "listApiKeys", actor.team)) {
|
||||
where = {
|
||||
...where,
|
||||
userWhere = {
|
||||
...userWhere,
|
||||
id: actor.id,
|
||||
};
|
||||
}
|
||||
@@ -72,18 +72,35 @@ router.post(
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(actor, "listApiKeys", user);
|
||||
|
||||
where = {
|
||||
...where,
|
||||
userWhere = {
|
||||
...userWhere,
|
||||
id: userId,
|
||||
};
|
||||
}
|
||||
|
||||
let where: WhereOptions<ApiKey> = {};
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
...where,
|
||||
[Op.and]: [
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER("apiKey"."name")) like unaccent(LOWER(:query))`
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const replacements = { query: `%${query}%` };
|
||||
|
||||
const apiKeys = await ApiKey.findAll({
|
||||
where,
|
||||
replacements,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
required: true,
|
||||
where,
|
||||
where: userWhere,
|
||||
},
|
||||
],
|
||||
order: [[sort, direction]],
|
||||
|
||||
@@ -24,6 +24,8 @@ export const APIKeysListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** The owner of the API key */
|
||||
userId: z.uuid().optional(),
|
||||
/** Search query to filter API keys by name */
|
||||
query: z.string().optional(),
|
||||
|
||||
/** API keys sorting direction */
|
||||
direction: z
|
||||
|
||||
@@ -1586,6 +1586,9 @@
|
||||
"Script name": "Script name",
|
||||
"The name of the script file that Umami uses to track analytics.": "The name of the script file that Umami uses to track analytics.",
|
||||
"An ID that uniquely identifies the website in your Umami instance.": "An ID that uniquely identifies the website in your Umami instance.",
|
||||
"New webhook": "New webhook",
|
||||
"Edit webhook": "Edit webhook",
|
||||
"Delete webhook": "Delete webhook",
|
||||
"Are you sure you want to delete the {{ name }} webhook?": "Are you sure you want to delete the {{ name }} webhook?",
|
||||
"Webhook updated": "Webhook updated",
|
||||
"Update": "Update",
|
||||
@@ -1597,14 +1600,11 @@
|
||||
"Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.": "Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.",
|
||||
"All events": "All events",
|
||||
"All {{ groupName }} events": "All {{ groupName }} events",
|
||||
"Delete webhook": "Delete webhook",
|
||||
"Subscribed events": "Subscribed events",
|
||||
"Edit webhook": "Edit webhook",
|
||||
"Webhook": "Webhook",
|
||||
"Webhook created": "Webhook created",
|
||||
"Could not load webhooks": "Could not load webhooks",
|
||||
"Webhooks": "Webhooks",
|
||||
"New webhook": "New webhook",
|
||||
"Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.",
|
||||
"Inactive": "Inactive",
|
||||
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.",
|
||||
"Delayed notification": "Delayed notification",
|
||||
"“{{ collectionName }}” created": "“{{ collectionName }}” created",
|
||||
|
||||
@@ -29,7 +29,7 @@ const defaultColors: Colors = {
|
||||
warmGrey: "#EDF2F7",
|
||||
danger: "#ed2651",
|
||||
warning: "#f08a24",
|
||||
success: "#2f3336",
|
||||
success: "#3ad984",
|
||||
info: "#a0d3e8",
|
||||
brand: {
|
||||
red: "#FF5C80",
|
||||
|
||||
Reference in New Issue
Block a user