diff --git a/.changeset/knockprovider-enabled-client-quiescence.md b/.changeset/knockprovider-enabled-client-quiescence.md new file mode 100644 index 000000000..05e831976 --- /dev/null +++ b/.changeset/knockprovider-enabled-client-quiescence.md @@ -0,0 +1,15 @@ +--- +"@knocklabs/client": minor +--- + +Add a subscribable authentication-state signal and make the client fully quiescent while unauthenticated. This is the `@knocklabs/client` foundation for an upcoming `enabled` prop on `KnockProvider`. + +- `Knock` now exposes a subscribable `authStore` (a `@tanstack/store`) and an `authStatus` getter (`"authenticated" | "unauthenticated"`), updated on every `authenticate()` and `logout()`. +- New `Knock.logout()` clears credentials and tears down all stateful connections (feed channels, socket, token-expiration timer, and page-visibility listener), then lazily recreates the API client on next use. Re-authenticating after a logout rewires any surviving feed instances. +- Unauthenticated calls are now quiet no-ops instead of firing requests or throwing: + - Feed `markAs*` / `markAll*` / `fetchNextPage` skip the network **and** the optimistic store update. + - Guide `fetch()` / `subscribe()` and step `markAsSeen` / `markAsInteracted` / `markAsArchived` no longer throw (`fetch()` resolves to an error status). This fixes a crash when Guides render before a user is authenticated. + - Slack and MS Teams `authCheck` return a disconnected result, and `getChannels` / `getTeams` return empty results. + - `messages.batchUpdateStatuses` returns an empty array. +- Fix: the guide client now re-reads its socket from the API client on each `subscribe()`, so guide real-time keeps working after a re-authentication (previously it captured the socket once at construction and went stale on user/token changes). +- Fix: the guide client's `history.pushState`/`replaceState` monkey-patch is now shared and idempotent per window, so remounting a guide provider (e.g. toggling `enabled`) no longer nests patches or leaves the originals unrestored. diff --git a/.changeset/knockprovider-enabled-prop.md b/.changeset/knockprovider-enabled-prop.md new file mode 100644 index 000000000..c07d8152a --- /dev/null +++ b/.changeset/knockprovider-enabled-prop.md @@ -0,0 +1,26 @@ +--- +"@knocklabs/react-core": minor +"@knocklabs/react": minor +"@knocklabs/react-native": minor +"@knocklabs/expo": minor +--- + +Add an `enabled` prop to `KnockProvider` (and an `enabled` option to `useAuthenticatedKnockClient`). + +When `enabled` is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no identify, no network requests, and no real-time socket activity. Flipping it to `true` authenticates and mounts everything (like a login); flipping it back to `false` tears everything down and clears the client's stores (like a logout). It defaults to `true`, so existing usage is unchanged. + +This is the recommended replacement for conditionally mounting `KnockProvider`, and the canonical way to defer activity until you have a complete identity — e.g. an enhanced-security user token that loads asynchronously: + +```tsx + +``` + +Also fixed while here: + +- `useFeedSettings` no longer fires `GET /v1/users/undefined/feeds/.../settings` for an unauthenticated user. +- `KnockProvider` now tears down its Knock client (socket, token-expiration timer, and page-visibility listener) on unmount instead of leaking it. diff --git a/.changeset/use-knock-auth-state-reactive-integrations.md b/.changeset/use-knock-auth-state-reactive-integrations.md new file mode 100644 index 000000000..fcc45cb79 --- /dev/null +++ b/.changeset/use-knock-auth-state-reactive-integrations.md @@ -0,0 +1,12 @@ +--- +"@knocklabs/react-core": minor +"@knocklabs/react": minor +"@knocklabs/react-native": minor +"@knocklabs/expo": minor +--- + +Add `useKnockAuthState()` and make the Slack, MS Teams, and Expo integrations react to authentication changes. + +- New `useKnockAuthState(knock)` hook subscribes to a client's authentication state (`{ status, userId, userToken }`), re-rendering on login, logout, or a user switch. Backed by the subscribable `authStore` on `@knocklabs/client`. +- Slack and MS Teams connection status now reset and re-run `authCheck` when the authenticated user changes, instead of latching on the first check. The provider keys now include the userId so a user switch reliably re-renders consumers. Combined with the client-side guards, an unauthenticated user resolves to `disconnected` (never `error`) without a network request. +- Expo: `autoRegister` now waits for an authenticated user before registering a push token — deferring the OS permission prompt (no longer prompting logged-out users) and re-running registration once a user signs in. A notification tapped while logged out no longer fires a message-status update. diff --git a/packages/client/README.md b/packages/client/README.md index da6d31573..88828ca4e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -43,6 +43,26 @@ knockClient.authenticate( ); ``` +### Logging out and authentication state + +While a client is unauthenticated it is fully quiescent: user-scoped calls (feed +fetches, mark-as-read, guides, Slack/Teams checks, etc.) become no-ops rather +than firing requests or throwing, and no real-time socket is opened. This makes +it safe to construct a client before you have a user. + +Call `logout()` to clear the current user and tear down all stateful +connections (the socket, the token-expiration timer, and the page-visibility +listener): + +```typescript +knockClient.logout(); +``` + +You can observe authentication state via `knockClient.authStatus` +(`"authenticated" | "unauthenticated"`) or subscribe to the `knockClient.authStore` +for changes. In React, prefer the `enabled` prop on `KnockProvider` (see +`@knocklabs/react`), which manages this lifecycle for you. + ### Retrieving new items from the feed ```typescript diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index d78f053ae..02dc3ca64 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -164,7 +164,25 @@ class Feed { return this.store.getState(); } + /** + * Returns `true` when the current user is authenticated. When not, logs and + * returns `false` so mutation methods can no-op early — without touching the + * store (no optimistic update) or the network. This upholds the SDK-wide + * "unauthenticated ⇒ quiescent" invariant for auto-fired paths such as the + * popover's `markAllAsSeen` on open or a cell's `markAsInteracted` on click. + */ + private canMutate(operation: string): boolean { + if (this.knock.isAuthenticated()) { + return true; + } + + this.knock.log(`[Feed] User is not authenticated, skipping ${operation}`); + return false; + } + async markAsSeen(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsSeen")) return; + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -177,6 +195,8 @@ class Feed { } async markAllAsSeen() { + if (!this.canMutate("markAllAsSeen")) return; + // To mark all of the messages as seen we: // 1. Optimistically update *everything* we have in the store // 2. We decrement the `unseen_count` to zero optimistically @@ -219,6 +239,8 @@ class Feed { } async markAsUnseen(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsUnseen")) return; + this.optimisticallyPerformStatusUpdate( itemOrItems, "unseen", @@ -230,6 +252,8 @@ class Feed { } async markAsRead(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsRead")) return; + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -242,6 +266,8 @@ class Feed { } async markAllAsRead() { + if (!this.canMutate("markAllAsRead")) return; + // To mark all of the messages as read we: // 1. Optimistically update *everything* we have in the store // 2. We decrement the `unread_count` to zero optimistically @@ -284,6 +310,8 @@ class Feed { } async markAsUnread(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsUnread")) return; + this.optimisticallyPerformStatusUpdate( itemOrItems, "unread", @@ -298,6 +326,8 @@ class Feed { itemOrItems: FeedItemOrItems, metadata?: Record, ) { + if (!this.canMutate("markAsInteracted")) return; + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -321,6 +351,8 @@ class Feed { TODO: how do we handle rollbacks? */ async markAsArchived(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsArchived")) return; + const state = this.store.getState(); const shouldOptimisticallyRemoveItems = @@ -392,6 +424,8 @@ class Feed { } async markAllAsArchived() { + if (!this.canMutate("markAllAsArchived")) return; + // Note: there is the potential for a race condition here because the bulk // update is an async method, so if a new message comes in during this window before // the update has been processed we'll effectively reset the `unseen_count` to be what it was. @@ -419,6 +453,8 @@ class Feed { } async markAllReadAsArchived() { + if (!this.canMutate("markAllReadAsArchived")) return; + // Note: there is the potential for a race condition here because the bulk // update is an async method, so if a new message comes in during this window before // the update has been processed we'll effectively reset the `unseen_count` to be what it was. @@ -461,6 +497,8 @@ class Feed { } async markAsUnarchived(itemOrItems: FeedItemOrItems) { + if (!this.canMutate("markAsUnarchived")) return; + const state = this.store.getState(); const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; @@ -595,6 +633,8 @@ class Feed { } async fetchNextPage(options: FetchFeedOptions = {}) { + if (!this.canMutate("fetchNextPage")) return; + // Attempts to fetch the next page of results (if we have any) const { pageInfo } = this.store.getState(); diff --git a/packages/client/src/clients/feed/index.ts b/packages/client/src/clients/feed/index.ts index d62505779..eb5356cc7 100644 --- a/packages/client/src/clients/feed/index.ts +++ b/packages/client/src/clients/feed/index.ts @@ -30,6 +30,11 @@ class FeedClient { this.feedInstances = this.feedInstances.filter((f) => f !== feed); } + /** Whether this client currently manages any feed instances. */ + hasInstances() { + return this.feedInstances.length > 0; + } + teardownInstances() { for (const feed of this.feedInstances) { feed.teardown(); diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index 37bc26a16..5abd97a02 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -70,6 +70,80 @@ const checkForWindow = () => { } }; +type LocationListener = () => void; + +// State for the shared history monkey-patch is stored on the `history` object +// itself so it is naturally scoped per-window and free of module-level globals. +type PatchedHistory = History & { + __knockGuidePatch?: { + listeners: Set; + originalPushState: History["pushState"]; + originalReplaceState: History["replaceState"]; + }; +}; + +// Register a location-change listener, installing a single shared patch of +// `history.pushState`/`replaceState` for programmatic navigation. This is +// idempotent across guide client instances: a provider remount (e.g. toggling +// `enabled`) constructs a new client while the old one is still tearing down, so +// patching per-instance would nest proxies and clobber the restore. Sharing one +// patch keyed on the `history` object avoids that entirely. +const addHistoryLocationListener = ( + history: PatchedHistory, + listener: LocationListener, +) => { + if (!history.__knockGuidePatch) { + const listeners = new Set(); + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + + // Notify on the next tick so the browser state can settle first. + const notify = () => + setTimeout(() => { + for (const l of listeners) l(); + }, 0); + + const wrap = ( + target: T, + ): T => + new Proxy(target, { + apply: (fn, thisArg, args) => { + const result = Reflect.apply(fn, thisArg, args); + notify(); + return result; + }, + }) as T; + + history.pushState = wrap(originalPushState); + history.replaceState = wrap(originalReplaceState); + history.__knockGuidePatch = { + listeners, + originalPushState, + originalReplaceState, + }; + } + + history.__knockGuidePatch.listeners.add(listener); +}; + +// Remove a location-change listener, restoring the original history methods once +// the last guide client on this window has gone away. +const removeHistoryLocationListener = ( + history: PatchedHistory, + listener: LocationListener, +) => { + const patch = history.__knockGuidePatch; + if (!patch) return; + + patch.listeners.delete(listener); + + if (patch.listeners.size === 0) { + history.pushState = patch.originalPushState; + history.replaceState = patch.originalReplaceState; + delete history.__knockGuidePatch; + } +}; + export const guidesApiRootPath = (userId: string | undefined | null) => `/v1/users/${userId}/guides`; @@ -196,10 +270,6 @@ export class KnockGuideClient { ]; private subscribeRetryCount = 0; - // Original history methods to monkey patch, or restore in cleanups. - private pushStateFn: History["pushState"] | undefined; - private replaceStateFn: History["replaceState"] | undefined; - // Guides that are competing to render are "staged" first without rendering // and ranked based on its relative order in the group over a duration of time // to resolve and render the prevailing one. @@ -278,9 +348,24 @@ export class KnockGuideClient { this.clearCounterInterval(); } - async fetch(opts?: { filters?: QueryFilterParams; force?: boolean }) { + async fetch(opts?: { + filters?: QueryFilterParams; + force?: boolean; + }): Promise { this.knock.log("[Guide] .fetch"); - this.knock.failIfNotAuthenticated(); + + // No user to fetch guides for. Return an error status (without caching it, + // so a later authenticated fetch still runs) rather than throwing — guide + // components auto-fire this and a throw crashes the host app. + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] User is not authenticated, skipping fetch"); + return { + status: "error", + error: new Error( + "Not authenticated. Please call `authenticate` first.", + ), + }; + } const queryParams = this.buildQueryParams(opts?.filters); const queryKey = this.formatQueryKey(queryParams); @@ -341,8 +426,20 @@ export class KnockGuideClient { } subscribe() { + // No user to subscribe for; skip silently rather than throwing (this is + // reachable from guide components rendered while unauthenticated). + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] User is not authenticated, skipping subscribe"); + return; + } + + // Re-read the socket from the API client on every subscribe. Re-authenticating + // replaces the API client (and therefore its socket), so a socket captured + // once in the constructor goes stale and reconnects with old credentials. + // Reading it here keeps guide real-time working across re-auth (E12). + this.socket = this.knock.client().socket; if (!this.socket) return; - this.knock.failIfNotAuthenticated(); + this.knock.log("[Guide] Subscribing to real time updates"); // Ensure a live socket connection if not yet connected. @@ -897,6 +994,10 @@ export class KnockGuideClient { // async markAsSeen(guide: GuideData, step: GuideStepData) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] User is not authenticated, skipping markAsSeen"); + return; + } if (step.message.seen_at) return; this.knock.log( @@ -934,6 +1035,13 @@ export class KnockGuideClient { step: GuideStepData, metadata?: GenericData, ) { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Guide] User is not authenticated, skipping markAsInteracted", + ); + return; + } + this.knock.log( `[Guide] Marking as interacted (Guide key: ${guide.key}; Step ref:${step.ref})`, ); @@ -966,6 +1074,12 @@ export class KnockGuideClient { } async markAsArchived(guide: GuideData, step: GuideStepData) { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Guide] User is not authenticated, skipping markAsArchived", + ); + return; + } if (step.message.archived_at) return; this.knock.log( @@ -1279,31 +1393,13 @@ export class KnockGuideClient { // 2. Listen for hash changes in case it's used for routing. win.addEventListener("hashchange", this.handleLocationChange); - // 3. Monkey-patch history methods to catch programmatic navigation. - const pushStateFn = win.history.pushState; - const replaceStateFn = win.history.replaceState; - - // Use setTimeout to allow the browser state to potentially settle. - win.history.pushState = new Proxy(pushStateFn, { - apply: (target, history, args) => { - Reflect.apply(target, history, args); - setTimeout(() => { - this.handleLocationChange(); - }, 0); - }, - }); - win.history.replaceState = new Proxy(replaceStateFn, { - apply: (target, history, args) => { - Reflect.apply(target, history, args); - setTimeout(() => { - this.handleLocationChange(); - }, 0); - }, - }); - - // 4. Keep refs to the original handlers so we can restore during cleanup. - this.pushStateFn = pushStateFn; - this.replaceStateFn = replaceStateFn; + // 3. Catch programmatic navigation via a shared history patch. This is + // idempotent across guide client instances so a remount doesn't nest + // patches (see `addHistoryLocationListener`). + addHistoryLocationListener( + win.history as PatchedHistory, + this.handleLocationChange, + ); } else { this.knock.log( "[Guide] Unable to access the `window.history` object to detect location changes", @@ -1318,14 +1414,10 @@ export class KnockGuideClient { win.removeEventListener("popstate", this.handleLocationChange); win.removeEventListener("hashchange", this.handleLocationChange); - if (this.pushStateFn) { - win.history.pushState = this.pushStateFn; - this.pushStateFn = undefined; - } - if (this.replaceStateFn) { - win.history.replaceState = this.replaceStateFn; - this.replaceStateFn = undefined; - } + removeHistoryLocationListener( + win.history as PatchedHistory, + this.handleLocationChange, + ); } static getToolbarRunConfigFromUrl() { diff --git a/packages/client/src/clients/messages/index.ts b/packages/client/src/clients/messages/index.ts index cf3701812..4550cfd1f 100644 --- a/packages/client/src/clients/messages/index.ts +++ b/packages/client/src/clients/messages/index.ts @@ -62,6 +62,16 @@ class MessageClient { status: MessageEngagementStatus | "unseen" | "unread" | "unarchived", options?: UpdateMessageStatusOptions, ): Promise { + // The feed's mark methods already guard on auth; this guards the same path + // against direct calls so a stale-rendered cell can't fire a status update + // for a logged-out user. + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Messages] User is not authenticated, skipping batchUpdateStatuses", + ); + return []; + } + // Metadata is only required for the "interacted" status const additionalPayload = status === "interacted" && options ? { metadata: options.metadata } : {}; diff --git a/packages/client/src/clients/ms-teams/index.ts b/packages/client/src/clients/ms-teams/index.ts index e0da5a5c4..647a0202e 100644 --- a/packages/client/src/clients/ms-teams/index.ts +++ b/packages/client/src/clients/ms-teams/index.ts @@ -18,6 +18,16 @@ class MsTeamsClient { } async authCheck({ tenant: tenantId, knockChannelId }: AuthCheckInput) { + // Without a user we can't check a tenant's connection. Return a "not + // connected" shape (never an error) so status hooks land on "disconnected" + // rather than firing a request to `/v1/providers/ms-teams/.../auth_check`. + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MsTeams] User is not authenticated, skipping authCheck", + ); + return { connection: { ok: false } }; + } + const result = await this.instance.client().makeRequest({ method: "GET", url: `/v1/providers/ms-teams/${knockChannelId}/auth_check`, @@ -36,6 +46,13 @@ class MsTeamsClient { async getTeams( input: GetMsTeamsTeamsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MsTeams] User is not authenticated, skipping getTeams", + ); + return { ms_teams_teams: [], skip_token: null }; + } + const { knockChannelId, tenant: tenantId } = input; const queryOptions = input.queryOptions || {}; @@ -62,6 +79,13 @@ class MsTeamsClient { async getChannels( input: GetMsTeamsChannelsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MsTeams] User is not authenticated, skipping getChannels", + ); + return { ms_teams_channels: [] }; + } + const { knockChannelId, teamId, tenant: tenantId } = input; const queryOptions = input.queryOptions || {}; diff --git a/packages/client/src/clients/slack/index.ts b/packages/client/src/clients/slack/index.ts index 1ad308db7..632c02637 100644 --- a/packages/client/src/clients/slack/index.ts +++ b/packages/client/src/clients/slack/index.ts @@ -13,6 +13,16 @@ class SlackClient { } async authCheck({ tenant, knockChannelId }: AuthCheckInput) { + // Without a user we can't check a tenant's connection. Return a "not + // connected" shape (never an error) so status hooks land on "disconnected" + // rather than firing a request to `/v1/providers/slack/.../auth_check`. + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[Slack] User is not authenticated, skipping authCheck", + ); + return { connection: { ok: false } }; + } + const result = await this.instance.client().makeRequest({ method: "GET", url: `/v1/providers/slack/${knockChannelId}/auth_check`, @@ -31,6 +41,13 @@ class SlackClient { async getChannels( input: GetSlackChannelsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[Slack] User is not authenticated, skipping getChannels", + ); + return { slack_channels: [], next_cursor: null }; + } + const { knockChannelId, tenant } = input; const queryOptions = input.queryOptions || {}; diff --git a/packages/client/src/interfaces.ts b/packages/client/src/interfaces.ts index fdf065ab0..d12350cc1 100644 --- a/packages/client/src/interfaces.ts +++ b/packages/client/src/interfaces.ts @@ -60,6 +60,24 @@ export interface AuthenticateOptions { identificationStrategy?: "inline" | "skip"; } +/** + * Whether a `Knock` instance currently has a user identity to act on behalf of. + * When `unauthenticated`, the instance is fully quiescent: no network requests + * and no real-time socket activity occur until `authenticate` is called. + */ +export type KnockAuthStatus = "authenticated" | "unauthenticated"; + +/** + * The shape of the subscribable auth-state store exposed on `knock.authStore`. + * Subsystems and React hooks can subscribe to this to react to auth transitions + * (login / logout / user switch) without polling `isAuthenticated()`. + */ +export interface KnockAuthState { + status: KnockAuthStatus; + userId: UserId; + userToken: string | undefined; +} + export interface BulkOperation { id: string; name: string; diff --git a/packages/client/src/knock.ts b/packages/client/src/knock.ts index 06d881da4..3f1ba3dd9 100644 --- a/packages/client/src/knock.ts +++ b/packages/client/src/knock.ts @@ -1,3 +1,5 @@ +import { Store } from "@tanstack/store"; + import ApiClient from "./api"; import FeedClient from "./clients/feed"; import MessageClient from "./clients/messages"; @@ -8,6 +10,8 @@ import SlackClient from "./clients/slack"; import UserClient from "./clients/users"; import { AuthenticateOptions, + KnockAuthState, + KnockAuthStatus, KnockOptions, LogLevel, UserId, @@ -35,6 +39,18 @@ class Knock { readonly user = new UserClient(this); readonly messages = new MessageClient(this); + /** + * A subscribable store describing whether this instance is currently + * authenticated. Fired on `authenticate()` and `logout()`. Subsystems (feed, + * guides, Slack/Teams status, push registration) and React hooks can + * subscribe to react to auth transitions without polling `isAuthenticated()`. + */ + readonly authStore = new Store({ + status: "unauthenticated", + userId: undefined, + userToken: undefined, + }); + constructor( readonly apiKey: string, options: KnockOptions = {}, @@ -90,13 +106,14 @@ class Knock { const currentApiClient = this.apiClient; const userId = this.getUserId(userIdOrUserWithProperties); const identificationStrategy = options?.identificationStrategy || "inline"; + const credentialsChanged = + this.userId !== userId || this.userToken !== userToken; - // If we've previously been initialized and the values have now changed, then we - // need to reinitialize any stateful connections we have - if ( - currentApiClient && - (this.userId !== userId || this.userToken !== userToken) - ) { + // If the credentials have changed and we have stateful connections to + // rewire, then we need to reinitialize them. This covers both an in-place + // user/token switch (live API client) and re-authenticating after a + // `logout()` cleared the API client but left feed instances in place. + if (credentialsChanged && (currentApiClient || this.feeds.hasInstances())) { this.log("userId or userToken changed; reinitializing connections"); this.feeds.teardownInstances(); this.teardown(); @@ -124,6 +141,11 @@ class Knock { this.log("Reinitialized real-time connections"); } + // Notify subscribers of the (possibly changed) auth state. Done before the + // inline identify below so subscribers observe the new credentials + // regardless of identification strategy. + this.syncAuthState(); + // We explicitly skip the inline identification if the strategy is set to "skip" if (identificationStrategy === "skip") { this.log("Skipping inline user identification"); @@ -166,6 +188,44 @@ class Knock { return checkUserToken ? !!(this.userId && this.userToken) : !!this.userId; } + /** The current authentication status of this instance. */ + get authStatus(): KnockAuthStatus { + return this.authStore.state.status; + } + + /** + * Clears the current authentication and tears down all stateful connections + * (real-time feed channels, the socket, the token-expiration timer, and the + * page-visibility listener). + * + * After `logout()` the instance is fully quiescent — no network or socket + * activity occurs until `authenticate()` is called again. The API client is + * dropped so that a fresh one is lazily constructed on next use rather than + * holding an open socket + document listener while logged out. + */ + logout() { + this.log("Logging out and tearing down connections"); + + // Leave any joined feed channels before we drop the socket. + this.feeds.teardownInstances(); + + // Disconnect the socket, clear the token-expiration timer, and remove the + // page-visibility listener. + this.teardown(); + + // Clear credentials. + this.userId = undefined; + this.userToken = undefined; + + // Drop the API client so a fresh one (with no socket/listener) is lazily + // constructed only if and when the instance is used again. Feed instances + // are left in place and get rewired by a subsequent `authenticate()`. + this.apiClient = null; + + // Notify subscribers that we are now unauthenticated. + this.syncAuthState(); + } + // Used to teardown any connected instances teardown() { if (this.tokenExpirationTimer) { @@ -174,6 +234,28 @@ class Knock { this.apiClient?.teardown(); } + /** + * Recomputes the auth status from the current credentials and pushes it into + * the subscribable auth store (no-op if nothing changed). + */ + private syncAuthState() { + const status: KnockAuthStatus = this.isAuthenticated() + ? "authenticated" + : "unauthenticated"; + + this.authStore.setState((prev) => { + if ( + prev.status === status && + prev.userId === this.userId && + prev.userToken === this.userToken + ) { + return prev; + } + + return { status, userId: this.userId, userToken: this.userToken }; + }); + } + log(message: string, force = false) { if (this.logLevel === "debug" || force) { console.log(`[Knock] ${message}`); diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index 4ba7ff30f..0f7fa85a3 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1494,4 +1494,61 @@ describe("Feed", () => { } }); }); + + describe("Unauthenticated mutations are quiescent", () => { + const getUnauthenticatedSetup = () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + return { knock, mockApiClient, feed }; + }; + + const mutations: Array<[string, (feed: Feed) => Promise]> = [ + ["markAsSeen", (f) => f.markAsSeen(createUnreadFeedItem())], + ["markAsUnseen", (f) => f.markAsUnseen(createUnreadFeedItem())], + ["markAsRead", (f) => f.markAsRead(createUnreadFeedItem())], + ["markAsUnread", (f) => f.markAsUnread(createUnreadFeedItem())], + ["markAsInteracted", (f) => f.markAsInteracted(createUnreadFeedItem())], + ["markAsArchived", (f) => f.markAsArchived(createUnreadFeedItem())], + ["markAsUnarchived", (f) => f.markAsUnarchived(createUnreadFeedItem())], + ["markAllAsSeen", (f) => f.markAllAsSeen()], + ["markAllAsRead", (f) => f.markAllAsRead()], + ["markAllAsArchived", (f) => f.markAllAsArchived()], + ["markAllReadAsArchived", (f) => f.markAllReadAsArchived()], + ["fetchNextPage", (f) => f.fetchNextPage()], + ]; + + test.each(mutations)( + "%s is a no-op that never touches the network", + async (_name, invoke) => { + const { mockApiClient, feed } = getUnauthenticatedSetup(); + + const result = await invoke(feed); + + expect(result).toBeUndefined(); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }, + ); + + test("skips the optimistic store update, leaving badge counts intact", async () => { + const { mockApiClient, feed } = getUnauthenticatedSetup(); + const item = createUnreadFeedItem(); + + // Seed badge counts so we can assert they are not optimistically changed. + feed.getState().setMetadata({ + total_count: 1, + unread_count: 1, + unseen_count: 1, + }); + + await feed.markAsSeen(item); + + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + expect(feed.getState().metadata.unseen_count).toBe(1); + }); + }); }); diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 1895324f9..2fec52bcf 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -251,7 +251,7 @@ describe("KnockGuideClient", () => { await client.fetch(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockKnock.user.getGuides).toHaveBeenCalledWith( channelId, expect.objectContaining({ @@ -323,7 +323,7 @@ describe("KnockGuideClient", () => { const client = new KnockGuideClient(mockKnock, channelId); await client.fetch(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockStore.setState).toHaveBeenCalledWith(expect.any(Function)); // Get the last setState call and execute its function @@ -400,7 +400,7 @@ describe("KnockGuideClient", () => { ); client.subscribe(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockSocket.channel).toHaveBeenCalledWith( `guides:${channelId}`, expect.objectContaining({ @@ -596,6 +596,137 @@ describe("KnockGuideClient", () => { }); }); + describe("Unauthenticated quiescence", () => { + const unauthGuide = { + __typename: "Guide", + channel_id: channelId, + id: "guide_123", + key: "test_guide", + type: "test", + semver: "1.0.0", + active: true, + steps: [], + activation_url_rules: [], + activation_url_patterns: [], + bypass_global_group_limit: false, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as unknown as KnockGuide; + + const unauthStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + } as unknown as KnockGuideStep; + + test("fetch returns an error status without calling the API or throwing", async () => { + // Regression: the guide client used to throw `failIfNotAuthenticated`, + // which crashed host apps that rendered Guides before a user was set. + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.fetch(); + + expect(result.status).toBe("error"); + expect(mockKnock.user.getGuides).not.toHaveBeenCalled(); + }); + + test("subscribe no-ops without joining a channel", () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const mockSocket = { + channel: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + connect: vi.fn(), + }; + mockApiClient.socket = mockSocket as unknown as Socket; + vi.mocked(mockKnock.client).mockReturnValue(mockApiClient as ApiClient); + + const client = new KnockGuideClient(mockKnock, channelId); + client.subscribe(); + + expect(mockSocket.channel).not.toHaveBeenCalled(); + }); + + test("markAsSeen no-ops without calling the engagement API", async () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.markAsSeen(unauthGuide, unauthStep); + + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsInteracted no-ops without calling the engagement API", async () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.markAsInteracted(unauthGuide, unauthStep); + + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsArchived no-ops without calling the engagement API", async () => { + mockKnock.isAuthenticated = vi.fn(() => false); + const client = new KnockGuideClient(mockKnock, channelId); + + const result = await client.markAsArchived(unauthGuide, unauthStep); + + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + }); + + describe("subscribe re-reads the socket from the api client (E12)", () => { + const makeMockSocket = () => ({ + channel: vi.fn().mockReturnValue({ + join: vi.fn().mockReturnValue({ + receive: vi.fn().mockReturnValue({ + receive: vi.fn().mockReturnValue({ receive: vi.fn() }), + }), + }), + on: vi.fn(), + off: vi.fn(), + leave: vi.fn(), + state: "closed", + }), + isConnected: vi.fn().mockReturnValue(true), + connect: vi.fn(), + }); + + test("uses the current socket after re-authentication, not the one captured at construction", () => { + const oldSocket = makeMockSocket(); + const newSocket = makeMockSocket(); + + // The constructor captures the old socket via `knock.client().socket`. + mockApiClient.socket = oldSocket as unknown as Socket; + vi.mocked(mockKnock.client).mockReturnValue(mockApiClient as ApiClient); + const client = new KnockGuideClient(mockKnock, channelId); + + // Simulate a re-authentication that swaps in a new socket on the client. + mockApiClient.socket = newSocket as unknown as Socket; + + client.subscribe(); + + expect(newSocket.channel).toHaveBeenCalledWith( + `guides:${channelId}`, + expect.anything(), + ); + expect(oldSocket.channel).not.toHaveBeenCalled(); + }); + }); + describe("guide operations", () => { const mockStep = { ref: "step_1", @@ -3686,6 +3817,56 @@ describe("KnockGuideClient", () => { expect(windowWithHistory.history.replaceState).toBe(originalReplaceState); }); + test("shares one history patch across instances and restores only when the last is cleaned up", () => { + const originalPushState = vi.fn(); + const originalReplaceState = vi.fn(); + + vi.stubGlobal("window", { + ...mockWindow, + history: { + pushState: originalPushState, + replaceState: originalReplaceState, + }, + }); + + const windowWithHistory = window as unknown as { + history: { pushState: unknown; replaceState: unknown }; + }; + + // Two guide clients patch the same window (as happens across a remount: + // the new client is constructed while the old one is still tearing down). + const clientA = new KnockGuideClient( + mockKnock, + channelId, + {}, + { trackLocationFromWindow: true }, + ); + const clientB = new KnockGuideClient( + mockKnock, + channelId, + {}, + { trackLocationFromWindow: true }, + ); + + // History is patched (not the originals) while either client is active. + expect(windowWithHistory.history.pushState).not.toBe(originalPushState); + expect(windowWithHistory.history.replaceState).not.toBe( + originalReplaceState, + ); + + // Cleaning up one client must NOT restore the originals — the other still + // needs location tracking (the pre-fix per-instance patch clobbered it). + clientA.cleanup(); + expect(windowWithHistory.history.pushState).not.toBe(originalPushState); + expect(windowWithHistory.history.replaceState).not.toBe( + originalReplaceState, + ); + + // Cleaning up the last client restores the originals exactly. + clientB.cleanup(); + expect(windowWithHistory.history.pushState).toBe(originalPushState); + expect(windowWithHistory.history.replaceState).toBe(originalReplaceState); + }); }); describe("private methods", () => { @@ -3912,7 +4093,7 @@ describe("KnockGuideClient", () => { await client.fetch({ filters: { type: "tooltip" } }); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockKnock.user.getGuides).toHaveBeenCalledWith( channelId, expect.objectContaining({ diff --git a/packages/client/test/clients/messages/messages.test.ts b/packages/client/test/clients/messages/messages.test.ts index 23ea85644..c47c166c3 100644 --- a/packages/client/test/clients/messages/messages.test.ts +++ b/packages/client/test/clients/messages/messages.test.ts @@ -747,4 +747,18 @@ describe("MessageClient", () => { ); }); }); + + describe("Unauthenticated quiescence", () => { + test("batchUpdateStatuses returns an empty array without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + + const result = await knock.messages.batchUpdateStatuses( + ["message_123", "message_456"], + "seen", + ); + + expect(result).toEqual([]); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/test/clients/ms-teams/ms-teams.test.ts b/packages/client/test/clients/ms-teams/ms-teams.test.ts index 0aae31f58..54eb23c63 100644 --- a/packages/client/test/clients/ms-teams/ms-teams.test.ts +++ b/packages/client/test/clients/ms-teams/ms-teams.test.ts @@ -630,4 +630,46 @@ describe("Microsoft Teams Client", () => { } }); }); + + describe("Unauthenticated quiescence", () => { + test("authCheck returns a disconnected shape without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + const client = new MsTeamsClient(knock); + + const result = await client.authCheck({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ connection: { ok: false } }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + + test("getTeams returns an empty list without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); + const client = new MsTeamsClient(knock); + + const result = await client.getTeams({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ ms_teams_teams: [], skip_token: null }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + + test("getChannels returns an empty list without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); + const client = new MsTeamsClient(knock); + + const result = await client.getChannels({ + tenant: "tenant_123", + knockChannelId: "channel_123", + teamId: "team_123", + }); + + expect(result).toEqual({ ms_teams_channels: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/test/clients/slack/slack.test.ts b/packages/client/test/clients/slack/slack.test.ts index 0c5871c5f..cb1fd8310 100644 --- a/packages/client/test/clients/slack/slack.test.ts +++ b/packages/client/test/clients/slack/slack.test.ts @@ -510,4 +510,32 @@ describe("Slack Client", () => { } }); }); + + describe("Unauthenticated quiescence", () => { + test("authCheck returns a disconnected shape without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); // not authenticated + const client = new SlackClient(knock); + + const result = await client.authCheck({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ connection: { ok: false } }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + + test("getChannels returns an empty list without calling the API", async () => { + const { knock, mockApiClient } = createMockKnock(); + const client = new SlackClient(knock); + + const result = await client.getChannels({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(result).toEqual({ slack_channels: [], next_cursor: null }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/test/knock.test.ts b/packages/client/test/knock.test.ts index dd5660346..be5f9b18a 100644 --- a/packages/client/test/knock.test.ts +++ b/packages/client/test/knock.test.ts @@ -779,4 +779,162 @@ describe("Knock Client", () => { expect(knock.isAuthenticated(true)).toBe(true); }); }); + + describe("Auth state store", () => { + test("starts unauthenticated", () => { + const knock = new Knock("pk_test_12345"); + + expect(knock.authStatus).toBe("unauthenticated"); + expect(knock.authStore.state).toEqual({ + status: "unauthenticated", + userId: undefined, + userToken: undefined, + }); + }); + + test("transitions to authenticated on authenticate()", () => { + const knock = new Knock("pk_test_12345"); + + knock.authenticate("user_123", "token_456"); + + expect(knock.authStatus).toBe("authenticated"); + expect(knock.authStore.state).toEqual({ + status: "authenticated", + userId: "user_123", + userToken: "token_456", + }); + }); + + test("notifies subscribers on login", () => { + const knock = new Knock("pk_test_12345"); + const listener = vi.fn(); + const unsubscribe = knock.authStore.subscribe(listener); + + knock.authenticate("user_123", "token_456"); + + expect(listener).toHaveBeenCalled(); + expect(knock.authStore.state.status).toBe("authenticated"); + unsubscribe(); + }); + + test("keeps a stable state reference when re-authenticating with identical credentials", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const stateBefore = knock.authStore.state; + + // Re-authenticating with the same credentials should not churn the store + // reference, so React `useStore` selectors don't re-render needlessly. + knock.authenticate("user_123", "token_456"); + + expect(knock.authStore.state).toBe(stateBefore); + }); + + test("updates the store userId on a user switch", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + // Realize the api client so the switch takes the reinitialize path. + knock.client(); + + knock.authenticate("user_789", "token_456"); + + expect(knock.authStatus).toBe("authenticated"); + expect(knock.authStore.state.userId).toBe("user_789"); + }); + }); + + describe("Logout", () => { + test("clears credentials and marks the instance unauthenticated", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + expect(knock.isAuthenticated()).toBe(true); + + knock.logout(); + + expect(knock.userId).toBeUndefined(); + expect(knock.userToken).toBeUndefined(); + expect(knock.isAuthenticated()).toBe(false); + expect(knock.authStatus).toBe("unauthenticated"); + }); + + test("tears down feed instances and connections", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const teardownInstancesSpy = vi.spyOn(knock.feeds, "teardownInstances"); + const teardownSpy = vi.spyOn(knock, "teardown"); + + knock.logout(); + + expect(teardownInstancesSpy).toHaveBeenCalled(); + expect(teardownSpy).toHaveBeenCalled(); + }); + + test("clears the token expiration timer", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + knock["tokenExpirationTimer"] = setTimeout(() => {}, 1000); + + knock.logout(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + test("drops the api client so a fresh one is created on next use", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const client1 = knock.client(); + knock.logout(); + const client2 = knock.client(); + + expect(client1).not.toBe(client2); + }); + + test("notifies auth-state subscribers", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const listener = vi.fn(); + const unsubscribe = knock.authStore.subscribe(listener); + + knock.logout(); + + expect(listener).toHaveBeenCalled(); + expect(knock.authStore.state.status).toBe("unauthenticated"); + unsubscribe(); + }); + + test("is safe to call when never authenticated", () => { + const knock = new Knock("pk_test_12345"); + + expect(() => knock.logout()).not.toThrow(); + expect(knock.authStatus).toBe("unauthenticated"); + }); + }); + + describe("Logout then re-authenticate", () => { + test("reinitializes existing feed instances after logout", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + // Create a feed instance so there is real-time state to rewire. + knock.feeds.initialize("01234567-89ab-cdef-0123-456789abcdef"); + expect(knock.feeds.hasInstances()).toBe(true); + + knock.logout(); + + const reinitializeSpy = vi.spyOn(knock.feeds, "reinitializeInstances"); + + // Re-authenticating must rewire the surviving feed instances even though + // `logout()` dropped the api client. + knock.authenticate("user_123", "token_456"); + + expect(reinitializeSpy).toHaveBeenCalled(); + expect(knock.isAuthenticated()).toBe(true); + }); + }); }); diff --git a/packages/expo/README.md b/packages/expo/README.md index 35ab2e44f..0c531e0b0 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -111,6 +111,23 @@ const YourAppLayout = () => { }; ``` +## Deferring activity with `enabled` + +`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Auto push-notification registration also waits for `enabled` to become `true`, so a logged-out user is never shown the OS permission prompt. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (like a logout). + +This is the recommended way to gate the provider on a complete identity — for example an enhanced-security user token that loads asynchronously — instead of conditionally mounting `KnockProvider`: + +```jsx + + {/* ... */} + +``` + ## Headless usage Alternatively, if you don't want to use our components you can render the feed in a headless mode using our hooks: diff --git a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx index 75c8df955..9b75d5d3c 100644 --- a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx +++ b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx @@ -1,5 +1,5 @@ import { Message, MessageEngagementStatus } from "@knocklabs/client"; -import { useKnockClient } from "@knocklabs/react-core"; +import { useKnockAuthState, useKnockClient } from "@knocklabs/react-core"; import { KnockPushNotificationProvider, usePushNotifications, @@ -67,6 +67,8 @@ const InternalExpoPushNotificationProvider: React.FC< autoRegister = true, }) => { const knockClient = useKnockClient(); + const { status: authStatus } = useKnockAuthState(knockClient); + const isAuthenticated = authStatus === "authenticated"; const { registerPushTokenToChannel, unregisterPushTokenFromChannel } = usePushNotifications(); @@ -150,6 +152,15 @@ const InternalExpoPushNotificationProvider: React.FC< return; } + // Skip when there's no authenticated user (e.g. a notification tapped + // after logout) — otherwise this fires a messages request with no user. + if (!knockClient.isAuthenticated()) { + knockClient.log( + "[Knock] Skipping status update; user is not authenticated", + ); + return; + } + return knockClient.messages.updateStatus(messageId, status); }, [knockClient], @@ -167,9 +178,12 @@ const InternalExpoPushNotificationProvider: React.FC< NotificationsModule.setNotificationHandler({ handleNotification }); }, [customNotificationHandler]); - // Auto-register for push notifications on mount if enabled + // Auto-register for push notifications once there is an authenticated user. + // Gating on auth defers the OS permission prompt (prompting a logged-out user + // is hostile) and avoids registering a token against no user. Because + // `isAuthenticated` is a dependency, enabling auth later re-runs registration. useEffect(() => { - if (!autoRegister) { + if (!autoRegister || !isAuthenticated) { return; } @@ -195,6 +209,7 @@ const InternalExpoPushNotificationProvider: React.FC< }; }, [ autoRegister, + isAuthenticated, knockExpoChannelId, registerForPushNotifications, registerPushTokenToChannel, diff --git a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx index 8ff4bec23..54309c814 100644 --- a/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx +++ b/packages/expo/test/modules/push/KnockExpoPushNotificationProvider.test.tsx @@ -81,6 +81,15 @@ const mockKnockClient = { vi.mock("@knocklabs/react-core", () => ({ useKnockClient: () => mockKnockClient, + // Derive auth state from the same `isAuthenticated` mock so tests only have to + // toggle it in one place. + useKnockAuthState: () => ({ + status: mockKnockClient.isAuthenticated() + ? "authenticated" + : "unauthenticated", + userId: mockKnockClient.isAuthenticated() ? "user_1" : undefined, + userToken: undefined, + }), })); describe("KnockExpoPushNotificationProvider", () => { @@ -158,6 +167,34 @@ describe("KnockExpoPushNotificationProvider", () => { expect(getByTestId("test-child")).toBeInTheDocument(); }); + test("defers auto-registration and the OS prompt while unauthenticated", async () => { + mockKnockClient.isAuthenticated.mockReturnValue(false); + mockRegisterPushTokenToChannel.mockClear(); + mockNotifications.getExpoPushTokenAsync.mockClear(); + + try { + const TestChild = () =>
Test Child
; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("test-child")).toBeInTheDocument(); + + // The auto-register effect must no-op: no channel registration and, + // crucially, no OS permission/token prompt for a logged-out user. + await waitFor(() => { + expect(getByTestId("test-child")).toBeInTheDocument(); + }); + expect(mockRegisterPushTokenToChannel).not.toHaveBeenCalled(); + expect(mockNotifications.getExpoPushTokenAsync).not.toHaveBeenCalled(); + } finally { + mockKnockClient.isAuthenticated.mockReturnValue(true); + } + }); + test("useExpoPushNotifications provides context values", () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( diff --git a/packages/react-core/src/index.ts b/packages/react-core/src/index.ts index 6994637ca..a7ca7a19b 100644 --- a/packages/react-core/src/index.ts +++ b/packages/react-core/src/index.ts @@ -18,6 +18,7 @@ export { useAuthenticatedKnockClient, useAuthPolling, useAuthPostMessageListener, + useKnockAuthState, useKnockClient, useStableOptions, } from "./modules/core"; diff --git a/packages/react-core/src/modules/core/context/KnockProvider.tsx b/packages/react-core/src/modules/core/context/KnockProvider.tsx index 084475889..915f80cbe 100644 --- a/packages/react-core/src/modules/core/context/KnockProvider.tsx +++ b/packages/react-core/src/modules/core/context/KnockProvider.tsx @@ -26,6 +26,26 @@ export type KnockProviderProps = { i18n?: I18nContent; logLevel?: LogLevel; branch?: string; + /** + * When `false`, render children but keep the Knock client unauthenticated and + * fully quiescent — no identify, network, or socket activity. Flipping it to + * `true` authenticates and mounts everything (like a login); flipping it back + * to `false` tears everything down (like a logout). Defaults to `true`. + * + * This is the recommended way to gate the provider on a complete identity — + * e.g. an enhanced-security token that loads asynchronously — instead of + * conditionally mounting `KnockProvider`: + * + * ```tsx + * + * ``` + */ + enabled?: boolean; } & ( | { /** @@ -66,6 +86,7 @@ export const KnockProvider: React.FC> = ({ i18n, identificationStrategy, branch, + enabled, ...props }) => { const userIdOrUserWithProperties = props?.user || props?.userId; @@ -79,6 +100,7 @@ export const KnockProvider: React.FC> = ({ logLevel, identificationStrategy, branch, + enabled, }), [ host, @@ -87,6 +109,7 @@ export const KnockProvider: React.FC> = ({ logLevel, identificationStrategy, branch, + enabled, ], ); diff --git a/packages/react-core/src/modules/core/hooks/index.ts b/packages/react-core/src/modules/core/hooks/index.ts index 4c2ce7d9b..28b3a3018 100644 --- a/packages/react-core/src/modules/core/hooks/index.ts +++ b/packages/react-core/src/modules/core/hooks/index.ts @@ -1,4 +1,5 @@ export { default as useAuthenticatedKnockClient } from "./useAuthenticatedKnockClient"; export { default as useStableOptions } from "./useStableOptions"; +export { default as useKnockAuthState } from "./useKnockAuthState"; export { useAuthPostMessageListener } from "./useAuthPostMessageListener"; export { useAuthPolling } from "./useAuthPolling"; diff --git a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts index 80382eac0..eb6e7eb71 100644 --- a/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts +++ b/packages/react-core/src/modules/core/hooks/useAuthenticatedKnockClient.ts @@ -22,7 +22,28 @@ function authenticateWithOptions( } export type AuthenticatedKnockClientOptions = KnockOptions & - AuthenticateOptions; + AuthenticateOptions & { + /** + * When `false`, the Knock client is created but left **unauthenticated and + * fully quiescent** — no identify, no network requests, and no real-time + * socket activity — while children still render. Flipping it back to `true` + * authenticates and mounts everything, like a login; flipping it to `false` + * tears everything down, like a logout. + * + * The canonical use is to defer all activity until you have a complete + * identity — e.g. an enhanced-security user token that loads asynchronously + * (without this, a present `userId` with a not-yet-loaded token fires 401s): + * + * ```tsx + * useAuthenticatedKnockClient(apiKey, { id: userId }, userToken, { + * enabled: Boolean(userId && userToken), + * }); + * ``` + * + * Defaults to `true`. + */ + enabled?: boolean; + }; /** * @deprecated Passing `userId` as a `string` is deprecated and will be removed in a future version. @@ -52,27 +73,40 @@ function useAuthenticatedKnockClient( ) { const knockRef = React.useRef(undefined); - const stableOptions = useStableOptions(options); + const { enabled = true, ...authenticateOptions } = options; + + const stableOptions = useStableOptions(authenticateOptions); const stableUserIdOrObject = useStableOptions(userIdOrUserWithProperties); - return React.useMemo(() => { + const knock = React.useMemo(() => { + // When disabled, behave as if no user was provided: null the credentials so + // a fresh, unauthenticated client is created and stays fully quiescent. This + // makes `enabled: false → true` behave like a login (and the reverse like a + // logout) through the same code path headless hook consumers already use. + const activeUserIdOrObject = enabled ? stableUserIdOrObject : undefined; + const activeUserToken = enabled ? userToken : undefined; + const userId = - typeof stableUserIdOrObject === "string" - ? stableUserIdOrObject - : stableUserIdOrObject?.id; + typeof activeUserIdOrObject === "string" + ? activeUserIdOrObject + : activeUserIdOrObject?.id; const currentKnock = knockRef.current; - // If the userId and the userToken changes then just reauth + // If we already have an authenticated client and only the credentials + // changed, just reauthenticate in place. (A userId change still remounts the + // feed subtree downstream because `feedProviderKey` includes the userId.) if ( + enabled && currentKnock && currentKnock.isAuthenticated() && - (currentKnock.userId !== userId || currentKnock.userToken !== userToken) + (currentKnock.userId !== userId || + currentKnock.userToken !== activeUserToken) ) { authenticateWithOptions( currentKnock, - stableUserIdOrObject, - userToken, + activeUserIdOrObject, + activeUserToken, stableOptions, ); return currentKnock; @@ -82,7 +116,10 @@ function useAuthenticatedKnockClient( currentKnock.teardown(); } - // Otherwise instantiate a new Knock client + // Otherwise instantiate a new Knock client. Creating a fresh instance on + // both the enable and disable transitions guarantees a new context identity + // (so the feed subtree remounts and refetches) and empty stores when + // disabling (no stale notifications/PII linger after a logout). const knock = new Knock(apiKey, { host: stableOptions.host, logLevel: stableOptions.logLevel, @@ -91,14 +128,26 @@ function useAuthenticatedKnockClient( authenticateWithOptions( knock, - stableUserIdOrObject, - userToken, + activeUserIdOrObject, + activeUserToken, stableOptions, ); knockRef.current = knock; return knock; - }, [apiKey, stableUserIdOrObject, userToken, stableOptions]); + }, [apiKey, enabled, stableUserIdOrObject, userToken, stableOptions]); + + // Tear the client down when the provider unmounts so we don't leak the socket, + // the token-expiration timer, or the page-visibility listener. Transition + // teardown is handled in the memo above, so this uses empty deps to fire only + // on unmount. + React.useEffect(() => { + return () => { + knockRef.current?.teardown(); + }; + }, []); + + return knock; } export default useAuthenticatedKnockClient; diff --git a/packages/react-core/src/modules/core/hooks/useKnockAuthState.ts b/packages/react-core/src/modules/core/hooks/useKnockAuthState.ts new file mode 100644 index 000000000..47d4cb58e --- /dev/null +++ b/packages/react-core/src/modules/core/hooks/useKnockAuthState.ts @@ -0,0 +1,21 @@ +import Knock, { KnockAuthState } from "@knocklabs/client"; +import { useStore } from "@tanstack/react-store"; + +/** + * Subscribes to a Knock client's authentication state, re-rendering when it + * changes — i.e. on login, logout, or a user switch. Backed by the subscribable + * `knock.authStore`, so it stays correct even when the client is + * re-authenticated in place or replaced (e.g. via the `enabled` prop on + * `KnockProvider`). + * + * @example + * ```ts + * const { status, userId } = useKnockAuthState(knock); + * const isAuthenticated = status === "authenticated"; + * ``` + */ +export function useKnockAuthState(knock: Knock): KnockAuthState { + return useStore(knock.authStore, (state) => state); +} + +export default useKnockAuthState; diff --git a/packages/react-core/src/modules/core/index.ts b/packages/react-core/src/modules/core/index.ts index 8d8d31868..b7e04a257 100644 --- a/packages/react-core/src/modules/core/index.ts +++ b/packages/react-core/src/modules/core/index.ts @@ -7,6 +7,7 @@ export { export { useAuthenticatedKnockClient, useStableOptions, + useKnockAuthState, useAuthPostMessageListener, useAuthPolling, } from "./hooks"; diff --git a/packages/react-core/src/modules/core/utils.ts b/packages/react-core/src/modules/core/utils.ts index c73567250..4c5eb9920 100644 --- a/packages/react-core/src/modules/core/utils.ts +++ b/packages/react-core/src/modules/core/utils.ts @@ -78,17 +78,19 @@ export function feedProviderKey( to trigger a re-render of the context when a key property changes. */ export function slackProviderKey({ + userId, knockSlackChannelId, tenantId, connectionStatus, errorLabel, }: { + userId?: Knock["userId"]; knockSlackChannelId: string; tenantId: string; connectionStatus: string; errorLabel: string | null; }) { - return [knockSlackChannelId, tenantId, connectionStatus, errorLabel] + return [userId, knockSlackChannelId, tenantId, connectionStatus, errorLabel] .filter((f) => f !== null && f !== undefined) .join("-"); } @@ -98,17 +100,19 @@ export function slackProviderKey({ to trigger a re-render of the context when a key property changes. */ export function msTeamsProviderKey({ + userId, knockMsTeamsChannelId, tenantId, connectionStatus, errorLabel, }: { + userId?: Knock["userId"]; knockMsTeamsChannelId: string; tenantId: string; connectionStatus: string; errorLabel: string | null; }) { - return [knockMsTeamsChannelId, tenantId, connectionStatus, errorLabel] + return [userId, knockMsTeamsChannelId, tenantId, connectionStatus, errorLabel] .filter((f) => f !== null && f !== undefined) .join("-"); } diff --git a/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts b/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts index 0f4013e4f..c694d7730 100644 --- a/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts +++ b/packages/react-core/src/modules/feed/hooks/useFeedSettings.ts @@ -19,6 +19,15 @@ function useFeedSettings(feedClient: Feed): { useEffect(() => { async function getSettings() { const knock = feedClient.knock; + + // Skip the branding fetch when there's no authenticated user — otherwise + // we'd fire `GET /v1/users/undefined/feeds/.../settings`. When the user + // authenticates the feed subtree remounts (its `feedProviderKey` includes + // the userId), which re-runs this effect with a real user. + if (!knock.isAuthenticated()) { + return; + } + const apiClient = knock.client(); const feedSettingsPath = `/v1/users/${knock.userId}/feeds/${feedClient.feedId}/settings`; setIsLoading(true); diff --git a/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx b/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx index eef983345..9861f4fc6 100644 --- a/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx +++ b/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx @@ -18,6 +18,26 @@ export const KnockGuideContext = React.createContext< export type KnockGuideProviderProps = { channelId: string; + /** + * Whether the targeting parameters for guide selection are loaded and ready. + * When `true`, the provider fetches guides and subscribes to real-time updates. + * + * This is independent of authentication. Effective guide activity requires + * **both** an authenticated user (i.e. a `KnockProvider` with a user and + * `enabled` not set to `false`) **and** `readyToTarget`: + * + * | `enabled` (auth) | `readyToTarget` | Guides fetch/subscribe? | + * | ---------------- | --------------- | ----------------------- | + * | `false` | `false` | No | + * | `false` | `true` | No (client no-ops) | + * | `true` | `false` | No | + * | `true` | `true` | Yes | + * + * `readyToTarget` means "my targeting params are loaded"; auth means "there is + * a user". When there is no user, the underlying guide client fetches/subscribes + * are safe no-ops (they neither throw nor hit the network), so it is fine to + * render `KnockGuideProvider` with `readyToTarget` while unauthenticated. + */ readyToTarget: boolean; listenForUpdates?: boolean; colorMode?: ColorMode; diff --git a/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx b/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx index 3c9e6e573..4f2855ac0 100644 --- a/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx +++ b/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx @@ -43,6 +43,7 @@ export const KnockMsTeamsProvider: React.FC< return ( ("connecting"); const [errorLabel, setErrorLabel] = useState(null); const [actionLabel, setActionLabel] = useState(null); + // When the authenticated user changes (login, logout, or switch), reset back + // to "connecting" so the effect below re-runs `authCheck` for the new user + // instead of leaving the previous user's latched status in place. + const previousUserIdRef = useRef(userId); + useEffect(() => { + if (previousUserIdRef.current !== userId) { + previousUserIdRef.current = userId; + setConnectionStatus("connecting"); + setErrorLabel(null); + } + }, [userId]); + useEffect(() => { const checkAuthStatus = async () => { if (connectionStatus !== "connecting") return; diff --git a/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx b/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx index 1d3152f98..d14e6f59c 100644 --- a/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx +++ b/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx @@ -58,6 +58,7 @@ export const KnockSlackProvider: React.FC< return ( ("connecting"); const [errorLabel, setErrorLabel] = useState(null); const [actionLabel, setActionLabel] = useState(null); + // When the authenticated user changes (login, logout, or switch), reset back + // to "connecting" so the effect below re-runs `authCheck` for the new user + // instead of leaving the previous user's latched status in place. + const previousUserIdRef = useRef(userId); + useEffect(() => { + if (previousUserIdRef.current !== userId) { + previousUserIdRef.current = userId; + setConnectionStatus("connecting"); + setErrorLabel(null); + } + }, [userId]); + useEffect(() => { const checkAuthStatus = async () => { if (connectionStatus !== "connecting") return; diff --git a/packages/react-core/test/core/KnockProvider.test.tsx b/packages/react-core/test/core/KnockProvider.test.tsx index 7cfc4376a..f0d6a05a3 100644 --- a/packages/react-core/test/core/KnockProvider.test.tsx +++ b/packages/react-core/test/core/KnockProvider.test.tsx @@ -331,4 +331,79 @@ describe("KnockProvider", () => { ); }); }); + + describe("enabled prop", () => { + test("renders children but does not authenticate when enabled is false", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return
API Key: {knock.apiKey}
; + }; + + const { getByTestId } = render( + + + , + ); + + // Children still render... + expect(getByTestId("consumer-msg")).toHaveTextContent( + "API Key: test_api_key", + ); + // ...but no identify (or any user request) fires while disabled. + expect(mockApiClient.makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: "/v1/users/test_user_id", + }), + ); + }); + + test("authenticates once enabled flips to true", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return
User Id: {knock.userId}
; + }; + + const { rerender } = render( + + + , + ); + + expect(mockApiClient.makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: "/v1/users/test_user_id", + }), + ); + + mockApiClient.makeRequest.mockClear(); + + rerender( + + + , + ); + + expect(mockApiClient.makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: "/v1/users/test_user_id", + data: { name: "John Doe" }, + }), + ); + }); + }); }); diff --git a/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts b/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts index 20f91f80a..7cea3dee4 100644 --- a/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts +++ b/packages/react-core/test/core/useAuthenticatedKnockClient.test.ts @@ -294,4 +294,107 @@ describe("useAuthenticatedKnockClient", () => { expect(result.current.branch).toEqual(TEST_BRANCH_SLUG); }); + + describe("enabled option", () => { + it("creates an unauthenticated, quiescent client when enabled is false", () => { + const { result } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { + ...defaultProps, + userToken: "token_123", + options: { enabled: false }, + }, + }, + ); + + expect(result.current).toBeInstanceOf(Knock); + expect(result.current.isAuthenticated()).toBe(false); + expect(result.current.userId).toBeUndefined(); + }); + + it("authenticates with a fresh instance when enabled flips true (like a login)", () => { + const { result, rerender } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { + ...defaultProps, + userToken: "token_123", + options: { enabled: false }, + }, + }, + ); + + const disabledInstance = result.current; + expect(disabledInstance.isAuthenticated()).toBe(false); + + rerender({ + ...defaultProps, + userToken: "token_123", + options: { enabled: true }, + }); + + // A brand-new instance is required so the feed subtree remounts and refetches. + expect(result.current).not.toBe(disabledInstance); + expect(result.current.isAuthenticated()).toBe(true); + expect(result.current.userId).toEqual("user_123"); + }); + + it("tears down and creates a fresh unauthenticated instance when enabled flips false (like a logout)", () => { + const { result, rerender } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { + ...defaultProps, + userToken: "token_123", + options: { enabled: true }, + }, + }, + ); + + const enabledInstance = result.current; + expect(enabledInstance.isAuthenticated()).toBe(true); + const teardownSpy = vi.spyOn(enabledInstance, "teardown"); + + rerender({ + ...defaultProps, + userToken: "token_123", + options: { enabled: false }, + }); + + expect(teardownSpy).toHaveBeenCalled(); + // Fresh instance guarantees the previous user's stores don't linger. + expect(result.current).not.toBe(enabledInstance); + expect(result.current.isAuthenticated()).toBe(false); + }); + + it("defaults to enabled (authenticated) when the option is omitted", () => { + const { result } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { + initialProps: { ...defaultProps, userToken: "token_123" }, + }, + ); + + expect(result.current.isAuthenticated()).toBe(true); + }); + }); + + it("tears down the client on unmount", () => { + const { result, unmount } = renderHook( + ({ apiKey, userId, userToken, options }) => + useAuthenticatedKnockClient(apiKey, userId, userToken, options), + { initialProps: { ...defaultProps, userToken: "token_123" } }, + ); + + const teardownSpy = vi.spyOn(result.current, "teardown"); + + unmount(); + + expect(teardownSpy).toHaveBeenCalled(); + }); }); diff --git a/packages/react-core/test/core/useKnockAuthState.test.ts b/packages/react-core/test/core/useKnockAuthState.test.ts new file mode 100644 index 000000000..691a4a5c5 --- /dev/null +++ b/packages/react-core/test/core/useKnockAuthState.test.ts @@ -0,0 +1,37 @@ +import Knock from "@knocklabs/client"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useKnockAuthState } from "../../src"; + +describe("useKnockAuthState", () => { + it("returns the unauthenticated state before authentication", () => { + const knock = new Knock("pk_test_12345"); + + const { result } = renderHook(() => useKnockAuthState(knock)); + + expect(result.current.status).toBe("unauthenticated"); + expect(result.current.userId).toBeUndefined(); + }); + + it("re-renders with the authenticated state on login and clears it on logout", () => { + const knock = new Knock("pk_test_12345"); + + const { result } = renderHook(() => useKnockAuthState(knock)); + + act(() => { + knock.authenticate("user_123", "token_456"); + }); + + expect(result.current.status).toBe("authenticated"); + expect(result.current.userId).toBe("user_123"); + expect(result.current.userToken).toBe("token_456"); + + act(() => { + knock.logout(); + }); + + expect(result.current.status).toBe("unauthenticated"); + expect(result.current.userId).toBeUndefined(); + }); +}); diff --git a/packages/react-core/test/feed/useFeedSettings.test.ts b/packages/react-core/test/feed/useFeedSettings.test.ts index fd56b2aa8..edc7ad69a 100644 --- a/packages/react-core/test/feed/useFeedSettings.test.ts +++ b/packages/react-core/test/feed/useFeedSettings.test.ts @@ -3,12 +3,17 @@ import { renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import useFeedSettings from "../../src/modules/feed/hooks/useFeedSettings"; -import { createMockFeed, mockNetworkSuccess } from "../test-utils/mocks"; +import { + authenticateKnock, + createMockFeed, + mockNetworkSuccess, +} from "../test-utils/mocks"; describe("useFeedSettings", () => { it("fetches and returns feed settings", async () => { // Arrange: create a mock feed and stub network response - const { feed, mockApiClient } = createMockFeed("feed_123"); + const { feed, knock, mockApiClient } = createMockFeed("feed_123"); + authenticateKnock(knock); const fakeSettings = { features: { @@ -36,7 +41,8 @@ describe("useFeedSettings", () => { }); it("handles api error by returning null settings", async () => { - const { feed, mockApiClient } = createMockFeed("feed_123"); + const { feed, knock, mockApiClient } = createMockFeed("feed_123"); + authenticateKnock(knock); mockApiClient.makeRequest.mockResolvedValue({ statusCode: "error", @@ -57,7 +63,8 @@ describe("useFeedSettings", () => { }); it("leaves settings null when a success response is missing the features payload", async () => { - const { feed, mockApiClient } = createMockFeed("feed_123"); + const { feed, knock, mockApiClient } = createMockFeed("feed_123"); + authenticateKnock(knock); // A degraded connection (captive portal / proxy) can return a 200 whose // body is not the feed settings object. We must not fabricate a default @@ -74,4 +81,17 @@ describe("useFeedSettings", () => { expect(result.current.settings).toBeNull(); }); + + it("does not fetch settings when the user is unauthenticated", async () => { + // No `authenticateKnock` here: the feed's knock has no user. + const { feed, mockApiClient } = createMockFeed("feed_123"); + + const { result } = renderHook(() => + useFeedSettings(feed as unknown as Feed), + ); + + expect(result.current.loading).toBe(false); + expect(result.current.settings).toBeNull(); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx index 4b04dd00a..75d4d783d 100644 --- a/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx +++ b/packages/react-core/test/ms-teams/useMsTeamsConnectionStatus.test.tsx @@ -12,6 +12,15 @@ const buildMockKnock = (authCheckImpl: () => Promise) => { msTeams: { authCheck: vi.fn(authCheckImpl), }, + // Minimal subscribable auth store so `useKnockAuthState` can read the userId. + authStore: { + state: { + status: "authenticated", + userId: "user_1", + userToken: undefined, + }, + subscribe: () => () => {}, + }, } as unknown as KnockClient; }; @@ -89,4 +98,38 @@ describe("useMsTeamsConnectionStatus", () => { await waitFor(() => expect(result.current.connectionStatus).toBe("error")); }); + + it("re-checks the connection when the authenticated user changes", async () => { + const authCheck = vi.fn(() => + Promise.resolve({ connection: { ok: true } }), + ); + const buildKnockWithUser = (userId: string) => + ({ + msTeams: { authCheck }, + authStore: { + state: { status: "authenticated", userId, userToken: undefined }, + subscribe: () => () => {}, + }, + }) as unknown as KnockClient; + + const { result, rerender } = renderHook( + ({ knock }) => useMsTeamsConnectionStatus(knock, channelId, tenantId), + { initialProps: { knock: buildKnockWithUser("user_A") } }, + ); + + await waitFor(() => + expect(result.current.connectionStatus).toBe("connected"), + ); + expect(authCheck).toHaveBeenCalledTimes(1); + + // Switching users must reset the latched status and re-run authCheck. + rerender({ knock: buildKnockWithUser("user_B") }); + + // Wait for the re-check to resolve back to "connected" (which only happens + // after the second authCheck resolves), then assert it ran again. + await waitFor(() => + expect(result.current.connectionStatus).toBe("connected"), + ); + expect(authCheck).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx index 18bbe958e..80ce761c7 100644 --- a/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx +++ b/packages/react-core/test/slack/useSlackConnectionStatus.test.tsx @@ -10,6 +10,15 @@ const buildMockKnock = (authCheckImpl: () => Promise) => { slack: { authCheck: vi.fn(authCheckImpl), }, + // Minimal subscribable auth store so `useKnockAuthState` can read the userId. + authStore: { + state: { + status: "authenticated", + userId: "user_1", + userToken: undefined, + }, + subscribe: () => () => {}, + }, } as unknown as KnockClient; }; @@ -89,4 +98,38 @@ describe("useSlackConnectionStatus", () => { expect(result.current.errorLabel).toBe("Account inactive"), ); }); + + it("re-checks the connection when the authenticated user changes", async () => { + const authCheck = vi.fn(() => + Promise.resolve({ connection: { ok: true } }), + ); + const buildKnockWithUser = (userId: string) => + ({ + slack: { authCheck }, + authStore: { + state: { status: "authenticated", userId, userToken: undefined }, + subscribe: () => () => {}, + }, + }) as unknown as KnockClient; + + const { result, rerender } = renderHook( + ({ knock }) => useSlackConnectionStatus(knock, channelId, tenantId), + { initialProps: { knock: buildKnockWithUser("user_A") } }, + ); + + await waitFor(() => + expect(result.current.connectionStatus).toBe("connected"), + ); + expect(authCheck).toHaveBeenCalledTimes(1); + + // Switching users must reset the latched status and re-run authCheck. + rerender({ knock: buildKnockWithUser("user_B") }); + + // Wait for the re-check to resolve back to "connected" (which only happens + // after the second authCheck resolves), then assert it ran again. + await waitFor(() => + expect(result.current.connectionStatus).toBe("connected"), + ); + expect(authCheck).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 41088ecb0..0858fffd8 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -55,6 +55,23 @@ const YourAppLayout = () => { }; ``` +## Deferring activity with `enabled` + +`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (like a logout). + +This is the recommended way to gate the provider on a complete identity — for example an enhanced-security user token that loads asynchronously — instead of conditionally mounting `KnockProvider`: + +```jsx + + {/* ... */} + +``` + ## Headless usage Alternatively, if you don't want to use our components you can render the feed in a headless mode using our hooks: diff --git a/packages/react/README.md b/packages/react/README.md index 388c916d9..4ee62f316 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -73,6 +73,23 @@ const YourAppLayout = () => { }; ``` +## Deferring activity with `enabled` + +`KnockProvider` accepts an `enabled` prop (default `true`). When it is `false`, the provider renders its children but keeps the Knock client **unauthenticated and fully quiescent** — no user identification, network requests, or real-time socket connections. Setting it back to `true` authenticates and connects everything (like a login); setting it to `false` again tears everything down and clears the client's stores (like a logout). + +This is the recommended way to gate the provider on a complete identity — for example an enhanced-security user token that loads asynchronously — instead of conditionally mounting `KnockProvider`: + +```jsx + + {/* ... */} + +``` + ## Headless usage Alternatively, if you don't want to use our components you can render the feed in a headless mode using our hooks: