Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/widget-setuser-token-function.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ declare const _default: {
setUser(user: {
email?: string;
name?: string;
token?: string;
token?: UserToken;
} | null): void;
setContext(data: Record<string, string> | null): void;
setConsent(value: boolean): void;
Expand Down Expand Up @@ -102,7 +102,7 @@ export declare class Widget {
setUser(user: {
email?: string;
name?: string;
token?: string;
token?: UserToken;
} | null): void;
setContext(data: Record<string, string> | null): void;
update(changes: WidgetUpdate): void;
Expand Down
3 changes: 2 additions & 1 deletion packages/widget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
},

Expand Down
3 changes: 2 additions & 1 deletion packages/widget/src/loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<WidgetLocaleStrings>, 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<string, string> | null): void;
/** Changes the theme, branding or wording of the chat. What you leave out stays as it is. Kept until the chat loads. */
Expand Down
12 changes: 7 additions & 5 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion packages/widget/tests/api.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatCopsLoader['setUser']>().parameters.toEqualTypeOf<[User]>();
expectTypeOf(ChatCops.setContext).parameters.toEqualTypeOf<[Record<string, string> | null]>();
expectTypeOf<ChatCopsLoader['setContext']>().parameters.toEqualTypeOf<[Record<string, string> | 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 });
});
Expand Down
19 changes: 19 additions & 0 deletions packages/widget/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
78 changes: 78 additions & 0 deletions packages/widget/tests/user-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
8 changes: 6 additions & 2 deletions website/src/content/docs/widget/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
Expand Down