@ulexid/react 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # @ulexid/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Version `0.1.1` — [Ulexid Documentation](https://docs.ulexid.com)
4
+
5
+ React hooks and `<AuthProvider>` components for [Ulexid](https://www.ulexid.com).
6
+
7
+ ```bash
8
+ npm install @ulexid/react
9
+ ```
10
+
11
+ ## License
12
+
13
+ Proprietary (`UNLICENSED`). Copyright (c) Ulexid. All rights reserved.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `@ulexid/react` — React 18/19 context, hooks, and components for ulex.
3
+ *
4
+ * Provides CIAM user sign-in, Workforce organization switching, and RFC 8693
5
+ * scoped agent token delegation on top of `@ulexid/js` PKCE `S256`.
6
+ */
7
+ import { type ReactNode } from 'react';
8
+ import { UlexClient, type FetchLike, type TokenSet, type UserInfo } from '@ulexid/js';
9
+ export { UlexOAuthError, type TokenSet, type UserInfo } from '@ulexid/js';
10
+ export interface AuthStateStorage {
11
+ getItem(key: string): string | null;
12
+ setItem(key: string, value: string): void;
13
+ removeItem(key: string): void;
14
+ }
15
+ export interface SignInOptions {
16
+ readonly organization?: string;
17
+ readonly connection?: string;
18
+ readonly loginHint?: string;
19
+ readonly scopes?: readonly string[];
20
+ readonly redirectUri?: string;
21
+ }
22
+ export interface UlexAuthContextValue {
23
+ readonly client: UlexClient;
24
+ readonly isLoaded: boolean;
25
+ readonly isSignedIn: boolean;
26
+ readonly user: UserInfo | null;
27
+ readonly organizationId: string | null;
28
+ readonly scopes: readonly string[];
29
+ readonly error: Error | null;
30
+ buildSignInUrl(options?: SignInOptions): Promise<string>;
31
+ signInWithRedirect(options?: SignInOptions): Promise<void>;
32
+ handleRedirectCallback(callbackUrl: string): Promise<UserInfo>;
33
+ getAccessToken(options?: {
34
+ forceRefresh?: boolean;
35
+ }): Promise<string | null>;
36
+ switchOrganization(organizationId: string): Promise<void>;
37
+ signOut(options?: {
38
+ postLogoutRedirectUri?: string;
39
+ }): string;
40
+ }
41
+ export interface UlexProviderProps {
42
+ readonly issuer: string;
43
+ readonly clientId: string;
44
+ readonly redirectUri: string;
45
+ readonly defaultScopes?: readonly string[];
46
+ readonly initialTokens?: TokenSet | null;
47
+ readonly initialUser?: UserInfo | null;
48
+ readonly storage?: AuthStateStorage;
49
+ readonly fetch?: FetchLike;
50
+ readonly onNavigate?: (url: string) => void;
51
+ readonly children: ReactNode;
52
+ }
53
+ export declare function createUlexAuthController(config: {
54
+ readonly issuer: string;
55
+ readonly clientId: string;
56
+ readonly redirectUri: string;
57
+ readonly defaultScopes?: readonly string[];
58
+ readonly storage?: AuthStateStorage;
59
+ readonly fetch?: FetchLike;
60
+ }): {
61
+ client: UlexClient;
62
+ storage: AuthStateStorage;
63
+ prepareAuthorizeUrl: (options?: SignInOptions) => Promise<{
64
+ url: string;
65
+ state: string;
66
+ }>;
67
+ completeCallback: (callbackUrl: string) => Promise<{
68
+ tokens: TokenSet;
69
+ user: UserInfo;
70
+ }>;
71
+ };
72
+ export declare function UlexProvider(props: UlexProviderProps): import("react").JSX.Element;
73
+ export declare function useUlex(): UlexAuthContextValue;
74
+ export declare function useAuth(): {
75
+ isLoaded: boolean;
76
+ isSignedIn: boolean;
77
+ userId: string | null;
78
+ orgId: string | null;
79
+ scopes: readonly string[];
80
+ getAccessToken: (options?: {
81
+ forceRefresh?: boolean;
82
+ }) => Promise<string | null>;
83
+ signInWithRedirect: (options?: SignInOptions) => Promise<void>;
84
+ signOut: (options?: {
85
+ postLogoutRedirectUri?: string;
86
+ }) => string;
87
+ };
88
+ export declare function useUser(): {
89
+ isLoaded: boolean;
90
+ isSignedIn: boolean;
91
+ user: UserInfo | null;
92
+ };
93
+ export declare function useOrganization(): {
94
+ isLoaded: boolean;
95
+ organizationId: string | null;
96
+ switchOrganization: (organizationId: string) => Promise<void>;
97
+ };
98
+ export declare function SignedIn({ children }: {
99
+ readonly children: ReactNode;
100
+ }): import("react").JSX.Element | null;
101
+ export declare function SignedOut({ children }: {
102
+ readonly children: ReactNode;
103
+ }): import("react").JSX.Element | null;
package/dist/index.js ADDED
@@ -0,0 +1,268 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * `@ulexid/react` — React 18/19 context, hooks, and components for ulex.
4
+ *
5
+ * Provides CIAM user sign-in, Workforce organization switching, and RFC 8693
6
+ * scoped agent token delegation on top of `@ulexid/js` PKCE `S256`.
7
+ */
8
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
9
+ import { generatePkcePair, generateState, UlexClient, UlexOAuthError, } from '@ulexid/js';
10
+ export { UlexOAuthError } from '@ulexid/js';
11
+ class MemoryStateStorage {
12
+ store = new Map();
13
+ getItem(key) {
14
+ return this.store.get(key) ?? null;
15
+ }
16
+ setItem(key, value) {
17
+ this.store.set(key, value);
18
+ }
19
+ removeItem(key) {
20
+ this.store.delete(key);
21
+ }
22
+ }
23
+ function defaultStorage() {
24
+ if (typeof window !== 'undefined' && window.sessionStorage) {
25
+ return window.sessionStorage;
26
+ }
27
+ return new MemoryStateStorage();
28
+ }
29
+ const UlexContext = createContext(null);
30
+ const PKCE_STORAGE_PREFIX = 'ulex:pkce:';
31
+ export function createUlexAuthController(config) {
32
+ const storage = config.storage ?? defaultStorage();
33
+ const client = new UlexClient({
34
+ issuer: config.issuer,
35
+ clientId: config.clientId,
36
+ redirectUri: config.redirectUri,
37
+ fetch: config.fetch,
38
+ });
39
+ async function prepareAuthorizeUrl(options = {}) {
40
+ const pkce = await generatePkcePair();
41
+ const state = generateState();
42
+ const redirectUri = options.redirectUri ?? config.redirectUri;
43
+ storage.setItem(`${PKCE_STORAGE_PREFIX}${state}`, JSON.stringify({
44
+ verifier: pkce.verifier,
45
+ redirectUri,
46
+ createdAt: Date.now(),
47
+ }));
48
+ const url = client.buildAuthorizeUrl({
49
+ codeChallenge: pkce.challenge,
50
+ state,
51
+ redirectUri,
52
+ scopes: options.scopes ?? config.defaultScopes ?? ['openid', 'profile', 'email'],
53
+ organization: options.organization,
54
+ connection: options.connection,
55
+ loginHint: options.loginHint,
56
+ });
57
+ return { url, state };
58
+ }
59
+ async function completeCallback(callbackUrl) {
60
+ const parsed = new URL(callbackUrl);
61
+ const oauthErr = parsed.searchParams.get('error');
62
+ if (oauthErr) {
63
+ throw new UlexOAuthError(oauthErr, 400, parsed.searchParams.get('error_description') ?? undefined);
64
+ }
65
+ const code = parsed.searchParams.get('code');
66
+ const state = parsed.searchParams.get('state');
67
+ if (!code || !state) {
68
+ throw new UlexOAuthError('invalid_callback', 400, 'Callback URL is missing `code` or `state`.');
69
+ }
70
+ const storedRaw = storage.getItem(`${PKCE_STORAGE_PREFIX}${state}`);
71
+ storage.removeItem(`${PKCE_STORAGE_PREFIX}${state}`);
72
+ if (!storedRaw) {
73
+ throw new UlexOAuthError('state_mismatch', 400, 'Unknown or replayed OAuth `state` parameter.');
74
+ }
75
+ const stored = JSON.parse(storedRaw);
76
+ const tokens = await client.exchangeAuthorizationCode({
77
+ code,
78
+ codeVerifier: stored.verifier,
79
+ redirectUri: stored.redirectUri,
80
+ });
81
+ const user = await client.getUserInfo(tokens.access_token);
82
+ return { tokens, user };
83
+ }
84
+ return { client, storage, prepareAuthorizeUrl, completeCallback };
85
+ }
86
+ export function UlexProvider(props) {
87
+ const storageRef = useRef(props.storage ?? defaultStorage());
88
+ const controller = useMemo(() => createUlexAuthController({
89
+ issuer: props.issuer,
90
+ clientId: props.clientId,
91
+ redirectUri: props.redirectUri,
92
+ defaultScopes: props.defaultScopes,
93
+ storage: storageRef.current,
94
+ fetch: props.fetch,
95
+ }), [props.issuer, props.clientId, props.redirectUri, props.defaultScopes, props.fetch]);
96
+ const [tokens, setTokens] = useState(props.initialTokens ?? null);
97
+ const [tokenExpiresAt, setTokenExpiresAt] = useState(props.initialTokens ? Date.now() + props.initialTokens.expires_in * 1000 : 0);
98
+ const [user, setUser] = useState(props.initialUser ?? null);
99
+ const [isLoaded, setIsLoaded] = useState(Boolean(props.initialUser || !props.initialTokens));
100
+ const [error, setError] = useState(null);
101
+ useEffect(() => {
102
+ let cancelled = false;
103
+ if (props.initialTokens && !props.initialUser) {
104
+ controller.client
105
+ .getUserInfo(props.initialTokens.access_token)
106
+ .then((u) => {
107
+ if (!cancelled) {
108
+ setUser(u);
109
+ setIsLoaded(true);
110
+ }
111
+ })
112
+ .catch((err) => {
113
+ if (!cancelled) {
114
+ setError(err instanceof Error ? err : new Error(String(err)));
115
+ setIsLoaded(true);
116
+ }
117
+ });
118
+ }
119
+ return () => {
120
+ cancelled = true;
121
+ };
122
+ }, [controller.client, props.initialTokens, props.initialUser]);
123
+ const navigate = useCallback((url) => {
124
+ if (props.onNavigate) {
125
+ props.onNavigate(url);
126
+ }
127
+ else if (typeof window !== 'undefined') {
128
+ window.location.assign(url);
129
+ }
130
+ }, [props]);
131
+ const buildSignInUrl = useCallback(async (options = {}) => {
132
+ const { url } = await controller.prepareAuthorizeUrl(options);
133
+ return url;
134
+ }, [controller]);
135
+ const signInWithRedirect = useCallback(async (options = {}) => {
136
+ const url = await buildSignInUrl(options);
137
+ navigate(url);
138
+ }, [buildSignInUrl, navigate]);
139
+ const handleRedirectCallback = useCallback(async (callbackUrl) => {
140
+ try {
141
+ const result = await controller.completeCallback(callbackUrl);
142
+ setTokens(result.tokens);
143
+ setTokenExpiresAt(Date.now() + result.tokens.expires_in * 1000);
144
+ setUser(result.user);
145
+ setError(null);
146
+ setIsLoaded(true);
147
+ return result.user;
148
+ }
149
+ catch (err) {
150
+ const wrapped = err instanceof Error ? err : new Error(String(err));
151
+ setError(wrapped);
152
+ setIsLoaded(true);
153
+ throw wrapped;
154
+ }
155
+ }, [controller]);
156
+ const getAccessToken = useCallback(async (options = {}) => {
157
+ if (!tokens)
158
+ return null;
159
+ const needsRefresh = options.forceRefresh || tokenExpiresAt - Date.now() < 30_000;
160
+ if (!needsRefresh) {
161
+ return tokens.access_token;
162
+ }
163
+ if (!tokens.refresh_token) {
164
+ return tokens.access_token;
165
+ }
166
+ const rotated = await controller.client.refreshTokens({
167
+ refreshToken: tokens.refresh_token,
168
+ });
169
+ setTokens(rotated);
170
+ setTokenExpiresAt(Date.now() + rotated.expires_in * 1000);
171
+ return rotated.access_token;
172
+ }, [controller.client, tokenExpiresAt, tokens]);
173
+ const switchOrganization = useCallback(async (organizationId) => {
174
+ await signInWithRedirect({ organization: organizationId });
175
+ }, [signInWithRedirect]);
176
+ const signOut = useCallback((options = {}) => {
177
+ const idTokenHint = tokens?.id_token;
178
+ setTokens(null);
179
+ setUser(null);
180
+ setTokenExpiresAt(0);
181
+ const logoutUrl = controller.client.buildLogoutUrl({
182
+ idTokenHint,
183
+ postLogoutRedirectUri: options.postLogoutRedirectUri,
184
+ });
185
+ if (props.onNavigate) {
186
+ props.onNavigate(logoutUrl);
187
+ }
188
+ return logoutUrl;
189
+ }, [controller.client, props, tokens?.id_token]);
190
+ const scopes = useMemo(() => (tokens?.scope ? tokens.scope.split(/\s+/).filter(Boolean) : []), [tokens?.scope]);
191
+ const value = useMemo(() => ({
192
+ client: controller.client,
193
+ isLoaded,
194
+ isSignedIn: Boolean(tokens && user),
195
+ user,
196
+ organizationId: typeof user?.org_id === 'string' ? user.org_id : null,
197
+ scopes,
198
+ error,
199
+ buildSignInUrl,
200
+ signInWithRedirect,
201
+ handleRedirectCallback,
202
+ getAccessToken,
203
+ switchOrganization,
204
+ signOut,
205
+ }), [
206
+ buildSignInUrl,
207
+ controller.client,
208
+ error,
209
+ getAccessToken,
210
+ handleRedirectCallback,
211
+ isLoaded,
212
+ scopes,
213
+ signInWithRedirect,
214
+ signOut,
215
+ switchOrganization,
216
+ tokens,
217
+ user,
218
+ ]);
219
+ return _jsx(UlexContext.Provider, { value: value, children: props.children });
220
+ }
221
+ export function useUlex() {
222
+ const ctx = useContext(UlexContext);
223
+ if (!ctx) {
224
+ throw new Error('`useUlex` must be called inside a `<UlexProvider>`.');
225
+ }
226
+ return ctx;
227
+ }
228
+ export function useAuth() {
229
+ const ctx = useUlex();
230
+ return {
231
+ isLoaded: ctx.isLoaded,
232
+ isSignedIn: ctx.isSignedIn,
233
+ userId: ctx.user?.sub ?? null,
234
+ orgId: ctx.organizationId,
235
+ scopes: ctx.scopes,
236
+ getAccessToken: ctx.getAccessToken,
237
+ signInWithRedirect: ctx.signInWithRedirect,
238
+ signOut: ctx.signOut,
239
+ };
240
+ }
241
+ export function useUser() {
242
+ const ctx = useUlex();
243
+ return {
244
+ isLoaded: ctx.isLoaded,
245
+ isSignedIn: ctx.isSignedIn,
246
+ user: ctx.user,
247
+ };
248
+ }
249
+ export function useOrganization() {
250
+ const ctx = useUlex();
251
+ return {
252
+ isLoaded: ctx.isLoaded,
253
+ organizationId: ctx.organizationId,
254
+ switchOrganization: ctx.switchOrganization,
255
+ };
256
+ }
257
+ export function SignedIn({ children }) {
258
+ const { isLoaded, isSignedIn } = useAuth();
259
+ if (!isLoaded || !isSignedIn)
260
+ return null;
261
+ return _jsx(_Fragment, { children: children });
262
+ }
263
+ export function SignedOut({ children }) {
264
+ const { isLoaded, isSignedIn } = useAuth();
265
+ if (!isLoaded || isSignedIn)
266
+ return null;
267
+ return _jsx(_Fragment, { children: children });
268
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@ulexid/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "description": "Official React 18/19 hooks, provider, and organization-aware auth components for Ulexid.",
7
+ "homepage": "https://www.ulexid.com",
8
+ "engines": {
9
+ "node": ">=18.0.0"
10
+ },
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "dependencies": {
27
+ "@ulexid/js": "0.1.1"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18.0.0 || >=19.0.0"
31
+ }
32
+ }