diff --git a/api/v1/memo.go b/api/v1/memo.go index 1c25fc95..9d60202d 100644 --- a/api/v1/memo.go +++ b/api/v1/memo.go @@ -426,7 +426,7 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error { } // Try to dispatch webhook when memo is created. if err := s.DispatchMemoCreatedWebhook(ctx, memoResponse); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to dispatch memo created webhook").SetInternal(err) + log.Warn("Failed to dispatch memo created webhook", zap.Error(err)) } metric.Enqueue("memo create") @@ -801,9 +801,9 @@ func (s *APIV1Service) UpdateMemo(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } - // Try to dispatch webhook when memo is created. - if err := s.DispatchMemoCreatedWebhook(ctx, memoResponse); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to dispatch memo created webhook").SetInternal(err) + // Try to dispatch webhook when memo is updated. + if err := s.DispatchMemoUpdatedWebhook(ctx, memoResponse); err != nil { + log.Warn("Failed to dispatch memo updated webhook", zap.Error(err)) } return c.JSON(http.StatusOK, memoResponse) diff --git a/web/src/components/CreateWebhookDialog.tsx b/web/src/components/CreateWebhookDialog.tsx new file mode 100644 index 00000000..1dd4de6c --- /dev/null +++ b/web/src/components/CreateWebhookDialog.tsx @@ -0,0 +1,150 @@ +import { Button, Input } from "@mui/joy"; +import React, { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; +import { webhookServiceClient } from "@/grpcweb"; +import useLoading from "@/hooks/useLoading"; +import { useTranslate } from "@/utils/i18n"; +import { generateDialog } from "./Dialog"; +import Icon from "./Icon"; + +interface Props extends DialogProps { + webhookId?: number; + onConfirm: () => void; +} + +interface State { + name: string; + url: string; +} + +const CreateWebhookDialog: React.FC = (props: Props) => { + const { webhookId, destroy, onConfirm } = props; + const t = useTranslate(); + const [state, setState] = useState({ + name: "", + url: "", + }); + const requestState = useLoading(false); + const isCreating = webhookId === undefined; + + useEffect(() => { + if (webhookId) { + webhookServiceClient + .getWebhook({ + id: webhookId, + }) + .then(({ webhook }) => { + if (!webhook) { + return; + } + + setState({ + name: webhook.name, + url: webhook.url, + }); + }); + } + }, []); + + const setPartialState = (partialState: Partial) => { + setState({ + ...state, + ...partialState, + }); + }; + + const handleTitleInputChange = (e: React.ChangeEvent) => { + setPartialState({ + name: e.target.value, + }); + }; + + const handleUrlInputChange = (e: React.ChangeEvent) => { + setPartialState({ + url: e.target.value, + }); + }; + + const handleSaveBtnClick = async () => { + if (!state.name || !state.url) { + toast.error("Please fill all required fields"); + return; + } + + try { + if (isCreating) { + await webhookServiceClient.createWebhook({ + name: state.name, + url: state.url, + }); + } else { + await webhookServiceClient.updateWebhook({ + webhook: { + id: webhookId, + name: state.name, + url: state.url, + }, + updateMask: ["name", "url"], + }); + } + + onConfirm(); + destroy(); + } catch (error: any) { + console.error(error); + toast.error(error.details); + } + }; + + return ( + <> +
+

{isCreating ? "Create webhook" : "Edit webhook"}

