From 6932c3a85d002f7b74adecdace5b04eff3ffa3e0 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Fri, 26 Jun 2026 14:50:03 +0800 Subject: [PATCH] feat(embed): always show channel key via detail API Fetch publish tokens when opening an embed channel drawer so deploy snippets stay available after browser restarts, while list endpoints still omit secrets. --- frontend/src/api/embed/index.ts | 4 ++ .../src/components/AgentEmbedChannelPanel.vue | 41 ++++++++----------- frontend/src/i18n/embed.ts | 8 ++-- frontend/src/i18n/locales/en-US.ts | 9 ++-- frontend/src/i18n/locales/ru-RU.ts | 9 ++-- frontend/src/i18n/locales/zh-CN.ts | 9 ++-- internal/handler/embed_channel.go | 16 ++++++++ internal/router/router.go | 1 + 8 files changed, 58 insertions(+), 39 deletions(-) diff --git a/frontend/src/api/embed/index.ts b/frontend/src/api/embed/index.ts index 8607199a..49459686 100644 --- a/frontend/src/api/embed/index.ts +++ b/frontend/src/api/embed/index.ts @@ -111,6 +111,10 @@ export async function createEmbedChannel(agentId: string, data: Partial(`/api/v1/agents/${agentId}/embed-channels`, data) } +export async function getEmbedChannel(channelId: string) { + return get<{ success: boolean; data: EmbedChannel }>(`/api/v1/embed-channels/${channelId}`) +} + export async function updateEmbedChannel(channelId: string, data: Partial) { return put<{ success: boolean; data: EmbedChannel }>(`/api/v1/embed-channels/${channelId}`, data) } diff --git a/frontend/src/components/AgentEmbedChannelPanel.vue b/frontend/src/components/AgentEmbedChannelPanel.vue index c3baea30..ced5a529 100644 --- a/frontend/src/components/AgentEmbedChannelPanel.vue +++ b/frontend/src/components/AgentEmbedChannelPanel.vue @@ -552,6 +552,7 @@ import EmbedChannelPreview from '@/components/EmbedChannelPreview.vue' import { listAllEmbedChannels, createEmbedChannel, + getEmbedChannel, updateEmbedChannel, deleteEmbedChannel, rotateEmbedToken, @@ -591,7 +592,6 @@ const channels = computed(() => { if (!filter) return allChannels.value return allChannels.value.filter((ch) => ch.agent_id === filter) }) -const tokenByChannel = ref>({}) const revealedTokens = reactive>({}) const previewVisible = ref(false) const previewChannel = ref(null) @@ -637,7 +637,6 @@ const agentImageUploadEnabledEffective = computed(() => drawerAgent.value?.config?.image_upload_enabled === true, ) -const EMBED_TOKEN_STORAGE = 'weknora_embed_publish_tokens' const WEKNORA_BRAND_COLOR = '#07C05F' function getDefaultEmbedPrimaryColor(): string { @@ -818,21 +817,13 @@ function agentForChannel(ch: EmbedChannel): CustomAgent | undefined { return agents.value.find((agent) => agent.id === ch.agent_id) } -const loadStoredTokens = (): Record => { - try { - return JSON.parse(sessionStorage.getItem(EMBED_TOKEN_STORAGE) || '{}') - } catch { - return {} +function mergeChannelDetail(detail: EmbedChannel) { + const idx = allChannels.value.findIndex((ch) => ch.id === detail.id) + if (idx >= 0) { + allChannels.value[idx] = { ...allChannels.value[idx], ...detail } } } -const storeToken = (channelId: string, token: string) => { - const map = loadStoredTokens() - map[channelId] = token - sessionStorage.setItem(EMBED_TOKEN_STORAGE, JSON.stringify(map)) - tokenByChannel.value = { ...tokenByChannel.value, [channelId]: token } -} - const load = async () => { loading.value = true try { @@ -842,7 +833,6 @@ const load = async () => { ]) allChannels.value = res?.data || [] agents.value = agentRes?.data || [] - tokenByChannel.value = { ...loadStoredTokens(), ...tokenByChannel.value } await Promise.all(allChannels.value.map(async (ch) => { try { const statsRes = await getEmbedChannelStats(ch.id) @@ -886,7 +876,7 @@ watch(createAgentId, (agentId, prev) => { previewNonce.value += 1 }) -const tokenFor = (ch: EmbedChannel) => tokenByChannel.value[ch.id] || ch.publish_token +const tokenFor = (ch: EmbedChannel) => ch.publish_token || '' const displayChannelKey = (channelId: string) => { const ch = channels.value.find((c) => c.id === channelId) @@ -1009,11 +999,19 @@ const openCreate = () => { showDrawer.value = true } -const openDrawer = (ch: EmbedChannel) => { +const openDrawer = async (ch: EmbedChannel) => { channelNameTouched.value = true fillFormFromChannel(ch) goToDeployStep() showDrawer.value = true + try { + const res = await getEmbedChannel(ch.id) + if (res?.data) { + mergeChannelDetail(res.data) + } + } catch { + MessagePlugin.warning(t('embedPublish.channelKeyLoadFailed')) + } } const closeDrawer = () => { @@ -1102,7 +1100,6 @@ const saveForm = async () => { } const res = await createEmbedChannel(targetAgentId, payload) if (res?.data?.publish_token) { - storeToken(res.data.id, res.data.publish_token) revealedTokens[res.data.id] = true MessagePlugin.success(t('embedPublish.createdWithToken')) } else { @@ -1112,7 +1109,8 @@ const saveForm = async () => { if (res?.data?.id) { const created = channels.value.find((ch) => ch.id === res.data.id) ?? res.data if (created) { - fillFormFromChannel(created) + mergeChannelDetail({ ...created, ...res.data }) + fillFormFromChannel(allChannels.value.find((ch) => ch.id === res.data.id) ?? created) wizardStep.value = stepTitles.value.length - 1 } } @@ -1202,7 +1200,7 @@ const performRotate = async (id: string) => { try { const res = await rotateEmbedToken(id) if (res?.data?.publish_token) { - storeToken(id, res.data.publish_token) + mergeChannelDetail(res.data) revealedTokens[id] = true MessagePlugin.success(t('embedPublish.resetKeySuccess')) } else { @@ -1218,9 +1216,6 @@ const performRotate = async (id: string) => { const removeChannel = async (id: string) => { await deleteEmbedChannel(id) - const map = loadStoredTokens() - delete map[id] - sessionStorage.setItem(EMBED_TOKEN_STORAGE, JSON.stringify(map)) if (editingId.value === id) closeDrawer() await load() MessagePlugin.success(t('embedPublish.deleted')) diff --git a/frontend/src/i18n/embed.ts b/frontend/src/i18n/embed.ts index f4a75ea8..6027da84 100644 --- a/frontend/src/i18n/embed.ts +++ b/frontend/src/i18n/embed.ts @@ -32,7 +32,7 @@ const messages = { "primaryColor": "主题色", "pageTitle": "页面标题", "pageTitlePlaceholder": "AI 客服", - "tokenHint": "Token 仅在创建或轮换时显示,请点击「轮换 Token」获取嵌入代码", + "tokenHint": "加载渠道密钥失败,请关闭后重新打开该渠道。", "created": "嵌入渠道已创建", "updated": "嵌入渠道已更新", "deleted": "已删除", @@ -53,7 +53,7 @@ const messages = { "positionTopRight": "右上角", "positionTopLeft": "左上角", "publishToken": "发布 Token", - "publishTokenHelp": "发布 Token(em_…)是嵌入渠道的长期密钥,相当于 API Key。创建或轮换时只显示一次,需写入第三方网站的浮窗脚本;列表接口不会返回,防止泄露。", + "publishTokenHelp": "发布 Token(em_…)是嵌入渠道的长期密钥,相当于 API Key。打开渠道详情即可查看并复制;轮换后旧 Token 立即失效。", "sessionTokenHelp": "访客打开聊天后,iframe 会用发布 Token 换取短期会话 Token(ems_…,约 30 分钟),后续请求使用会话 Token,避免在 URL 中长期暴露发布 Token。", "rotateTokenHelp": "轮换会作废旧发布 Token,所有已部署的嵌入代码需同步更新,否则第三方站点将无法访问。", "revealToken": "显示", @@ -467,7 +467,7 @@ const messages = { "primaryColor": "Primary color", "pageTitle": "Page title", "pageTitlePlaceholder": "AI Assistant", - "tokenHint": "Token is shown only on create or rotate — click Rotate token to get embed code", + "tokenHint": "Failed to load the channel key. Close and reopen this channel.", "created": "Embed channel created", "updated": "Embed channel updated", "deleted": "Deleted", @@ -488,7 +488,7 @@ const messages = { "positionTopRight": "Top right", "positionTopLeft": "Top left", "publishToken": "Publish token", - "publishTokenHelp": "The publish token (em_…) is a long-lived secret for this embed channel—like an API key. It is shown only when created or rotated and must be placed in the host site widget script; list APIs never return it.", + "publishTokenHelp": "The publish token (em_…) is a long-lived secret for this embed channel—like an API key. Open channel details to view and copy it; rotating invalidates the previous token immediately.", "sessionTokenHelp": "After a visitor opens chat, the iframe exchanges the publish token for a short-lived session token (ems_…, ~30 min). Later API calls use the session token so the publish token is not kept in the URL.", "rotateTokenHelp": "Rotating invalidates the previous publish token. Every deployed embed snippet must be updated or third-party sites will lose access.", "revealToken": "Reveal", diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 64505971..68067f5b 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2340,7 +2340,8 @@ export default { rotatePublishToken: 'Rotate publish token', channelKey: 'Channel key', channelKeyDesc: 'Long-lived secret for third-party site integration—store it safely', - channelKeyHint: 'For security, channel keys are not returned in list APIs. If you did not save it, reset to generate a new key (stored for this browser session).', + channelKeyHint: 'Failed to load the channel key. Close and reopen this channel, or refresh the page.', + channelKeyLoadFailed: 'Failed to load channel key', channelKeyUnavailable: 'Key unavailable', resetKeyTitle: 'Reset channel key', resetKeyConfirmOk: 'Reset', @@ -2387,8 +2388,8 @@ export default { headerTitleModeChannel: 'Fixed channel title', headerTitleModeSession: 'Follow session title', headerTitleModeDesc: 'Fixed: always show the page title. Session: auto-title from the first message; falls back to page title until generated.', - tokenHint: 'For security, channel keys are not returned in list APIs. Save the key when creating or resetting before copying the widget snippet (stored for this browser session).', - createdWithToken: 'Embed channel created — the new key is shown in the input field; copy and store it now', + tokenHint: 'Failed to load the channel key. Close and reopen this channel.', + createdWithToken: 'Embed channel created — the channel key is shown below', created: 'Embed channel created', updated: 'Embed channel updated', saveFailed: 'Save failed', @@ -2411,7 +2412,7 @@ export default { positionTopRight: 'Top right', positionTopLeft: 'Top left', publishToken: 'Channel key', - publishTokenHelp: 'The channel key (em_…) is a long-lived secret for this embed channel—like an API key. It is shown only when created or reset and must be placed in the host site widget script; list APIs never return it.', + publishTokenHelp: 'The channel key (em_…) is a long-lived secret for this embed channel—like an API key. Open the channel details to view and copy it; resetting invalidates the previous key immediately.', sessionTokenHelp: 'After a visitor opens chat, the iframe exchanges the channel key for a short-lived session credential (ems_…, ~30 min). Later API calls use the session credential so the channel key is not kept in the URL.', rotateTokenHelp: 'Resetting invalidates the previous channel key. Every deployed embed snippet must be updated or third-party sites will lose access.', revealToken: 'Reveal', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 567bcf41..d7c67875 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -2725,7 +2725,8 @@ export default { rotatePublishToken: 'Сменить токен публикации', channelKey: 'Ключ канала', channelKeyDesc: 'Долгоживущий секрет для интеграции на сторонних сайтах — храните в безопасности', - channelKeyHint: 'Из соображений безопасности ключи каналов не возвращаются в API списка. Если не сохранили — сбросьте для нового ключа (временно в сессии браузера).', + channelKeyHint: 'Не удалось загрузить ключ канала. Закройте и снова откройте канал или обновите страницу.', + channelKeyLoadFailed: 'Не удалось загрузить ключ канала', channelKeyUnavailable: 'Ключ недоступен', resetKeyTitle: 'Сбросить ключ канала', resetKeyConfirmOk: 'Сбросить', @@ -2772,8 +2773,8 @@ export default { headerTitleModeChannel: 'Фиксированный заголовок канала', headerTitleModeSession: 'Следовать заголовку сессии', headerTitleModeDesc: 'Фиксированный: всегда заголовок страницы. Сессия: авто-заголовок из первого сообщения; до генерации — заголовок страницы.', - tokenHint: 'Из соображений безопасности ключи не возвращаются в API списка. Сохраните ключ при создании или сбросе перед копированием виджета (в сессии браузера).', - createdWithToken: 'Канал создан — новый ключ в поле ввода; скопируйте и сохраните сейчас', + tokenHint: 'Не удалось загрузить ключ канала. Закройте и снова откройте канал.', + createdWithToken: 'Канал создан — ключ канала показан ниже', created: 'Канал встраивания создан', updated: 'Канал встраивания обновлён', saveFailed: 'Не удалось сохранить', @@ -2796,7 +2797,7 @@ export default { positionTopRight: 'Справа вверху', positionTopLeft: 'Слева вверху', publishToken: 'Ключ канала', - publishTokenHelp: 'Ключ канала (em_…) — долгоживущий секрет канала, как API Key. Показывается только при создании или сбросе; вставляется в скрипт виджета на сайте.', + publishTokenHelp: 'Ключ канала (em_…) — долгоживущий секрет канала, как API Key. Откройте детали канала, чтобы просмотреть и скопировать; сброс немедленно аннулирует предыдущий ключ.', sessionTokenHelp: 'После открытия чата iframe обменивает ключ канала на краткосрочный сеансовый (ems_…, ~30 мин). Дальнейшие запросы используют сеансовый, ключ не остаётся в URL.', rotateTokenHelp: 'Сброс отзывает предыдущий ключ. Обновите все развёрнутые фрагменты встраивания.', revealToken: 'Показать', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 18d65519..511b6320 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3242,7 +3242,8 @@ export default { rotatePublishToken: "轮换发布 Token", channelKey: "渠道密钥", channelKeyDesc: "用于第三方网站接入的长期密钥,请妥善保管", - channelKeyHint: "渠道密钥出于安全考虑不会在列表中返回。若未保存,请点击重置获取新密钥(当前浏览器会话内会暂存)。", + channelKeyHint: "加载渠道密钥失败,请关闭后重新打开或刷新页面重试。", + channelKeyLoadFailed: "加载渠道密钥失败", channelKeyUnavailable: "密钥不可用", resetKeyTitle: "重置渠道密钥", resetKeyConfirmOk: "确认重置", @@ -3289,8 +3290,8 @@ export default { headerTitleModeChannel: "固定渠道标题", headerTitleModeSession: "跟随会话标题", headerTitleModeDesc: "固定渠道标题:始终显示页面标题;跟随会话标题:首条消息后按对话内容自动命名,未生成前显示页面标题", - tokenHint: "渠道密钥出于安全考虑不会在列表中返回。复制浮窗脚本前,请在创建或重置时保存密钥(当前浏览器会话内会暂存)。", - createdWithToken: "嵌入渠道已创建,新密钥已显示在输入框中,请立即复制并妥善保存", + tokenHint: "加载渠道密钥失败,请关闭后重新打开该渠道。", + createdWithToken: "嵌入渠道已创建,渠道密钥已显示在下方", created: "嵌入渠道已创建", updated: "嵌入渠道已更新", saveFailed: "保存失败", @@ -3313,7 +3314,7 @@ export default { positionTopRight: "右上角", positionTopLeft: "左上角", publishToken: "渠道密钥", - publishTokenHelp: "渠道密钥(em_…)是嵌入渠道的长期密钥,相当于 API Key。创建或重置时只显示一次,需写入第三方网站的浮窗脚本;列表接口不会返回,防止泄露。", + publishTokenHelp: "渠道密钥(em_…)是嵌入渠道的长期密钥,相当于 API Key。打开渠道详情即可查看并复制;重置后旧密钥立即失效。", sessionTokenHelp: "访客打开聊天后,iframe 会用渠道密钥换取短期会话凭证(ems_…,约 30 分钟),后续请求使用会话凭证,避免在 URL 中长期暴露渠道密钥。", rotateTokenHelp: "重置会作废旧渠道密钥,所有已部署的嵌入代码需同步更新,否则第三方站点将无法访问。", revealToken: "显示", diff --git a/internal/handler/embed_channel.go b/internal/handler/embed_channel.go index 770ca23b..87be08c5 100644 --- a/internal/handler/embed_channel.go +++ b/internal/handler/embed_channel.go @@ -603,6 +603,22 @@ func patchEmbedChatPayload(body io.Reader, ch *types.EmbedChannel, agentMode boo return patched, nil } +// GetEmbedChannel returns a single embed channel for management, including the +// publish token so admins can copy deploy snippets at any time. +func (h *EmbedChannelHandler) GetEmbedChannel(c *gin.Context) { + channelID := strings.TrimSpace(c.Param("channel_id")) + tenantID := c.GetUint64(types.TenantIDContextKey.String()) + ch, err := h.embedSvc.GetOwnedChannel(c.Request.Context(), tenantID, channelID) + if err != nil { + writeEmbedMgmtError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": embedChannelResponse(ch, ch.PublishToken), + }) +} + // GetEmbedChannelStats returns lightweight usage stats for an embed channel. func (h *EmbedChannelHandler) GetEmbedChannelStats(c *gin.Context) { channelID := strings.TrimSpace(c.Param("channel_id")) diff --git a/internal/router/router.go b/internal/router/router.go index c604c368..8ae92a3a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1178,6 +1178,7 @@ func RegisterEmbedChannelRoutes(r *gin.RouterGroup, embedHandler *handler.EmbedC channels := r.Group("/embed-channels") { channels.GET("", g.Viewer(), embedHandler.ListAllEmbedChannels) + channels.GET("/:channel_id", g.Viewer(), embedHandler.GetEmbedChannel) channels.PUT("/:channel_id", g.Admin(), embedHandler.UpdateEmbedChannel) channels.DELETE("/:channel_id", g.Admin(), embedHandler.DeleteEmbedChannel) channels.POST("/:channel_id/rotate-token", g.Admin(), embedHandler.RotateEmbedToken)