diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index c4816d3d19c..2bef62c5f4e 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -47,6 +47,16 @@ export type { } from './useOrganizationEnterpriseConnections'; export { __internal_useOrganizationDomains } from './useOrganizationDomains'; export type { UseOrganizationDomainsParams, UseOrganizationDomainsReturn } from './useOrganizationDomains'; +export { __internal_useOrganizationDirectorySync } from './useOrganizationDirectorySync'; +export type { + UseOrganizationDirectorySyncParams, + UseOrganizationDirectorySyncReturn, +} from './useOrganizationDirectorySync'; +export { __internal_useOrganizationDirectorySyncUsers } from './useOrganizationDirectorySyncUsers'; +export type { + UseOrganizationDirectorySyncUsersParams, + UseOrganizationDirectorySyncUsersReturn, +} from './useOrganizationDirectorySyncUsers'; export { __internal_useOrganizationEnterpriseConnectionTestRuns } from './useOrganizationEnterpriseConnectionTestRuns'; export type { UseOrganizationEnterpriseConnectionTestRunsParams, diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts new file mode 100644 index 00000000000..26d51bd0d32 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; + +import type { GetDirectorySyncUsersParams } from '../../types/directorySync'; +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +/** + * @internal + */ +export function useOrganizationDirectorySyncCacheKeys(params: { + organizationId: string | null; + enterpriseConnectionId: string | null; +}) { + const { organizationId, enterpriseConnectionId } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + }, + untracked: { + args: {}, + }, + }); + }, [organizationId, enterpriseConnectionId]); +} + +/** + * @internal + */ +export function useOrganizationDirectorySyncUsersCacheKeys(params: { + organizationId: string | null; + enterpriseConnectionId: string | null; + directoryId: string | null; + args: GetDirectorySyncUsersParams; +}) { + const { organizationId, enterpriseConnectionId, directoryId, args } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_USERS_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + directoryId: directoryId ?? null, + }, + untracked: { + args, + }, + }); + // The args object is intentionally serialized via the consumer to keep stability. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organizationId, enterpriseConnectionId, directoryId, JSON.stringify(args)]); +} diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx new file mode 100644 index 00000000000..6b4508e3008 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx @@ -0,0 +1,146 @@ +import { useCallback } from 'react'; + +import { isClerkAPIResponseError } from '../../error'; +import type { DeletedObjectResource } from '../../types/deletedObject'; +import type { + CreateDirectorySyncParams, + DirectorySyncResource, + UpdateDirectorySyncParams, +} from '../../types/directorySync'; +import { useClerkInstanceContext } from '../contexts'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationDirectorySyncCacheKeys } from './useOrganizationDirectorySync.shared'; + +export type UseOrganizationDirectorySyncParams = { + enterpriseConnectionId: string | null; + enabled?: boolean; +}; + +export type UseOrganizationDirectorySyncReturn = { + /** + * The connection's directory, `null` when none has been created yet, `undefined` while loading. + * Never carries the bearer token — that only exists on the resources resolved by + * `createDirectorySync` and `rotateDirectorySyncToken`. + */ + data: DirectorySyncResource | null | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + createDirectorySync: (params?: CreateDirectorySyncParams) => Promise; + /** Resolves `undefined` until `data` has loaded, since the mutations act on the loaded directory. */ + updateDirectorySync: (params: UpdateDirectorySyncParams) => Promise; + rotateDirectorySyncToken: () => Promise; + deleteDirectorySync: () => Promise; + revalidate: () => Promise; +}; + +/** + * The Directory Sync directory bound to an enterprise connection of the active organization. + * + * @internal + */ +function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams): UseOrganizationDirectorySyncReturn { + const { enterpriseConnectionId, enabled = true } = params; + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + + const { queryKey, stableKey, authenticated } = useOrganizationDirectorySyncCacheKeys({ + organizationId: organization?.id ?? null, + enterpriseConnectionId, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const query = useClerkQuery({ + queryKey, + queryFn: async () => { + if (!enterpriseConnectionId) { + throw new Error('enterpriseConnectionId is required to fetch the directory'); + } + try { + return (await organization?.getDirectorySync(enterpriseConnectionId)) ?? null; + } catch (err) { + // No directory yet is a first-class state of the setup flow, not an error. + if (isClerkAPIResponseError(err) && err.status === 404) { + return null; + } + throw err; + } + }, + enabled: queryEnabled, + // No placeholderData: any key change is an identity change, and the mutations act on `query.data`. + }); + + const revalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: [stableKey] }), + [queryClient, stableKey], + ); + + const createDirectorySync = useCallback( + async (createParams?: CreateDirectorySyncParams) => { + if (!enterpriseConnectionId) { + return undefined; + } + const created = await organization?.createDirectorySync(enterpriseConnectionId, createParams); + await revalidate(); + return created; + }, + [organization, enterpriseConnectionId, revalidate], + ); + + const directory = query.data; + + const updateDirectorySync = useCallback( + async (updateParams: UpdateDirectorySyncParams) => { + if (!directory) { + return undefined; + } + const updated = await directory.update(updateParams); + await revalidate(); + return updated; + }, + [directory, revalidate], + ); + + const rotateDirectorySyncToken = useCallback(async () => { + if (!directory) { + return undefined; + } + const rotated = await directory.rotateToken(); + await revalidate(); + return rotated; + }, [directory, revalidate]); + + const deleteDirectorySync = useCallback(async () => { + if (!directory) { + return undefined; + } + const deleted = await directory.delete(); + await revalidate(); + return deleted; + }, [directory, revalidate]); + + return { + data: query.data, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + createDirectorySync, + updateDirectorySync, + rotateDirectorySyncToken, + deleteDirectorySync, + revalidate, + }; +} + +export { useOrganizationDirectorySync as __internal_useOrganizationDirectorySync }; diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx new file mode 100644 index 00000000000..958ad7f5747 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncUsers.tsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { + DirectorySyncResource, + DirectorySyncUserResource, + GetDirectorySyncUsersParams, +} from '../../types/directorySync'; +import { useClerkInstanceContext } from '../contexts'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationDirectorySyncUsersCacheKeys } from './useOrganizationDirectorySync.shared'; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +export type UseOrganizationDirectorySyncUsersParams = { + /** The directory to list users for, e.g. `data` from `useOrganizationDirectorySync`. Dormant while nullish. */ + directory: DirectorySyncResource | null | undefined; + /** + * Pass-through fetch parameters (pagination). + * Defaults to `{ initialPage: 1, pageSize: 10 }`. + */ + params?: GetDirectorySyncUsersParams; + /** + * Polling interval (ms) applied while polling is armed via `startPolling`. + * + * @default 2000 + */ + pollIntervalMs?: number; + /** + * If `false`, the hook is dormant — no fetch, no polling. + * + * @default true + */ + enabled?: boolean; + keepPreviousData?: boolean; +}; + +export type UseOrganizationDirectorySyncUsersReturn = { + /** `undefined` while loading and while the hook is dormant. */ + data: DirectorySyncUserResource[] | undefined; + totalCount: number | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + /** + * `true` while the hook is actively polling + */ + isPolling: boolean; + /** + * Start polling. Polling runs continuously (new provisions, updates, and + * deprovisions keep appearing) until `stopPolling` is called — callers + * should stop on unmount of the view that armed it. + */ + startPolling: () => void; + /** + * Stop polling. + */ + stopPolling: () => void; + /** + * Force a refetch. + */ + revalidate: () => Promise; +}; + +/** + * The users provisioned into an enterprise connection's Directory Sync + * directory, most recently touched first. Polls continuously while armed via + * `startPolling`, so the setup flow doubles as a recent-activity feed. + * + * @internal + */ +function useOrganizationDirectorySyncUsers( + params: UseOrganizationDirectorySyncUsersParams, +): UseOrganizationDirectorySyncUsersReturn { + const { + directory, + params: fetchParams = { initialPage: 1, pageSize: 10 }, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + enabled = true, + keepPreviousData = true, + } = params; + + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + const enterpriseConnectionId = directory?.enterpriseConnectionId ?? null; + + const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncUsersCacheKeys({ + organizationId: organization?.id ?? null, + enterpriseConnectionId, + directoryId: directory?.id ?? null, + args: fetchParams, + }); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(directory); + + const [shouldPoll, setShouldPoll] = useState(false); + + useEffect(() => { + // Polling intent is scoped to the current directory — clear it when the + // identity changes so a reset/recreate doesn't inherit a stale armed poll. + setShouldPoll(false); + }, [enterpriseConnectionId, directory?.id]); + + const currentTracked = queryKey[2]; + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!directory) { + throw new Error('directory is required to fetch directory users'); + } + return directory.getUsers(fetchParams); + }, + refetchInterval: () => (shouldPoll ? pollIntervalMs : false), + enabled: queryEnabled, + refetchIntervalInBackground: false, + // Carry previous data only across pagination within the same organization + // and directory — never across an identity change, where stale rows would + // leak into the new context. + placeholderData: keepPreviousData + ? (previousData, previousQuery) => { + const previousTracked = previousQuery?.queryKey[2]; + const sameIdentity = + Boolean(currentTracked.organizationId) && + Boolean(currentTracked.directoryId) && + previousTracked?.organizationId === currentTracked.organizationId && + previousTracked?.directoryId === currentTracked.directoryId; + return sameIdentity ? previousData : undefined; + } + : undefined, + }); + + const startPolling = useCallback(() => { + setShouldPoll(true); + }, []); + + const stopPolling = useCallback(() => { + setShouldPoll(false); + }, []); + + const revalidate = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: invalidationKey }); + }, [queryClient, invalidationKey]); + + const isPolling = queryEnabled && shouldPoll; + + return { + // Dormant means dormant: never surface cached rows while the query cannot run. + data: queryEnabled ? query.data?.data : undefined, + totalCount: queryEnabled ? query.data?.total_count : undefined, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isPolling, + startPolling, + stopPolling, + revalidate, + }; +} + +export { useOrganizationDirectorySyncUsers as __internal_useOrganizationDirectorySyncUsers }; diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index 6d7c6be925c..e7ae049abe6 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -83,6 +83,8 @@ const ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'enterpriseConnectionTestRuns'; const ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY = 'organizationEnterpriseConnections'; const ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'organizationEnterpriseConnectionTestRuns'; const ORGANIZATION_DOMAINS_KEY = 'organizationDomains'; +const ORGANIZATION_DIRECTORY_SYNC_KEY = 'organizationDirectorySync'; +const ORGANIZATION_DIRECTORY_SYNC_USERS_KEY = 'organizationDirectorySyncUsers'; const CREDIT_HISTORY_KEY = 'billing-credit-history'; @@ -96,6 +98,8 @@ export const INTERNAL_STABLE_KEYS = { ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY, ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY, ORGANIZATION_DOMAINS_KEY, + ORGANIZATION_DIRECTORY_SYNC_KEY, + ORGANIZATION_DIRECTORY_SYNC_USERS_KEY, } as const; export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS];