feat: add webhook edit UI and signing secret status indicator (#6027)

pull/6051/head
Yiges.M.x. 2 weeks ago committed by GitHub
parent 9e84f61029
commit c703b05dab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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)}

@ -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
Loading…
Cancel
Save