feat: add webhook edit UI and signing secret status indicator (#6027)
This commit is contained in:
@@ -714,6 +714,9 @@ message UserWebhook {
|
||||
// Optional. Signing secret used to HMAC-SHA256 sign the webhook request body.
|
||||
// This field is input-only; it is never returned in responses.
|
||||
string signing_secret = 6 [(google.api.field_behavior) = INPUT_ONLY];
|
||||
|
||||
// Whether this webhook has a signing secret configured.
|
||||
bool has_signing_secret = 7 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
}
|
||||
|
||||
message ListUserWebhooksRequest {
|
||||
|
||||
@@ -2166,8 +2166,10 @@ type UserWebhook struct {
|
||||
// Optional. Signing secret used to HMAC-SHA256 sign the webhook request body.
|
||||
// This field is input-only; it is never returned in responses.
|
||||
SigningSecret string `protobuf:"bytes,6,opt,name=signing_secret,json=signingSecret,proto3" json:"signing_secret,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// Whether this webhook has a signing secret configured.
|
||||
HasSigningSecret bool `protobuf:"varint,7,opt,name=has_signing_secret,json=hasSigningSecret,proto3" json:"has_signing_secret,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UserWebhook) Reset() {
|
||||
@@ -2242,6 +2244,13 @@ func (x *UserWebhook) GetSigningSecret() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UserWebhook) GetHasSigningSecret() bool {
|
||||
if x != nil {
|
||||
return x.HasSigningSecret
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ListUserWebhooksRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The parent user resource.
|
||||
@@ -3467,7 +3476,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"\x05token\x18\x02 \x01(\tR\x05token\"`\n" +
|
||||
" DeletePersonalAccessTokenRequest\x12<\n" +
|
||||
"\x04name\x18\x01 \x01(\tB(\xe0A\x02\xfaA\"\n" +
|
||||
" memos.api.v1/PersonalAccessTokenR\x04name\"\x86\x02\n" +
|
||||
" memos.api.v1/PersonalAccessTokenR\x04name\"\xb9\x02\n" +
|
||||
"\vUserWebhook\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" +
|
||||
"\x03url\x18\x02 \x01(\tR\x03url\x12!\n" +
|
||||
@@ -3476,7 +3485,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"createTime\x12@\n" +
|
||||
"\vupdate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
|
||||
"updateTime\x12*\n" +
|
||||
"\x0esigning_secret\x18\x06 \x01(\tB\x03\xe0A\x04R\rsigningSecret\"6\n" +
|
||||
"\x0esigning_secret\x18\x06 \x01(\tB\x03\xe0A\x04R\rsigningSecret\x121\n" +
|
||||
"\x12has_signing_secret\x18\a \x01(\bB\x03\xe0A\x03R\x10hasSigningSecret\"6\n" +
|
||||
"\x17ListUserWebhooksRequest\x12\x1b\n" +
|
||||
"\x06parent\x18\x01 \x01(\tB\x03\xe0A\x02R\x06parent\"Q\n" +
|
||||
"\x18ListUserWebhooksResponse\x125\n" +
|
||||
|
||||
@@ -3993,6 +3993,10 @@ components:
|
||||
description: |-
|
||||
Optional. Signing secret used to HMAC-SHA256 sign the webhook request body.
|
||||
This field is input-only; it is never returned in responses.
|
||||
hasSigningSecret:
|
||||
readOnly: true
|
||||
type: boolean
|
||||
description: Whether this webhook has a signing secret configured.
|
||||
description: UserWebhook represents a webhook owned by a user.
|
||||
tags:
|
||||
- name: AIService
|
||||
|
||||
@@ -1271,9 +1271,10 @@ func generateUserWebhookID() string {
|
||||
// convertUserWebhookFromUserSetting converts a storepb webhook to a v1pb UserWebhook.
|
||||
func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webhook, user *store.User) *v1pb.UserWebhook {
|
||||
return &v1pb.UserWebhook{
|
||||
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
|
||||
Url: webhook.Url,
|
||||
DisplayName: webhook.Title,
|
||||
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
|
||||
Url: webhook.Url,
|
||||
DisplayName: webhook.Title,
|
||||
HasSigningSecret: webhook.SigningSecret != "",
|
||||
// Note: create_time and update_time are not available in the user setting webhook structure
|
||||
// This is a limitation of storing webhooks in user settings vs the dedicated webhook table
|
||||
}
|
||||
@@ -1470,9 +1471,10 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, user *store.
|
||||
apiWebhooks = make([]*v1pb.UserWebhook, 0, len(webhooks.Webhooks))
|
||||
for _, webhook := range webhooks.Webhooks {
|
||||
apiWebhook := &v1pb.UserWebhook{
|
||||
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
|
||||
Url: webhook.Url,
|
||||
DisplayName: webhook.Title,
|
||||
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
|
||||
Url: webhook.Url,
|
||||
DisplayName: webhook.Title,
|
||||
HasSigningSecret: webhook.SigningSecret != "",
|
||||
}
|
||||
apiWebhooks = append(apiWebhooks, apiWebhook)
|
||||
}
|
||||
|
||||
@@ -26,4 +26,5 @@ func TestConvertUserWebhookFromUserSettingOmitsSigningSecret(t *testing.T) {
|
||||
require.Equal(t, "My Webhook", apiWebhook.DisplayName)
|
||||
require.Equal(t, "https://example.com/postreceive", apiWebhook.Url)
|
||||
require.Empty(t, apiWebhook.SigningSecret, "signing secret must never be returned in API responses")
|
||||
require.True(t, apiWebhook.HasSigningSecret, "has_signing_secret must be true when secret is configured")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,6 +37,7 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
|
||||
signingSecret: isCreating ? "" : undefined,
|
||||
});
|
||||
const requestState = useLoading(false);
|
||||
const [hasExistingSecret, setHasExistingSecret] = useState(false);
|
||||
const [secretCopied, setSecretCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,6 +54,7 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
|
||||
url: webhook.url,
|
||||
signingSecret: undefined,
|
||||
});
|
||||
setHasExistingSecret(webhook.hasSigningSecret);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -65,6 +67,8 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
|
||||
url: "",
|
||||
signingSecret: "",
|
||||
});
|
||||
setHasExistingSecret(false);
|
||||
setSecretCopied(false);
|
||||
}
|
||||
}, [open, isCreating]);
|
||||
|
||||
@@ -87,28 +91,35 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
|
||||
});
|
||||
};
|
||||
|
||||
const handleSigningSecretInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
signingSecret: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerateSecret = () => {
|
||||
const handleGenerateAndCopy = () => {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const secret = "whsec_" + btoa(String.fromCharCode(...bytes));
|
||||
setPartialState({ signingSecret: secret });
|
||||
setSecretCopied(false);
|
||||
};
|
||||
|
||||
const handleCopySecret = () => {
|
||||
if (!state.signingSecret) return;
|
||||
copy(state.signingSecret.trim());
|
||||
copy(secret);
|
||||
setSecretCopied(true);
|
||||
setTimeout(() => setSecretCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleClearSecret = () => {
|
||||
setPartialState({ signingSecret: "" });
|
||||
};
|
||||
|
||||
const normalizedSigningSecret = state.signingSecret?.trim() ?? "";
|
||||
|
||||
const getPendingLabel = () => {
|
||||
const prefix = `${t("setting.webhook.create-dialog.signing-secret-pending")}: `;
|
||||
if (state.signingSecret === undefined) {
|
||||
return prefix + t("setting.webhook.create-dialog.signing-secret-pending-no-changes");
|
||||
}
|
||||
if (state.signingSecret === "") {
|
||||
if (isCreating) {
|
||||
return prefix + t("setting.webhook.create-dialog.signing-secret-pending-no-changes");
|
||||
}
|
||||
return prefix + t("setting.webhook.create-dialog.signing-secret-pending-cleared");
|
||||
}
|
||||
return prefix + t("setting.webhook.create-dialog.signing-secret-pending-generated");
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.displayName || !state.url) {
|
||||
toast.error(t("message.fill-all-required-fields"));
|
||||
@@ -192,30 +203,47 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="signingSecret">{t("setting.webhook.create-dialog.signing-secret")}</Label>
|
||||
<span className="text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-description")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="signingSecret"
|
||||
type="password"
|
||||
placeholder={t("setting.webhook.create-dialog.signing-secret-placeholder")}
|
||||
value={state.signingSecret ?? ""}
|
||||
onChange={handleSigningSecretInputChange}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t("setting.webhook.create-dialog.signing-secret")}</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("setting.webhook.create-dialog.signing-secret-status")}:{" "}
|
||||
{hasExistingSecret
|
||||
? t("setting.webhook.create-dialog.signing-secret-configured")
|
||||
: t("setting.webhook.create-dialog.signing-secret-not-configured")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopySecret}
|
||||
disabled={!state.signingSecret}
|
||||
aria-label={t("setting.webhook.create-dialog.copy-secret")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateAndCopy}
|
||||
aria-label={t("setting.webhook.create-dialog.generate-and-copy-secret")}
|
||||
>
|
||||
{secretCopied ? <CheckIcon className="h-4 w-4 text-success" /> : <CopyIcon className="h-4 w-4" />}
|
||||
{secretCopied ? (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3.5 w-3.5 text-success" />
|
||||
{t("setting.webhook.create-dialog.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="mr-1 h-3.5 w-3.5" />
|
||||
{t("setting.webhook.create-dialog.generate-and-copy-secret")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleGenerateSecret}>
|
||||
{t("setting.webhook.create-dialog.generate-secret")}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearSecret}
|
||||
disabled={!state.signingSecret && !hasExistingSecret}
|
||||
aria-label={t("setting.webhook.create-dialog.clear-secret")}
|
||||
>
|
||||
<Trash2Icon className="mr-1 h-3.5 w-3.5" />
|
||||
{t("setting.webhook.create-dialog.clear-secret")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{getPendingLabel()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,7 +252,7 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
{isCreating ? t("common.create") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
@@ -17,6 +17,7 @@ const WebhookSection = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
const [webhooks, setWebhooks] = useState<UserWebhook[]>([]);
|
||||
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
|
||||
const [editingWebhookName, setEditingWebhookName] = useState<string | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
|
||||
|
||||
const fetchWebhooks = async () => {
|
||||
@@ -39,6 +40,16 @@ const WebhookSection = () => {
|
||||
toast.success(t("setting.webhook.create-dialog.create-webhook-success", { name }));
|
||||
};
|
||||
|
||||
const handleEditWebhook = (webhook: UserWebhook) => {
|
||||
setEditingWebhookName(webhook.name);
|
||||
};
|
||||
|
||||
const handleEditWebhookDialogConfirm = async () => {
|
||||
const webhooks = await fetchWebhooks();
|
||||
setWebhooks(webhooks);
|
||||
setEditingWebhookName(undefined);
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = (webhook: UserWebhook) => {
|
||||
setDeleteTarget(webhook);
|
||||
};
|
||||
@@ -88,9 +99,14 @@ const WebhookSection = () => {
|
||||
header: "",
|
||||
className: "text-right",
|
||||
render: (_, webhook: UserWebhook) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteWebhook(webhook)}>
|
||||
<TrashIcon className="text-destructive w-4 h-auto" />
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditWebhook(webhook)} aria-label={t("common.edit")}>
|
||||
<PencilIcon className="w-4 h-auto" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteWebhook(webhook)} aria-label={t("common.delete")}>
|
||||
<TrashIcon className="text-destructive w-4 h-auto" />
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -104,6 +120,13 @@ const WebhookSection = () => {
|
||||
onOpenChange={setIsCreateWebhookDialogOpen}
|
||||
onSuccess={handleCreateWebhookDialogConfirm}
|
||||
/>
|
||||
<CreateWebhookDialog
|
||||
key={editingWebhookName ?? "edit-webhook-dialog"}
|
||||
open={!!editingWebhookName}
|
||||
onOpenChange={(open) => !open && setEditingWebhookName(undefined)}
|
||||
webhookName={editingWebhookName}
|
||||
onSuccess={handleEditWebhookDialogConfirm}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
|
||||
+10
-4
@@ -759,15 +759,21 @@
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
"an-easy-to-remember-name": "An easy-to-remember name",
|
||||
"clear-secret": "Clear",
|
||||
"copied": "Copied",
|
||||
"create-webhook": "Create webhook",
|
||||
"create-webhook-success": "Webhook `{{name}}` created",
|
||||
"copy-secret": "Copy signing secret",
|
||||
"edit-webhook": "Edit webhook",
|
||||
"generate-secret": "Generate",
|
||||
"generate-and-copy-secret": "Generate & Copy",
|
||||
"payload-url": "Payload URL",
|
||||
"signing-secret": "Signing Secret",
|
||||
"signing-secret-description": "Optional. Used to HMAC-SHA256 sign the request body following the Standard Webhooks spec.",
|
||||
"signing-secret-placeholder": "Enter a secret or generate one",
|
||||
"signing-secret-configured": "Configured",
|
||||
"signing-secret-not-configured": "Not configured",
|
||||
"signing-secret-pending": "Pending",
|
||||
"signing-secret-pending-cleared": "cleared",
|
||||
"signing-secret-pending-generated": "new secret",
|
||||
"signing-secret-pending-no-changes": "no changes",
|
||||
"signing-secret-status": "Status",
|
||||
"title": "Title",
|
||||
"url-example-post-receive": "https://example.com/postreceive"
|
||||
},
|
||||
|
||||
@@ -669,15 +669,21 @@
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
"an-easy-to-remember-name": "请输入一个容易记住的标题",
|
||||
"clear-secret": "置空",
|
||||
"copied": "已复制",
|
||||
"create-webhook": "创建 Webhook",
|
||||
"create-webhook-success": "Webhook `{{name}}` 已创建",
|
||||
"copy-secret": "复制签名密钥",
|
||||
"edit-webhook": "编辑 Webhook",
|
||||
"generate-secret": "生成",
|
||||
"generate-and-copy-secret": "生成并复制",
|
||||
"payload-url": "请输入有效的 URL",
|
||||
"signing-secret": "签名密钥",
|
||||
"signing-secret-description": "可选。用于对请求体进行 HMAC-SHA256 签名,遵循 Standard Webhooks 规范。",
|
||||
"signing-secret-placeholder": "输入密钥或点击生成",
|
||||
"signing-secret-configured": "已配置",
|
||||
"signing-secret-not-configured": "未配置",
|
||||
"signing-secret-pending": "待保存",
|
||||
"signing-secret-pending-cleared": "置空",
|
||||
"signing-secret-pending-generated": "新生成",
|
||||
"signing-secret-pending-no-changes": "密钥无改动",
|
||||
"signing-secret-status": "状态",
|
||||
"title": "标题",
|
||||
"url-example-post-receive": "https://example.com/postreceive"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user