@oxyhq/auth 2.0.5 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/WebOxyProvider.js +30 -8
- package/dist/cjs/hooks/mutations/index.js +5 -1
- package/dist/cjs/hooks/mutations/useAppData.js +133 -0
- package/dist/cjs/hooks/queries/appDataQueryKeys.js +46 -0
- package/dist/cjs/hooks/queries/index.js +8 -1
- package/dist/cjs/hooks/queries/useAppData.js +87 -0
- package/dist/cjs/hooks/useWebSSO.js +46 -2
- package/dist/cjs/index.js +8 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/WebOxyProvider.js +30 -8
- package/dist/esm/hooks/mutations/index.js +2 -0
- package/dist/esm/hooks/mutations/useAppData.js +128 -0
- package/dist/esm/hooks/queries/appDataQueryKeys.js +42 -0
- package/dist/esm/hooks/queries/index.js +3 -0
- package/dist/esm/hooks/queries/useAppData.js +82 -0
- package/dist/esm/hooks/useWebSSO.js +46 -2
- package/dist/esm/index.js +2 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/hooks/mutations/index.d.ts +1 -0
- package/dist/types/hooks/mutations/useAppData.d.ts +47 -0
- package/dist/types/hooks/queries/appDataQueryKeys.d.ts +24 -0
- package/dist/types/hooks/queries/index.d.ts +2 -0
- package/dist/types/hooks/queries/useAppData.d.ts +46 -0
- package/dist/types/index.d.ts +2 -2
- package/package.json +1 -1
- package/src/WebOxyProvider.tsx +31 -7
- package/src/hooks/mutations/index.ts +3 -0
- package/src/hooks/mutations/useAppData.ts +167 -0
- package/src/hooks/queries/appDataQueryKeys.ts +53 -0
- package/src/hooks/queries/index.ts +4 -0
- package/src/hooks/queries/useAppData.ts +105 -0
- package/src/hooks/useWebSSO.ts +48 -2
- package/src/index.ts +6 -0
|
@@ -6,3 +6,4 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { useUpdateProfile, useUploadAvatar, useUpdateAccountSettings, useUpdatePrivacySettings, useUploadFile, } from './useAccountMutations';
|
|
8
8
|
export { useSwitchSession, useLogoutSession, useLogoutAll, useUpdateDeviceName, useRemoveDevice, } from './useServicesMutations';
|
|
9
|
+
export { useSetAppData, useDeleteAppData } from './useAppData';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-Data Mutation Hooks
|
|
3
|
+
*
|
|
4
|
+
* Write side of the per-user JSON KV store. Both `useSetAppData` and
|
|
5
|
+
* `useDeleteAppData` apply optimistic updates against the two query keys
|
|
6
|
+
* that observe this data (`appDataQueryKeys.value` and the surrounding
|
|
7
|
+
* `appDataQueryKeys.namespace`) and roll back on error.
|
|
8
|
+
*
|
|
9
|
+
* When the underlying request fails because the endpoint isn't reachable
|
|
10
|
+
* (404 / network), the mutation still surfaces the error — write attempts
|
|
11
|
+
* are user-initiated and the caller may want to retry or fall back to
|
|
12
|
+
* local persistence. Reads are silent about missing endpoints; writes are
|
|
13
|
+
* not.
|
|
14
|
+
*/
|
|
15
|
+
interface SetAppDataVariables<T> {
|
|
16
|
+
namespace: string;
|
|
17
|
+
key: string;
|
|
18
|
+
value: T;
|
|
19
|
+
}
|
|
20
|
+
interface SetAppDataContext<T> {
|
|
21
|
+
previousValue: T | null | undefined;
|
|
22
|
+
previousNamespace: Record<string, T> | undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Upsert a per-user JSON value. Returns the value the server confirmed it
|
|
26
|
+
* stored — typically identical to the input but consumers should prefer the
|
|
27
|
+
* returned value (the server is the source of truth).
|
|
28
|
+
*
|
|
29
|
+
* Applies optimistic updates against both the single-value query key and
|
|
30
|
+
* the surrounding namespace query key, then rolls back on error.
|
|
31
|
+
*/
|
|
32
|
+
export declare const useSetAppData: <T = unknown>() => import("@tanstack/react-query").UseMutationResult<T, Error, SetAppDataVariables<T>, SetAppDataContext<T>>;
|
|
33
|
+
interface DeleteAppDataVariables {
|
|
34
|
+
namespace: string;
|
|
35
|
+
key: string;
|
|
36
|
+
}
|
|
37
|
+
interface DeleteAppDataContext<T> {
|
|
38
|
+
previousValue: T | null | undefined;
|
|
39
|
+
previousNamespace: Record<string, T> | undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Delete a per-user JSON value. Optimistically removes the entry from the
|
|
43
|
+
* single-value cache and from the surrounding namespace map, then rolls back
|
|
44
|
+
* on error.
|
|
45
|
+
*/
|
|
46
|
+
export declare const useDeleteAppData: <T = unknown>() => import("@tanstack/react-query").UseMutationResult<void, Error, DeleteAppDataVariables, DeleteAppDataContext<T>>;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query keys + error utilities for `useAppData` hooks.
|
|
3
|
+
*
|
|
4
|
+
* Lives next to the hook file so consumers can import the keys directly
|
|
5
|
+
* when they need to imperatively invalidate a value (e.g. after a non-React
|
|
6
|
+
* write through `oxyServices.setAppData`).
|
|
7
|
+
*/
|
|
8
|
+
export declare const appDataQueryKeys: {
|
|
9
|
+
readonly all: readonly ["appData"];
|
|
10
|
+
readonly namespaces: () => readonly ["appData", "namespace"];
|
|
11
|
+
readonly namespace: (namespace: string) => readonly ["appData", "namespace", string];
|
|
12
|
+
readonly values: () => readonly ["appData", "value"];
|
|
13
|
+
readonly value: (namespace: string, key: string) => readonly ["appData", "value", string, string];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* True when `error` indicates the app-data endpoint isn't reachable — either
|
|
17
|
+
* because the API deployment doesn't have it yet (404) or there's a network
|
|
18
|
+
* failure with no response. We treat these as "no value stored" so consumers
|
|
19
|
+
* fall back to local persistence without surfacing a user-facing error.
|
|
20
|
+
*
|
|
21
|
+
* Anything else (401, 403, 500) propagates normally — those are real bugs
|
|
22
|
+
* the auth or retry pipeline needs to see.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isMissingAppDataEndpointError(error: unknown): boolean;
|
|
@@ -8,3 +8,5 @@ export { useUserProfile, useUserProfiles, useCurrentUser, useUserById, useUserBy
|
|
|
8
8
|
export { useSessions, useSession, useDeviceSessions, useUserDevices, useSecurityInfo, } from './useServicesQueries';
|
|
9
9
|
export { useSecurityActivity, useRecentSecurityActivity, } from './useSecurityQueries';
|
|
10
10
|
export { queryKeys, invalidateAccountQueries, invalidateUserQueries, invalidateSessionQueries } from './queryKeys';
|
|
11
|
+
export { useAppData, useAppDataNamespace } from './useAppData';
|
|
12
|
+
export { appDataQueryKeys, isMissingAppDataEndpointError } from './appDataQueryKeys';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-Data Query Hooks
|
|
3
|
+
*
|
|
4
|
+
* Read side of the `/users/me/app-data/...` per-user JSON KV store. Gated on
|
|
5
|
+
* `isAuthenticated` — when signed out the query stays `enabled: false` and
|
|
6
|
+
* `data` is `null`, so consumers can fall back to localStorage without ever
|
|
7
|
+
* issuing a doomed request.
|
|
8
|
+
*
|
|
9
|
+
* Errors from the network (404 because the endpoint isn't deployed yet,
|
|
10
|
+
* 401 because the session lapsed, etc.) are not user-facing here. Hooks
|
|
11
|
+
* return `data: null` on error so the calling component renders the
|
|
12
|
+
* "nothing yet" state and the consuming app can quietly fall back to local
|
|
13
|
+
* persistence. Mutations still propagate errors so write attempts surface
|
|
14
|
+
* a toast — only reads are silent.
|
|
15
|
+
*/
|
|
16
|
+
import { type UseQueryResult } from '@tanstack/react-query';
|
|
17
|
+
interface AppDataQueryOptions {
|
|
18
|
+
/** Disable the query without unmounting the component. */
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
/** Override the default 1-minute stale time. */
|
|
21
|
+
staleTime?: number;
|
|
22
|
+
/** Override the default 30-minute gc time. */
|
|
23
|
+
gcTime?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read a single per-user JSON value.
|
|
27
|
+
*
|
|
28
|
+
* @param namespace - kebab/snake-case identifier (e.g. `"academy"`).
|
|
29
|
+
* @param key - kebab/snake-case identifier (e.g. course slug).
|
|
30
|
+
* @param options - optional `enabled`/`staleTime`/`gcTime` overrides.
|
|
31
|
+
*
|
|
32
|
+
* @returns A `useQuery` result with `data` of type `T | null`. The query
|
|
33
|
+
* stays disabled when the user is signed out; when enabled but the server
|
|
34
|
+
* has no stored value, `data` is `null`. Reads that fail because the
|
|
35
|
+
* endpoint isn't reachable also resolve to `null` so the consumer can
|
|
36
|
+
* fall back to local persistence.
|
|
37
|
+
*/
|
|
38
|
+
export declare const useAppData: <T = unknown>(namespace: string, key: string, options?: AppDataQueryOptions) => UseQueryResult<T | null, Error>;
|
|
39
|
+
/**
|
|
40
|
+
* Read every value in a namespace.
|
|
41
|
+
*
|
|
42
|
+
* @returns A `useQuery` result with `data` as a `Record<string, T>`. Empty
|
|
43
|
+
* object when the namespace contains nothing (or when fetching failed).
|
|
44
|
+
*/
|
|
45
|
+
export declare const useAppDataNamespace: <T = unknown>(namespace: string, options?: AppDataQueryOptions) => UseQueryResult<Record<string, T>, Error>;
|
|
46
|
+
export {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -29,8 +29,8 @@ export { useAssetStore, useAssets as useAssetsStore, useAsset, useUploadProgress
|
|
|
29
29
|
export { useAccountStore, useAccounts, useAccountLoading, useAccountError, useAccountLoadingSession, } from './stores/accountStore';
|
|
30
30
|
export type { QuickAccount } from './stores/accountStore';
|
|
31
31
|
export { useFollowStore, } from './stores/followStore';
|
|
32
|
-
export { useUserProfile, useUserProfiles, useCurrentUser, useUserById, useUserByUsername, useUsersBySessions, usePrivacySettings, useSessions, useSession, useDeviceSessions, useUserDevices, useSecurityInfo, useSecurityActivity, useRecentSecurityActivity, } from './hooks/queries';
|
|
33
|
-
export { useUpdateProfile, useUploadAvatar, useUpdateAccountSettings, useUpdatePrivacySettings, useUploadFile, useSwitchSession, useLogoutSession, useLogoutAll, useUpdateDeviceName, useRemoveDevice, } from './hooks/mutations';
|
|
32
|
+
export { useUserProfile, useUserProfiles, useCurrentUser, useUserById, useUserByUsername, useUsersBySessions, usePrivacySettings, useSessions, useSession, useDeviceSessions, useUserDevices, useSecurityInfo, useSecurityActivity, useRecentSecurityActivity, useAppData, useAppDataNamespace, appDataQueryKeys, isMissingAppDataEndpointError, } from './hooks/queries';
|
|
33
|
+
export { useUpdateProfile, useUploadAvatar, useUpdateAccountSettings, useUpdatePrivacySettings, useUploadFile, useSwitchSession, useLogoutSession, useLogoutAll, useUpdateDeviceName, useRemoveDevice, useSetAppData, useDeleteAppData, } from './hooks/mutations';
|
|
34
34
|
export { createProfileMutation, createGenericMutation, } from './hooks/mutations/mutationFactory';
|
|
35
35
|
export type { ProfileMutationConfig, GenericMutationConfig, } from './hooks/mutations/mutationFactory';
|
|
36
36
|
export { useWebSSO, isWebBrowser } from './hooks/useWebSSO';
|
package/package.json
CHANGED
package/src/WebOxyProvider.tsx
CHANGED
|
@@ -58,6 +58,23 @@ export interface WebOxyContextValue extends WebAuthState, WebAuthActions {
|
|
|
58
58
|
|
|
59
59
|
const WebOxyContext = createContext<WebOxyContextValue | null>(null);
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Module-level run-once guard for FedCM silent sign-in.
|
|
63
|
+
*
|
|
64
|
+
* The init effect runs again whenever the provider remounts (route change,
|
|
65
|
+
* StrictMode double-invoke, error-boundary recovery). The redirect-callback
|
|
66
|
+
* and local-session-restore steps are cheap and idempotent, but the FedCM
|
|
67
|
+
* `silentSignIn()` step triggers `navigator.credentials.get`, which must fire
|
|
68
|
+
* AT MOST ONCE per page load — otherwise a remount storm becomes a credential
|
|
69
|
+
* request storm. Keyed by origin so the guard survives instance churn; never
|
|
70
|
+
* cleared because only a fresh page load can change the IdP session state.
|
|
71
|
+
*/
|
|
72
|
+
const fedcmSilentSignInAttempted = new Set<string>();
|
|
73
|
+
|
|
74
|
+
function silentSignInKey(): string {
|
|
75
|
+
return typeof window !== 'undefined' ? window.location.origin : 'no-origin';
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
export interface WebOxyProviderProps {
|
|
62
79
|
children: ReactNode;
|
|
63
80
|
baseURL: string;
|
|
@@ -191,14 +208,21 @@ export function WebOxyProvider({
|
|
|
191
208
|
}
|
|
192
209
|
}
|
|
193
210
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
211
|
+
// FedCM silent sign-in: run AT MOST ONCE per page load. A remount
|
|
212
|
+
// (route change / StrictMode / error recovery) must not re-trigger
|
|
213
|
+
// the browser credential request.
|
|
214
|
+
const ssoKey = silentSignInKey();
|
|
215
|
+
if (!fedcmSilentSignInAttempted.has(ssoKey)) {
|
|
216
|
+
fedcmSilentSignInAttempted.add(ssoKey);
|
|
217
|
+
try {
|
|
218
|
+
const session = await crossDomainAuth.silentSignIn();
|
|
219
|
+
if (mounted && session?.user) {
|
|
220
|
+
await handleAuthSuccess(session, 'fedcm');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// Silent sign-in failed — resolve to unauthenticated below.
|
|
199
225
|
}
|
|
200
|
-
} catch {
|
|
201
|
-
// Silent sign-in failed
|
|
202
226
|
}
|
|
203
227
|
|
|
204
228
|
if (mounted) setIsLoading(false);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-Data Mutation Hooks
|
|
3
|
+
*
|
|
4
|
+
* Write side of the per-user JSON KV store. Both `useSetAppData` and
|
|
5
|
+
* `useDeleteAppData` apply optimistic updates against the two query keys
|
|
6
|
+
* that observe this data (`appDataQueryKeys.value` and the surrounding
|
|
7
|
+
* `appDataQueryKeys.namespace`) and roll back on error.
|
|
8
|
+
*
|
|
9
|
+
* When the underlying request fails because the endpoint isn't reachable
|
|
10
|
+
* (404 / network), the mutation still surfaces the error — write attempts
|
|
11
|
+
* are user-initiated and the caller may want to retry or fall back to
|
|
12
|
+
* local persistence. Reads are silent about missing endpoints; writes are
|
|
13
|
+
* not.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
17
|
+
import { authenticatedApiCall } from '@oxyhq/core';
|
|
18
|
+
import { useWebOxy } from '../../WebOxyProvider';
|
|
19
|
+
import { appDataQueryKeys } from '../queries/appDataQueryKeys';
|
|
20
|
+
|
|
21
|
+
interface SetAppDataVariables<T> {
|
|
22
|
+
namespace: string;
|
|
23
|
+
key: string;
|
|
24
|
+
value: T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SetAppDataContext<T> {
|
|
28
|
+
previousValue: T | null | undefined;
|
|
29
|
+
previousNamespace: Record<string, T> | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Upsert a per-user JSON value. Returns the value the server confirmed it
|
|
34
|
+
* stored — typically identical to the input but consumers should prefer the
|
|
35
|
+
* returned value (the server is the source of truth).
|
|
36
|
+
*
|
|
37
|
+
* Applies optimistic updates against both the single-value query key and
|
|
38
|
+
* the surrounding namespace query key, then rolls back on error.
|
|
39
|
+
*/
|
|
40
|
+
export const useSetAppData = <T = unknown>() => {
|
|
41
|
+
const { oxyServices, activeSessionId } = useWebOxy();
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
|
|
44
|
+
return useMutation<T, Error, SetAppDataVariables<T>, SetAppDataContext<T>>({
|
|
45
|
+
mutationKey: ['appData', 'set'],
|
|
46
|
+
mutationFn: async ({ namespace, key, value }) => {
|
|
47
|
+
return authenticatedApiCall(oxyServices, activeSessionId, () =>
|
|
48
|
+
oxyServices.setAppData<T>(namespace, key, value),
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
onMutate: async ({ namespace, key, value }) => {
|
|
52
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
53
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
54
|
+
|
|
55
|
+
await Promise.all([
|
|
56
|
+
queryClient.cancelQueries({ queryKey: valueKey }),
|
|
57
|
+
queryClient.cancelQueries({ queryKey: namespaceKey }),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const previousValue = queryClient.getQueryData<T | null>(valueKey);
|
|
61
|
+
const previousNamespace = queryClient.getQueryData<Record<string, T>>(namespaceKey);
|
|
62
|
+
|
|
63
|
+
queryClient.setQueryData<T | null>(valueKey, value);
|
|
64
|
+
if (previousNamespace) {
|
|
65
|
+
queryClient.setQueryData<Record<string, T>>(namespaceKey, {
|
|
66
|
+
...previousNamespace,
|
|
67
|
+
[key]: value,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { previousValue, previousNamespace };
|
|
72
|
+
},
|
|
73
|
+
onError: (_error, { namespace, key }, context) => {
|
|
74
|
+
if (!context) return;
|
|
75
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
76
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
77
|
+
|
|
78
|
+
// Restore exactly the snapshots we captured in onMutate. Don't merge
|
|
79
|
+
// with whatever's currently in the cache — that could splice in writes
|
|
80
|
+
// from concurrent mutations and undo their state.
|
|
81
|
+
queryClient.setQueryData(valueKey, context.previousValue ?? null);
|
|
82
|
+
if (context.previousNamespace !== undefined) {
|
|
83
|
+
queryClient.setQueryData(namespaceKey, context.previousNamespace);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
onSuccess: (data, { namespace, key }) => {
|
|
87
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
88
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
89
|
+
|
|
90
|
+
queryClient.setQueryData(valueKey, data);
|
|
91
|
+
const existingNamespace = queryClient.getQueryData<Record<string, T>>(namespaceKey);
|
|
92
|
+
if (existingNamespace) {
|
|
93
|
+
queryClient.setQueryData<Record<string, T>>(namespaceKey, {
|
|
94
|
+
...existingNamespace,
|
|
95
|
+
[key]: data,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
interface DeleteAppDataVariables {
|
|
103
|
+
namespace: string;
|
|
104
|
+
key: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface DeleteAppDataContext<T> {
|
|
108
|
+
previousValue: T | null | undefined;
|
|
109
|
+
previousNamespace: Record<string, T> | undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Delete a per-user JSON value. Optimistically removes the entry from the
|
|
114
|
+
* single-value cache and from the surrounding namespace map, then rolls back
|
|
115
|
+
* on error.
|
|
116
|
+
*/
|
|
117
|
+
export const useDeleteAppData = <T = unknown>() => {
|
|
118
|
+
const { oxyServices, activeSessionId } = useWebOxy();
|
|
119
|
+
const queryClient = useQueryClient();
|
|
120
|
+
|
|
121
|
+
return useMutation<void, Error, DeleteAppDataVariables, DeleteAppDataContext<T>>({
|
|
122
|
+
mutationKey: ['appData', 'delete'],
|
|
123
|
+
mutationFn: async ({ namespace, key }) => {
|
|
124
|
+
await authenticatedApiCall(oxyServices, activeSessionId, () =>
|
|
125
|
+
oxyServices.deleteAppData(namespace, key),
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
onMutate: async ({ namespace, key }) => {
|
|
129
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
130
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
131
|
+
|
|
132
|
+
await Promise.all([
|
|
133
|
+
queryClient.cancelQueries({ queryKey: valueKey }),
|
|
134
|
+
queryClient.cancelQueries({ queryKey: namespaceKey }),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const previousValue = queryClient.getQueryData<T | null>(valueKey);
|
|
138
|
+
const previousNamespace = queryClient.getQueryData<Record<string, T>>(namespaceKey);
|
|
139
|
+
|
|
140
|
+
queryClient.setQueryData<T | null>(valueKey, null);
|
|
141
|
+
if (previousNamespace && key in previousNamespace) {
|
|
142
|
+
const next: Record<string, T> = { ...previousNamespace };
|
|
143
|
+
delete next[key];
|
|
144
|
+
queryClient.setQueryData(namespaceKey, next);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { previousValue, previousNamespace };
|
|
148
|
+
},
|
|
149
|
+
onError: (_error, { namespace, key }, context) => {
|
|
150
|
+
if (!context) return;
|
|
151
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
152
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
153
|
+
|
|
154
|
+
queryClient.setQueryData(valueKey, context.previousValue ?? null);
|
|
155
|
+
if (context.previousNamespace !== undefined) {
|
|
156
|
+
queryClient.setQueryData(namespaceKey, context.previousNamespace);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
onSuccess: (_data, { namespace, key }) => {
|
|
160
|
+
queryClient.setQueryData(appDataQueryKeys.value(namespace, key), null);
|
|
161
|
+
// Confirm the value is gone from the namespace cache too. If the
|
|
162
|
+
// optimistic update wasn't applied (e.g. cache was empty), this is a
|
|
163
|
+
// no-op; if it was, we already removed it in onMutate, so this is also
|
|
164
|
+
// a no-op. The work happens in onMutate — onSuccess is the commit point.
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query keys + error utilities for `useAppData` hooks.
|
|
3
|
+
*
|
|
4
|
+
* Lives next to the hook file so consumers can import the keys directly
|
|
5
|
+
* when they need to imperatively invalidate a value (e.g. after a non-React
|
|
6
|
+
* write through `oxyServices.setAppData`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const appDataQueryKeys = {
|
|
10
|
+
all: ['appData'] as const,
|
|
11
|
+
namespaces: () => [...appDataQueryKeys.all, 'namespace'] as const,
|
|
12
|
+
namespace: (namespace: string) =>
|
|
13
|
+
[...appDataQueryKeys.namespaces(), namespace] as const,
|
|
14
|
+
values: () => [...appDataQueryKeys.all, 'value'] as const,
|
|
15
|
+
value: (namespace: string, key: string) =>
|
|
16
|
+
[...appDataQueryKeys.values(), namespace, key] as const,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* True when `error` indicates the app-data endpoint isn't reachable — either
|
|
21
|
+
* because the API deployment doesn't have it yet (404) or there's a network
|
|
22
|
+
* failure with no response. We treat these as "no value stored" so consumers
|
|
23
|
+
* fall back to local persistence without surfacing a user-facing error.
|
|
24
|
+
*
|
|
25
|
+
* Anything else (401, 403, 500) propagates normally — those are real bugs
|
|
26
|
+
* the auth or retry pipeline needs to see.
|
|
27
|
+
*/
|
|
28
|
+
export function isMissingAppDataEndpointError(error: unknown): boolean {
|
|
29
|
+
if (!error || typeof error !== 'object') {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const candidate = error as {
|
|
33
|
+
status?: number;
|
|
34
|
+
statusCode?: number;
|
|
35
|
+
response?: { status?: number };
|
|
36
|
+
code?: string;
|
|
37
|
+
message?: string;
|
|
38
|
+
};
|
|
39
|
+
const status =
|
|
40
|
+
candidate.status ?? candidate.statusCode ?? candidate.response?.status;
|
|
41
|
+
|
|
42
|
+
// 404: endpoint not deployed on this API instance yet.
|
|
43
|
+
if (status === 404) return true;
|
|
44
|
+
|
|
45
|
+
// Network errors: no response received at all. Common during local dev
|
|
46
|
+
// when the API server is down, or when offline.
|
|
47
|
+
if (candidate.code === 'NETWORK_ERROR') return true;
|
|
48
|
+
const message = typeof candidate.message === 'string' ? candidate.message : '';
|
|
49
|
+
if (message.includes('Network Error') || message.includes('Failed to fetch')) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -34,3 +34,7 @@ export {
|
|
|
34
34
|
// Query keys and invalidation helpers (for advanced usage)
|
|
35
35
|
export { queryKeys, invalidateAccountQueries, invalidateUserQueries, invalidateSessionQueries } from './queryKeys';
|
|
36
36
|
|
|
37
|
+
// App-data KV store query hooks
|
|
38
|
+
export { useAppData, useAppDataNamespace } from './useAppData';
|
|
39
|
+
export { appDataQueryKeys, isMissingAppDataEndpointError } from './appDataQueryKeys';
|
|
40
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-Data Query Hooks
|
|
3
|
+
*
|
|
4
|
+
* Read side of the `/users/me/app-data/...` per-user JSON KV store. Gated on
|
|
5
|
+
* `isAuthenticated` — when signed out the query stays `enabled: false` and
|
|
6
|
+
* `data` is `null`, so consumers can fall back to localStorage without ever
|
|
7
|
+
* issuing a doomed request.
|
|
8
|
+
*
|
|
9
|
+
* Errors from the network (404 because the endpoint isn't deployed yet,
|
|
10
|
+
* 401 because the session lapsed, etc.) are not user-facing here. Hooks
|
|
11
|
+
* return `data: null` on error so the calling component renders the
|
|
12
|
+
* "nothing yet" state and the consuming app can quietly fall back to local
|
|
13
|
+
* persistence. Mutations still propagate errors so write attempts surface
|
|
14
|
+
* a toast — only reads are silent.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
|
18
|
+
import { authenticatedApiCall } from '@oxyhq/core';
|
|
19
|
+
import { useWebOxy } from '../../WebOxyProvider';
|
|
20
|
+
import { appDataQueryKeys, isMissingAppDataEndpointError } from './appDataQueryKeys';
|
|
21
|
+
|
|
22
|
+
interface AppDataQueryOptions {
|
|
23
|
+
/** Disable the query without unmounting the component. */
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
/** Override the default 1-minute stale time. */
|
|
26
|
+
staleTime?: number;
|
|
27
|
+
/** Override the default 30-minute gc time. */
|
|
28
|
+
gcTime?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a single per-user JSON value.
|
|
33
|
+
*
|
|
34
|
+
* @param namespace - kebab/snake-case identifier (e.g. `"academy"`).
|
|
35
|
+
* @param key - kebab/snake-case identifier (e.g. course slug).
|
|
36
|
+
* @param options - optional `enabled`/`staleTime`/`gcTime` overrides.
|
|
37
|
+
*
|
|
38
|
+
* @returns A `useQuery` result with `data` of type `T | null`. The query
|
|
39
|
+
* stays disabled when the user is signed out; when enabled but the server
|
|
40
|
+
* has no stored value, `data` is `null`. Reads that fail because the
|
|
41
|
+
* endpoint isn't reachable also resolve to `null` so the consumer can
|
|
42
|
+
* fall back to local persistence.
|
|
43
|
+
*/
|
|
44
|
+
export const useAppData = <T = unknown>(
|
|
45
|
+
namespace: string,
|
|
46
|
+
key: string,
|
|
47
|
+
options?: AppDataQueryOptions,
|
|
48
|
+
): UseQueryResult<T | null, Error> => {
|
|
49
|
+
const { oxyServices, activeSessionId, isAuthenticated } = useWebOxy();
|
|
50
|
+
|
|
51
|
+
return useQuery<T | null, Error>({
|
|
52
|
+
queryKey: appDataQueryKeys.value(namespace, key),
|
|
53
|
+
queryFn: async () => {
|
|
54
|
+
try {
|
|
55
|
+
return await authenticatedApiCall(oxyServices, activeSessionId, () =>
|
|
56
|
+
oxyServices.getAppData<T>(namespace, key),
|
|
57
|
+
);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// Endpoint not deployed yet, no network, etc. — return null so the
|
|
60
|
+
// consumer falls back to localStorage rather than rendering a broken
|
|
61
|
+
// UI state. Authentication errors still bubble up so the auth retry
|
|
62
|
+
// pipeline can surface them at the provider level.
|
|
63
|
+
if (isMissingAppDataEndpointError(error)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
enabled: (options?.enabled !== false) && isAuthenticated,
|
|
70
|
+
staleTime: options?.staleTime ?? 60 * 1000,
|
|
71
|
+
gcTime: options?.gcTime ?? 30 * 60 * 1000,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read every value in a namespace.
|
|
77
|
+
*
|
|
78
|
+
* @returns A `useQuery` result with `data` as a `Record<string, T>`. Empty
|
|
79
|
+
* object when the namespace contains nothing (or when fetching failed).
|
|
80
|
+
*/
|
|
81
|
+
export const useAppDataNamespace = <T = unknown>(
|
|
82
|
+
namespace: string,
|
|
83
|
+
options?: AppDataQueryOptions,
|
|
84
|
+
): UseQueryResult<Record<string, T>, Error> => {
|
|
85
|
+
const { oxyServices, activeSessionId, isAuthenticated } = useWebOxy();
|
|
86
|
+
|
|
87
|
+
return useQuery<Record<string, T>, Error>({
|
|
88
|
+
queryKey: appDataQueryKeys.namespace(namespace),
|
|
89
|
+
queryFn: async () => {
|
|
90
|
+
try {
|
|
91
|
+
return await authenticatedApiCall(oxyServices, activeSessionId, () =>
|
|
92
|
+
oxyServices.listAppData<T>(namespace),
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (isMissingAppDataEndpointError(error)) {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
enabled: (options?.enabled !== false) && isAuthenticated,
|
|
102
|
+
staleTime: options?.staleTime ?? 60 * 1000,
|
|
103
|
+
gcTime: options?.gcTime ?? 30 * 60 * 1000,
|
|
104
|
+
});
|
|
105
|
+
};
|
package/src/hooks/useWebSSO.ts
CHANGED
|
@@ -38,6 +38,36 @@ interface UseWebSSOResult {
|
|
|
38
38
|
isFedCMSupported: boolean;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Module-level guard tracking which (origin + API) signatures have already
|
|
43
|
+
* had a silent SSO attempt this page load.
|
|
44
|
+
*
|
|
45
|
+
* A per-component `useRef` guard resets whenever the provider remounts (route
|
|
46
|
+
* churn, StrictMode double-invoke, error-boundary recovery), which previously
|
|
47
|
+
* allowed silent SSO to re-fire and — combined with a routing redirect loop —
|
|
48
|
+
* produced an accelerating `navigator.credentials.get` retry storm. Keying the
|
|
49
|
+
* guard on a stable signature instead of the component instance makes silent
|
|
50
|
+
* SSO fire EXACTLY ONCE per page load regardless of how many times the
|
|
51
|
+
* provider mounts. The set is intentionally never cleared: a fresh page load
|
|
52
|
+
* (the only thing that can change the answer) starts a fresh module scope.
|
|
53
|
+
*/
|
|
54
|
+
const silentSSOAttempted = new Set<string>();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a stable signature for the silent-SSO run-once guard. Two providers
|
|
58
|
+
* pointed at the same API from the same origin share one attempt.
|
|
59
|
+
*/
|
|
60
|
+
function ssoSignature(oxyServices: OxyServices): string {
|
|
61
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
|
|
62
|
+
let baseURL = '';
|
|
63
|
+
try {
|
|
64
|
+
baseURL = oxyServices.getBaseURL();
|
|
65
|
+
} catch {
|
|
66
|
+
baseURL = '';
|
|
67
|
+
}
|
|
68
|
+
return `${origin}|${baseURL}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
41
71
|
/**
|
|
42
72
|
* Check if we're running in a web browser environment (not React Native)
|
|
43
73
|
*/
|
|
@@ -158,7 +188,14 @@ export function useWebSSO({
|
|
|
158
188
|
}
|
|
159
189
|
}, [oxyServices, onSessionFound, onError, fedCMSupported]);
|
|
160
190
|
|
|
161
|
-
// Auto-check SSO on mount (web only, FedCM only, not on auth domain)
|
|
191
|
+
// Auto-check SSO on mount (web only, FedCM only, not on auth domain).
|
|
192
|
+
//
|
|
193
|
+
// Run-once is enforced by TWO guards:
|
|
194
|
+
// 1. `hasCheckedRef` — cheap per-instance fast-path so effect re-runs
|
|
195
|
+
// (from changing deps) within one mount never re-fire.
|
|
196
|
+
// 2. `silentSSOAttempted` — module-level, survives remounts/StrictMode so
|
|
197
|
+
// silent SSO fires exactly once per page load even if the provider
|
|
198
|
+
// unmounts and remounts.
|
|
162
199
|
useEffect(() => {
|
|
163
200
|
if (!enabled || !isWebBrowser() || hasCheckedRef.current || isIdentityProvider()) {
|
|
164
201
|
if (isIdentityProvider()) {
|
|
@@ -167,14 +204,23 @@ export function useWebSSO({
|
|
|
167
204
|
return;
|
|
168
205
|
}
|
|
169
206
|
|
|
207
|
+
const signature = ssoSignature(oxyServices);
|
|
208
|
+
if (silentSSOAttempted.has(signature)) {
|
|
209
|
+
// Already attempted this page load (e.g. before a remount) — do not
|
|
210
|
+
// re-fire. Mark the local fast-path too so subsequent re-renders skip.
|
|
211
|
+
hasCheckedRef.current = true;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
170
215
|
hasCheckedRef.current = true;
|
|
216
|
+
silentSSOAttempted.add(signature);
|
|
171
217
|
|
|
172
218
|
if (fedCMSupported) {
|
|
173
219
|
checkSSO();
|
|
174
220
|
} else {
|
|
175
221
|
onSSOUnavailable?.();
|
|
176
222
|
}
|
|
177
|
-
}, [enabled, checkSSO, fedCMSupported, onSSOUnavailable]);
|
|
223
|
+
}, [enabled, checkSSO, fedCMSupported, onSSOUnavailable, oxyServices]);
|
|
178
224
|
|
|
179
225
|
return {
|
|
180
226
|
checkSSO,
|
package/src/index.ts
CHANGED
|
@@ -74,6 +74,10 @@ export {
|
|
|
74
74
|
useSecurityInfo,
|
|
75
75
|
useSecurityActivity,
|
|
76
76
|
useRecentSecurityActivity,
|
|
77
|
+
useAppData,
|
|
78
|
+
useAppDataNamespace,
|
|
79
|
+
appDataQueryKeys,
|
|
80
|
+
isMissingAppDataEndpointError,
|
|
77
81
|
} from './hooks/queries';
|
|
78
82
|
|
|
79
83
|
// --- Mutation Hooks ---
|
|
@@ -88,6 +92,8 @@ export {
|
|
|
88
92
|
useLogoutAll,
|
|
89
93
|
useUpdateDeviceName,
|
|
90
94
|
useRemoveDevice,
|
|
95
|
+
useSetAppData,
|
|
96
|
+
useDeleteAppData,
|
|
91
97
|
} from './hooks/mutations';
|
|
92
98
|
|
|
93
99
|
export {
|