feat: add JWT authentication support to KefuWidgetDemo
This commit is contained in:
@@ -1,16 +1,28 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { SignJWT } from "jose"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config"
|
import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config"
|
||||||
|
|
||||||
const STORAGE_KEY = "cs-agent-web-widget-test-config"
|
const STORAGE_KEY = "cs-agent-web-widget-test-config"
|
||||||
|
const DEFAULT_JWT_TTL_MINUTES = "30"
|
||||||
const INITIAL_CONFIG: KefuWidgetHostConfig = {
|
const INITIAL_CONFIG: KefuWidgetHostConfig = {
|
||||||
channelId: "",
|
channelId: "",
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthMode = "guest" | "jwt"
|
||||||
|
|
||||||
|
type WidgetDemoConfig = KefuWidgetHostConfig & {
|
||||||
|
authMode?: AuthMode
|
||||||
|
jwtSecret?: string
|
||||||
|
jwtUserId?: string
|
||||||
|
jwtName?: string
|
||||||
|
jwtTtlMinutes?: string
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
CSAgentWidget?: {
|
CSAgentWidget?: {
|
||||||
@@ -22,14 +34,14 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultConfig(): KefuWidgetHostConfig {
|
function getDefaultConfig(): WidgetDemoConfig {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return INITIAL_CONFIG
|
return INITIAL_CONFIG
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedText = window.localStorage.getItem(STORAGE_KEY)
|
const savedText = window.localStorage.getItem(STORAGE_KEY)
|
||||||
const savedConfig = savedText
|
const savedConfig = savedText
|
||||||
? (JSON.parse(savedText) as Partial<KefuWidgetHostConfig>)
|
? (JSON.parse(savedText) as Partial<WidgetDemoConfig>)
|
||||||
: {}
|
: {}
|
||||||
const query = new URLSearchParams(window.location.search)
|
const query = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
@@ -37,6 +49,11 @@ function getDefaultConfig(): KefuWidgetHostConfig {
|
|||||||
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
|
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
|
authMode: (query.get("authMode") as AuthMode | null) ?? savedConfig.authMode ?? "guest",
|
||||||
|
jwtSecret: savedConfig.jwtSecret ?? "",
|
||||||
|
jwtUserId: query.get("userId") ?? savedConfig.jwtUserId ?? "demo-user-001",
|
||||||
|
jwtName: query.get("name") ?? savedConfig.jwtName ?? "测试用户",
|
||||||
|
jwtTtlMinutes: savedConfig.jwtTtlMinutes ?? DEFAULT_JWT_TTL_MINUTES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +86,79 @@ function injectWidget(config: KefuWidgetHostConfig) {
|
|||||||
document.body.appendChild(script)
|
document.body.appendChild(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWidgetConfig(config: WidgetDemoConfig, userToken: string): WidgetDemoConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
channelId: config.channelId.trim(),
|
||||||
|
baseUrl: "",
|
||||||
|
apiBaseUrl: "",
|
||||||
|
userToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signUserToken(config: WidgetDemoConfig) {
|
||||||
|
const userId = (config.jwtUserId || "").trim()
|
||||||
|
const name = (config.jwtName || "").trim()
|
||||||
|
const secret = (config.jwtSecret || "").trim()
|
||||||
|
const ttl = Number(config.jwtTtlMinutes || DEFAULT_JWT_TTL_MINUTES)
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("请填写 userId")
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("请填写用户名称")
|
||||||
|
}
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("请填写 JWT Secret")
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(ttl) || ttl <= 0) {
|
||||||
|
throw new Error("有效期必须大于 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SignJWT({ userId, name })
|
||||||
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${ttl}m`)
|
||||||
|
.sign(new TextEncoder().encode(secret))
|
||||||
|
}
|
||||||
|
|
||||||
export function KefuWidgetDemo() {
|
export function KefuWidgetDemo() {
|
||||||
const [config, setConfig] = useState<KefuWidgetHostConfig>(INITIAL_CONFIG)
|
const [config, setConfig] = useState<WidgetDemoConfig>({
|
||||||
|
...INITIAL_CONFIG,
|
||||||
|
authMode: "guest",
|
||||||
|
jwtSecret: "",
|
||||||
|
jwtUserId: "demo-user-001",
|
||||||
|
jwtName: "测试用户",
|
||||||
|
jwtTtlMinutes: DEFAULT_JWT_TTL_MINUTES,
|
||||||
|
})
|
||||||
const [status, setStatus] = useState("请填写 channelId")
|
const [status, setStatus] = useState("请填写 channelId")
|
||||||
const [origin, setOrigin] = useState("")
|
const [origin, setOrigin] = useState("")
|
||||||
|
const [generatedToken, setGeneratedToken] = useState("")
|
||||||
|
|
||||||
|
async function mountWidget(configToMount: WidgetDemoConfig) {
|
||||||
|
let userToken = ""
|
||||||
|
if (configToMount.authMode === "jwt") {
|
||||||
|
userToken = await signUserToken(configToMount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextConfig = buildWidgetConfig(configToMount, userToken)
|
||||||
|
setConfig(nextConfig)
|
||||||
|
setGeneratedToken(userToken)
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig))
|
||||||
|
|
||||||
|
if (!nextConfig.channelId) {
|
||||||
|
removeMountedWidget()
|
||||||
|
setStatus("请填写 channelId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
injectWidget(nextConfig)
|
||||||
|
setStatus(
|
||||||
|
nextConfig.authMode === "jwt"
|
||||||
|
? "Widget 已挂载:JWT 用户模式"
|
||||||
|
: "Widget 已挂载:访客模式"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -82,7 +168,11 @@ export function KefuWidgetDemo() {
|
|||||||
setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId")
|
setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId")
|
||||||
|
|
||||||
if (initialConfig.channelId) {
|
if (initialConfig.channelId) {
|
||||||
injectWidget(initialConfig)
|
void mountWidget(initialConfig).catch((error) => {
|
||||||
|
removeMountedWidget()
|
||||||
|
setGeneratedToken("")
|
||||||
|
setStatus(error instanceof Error ? error.message : "生成 userToken 失败")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
@@ -97,40 +187,34 @@ export function KefuWidgetDemo() {
|
|||||||
? `${origin}/sdk/cs-ai-agent-sdk.min.js`
|
? `${origin}/sdk/cs-ai-agent-sdk.min.js`
|
||||||
: "/sdk/cs-ai-agent-sdk.min.js"
|
: "/sdk/cs-ai-agent-sdk.min.js"
|
||||||
|
|
||||||
|
const configLines = [` channelId: "${config.channelId || ""}"`]
|
||||||
|
if (config.authMode === "jwt") {
|
||||||
|
configLines.push(` userToken: "${generatedToken || "业务系统后端签发的 JWT"}"`)
|
||||||
|
}
|
||||||
|
|
||||||
return `<script>
|
return `<script>
|
||||||
window.CSAgentConfig = {
|
window.CSAgentConfig = {
|
||||||
channelId: "${config.channelId || ""}"
|
${configLines.join(",\n")}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script async src="${scriptSrc}"></script>`
|
<script async src="${scriptSrc}"></script>`
|
||||||
}, [config, origin])
|
}, [config, generatedToken, origin])
|
||||||
|
|
||||||
function updateField<K extends keyof KefuWidgetHostConfig>(
|
function updateField<K extends keyof WidgetDemoConfig>(
|
||||||
key: K,
|
key: K,
|
||||||
value: KefuWidgetHostConfig[K]
|
value: WidgetDemoConfig[K]
|
||||||
) {
|
) {
|
||||||
setConfig((current) => ({ ...current, [key]: value }))
|
setConfig((current) => ({ ...current, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMount() {
|
async function handleMount() {
|
||||||
const nextConfig: KefuWidgetHostConfig = {
|
try {
|
||||||
...config,
|
await mountWidget(config)
|
||||||
channelId: config.channelId.trim(),
|
} catch (error) {
|
||||||
baseUrl: "",
|
|
||||||
apiBaseUrl: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(nextConfig)
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig))
|
|
||||||
|
|
||||||
if (!nextConfig.channelId) {
|
|
||||||
removeMountedWidget()
|
removeMountedWidget()
|
||||||
setStatus("请填写 channelId")
|
setGeneratedToken("")
|
||||||
return
|
setStatus(error instanceof Error ? error.message : "生成 userToken 失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
injectWidget(nextConfig)
|
|
||||||
setStatus("Widget 已挂载")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,12 +230,47 @@ export function KefuWidgetDemo() {
|
|||||||
value={config.channelId}
|
value={config.channelId}
|
||||||
onChange={(value) => updateField("channelId", value)}
|
onChange={(value) => updateField("channelId", value)}
|
||||||
/>
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
label="鉴权模式"
|
||||||
|
value={config.authMode || "guest"}
|
||||||
|
onChange={(value) => updateField("authMode", value)}
|
||||||
|
options={[
|
||||||
|
{ label: "访客", value: "guest" },
|
||||||
|
{ label: "JWT 用户", value: "jwt" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{config.authMode === "jwt" ? (
|
||||||
|
<div className="grid gap-3 rounded-md border border-slate-200 p-3">
|
||||||
|
<TextField
|
||||||
|
label="userId"
|
||||||
|
value={config.jwtUserId}
|
||||||
|
onChange={(value) => updateField("jwtUserId", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="name"
|
||||||
|
value={config.jwtName}
|
||||||
|
onChange={(value) => updateField("jwtName", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="JWT Secret"
|
||||||
|
value={config.jwtSecret}
|
||||||
|
onChange={(value) => updateField("jwtSecret", value)}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="有效期(分钟)"
|
||||||
|
value={config.jwtTtlMinutes}
|
||||||
|
onChange={(value) => updateField("jwtTtlMinutes", value)}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex gap-2">
|
<div className="mt-5 flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleMount}
|
onClick={() => void handleMount()}
|
||||||
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
|
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
|
||||||
>
|
>
|
||||||
挂载
|
挂载
|
||||||
@@ -178,9 +297,24 @@ export function KefuWidgetDemo() {
|
|||||||
|
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<div className="text-base font-semibold">接入代码</div>
|
<div className="text-base font-semibold">接入代码</div>
|
||||||
|
{config.authMode === "jwt" ? (
|
||||||
|
<div className="mt-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||||
|
当前页面仅用于本地模拟。正式接入时,userToken 应由业务系统后端签发。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-5 text-slate-100">
|
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-5 text-slate-100">
|
||||||
<code>{snippet}</code>
|
<code>{snippet}</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
{generatedToken ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-sm font-medium text-slate-700">当前 userToken</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={generatedToken}
|
||||||
|
className="mt-2 h-28 w-full resize-none rounded-md border border-slate-200 p-3 font-mono text-xs outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -191,15 +325,18 @@ function TextField({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
type = "text",
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
value?: string
|
value?: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
type?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="grid gap-1.5 text-sm">
|
<label className="grid gap-1.5 text-sm">
|
||||||
<span className="font-medium text-slate-700">{label}</span>
|
<span className="font-medium text-slate-700">{label}</span>
|
||||||
<input
|
<input
|
||||||
|
type={type}
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
className="h-9 rounded-md border border-slate-200 px-3 text-sm outline-none focus:border-slate-400"
|
className="h-9 rounded-md border border-slate-200 px-3 text-sm outline-none focus:border-slate-400"
|
||||||
@@ -207,3 +344,37 @@ function TextField({
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SegmentedControl<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
options: Array<{ label: string; value: T }>
|
||||||
|
onChange: (value: T) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5 text-sm">
|
||||||
|
<div className="font-medium text-slate-700">{label}</div>
|
||||||
|
<div className="grid grid-cols-2 rounded-md border border-slate-200 bg-slate-100 p-1">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={
|
||||||
|
option.value === value
|
||||||
|
? "rounded bg-white px-3 py-1.5 text-sm font-medium shadow-sm"
|
||||||
|
: "rounded px-3 py-1.5 text-sm text-slate-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"md-editor-rt": "^6.4.1",
|
"md-editor-rt": "^6.4.1",
|
||||||
|
|||||||
Generated
+7
-4
@@ -62,6 +62,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
jose:
|
||||||
|
specifier: ^6.2.3
|
||||||
|
version: 6.2.3
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
version: 0.577.0(react@19.2.3)
|
version: 0.577.0(react@19.2.3)
|
||||||
@@ -2876,8 +2879,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jose@6.2.1:
|
jose@6.2.3:
|
||||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
@@ -5065,7 +5068,7 @@ snapshots:
|
|||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
express-rate-limit: 8.3.1(express@5.2.1)
|
express-rate-limit: 8.3.1(express@5.2.1)
|
||||||
hono: 4.12.7
|
hono: 4.12.7
|
||||||
jose: 6.2.1
|
jose: 6.2.3
|
||||||
json-schema-typed: 8.0.2
|
json-schema-typed: 8.0.2
|
||||||
pkce-challenge: 5.0.1
|
pkce-challenge: 5.0.1
|
||||||
raw-body: 3.0.2
|
raw-body: 3.0.2
|
||||||
@@ -7156,7 +7159,7 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
jose@6.2.1: {}
|
jose@6.2.3: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user