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.
This commit is contained in:
@@ -111,6 +111,10 @@ export async function createEmbedChannel(agentId: string, data: Partial<EmbedCha
|
||||
return post<{ success: boolean; data: EmbedChannel }>(`/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<EmbedChannel>) {
|
||||
return put<{ success: boolean; data: EmbedChannel }>(`/api/v1/embed-channels/${channelId}`, data)
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>>({})
|
||||
const revealedTokens = reactive<Record<string, boolean>>({})
|
||||
const previewVisible = ref(false)
|
||||
const previewChannel = ref<EmbedChannel | null>(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<string, string> => {
|
||||
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'))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Показать',
|
||||
|
||||
@@ -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: "显示",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user