Skip to content

Commit 2291ba0

Browse files
committed
feat(self-serve-ds): add internal Directory Sync react-query hooks
__internal_useOrganizationDirectorySync and __internal_useOrganizationDirectorySyncUsers; the users hook polls continuously while enabled so the test step can double as a recent-activity feed.
1 parent 7b47ade commit 2291ba0

5 files changed

Lines changed: 360 additions & 0 deletions

File tree

‎packages/shared/src/react/hooks/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ export type {
4747
} from './useOrganizationEnterpriseConnections';
4848
export { __internal_useOrganizationDomains } from './useOrganizationDomains';
4949
export type { UseOrganizationDomainsParams, UseOrganizationDomainsReturn } from './useOrganizationDomains';
50+
export { __internal_useOrganizationDirectorySync } from './useOrganizationDirectorySync';
51+
export type {
52+
UseOrganizationDirectorySyncParams,
53+
UseOrganizationDirectorySyncReturn,
54+
} from './useOrganizationDirectorySync';
55+
export { __internal_useOrganizationDirectorySyncUsers } from './useOrganizationDirectorySyncUsers';
56+
export type {
57+
UseOrganizationDirectorySyncUsersParams,
58+
UseOrganizationDirectorySyncUsersReturn,
59+
} from './useOrganizationDirectorySyncUsers';
5060
export { __internal_useOrganizationEnterpriseConnectionTestRuns } from './useOrganizationEnterpriseConnectionTestRuns';
5161
export type {
5262
UseOrganizationEnterpriseConnectionTestRunsParams,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useMemo } from 'react';
2+
3+
import type { GetDirectorySyncUsersParams } from '../../types/directorySync';
4+
import { INTERNAL_STABLE_KEYS } from '../stable-keys';
5+
import { createCacheKeys } from './createCacheKeys';
6+
7+
/**
8+
* @internal
9+
*/
10+
export function useOrganizationDirectorySyncCacheKeys(params: {
11+
organizationId: string | null;
12+
enterpriseConnectionId: string | null;
13+
}) {
14+
const { organizationId, enterpriseConnectionId } = params;
15+
return useMemo(() => {
16+
return createCacheKeys({
17+
stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_KEY,
18+
authenticated: Boolean(organizationId),
19+
tracked: {
20+
organizationId: organizationId ?? null,
21+
enterpriseConnectionId: enterpriseConnectionId ?? null,
22+
},
23+
untracked: {
24+
args: {},
25+
},
26+
});
27+
}, [organizationId, enterpriseConnectionId]);
28+
}
29+
30+
/**
31+
* @internal
32+
*/
33+
export function useOrganizationDirectorySyncUsersCacheKeys(params: {
34+
organizationId: string | null;
35+
enterpriseConnectionId: string | null;
36+
args: GetDirectorySyncUsersParams;
37+
}) {
38+
const { organizationId, enterpriseConnectionId, args } = params;
39+
return useMemo(() => {
40+
return createCacheKeys({
41+
stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_USERS_KEY,
42+
authenticated: Boolean(organizationId),
43+
tracked: {
44+
organizationId: organizationId ?? null,
45+
enterpriseConnectionId: enterpriseConnectionId ?? null,
46+
},
47+
untracked: {
48+
args,
49+
},
50+
});
51+
// The args object is intentionally serialized via the consumer to keep stability.
52+
// eslint-disable-next-line react-hooks/exhaustive-deps
53+
}, [organizationId, enterpriseConnectionId, JSON.stringify(args)]);
54+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { useCallback } from 'react';
2+
3+
import { isClerkAPIResponseError } from '../../error';
4+
import type { DeletedObjectResource } from '../../types/deletedObject';
5+
import type {
6+
CreateDirectorySyncParams,
7+
DirectorySyncResource,
8+
UpdateDirectorySyncParams,
9+
} from '../../types/directorySync';
10+
import { useClerkInstanceContext } from '../contexts';
11+
import { defineKeepPreviousDataFn } from '../query/keep-previous-data';
12+
import { useClerkQueryClient } from '../query/use-clerk-query-client';
13+
import { useClerkQuery } from '../query/useQuery';
14+
import { useOrganizationBase } from './base/useOrganizationBase';
15+
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';
16+
import { useOrganizationDirectorySyncCacheKeys } from './useOrganizationDirectorySync.shared';
17+
18+
export type UseOrganizationDirectorySyncParams = {
19+
enterpriseConnectionId: string | null;
20+
enabled?: boolean;
21+
keepPreviousData?: boolean;
22+
};
23+
24+
export type UseOrganizationDirectorySyncReturn = {
25+
/**
26+
* The connection's directory, `null` when none has been created yet, `undefined` while loading.
27+
* Never carries the bearer token — that only exists on the resources resolved by
28+
* `createDirectorySync` and `rotateDirectorySyncToken`.
29+
*/
30+
data: DirectorySyncResource | null | undefined;
31+
error: Error | null;
32+
isLoading: boolean;
33+
isFetching: boolean;
34+
createDirectorySync: (params?: CreateDirectorySyncParams) => Promise<DirectorySyncResource | undefined>;
35+
updateDirectorySync: (params: UpdateDirectorySyncParams) => Promise<DirectorySyncResource | undefined>;
36+
rotateDirectorySyncToken: () => Promise<DirectorySyncResource | undefined>;
37+
deleteDirectorySync: () => Promise<DeletedObjectResource | undefined>;
38+
revalidate: () => Promise<void>;
39+
};
40+
41+
/**
42+
* The Directory Sync directory bound to an enterprise connection of the active organization.
43+
*
44+
* @internal
45+
*/
46+
function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams): UseOrganizationDirectorySyncReturn {
47+
const { enterpriseConnectionId, enabled = true, keepPreviousData = true } = params;
48+
const clerk = useClerkInstanceContext();
49+
const organization = useOrganizationBase();
50+
const [queryClient] = useClerkQueryClient();
51+
52+
const { queryKey, stableKey, authenticated } = useOrganizationDirectorySyncCacheKeys({
53+
organizationId: organization?.id ?? null,
54+
enterpriseConnectionId,
55+
});
56+
57+
const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId);
58+
59+
useClearQueriesOnSignOut({
60+
isSignedOut: organization === null,
61+
authenticated,
62+
stableKeys: stableKey,
63+
});
64+
65+
const query = useClerkQuery({
66+
queryKey,
67+
queryFn: async () => {
68+
if (!enterpriseConnectionId) {
69+
throw new Error('enterpriseConnectionId is required to fetch the directory');
70+
}
71+
try {
72+
return (await organization?.getDirectorySync(enterpriseConnectionId)) ?? null;
73+
} catch (err) {
74+
// No directory yet is a first-class state of the setup flow, not an error.
75+
if (isClerkAPIResponseError(err) && err.status === 404) {
76+
return null;
77+
}
78+
throw err;
79+
}
80+
},
81+
enabled: queryEnabled,
82+
placeholderData: defineKeepPreviousDataFn(keepPreviousData),
83+
});
84+
85+
const revalidate = useCallback(
86+
() => queryClient.invalidateQueries({ queryKey: [stableKey] }),
87+
[queryClient, stableKey],
88+
);
89+
90+
const createDirectorySync = useCallback(
91+
async (createParams?: CreateDirectorySyncParams) => {
92+
if (!enterpriseConnectionId) {
93+
return undefined;
94+
}
95+
const created = await organization?.createDirectorySync(enterpriseConnectionId, createParams);
96+
await revalidate();
97+
return created;
98+
},
99+
[organization, enterpriseConnectionId, revalidate],
100+
);
101+
102+
const updateDirectorySync = useCallback(
103+
async (updateParams: UpdateDirectorySyncParams) => {
104+
if (!enterpriseConnectionId) {
105+
return undefined;
106+
}
107+
const updated = await organization?.updateDirectorySync(enterpriseConnectionId, updateParams);
108+
await revalidate();
109+
return updated;
110+
},
111+
[organization, enterpriseConnectionId, revalidate],
112+
);
113+
114+
const rotateDirectorySyncToken = useCallback(async () => {
115+
if (!enterpriseConnectionId) {
116+
return undefined;
117+
}
118+
const rotated = await organization?.rotateDirectorySyncToken(enterpriseConnectionId);
119+
await revalidate();
120+
return rotated;
121+
}, [organization, enterpriseConnectionId, revalidate]);
122+
123+
const deleteDirectorySync = useCallback(async () => {
124+
if (!enterpriseConnectionId) {
125+
return undefined;
126+
}
127+
const deleted = await organization?.deleteDirectorySync(enterpriseConnectionId);
128+
await revalidate();
129+
return deleted;
130+
}, [organization, enterpriseConnectionId, revalidate]);
131+
132+
return {
133+
data: query.data,
134+
error: query.error ?? null,
135+
isLoading: query.isLoading,
136+
isFetching: query.isFetching,
137+
createDirectorySync,
138+
updateDirectorySync,
139+
rotateDirectorySyncToken,
140+
deleteDirectorySync,
141+
revalidate,
142+
};
143+
}
144+
145+
export { useOrganizationDirectorySync as __internal_useOrganizationDirectorySync };
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
3+
import type { DirectorySyncUserResource, GetDirectorySyncUsersParams } from '../../types/directorySync';
4+
import { useClerkInstanceContext } from '../contexts';
5+
import { defineKeepPreviousDataFn } from '../query/keep-previous-data';
6+
import { useClerkQueryClient } from '../query/use-clerk-query-client';
7+
import { useClerkQuery } from '../query/useQuery';
8+
import { useOrganizationBase } from './base/useOrganizationBase';
9+
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';
10+
import { useOrganizationDirectorySyncUsersCacheKeys } from './useOrganizationDirectorySync.shared';
11+
12+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
13+
14+
export type UseOrganizationDirectorySyncUsersParams = {
15+
enterpriseConnectionId: string | null;
16+
/**
17+
* Pass-through fetch parameters (pagination).
18+
* Defaults to `{ initialPage: 1, pageSize: 10 }`.
19+
*/
20+
params?: GetDirectorySyncUsersParams;
21+
/**
22+
* Polling interval (ms) applied while polling is armed via `startPolling`.
23+
*
24+
* @default 2000
25+
*/
26+
pollIntervalMs?: number;
27+
/**
28+
* If `false`, the hook is dormant — no fetch, no polling.
29+
*
30+
* @default true
31+
*/
32+
enabled?: boolean;
33+
keepPreviousData?: boolean;
34+
};
35+
36+
export type UseOrganizationDirectorySyncUsersReturn = {
37+
data: DirectorySyncUserResource[] | undefined;
38+
totalCount: number | undefined;
39+
error: Error | null;
40+
isLoading: boolean;
41+
isFetching: boolean;
42+
/**
43+
* `true` while the hook is actively polling
44+
*/
45+
isPolling: boolean;
46+
/**
47+
* Start polling. Polling runs continuously (new provisions, updates, and
48+
* deprovisions keep appearing) until `stopPolling` is called — callers
49+
* should stop on unmount of the view that armed it.
50+
*/
51+
startPolling: () => void;
52+
/**
53+
* Stop polling.
54+
*/
55+
stopPolling: () => void;
56+
/**
57+
* Force a refetch.
58+
*/
59+
revalidate: () => Promise<void>;
60+
};
61+
62+
/**
63+
* The users provisioned into an enterprise connection's Directory Sync
64+
* directory, most recently touched first. Polls continuously while armed via
65+
* `startPolling`, so the setup flow doubles as a recent-activity feed.
66+
*
67+
* @internal
68+
*/
69+
function useOrganizationDirectorySyncUsers(
70+
params: UseOrganizationDirectorySyncUsersParams,
71+
): UseOrganizationDirectorySyncUsersReturn {
72+
const {
73+
enterpriseConnectionId,
74+
params: fetchParams = { initialPage: 1, pageSize: 10 },
75+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
76+
enabled = true,
77+
keepPreviousData = true,
78+
} = params;
79+
80+
const clerk = useClerkInstanceContext();
81+
const organization = useOrganizationBase();
82+
const [queryClient] = useClerkQueryClient();
83+
84+
const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncUsersCacheKeys({
85+
organizationId: organization?.id ?? null,
86+
enterpriseConnectionId,
87+
args: fetchParams,
88+
});
89+
90+
useClearQueriesOnSignOut({
91+
isSignedOut: organization === null,
92+
authenticated,
93+
stableKeys: stableKey,
94+
});
95+
96+
const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId);
97+
98+
const [shouldPoll, setShouldPoll] = useState(false);
99+
100+
useEffect(() => {
101+
// Polling intent is scoped to the current connection — clear it when the
102+
// connection changes so a reset/recreate doesn't inherit a stale armed poll.
103+
setShouldPoll(false);
104+
}, [enterpriseConnectionId]);
105+
106+
const query = useClerkQuery({
107+
queryKey,
108+
queryFn: () => {
109+
if (!enterpriseConnectionId) {
110+
throw new Error('enterpriseConnectionId is required to fetch directory users');
111+
}
112+
return organization?.getDirectorySyncUsers(enterpriseConnectionId, fetchParams);
113+
},
114+
refetchInterval: () => (shouldPoll ? pollIntervalMs : false),
115+
enabled: queryEnabled,
116+
refetchIntervalInBackground: false,
117+
placeholderData: defineKeepPreviousDataFn(keepPreviousData),
118+
});
119+
120+
const startPolling = useCallback(() => {
121+
setShouldPoll(true);
122+
}, []);
123+
124+
const stopPolling = useCallback(() => {
125+
setShouldPoll(false);
126+
}, []);
127+
128+
const revalidate = useCallback(async () => {
129+
await queryClient.invalidateQueries({ queryKey: invalidationKey });
130+
}, [queryClient, invalidationKey]);
131+
132+
const isPolling = queryEnabled && shouldPoll;
133+
134+
return {
135+
data: query.data?.data,
136+
totalCount: query.data?.total_count,
137+
error: query.error ?? null,
138+
isLoading: query.isLoading,
139+
isFetching: query.isFetching,
140+
isPolling,
141+
startPolling,
142+
stopPolling,
143+
revalidate,
144+
};
145+
}
146+
147+
export { useOrganizationDirectorySyncUsers as __internal_useOrganizationDirectorySyncUsers };

‎packages/shared/src/react/stable-keys.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ const ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'enterpriseConnectionTestRuns';
8383
const ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY = 'organizationEnterpriseConnections';
8484
const ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'organizationEnterpriseConnectionTestRuns';
8585
const ORGANIZATION_DOMAINS_KEY = 'organizationDomains';
86+
const ORGANIZATION_DIRECTORY_SYNC_KEY = 'organizationDirectorySync';
87+
const ORGANIZATION_DIRECTORY_SYNC_USERS_KEY = 'organizationDirectorySyncUsers';
8688

8789
const CREDIT_HISTORY_KEY = 'billing-credit-history';
8890

@@ -96,6 +98,8 @@ export const INTERNAL_STABLE_KEYS = {
9698
ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY,
9799
ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY,
98100
ORGANIZATION_DOMAINS_KEY,
101+
ORGANIZATION_DIRECTORY_SYNC_KEY,
102+
ORGANIZATION_DIRECTORY_SYNC_USERS_KEY,
99103
} as const;
100104

101105
export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS];

0 commit comments

Comments
 (0)