From 0f807c3b95b65f7adc2caa934805220f43c04fb5 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Sat, 31 Oct 2026 00:29:53 +0530 Subject: [PATCH] feat(widget): setUser({ token }) takes a token function, like userToken (#482) The token in setUser(), ChatCops.setUser() and the script tag loader is now a UserToken: a string, or a function (sync or async) that returns a fresh token each time a session is requested. Passing the same function again keeps the chat; a different function, a string or no token counts as another visitor. --- .changeset/widget-setuser-token-function.md | 5 ++ api/widget.api.md | 4 +- packages/widget/src/index.ts | 3 +- packages/widget/src/loader/loader.ts | 3 +- packages/widget/src/widget.ts | 12 ++-- packages/widget/tests/api.test-d.ts | 8 ++- packages/widget/tests/api.test.ts | 19 +++++ packages/widget/tests/user-token.test.ts | 78 +++++++++++++++++++++ website/src/content/docs/widget/api.mdx | 8 ++- 9 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 .changeset/widget-setuser-token-function.md diff --git a/.changeset/widget-setuser-token-function.md b/.changeset/widget-setuser-token-function.md new file mode 100644 index 00000000..a0db01a9 --- /dev/null +++ b/.changeset/widget-setuser-token-function.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": patch +--- + +`ChatCops.setUser({ token })` takes a function (sync or async) that returns a fresh token, the same as `userToken`. Passing the same function again keeps the chat; a different function, a string or no token starts a new one. diff --git a/api/widget.api.md b/api/widget.api.md index 646e5364..daa7087e 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -22,7 +22,7 @@ declare const _default: { setUser(user: { email?: string; name?: string; - token?: string; + token?: UserToken; } | null): void; setContext(data: Record | null): void; setConsent(value: boolean): void; @@ -102,7 +102,7 @@ export declare class Widget { setUser(user: { email?: string; name?: string; - token?: string; + token?: UserToken; } | null): void; setContext(data: Record | null): void; update(changes: WidgetUpdate): void; diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 9611c4c0..708c79dc 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -8,6 +8,7 @@ import { import { configFromScript } from './script-config.js'; import { setLanguageDirection } from './dom/shadow.js'; import { registerWidgetLocale, type WidgetLocaleStrings } from './i18n.js'; +import type { UserToken } from './api/client.js'; import { VERSION } from './version.js'; export { @@ -88,7 +89,7 @@ const ChatCops = { * The signed-in visitor: `token` for the server, `name` and `email` as * visitor details. Another user, or `null` to sign out, clears the chat. */ - setUser(user: { name?: string; email?: string; token?: string } | null): void { + setUser(user: { name?: string; email?: string; token?: UserToken } | null): void { instance?.setUser(user); }, diff --git a/packages/widget/src/loader/loader.ts b/packages/widget/src/loader/loader.ts index 2d1afc7a..8bdc03b2 100644 --- a/packages/widget/src/loader/loader.ts +++ b/packages/widget/src/loader/loader.ts @@ -4,6 +4,7 @@ import { HOST_ID, nextHostId, resolveDirection, setLanguageDirection } from '../ import { applyTheme } from '../theme.js'; import type { WidgetLocaleStrings } from '../i18n.js'; import type { Widget, WidgetConfig, WidgetEventHandler, WidgetEventType, WidgetUpdate } from '../widget.js'; +import type { UserToken } from '../api/client.js'; import { VERSION } from '../version.js'; export interface PanelModule { @@ -31,7 +32,7 @@ export interface ChatCopsLoader { /** Adds or replaces a language for `locale`: its strings (English for any left out), and `dir`. */ registerLocale(code: string, strings: Partial, options?: { dir?: 'ltr' | 'rtl' }): void; /** The signed-in visitor (token, name, email). Another user, or `null`, clears the chat. Kept until the chat loads. */ - setUser(user: { name?: string; email?: string; token?: string } | null): void; + setUser(user: { name?: string; email?: string; token?: UserToken } | null): void; /** Details the bot should know for the next messages. `null` removes them. Kept until the chat loads. */ setContext(data: Record | null): void; /** Changes the theme, branding or wording of the chat. What you leave out stays as it is. Kept until the chat loads. */ diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 8b1cc40f..1f01f001 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -493,12 +493,14 @@ export class Widget { /** * The signed-in visitor. `token` is the identity token for the server - * (signed-in visitors); `name` and `email` are sent as visitor details, and - * the name greets them. Switching to another user, or signing out with - * `null`, clears the chat, so one person's conversation never shows for - * the next. + * (signed-in visitors), or a function that returns one, as `userToken` + * does; `name` and `email` are sent as visitor details, and the name + * greets them. Switching to another user, or signing out with `null`, + * clears the chat, so one person's conversation never shows for the next. + * The same function passed again counts as the same visitor; any other + * function, a string or no token counts as another. */ - setUser(user: { name?: string; email?: string; token?: string } | null): void { + setUser(user: { name?: string; email?: string; token?: UserToken } | null): void { const previous = this.client.identity; const token = user?.token; if (previous !== undefined && previous !== token) this.clear(); diff --git a/packages/widget/tests/api.test-d.ts b/packages/widget/tests/api.test-d.ts index 9c46b6b1..63c78651 100644 --- a/packages/widget/tests/api.test-d.ts +++ b/packages/widget/tests/api.test-d.ts @@ -26,11 +26,17 @@ describe('the widget API types', () => { }); it('types setUser and setContext the same in both builds', () => { - type User = { name?: string; email?: string; token?: string } | null; + type User = { name?: string; email?: string; token?: UserToken } | null; expectTypeOf(ChatCops.setUser).parameters.toEqualTypeOf<[User]>(); expectTypeOf().parameters.toEqualTypeOf<[User]>(); expectTypeOf(ChatCops.setContext).parameters.toEqualTypeOf<[Record | null]>(); expectTypeOf().parameters.toEqualTypeOf<[Record | null]>(); + // The token is a string or a function, as in `userToken`. + ChatCops.setUser({ token: 'signed.token' }); + ChatCops.setUser({ token: async () => 'signed.token' }); + ChatCops.setUser({ name: 'Ada', token: () => undefined }); + // @ts-expect-error: a function has to return a string + ChatCops.setUser({ token: () => 42 }); // @ts-expect-error: context values are strings ChatCops.setContext({ seats: 12 }); }); diff --git a/packages/widget/tests/api.test.ts b/packages/widget/tests/api.test.ts index c6a833a8..31f9f655 100644 --- a/packages/widget/tests/api.test.ts +++ b/packages/widget/tests/api.test.ts @@ -441,6 +441,25 @@ describe('the script tag loader API', () => { loader.destroy(); }); + it('keeps a token function given to setUser() until the chat loads, and passes it on as it is', async () => { + const load = panel(); + const loader = createLoader(load); + const token = vi.fn(async () => 'fresh.token'); + loader.init({ apiUrl: '/chat' }); + loader.setUser({ token }); + await tick(); + expect(load).not.toHaveBeenCalled(); + expect(token).not.toHaveBeenCalled(); + + const setUser = vi.spyOn(Widget.prototype, 'setUser'); + loader.open(); + await tick(); + await tick(); + expect(setUser).toHaveBeenCalledWith({ token }); + expect(token).not.toHaveBeenCalled(); + loader.destroy(); + }); + it('registerLocale uses the strings without fetching a file, and passes the direction to the panel', async () => { const loadLocale = vi.fn(); const loader = createLoader(panel(), loadLocale); diff --git a/packages/widget/tests/user-token.test.ts b/packages/widget/tests/user-token.test.ts index 2967ec63..f093b70c 100644 --- a/packages/widget/tests/user-token.test.ts +++ b/packages/widget/tests/user-token.test.ts @@ -85,3 +85,81 @@ describe('userToken as a function', () => { expect(sent()).toEqual(['fixed.token']); }); }); + +describe('setUser with a token function', () => { + type Internals = { messages: Array<{ role: string; content: string }> }; + const said = (w: Widget, text: string) => (w as unknown as Internals).messages.some((m) => m.content === text); + const remember = (w: Widget, text: string) => (w as unknown as Internals).messages.push({ role: 'user', content: text }); + + it('calls the function when a session is requested, not when setUser() is called', async () => { + const sent = sessionRequests(); + const token = vi.fn(async () => 'fresh.from.setUser'); + widget = new Widget({ apiUrl: API }); + widget.init(); + widget.setUser({ name: 'Ada', token }); + expect(token).not.toHaveBeenCalled(); + + await widget.sendMessage('Hello'); + expect(token).toHaveBeenCalledTimes(1); + expect(sent()).toEqual(['fresh.from.setUser']); + }); + + it('keeps the chat when the same function is passed again', () => { + widget = new Widget({ apiUrl: API }); + widget.init(); + const token = () => 'signed'; + widget.setUser({ token }); + remember(widget, 'A question'); + widget.setUser({ name: 'Ada', token }); + expect(said(widget, 'A question')).toBe(true); + }); + + it('clears the chat for a different function, a string, or null', () => { + widget = new Widget({ apiUrl: API }); + widget.init(); + const first = () => 'one'; + for (const next of [() => 'one', 'a.string.token', undefined, null] as const) { + widget.setUser({ token: first }); + remember(widget, 'Visitor A wrote this'); + widget.setUser(next === null ? null : { token: next }); + expect(said(widget, 'Visitor A wrote this')).toBe(false); + } + }); + + it('clears the chat when a function is replaced by a user with no token', () => { + widget = new Widget({ apiUrl: API }); + widget.init(); + widget.setUser({ token: () => 'one' }); + remember(widget, 'A question'); + widget.setUser({ name: 'Ada' }); + expect(said(widget, 'A question')).toBe(false); + }); + + it('keeps the chat when a function is given to a visitor who was anonymous', () => { + widget = new Widget({ apiUrl: API }); + widget.init(); + remember(widget, 'A question'); + widget.setUser({ token: () => 'signed' }); + expect(said(widget, 'A question')).toBe(true); + }); + + it('takes a function through ChatCops.setUser() in the npm and single-file builds', async () => { + const sent = sessionRequests(); + widget = ChatCops.init({ apiUrl: API }); + ChatCops.setUser({ token: async () => 'through.ChatCops' }); + await ChatCops.sendMessage('Hello'); + expect(sent()).toEqual(['through.ChatCops']); + }); + + it('is replaced by a later string, which is sent instead', async () => { + const sent = sessionRequests(); + const token = vi.fn(() => 'from.function'); + widget = new Widget({ apiUrl: API }); + widget.init(); + widget.setUser({ token }); + widget.setUser({ token: 'a.string.token' }); + await widget.sendMessage('Hello'); + expect(sent()).toEqual(['a.string.token']); + expect(token).not.toHaveBeenCalled(); + }); +}); diff --git a/website/src/content/docs/widget/api.mdx b/website/src/content/docs/widget/api.mdx index 5ccfd757..75e107bf 100644 --- a/website/src/content/docs/widget/api.mdx +++ b/website/src/content/docs/widget/api.mdx @@ -134,16 +134,20 @@ ChatCops.off('open', onOpen); Tell the widget who the signed-in visitor is. -- `token` is the identity token for [signed-in visitors](/server/setup/#signed-in-visitors), which your server signs. +- `token` is the identity token for [signed-in visitors](/server/setup/#signed-in-visitors), which your server signs. It can also be a function (sync or async) that returns a fresh token each time the widget asks for a session, the same as [`userToken`](/server/setup/#a-fresh-token-for-each-session). - `name` and `email` are sent to the bot as visitor details with the first message of a conversation, and the name greets them. -Switching to another user, or `setUser(null)` when the visitor signs out, clears the chat, so one person's conversation never shows for the next. With the script tag build, a call before the chat has loaded is kept until it does. +Switching to another user, or `setUser(null)` when the visitor signs out, clears the chat, so one person's conversation never shows for the next. With a function, passing the same function again counts as the same visitor and keeps the chat; a different function, a string or no token counts as another visitor and clears it. Keep one reference to the function if you call `setUser` more than once. With the script tag build, a call before the chat has loaded is kept until it does. ```typescript declare const session: { name: string; email: string; chatToken: string }; ChatCops.setUser({ name: session.name, email: session.email, token: session.chatToken }); +// Or, so the token is signed when the chat needs it and cannot have expired: +const signToken = async () => (await fetch('/api/chat-token')).text(); +ChatCops.setUser({ name: session.name, email: session.email, token: signToken }); + // When the visitor signs out: ChatCops.setUser(null); ```