+ +
+
+
+ + Title * + +
+ +
+
+
+ + Url * + +
+ +
+
+
+ + +
+
+ + ); +}; + +function showCreateWebhookDialog(onConfirm: () => void) { + generateDialog( + { + className: "create-webhook-dialog", + dialogName: "create-webhook-dialog", + }, + CreateWebhookDialog, + { + onConfirm, + } + ); +} + +export default showCreateWebhookDialog; diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx index 4ab9792b..eb42cb12 100644 --- a/web/src/components/Settings/PreferencesSection.tsx +++ b/web/src/components/Settings/PreferencesSection.tsx @@ -8,6 +8,7 @@ import AppearanceSelect from "../AppearanceSelect"; import LearnMore from "../LearnMore"; import LocaleSelect from "../LocaleSelect"; import VisibilityIcon from "../VisibilityIcon"; +import WebhookSection from "./WebhookSection"; import "@/less/settings/preferences-section.less"; const PreferencesSection = () => { @@ -106,6 +107,10 @@ const PreferencesSection = () => { onChange={(event) => handleTelegramUserIdChanged(event.target.value)} placeholder={t("setting.preference-section.telegram-user-id-placeholder")} /> + + + + ); }; diff --git a/web/src/components/Settings/WebhookSection.tsx b/web/src/components/Settings/WebhookSection.tsx new file mode 100644 index 00000000..4f5ca5e9 --- /dev/null +++ b/web/src/components/Settings/WebhookSection.tsx @@ -0,0 +1,126 @@ +import { Button, IconButton } from "@mui/joy"; +import { useEffect, useState } from "react"; +import { webhookServiceClient } from "@/grpcweb"; +import useCurrentUser from "@/hooks/useCurrentUser"; +import { Webhook } from "@/types/proto/api/v2/webhook_service"; +import { useTranslate } from "@/utils/i18n"; +import showCreateWebhookDialog from "../CreateWebhookDialog"; +import { showCommonDialog } from "../Dialog/CommonDialog"; +import Icon from "../Icon"; +import LearnMore from "../LearnMore"; + +const listWebhooks = async (userId: number) => { + const { webhooks } = await webhookServiceClient.listWebhooks({ + creatorId: userId, + }); + return webhooks; +}; + +const WebhookSection = () => { + const t = useTranslate(); + const currentUser = useCurrentUser(); + const [webhooks, setWebhooks] = useState([]); + + useEffect(() => { + listWebhooks(currentUser.id).then((webhooks) => { + setWebhooks(webhooks); + }); + }, []); + + const handleCreateAccessTokenDialogConfirm = async () => { + const webhooks = await listWebhooks(currentUser.id); + setWebhooks(webhooks); + }; + + const handleDeleteWebhook = async (webhook: Webhook) => { + showCommonDialog({ + title: "Delete Webhook", + content: `Are you sure to delete webhook \`${webhook.name}\`? You cannot undo this action.`, + style: "danger", + dialogName: "delete-webhook-dialog", + onConfirm: async () => { + await webhookServiceClient.deleteWebhook({ id: webhook.id }); + setWebhooks(webhooks.filter((item) => item.id !== webhook.id)); + }, + }); + }; + + return ( + <> +
+
+
+
+

+ Webhooks + +

+
+
+ +
+
+
+
+
+ + + + + + + + + + {webhooks.map((webhook) => ( + + + + + + ))} + + {webhooks.length === 0 && ( + + + + )} + +
+ Name + + Url + + {t("common.delete")} +
{webhook.name}{webhook.url} + { + handleDeleteWebhook(webhook); + }} + > + + +
+ No webhooks found. +
+
+
+
+
+
+ + ); +}; + +export default WebhookSection; diff --git a/web/src/grpcweb.ts b/web/src/grpcweb.ts index 432c2d39..5aa75e32 100644 --- a/web/src/grpcweb.ts +++ b/web/src/grpcweb.ts @@ -6,6 +6,7 @@ import { ResourceServiceDefinition } from "./types/proto/api/v2/resource_service import { SystemServiceDefinition } from "./types/proto/api/v2/system_service"; import { TagServiceDefinition } from "./types/proto/api/v2/tag_service"; import { UserServiceDefinition } from "./types/proto/api/v2/user_service"; +import { WebhookServiceDefinition } from "./types/proto/api/v2/webhook_service"; const channel = createChannel( window.location.origin, @@ -29,3 +30,5 @@ export const tagServiceClient = clientFactory.create(TagServiceDefinition, chann export const inboxServiceClient = clientFactory.create(InboxServiceDefinition, channel); export const activityServiceClient = clientFactory.create(ActivityServiceDefinition, channel); + +export const webhookServiceClient = clientFactory.create(WebhookServiceDefinition, channel);