@hanzo/iam 0.1.0
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/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +130 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +41 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +154 -0
- package/dist/billing.js.map +1 -0
- package/dist/browser.d.ts +83 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +370 -0
- package/dist/browser.js.map +1 -0
- package/dist/client.d.ts +55 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +238 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/pkce.d.ts +13 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +36 -0
- package/dist/pkce.js.map +1 -0
- package/dist/react.d.ts +123 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +422 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +93 -0
- package/src/auth.ts +151 -0
- package/src/billing.ts +238 -0
- package/src/browser.ts +451 -0
- package/src/client.ts +316 -0
- package/src/index.ts +52 -0
- package/src/pkce.ts +43 -0
- package/src/react.ts +533 -0
- package/src/types.ts +221 -0
package/src/react.ts
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings for @hanzo/iam.
|
|
3
|
+
*
|
|
4
|
+
* Provides a context provider, auth hooks, and org/project switching
|
|
5
|
+
* that can be dropped into any React application.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { IamProvider, useIam, useOrganizations } from '@hanzo/iam/react'
|
|
10
|
+
*
|
|
11
|
+
* function App() {
|
|
12
|
+
* return (
|
|
13
|
+
* <IamProvider config={{
|
|
14
|
+
* serverUrl: 'https://iam.hanzo.ai',
|
|
15
|
+
* clientId: 'my-app',
|
|
16
|
+
* redirectUri: `${window.location.origin}/auth/callback`,
|
|
17
|
+
* }}>
|
|
18
|
+
* <MyApp />
|
|
19
|
+
* </IamProvider>
|
|
20
|
+
* )
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* function MyApp() {
|
|
24
|
+
* const { user, isAuthenticated, login, logout } = useIam()
|
|
25
|
+
* const { organizations, currentOrg, switchOrg } = useOrganizations()
|
|
26
|
+
*
|
|
27
|
+
* if (!isAuthenticated) return <button onClick={() => login()}>Log in</button>
|
|
28
|
+
* return <div>Welcome, {user?.displayName}</div>
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @packageDocumentation
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
createContext,
|
|
37
|
+
createElement,
|
|
38
|
+
useCallback,
|
|
39
|
+
useContext,
|
|
40
|
+
useEffect,
|
|
41
|
+
useMemo,
|
|
42
|
+
useRef,
|
|
43
|
+
useState,
|
|
44
|
+
} from "react";
|
|
45
|
+
import type { ReactNode } from "react";
|
|
46
|
+
import { BrowserIamSdk } from "./browser.js";
|
|
47
|
+
import type { BrowserIamConfig } from "./browser.js";
|
|
48
|
+
import { IamClient } from "./client.js";
|
|
49
|
+
import type { IamUser, IamOrganization, TokenResponse } from "./types.js";
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Types
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export interface IamProviderProps {
|
|
56
|
+
/** Browser IAM SDK configuration. */
|
|
57
|
+
config: BrowserIamConfig;
|
|
58
|
+
/** Auto-initialize on mount (check stored tokens). Default: true. */
|
|
59
|
+
autoInit?: boolean;
|
|
60
|
+
/** Called when authentication state changes. */
|
|
61
|
+
onAuthChange?: (authenticated: boolean) => void;
|
|
62
|
+
children: ReactNode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IamContextValue {
|
|
66
|
+
/** The underlying BrowserIamSdk instance for advanced use. */
|
|
67
|
+
sdk: BrowserIamSdk;
|
|
68
|
+
/** The IAM configuration. */
|
|
69
|
+
config: BrowserIamConfig;
|
|
70
|
+
/** Authenticated user (null if not logged in). */
|
|
71
|
+
user: IamUser | null;
|
|
72
|
+
/** Whether the user is currently authenticated. */
|
|
73
|
+
isAuthenticated: boolean;
|
|
74
|
+
/** Whether initial auth check is in progress. */
|
|
75
|
+
isLoading: boolean;
|
|
76
|
+
/** Current access token (null if not authenticated). */
|
|
77
|
+
accessToken: string | null;
|
|
78
|
+
/** Redirect to IAM login page. */
|
|
79
|
+
login: (params?: { additionalParams?: Record<string, string> }) => Promise<void>;
|
|
80
|
+
/** Open IAM login in a popup. */
|
|
81
|
+
loginPopup: (params?: { width?: number; height?: number }) => Promise<void>;
|
|
82
|
+
/** Handle OAuth callback — call on your /auth/callback route. */
|
|
83
|
+
handleCallback: (callbackUrl?: string) => Promise<TokenResponse>;
|
|
84
|
+
/** Log out and clear all tokens. */
|
|
85
|
+
logout: () => void;
|
|
86
|
+
/** Last auth error, if any. */
|
|
87
|
+
error: Error | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface OrgState {
|
|
91
|
+
/** All organizations the user belongs to. */
|
|
92
|
+
organizations: IamOrganization[];
|
|
93
|
+
/** Currently selected organization. */
|
|
94
|
+
currentOrg: IamOrganization | null;
|
|
95
|
+
/** Currently selected org ID. */
|
|
96
|
+
currentOrgId: string | null;
|
|
97
|
+
/** Switch to a different organization. */
|
|
98
|
+
switchOrg: (orgId: string) => void;
|
|
99
|
+
/** Currently selected project ID within the org. */
|
|
100
|
+
currentProjectId: string | null;
|
|
101
|
+
/** Switch to a different project (null to clear). */
|
|
102
|
+
switchProject: (projectId: string | null) => void;
|
|
103
|
+
/** Whether organizations are loading. */
|
|
104
|
+
isLoading: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Context
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
const IamContext = createContext<IamContextValue | null>(null);
|
|
112
|
+
IamContext.displayName = "HanzoIamContext";
|
|
113
|
+
|
|
114
|
+
// Storage keys for tenant persistence
|
|
115
|
+
const STORAGE_ORG_KEY = "hanzo_iam_current_org";
|
|
116
|
+
const STORAGE_PROJECT_KEY = "hanzo_iam_current_project";
|
|
117
|
+
const STORAGE_EXPIRES_KEY = "hanzo_iam_expires_at";
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// IamProvider
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Root provider for Hanzo IAM in React applications.
|
|
125
|
+
*
|
|
126
|
+
* Wrap your app (or a subtree) with this provider to enable IAM auth.
|
|
127
|
+
* Manages the BrowserIamSdk instance, token lifecycle, and auth state.
|
|
128
|
+
*/
|
|
129
|
+
export function IamProvider(props: IamProviderProps) {
|
|
130
|
+
const { config, autoInit = true, onAuthChange, children } = props;
|
|
131
|
+
|
|
132
|
+
const sdk = useMemo(
|
|
133
|
+
() => new BrowserIamSdk(config),
|
|
134
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
|
+
[config.serverUrl, config.clientId, config.redirectUri],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const [user, setUser] = useState<IamUser | null>(null);
|
|
139
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
140
|
+
const [isLoading, setIsLoading] = useState(autoInit);
|
|
141
|
+
const [accessToken, setAccessToken] = useState<string | null>(
|
|
142
|
+
sdk.getAccessToken(),
|
|
143
|
+
);
|
|
144
|
+
const [error, setError] = useState<Error | null>(null);
|
|
145
|
+
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
146
|
+
|
|
147
|
+
// Schedule token refresh ~60s before expiry
|
|
148
|
+
const scheduleRefresh = useCallback(() => {
|
|
149
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
150
|
+
if (sdk.isTokenExpired()) return;
|
|
151
|
+
|
|
152
|
+
const storage = config.storage ?? sessionStorage;
|
|
153
|
+
const expiresAtStr = storage.getItem(STORAGE_EXPIRES_KEY);
|
|
154
|
+
if (!expiresAtStr) return;
|
|
155
|
+
|
|
156
|
+
const msUntilRefresh = Number(expiresAtStr) - Date.now() - 60_000;
|
|
157
|
+
if (msUntilRefresh <= 0) {
|
|
158
|
+
sdk
|
|
159
|
+
.refreshAccessToken()
|
|
160
|
+
.then((tokens) => {
|
|
161
|
+
setAccessToken(tokens.access_token);
|
|
162
|
+
scheduleRefresh();
|
|
163
|
+
})
|
|
164
|
+
.catch(() => {
|
|
165
|
+
setIsAuthenticated(false);
|
|
166
|
+
setUser(null);
|
|
167
|
+
setAccessToken(null);
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
refreshTimerRef.current = setTimeout(async () => {
|
|
173
|
+
try {
|
|
174
|
+
const tokens = await sdk.refreshAccessToken();
|
|
175
|
+
setAccessToken(tokens.access_token);
|
|
176
|
+
scheduleRefresh();
|
|
177
|
+
} catch {
|
|
178
|
+
setIsAuthenticated(false);
|
|
179
|
+
setUser(null);
|
|
180
|
+
setAccessToken(null);
|
|
181
|
+
}
|
|
182
|
+
}, msUntilRefresh);
|
|
183
|
+
}, [sdk, config.storage]);
|
|
184
|
+
|
|
185
|
+
// Auto-init: check stored tokens on mount
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!autoInit) {
|
|
188
|
+
setIsLoading(false);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let cancelled = false;
|
|
193
|
+
|
|
194
|
+
const init = async () => {
|
|
195
|
+
try {
|
|
196
|
+
const token = await sdk.getValidAccessToken();
|
|
197
|
+
if (cancelled) return;
|
|
198
|
+
if (token) {
|
|
199
|
+
setAccessToken(token);
|
|
200
|
+
setIsAuthenticated(true);
|
|
201
|
+
try {
|
|
202
|
+
const info = await sdk.getUserInfo();
|
|
203
|
+
if (!cancelled) setUser(info as unknown as IamUser);
|
|
204
|
+
} catch {
|
|
205
|
+
// Token valid but userinfo failed — still authenticated
|
|
206
|
+
}
|
|
207
|
+
scheduleRefresh();
|
|
208
|
+
onAuthChange?.(true);
|
|
209
|
+
} else {
|
|
210
|
+
onAuthChange?.(false);
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (!cancelled) {
|
|
214
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
215
|
+
onAuthChange?.(false);
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
if (!cancelled) setIsLoading(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
init();
|
|
223
|
+
return () => {
|
|
224
|
+
cancelled = true;
|
|
225
|
+
};
|
|
226
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
227
|
+
}, [sdk, autoInit]);
|
|
228
|
+
|
|
229
|
+
// Cleanup refresh timer on unmount
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
return () => {
|
|
232
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
233
|
+
};
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
// Complete authentication after login/callback
|
|
237
|
+
const completeAuth = useCallback(
|
|
238
|
+
async (tokens: TokenResponse) => {
|
|
239
|
+
setAccessToken(tokens.access_token);
|
|
240
|
+
setIsAuthenticated(true);
|
|
241
|
+
try {
|
|
242
|
+
const info = await sdk.getUserInfo();
|
|
243
|
+
setUser(info as unknown as IamUser);
|
|
244
|
+
} catch {
|
|
245
|
+
// ok — token valid, userinfo is optional
|
|
246
|
+
}
|
|
247
|
+
scheduleRefresh();
|
|
248
|
+
onAuthChange?.(true);
|
|
249
|
+
},
|
|
250
|
+
[sdk, scheduleRefresh, onAuthChange],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const login = useCallback(
|
|
254
|
+
async (params?: { additionalParams?: Record<string, string> }) => {
|
|
255
|
+
setError(null);
|
|
256
|
+
await sdk.signinRedirect(params);
|
|
257
|
+
},
|
|
258
|
+
[sdk],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const loginPopup = useCallback(
|
|
262
|
+
async (params?: { width?: number; height?: number }) => {
|
|
263
|
+
setError(null);
|
|
264
|
+
try {
|
|
265
|
+
const tokens = await sdk.signinPopup(params);
|
|
266
|
+
await completeAuth(tokens);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
269
|
+
setError(e);
|
|
270
|
+
throw e;
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[sdk, completeAuth],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const handleCallback = useCallback(
|
|
277
|
+
async (callbackUrl?: string) => {
|
|
278
|
+
setError(null);
|
|
279
|
+
try {
|
|
280
|
+
const tokens = await sdk.handleCallback(callbackUrl);
|
|
281
|
+
await completeAuth(tokens);
|
|
282
|
+
return tokens;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
285
|
+
setError(e);
|
|
286
|
+
throw e;
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
[sdk, completeAuth],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const logout = useCallback(() => {
|
|
293
|
+
sdk.clearTokens();
|
|
294
|
+
setUser(null);
|
|
295
|
+
setIsAuthenticated(false);
|
|
296
|
+
setAccessToken(null);
|
|
297
|
+
setError(null);
|
|
298
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
299
|
+
try {
|
|
300
|
+
localStorage.removeItem(STORAGE_ORG_KEY);
|
|
301
|
+
localStorage.removeItem(STORAGE_PROJECT_KEY);
|
|
302
|
+
} catch {
|
|
303
|
+
/* ok */
|
|
304
|
+
}
|
|
305
|
+
onAuthChange?.(false);
|
|
306
|
+
}, [sdk, onAuthChange]);
|
|
307
|
+
|
|
308
|
+
const value = useMemo<IamContextValue>(
|
|
309
|
+
() => ({
|
|
310
|
+
sdk,
|
|
311
|
+
config,
|
|
312
|
+
user,
|
|
313
|
+
isAuthenticated,
|
|
314
|
+
isLoading,
|
|
315
|
+
accessToken,
|
|
316
|
+
login,
|
|
317
|
+
loginPopup,
|
|
318
|
+
handleCallback,
|
|
319
|
+
logout,
|
|
320
|
+
error,
|
|
321
|
+
}),
|
|
322
|
+
[
|
|
323
|
+
sdk,
|
|
324
|
+
config,
|
|
325
|
+
user,
|
|
326
|
+
isAuthenticated,
|
|
327
|
+
isLoading,
|
|
328
|
+
accessToken,
|
|
329
|
+
login,
|
|
330
|
+
loginPopup,
|
|
331
|
+
handleCallback,
|
|
332
|
+
logout,
|
|
333
|
+
error,
|
|
334
|
+
],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return createElement(IamContext.Provider, { value }, children);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// useIam
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Access Hanzo IAM auth state and methods.
|
|
346
|
+
* Must be used within an `<IamProvider>`.
|
|
347
|
+
*/
|
|
348
|
+
export function useIam(): IamContextValue {
|
|
349
|
+
const ctx = useContext(IamContext);
|
|
350
|
+
if (!ctx) {
|
|
351
|
+
throw new Error("useIam() must be used within an <IamProvider>");
|
|
352
|
+
}
|
|
353
|
+
return ctx;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// useOrganizations
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Manage organization and project switching.
|
|
362
|
+
*
|
|
363
|
+
* Fetches the user's organizations from IAM and provides
|
|
364
|
+
* `switchOrg` / `switchProject` to change the active tenant.
|
|
365
|
+
* Selection is persisted to localStorage.
|
|
366
|
+
*/
|
|
367
|
+
export function useOrganizations(): OrgState {
|
|
368
|
+
const { config, isAuthenticated, accessToken } = useIam();
|
|
369
|
+
const [organizations, setOrganizations] = useState<IamOrganization[]>([]);
|
|
370
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
371
|
+
|
|
372
|
+
const [currentOrgId, setCurrentOrgId] = useState<string | null>(() => {
|
|
373
|
+
try {
|
|
374
|
+
return localStorage.getItem(STORAGE_ORG_KEY);
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const [currentProjectId, setCurrentProjectId] = useState<string | null>(
|
|
381
|
+
() => {
|
|
382
|
+
try {
|
|
383
|
+
return localStorage.getItem(STORAGE_PROJECT_KEY);
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
// Fetch organizations when authenticated
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (!isAuthenticated || !accessToken) {
|
|
393
|
+
setOrganizations([]);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let cancelled = false;
|
|
398
|
+
|
|
399
|
+
const fetchOrgs = async () => {
|
|
400
|
+
setIsLoading(true);
|
|
401
|
+
|
|
402
|
+
// 1. Parse JWT sub claim for primary org (immediate, no API call)
|
|
403
|
+
try {
|
|
404
|
+
const payload = JSON.parse(atob(accessToken.split(".")[1]));
|
|
405
|
+
const sub = payload.sub as string;
|
|
406
|
+
if (sub?.includes("/")) {
|
|
407
|
+
const primaryOrg = sub.split("/")[0];
|
|
408
|
+
if (!cancelled) {
|
|
409
|
+
const syntheticOrg: IamOrganization = {
|
|
410
|
+
owner: "admin",
|
|
411
|
+
name: primaryOrg,
|
|
412
|
+
displayName: primaryOrg,
|
|
413
|
+
};
|
|
414
|
+
setOrganizations([syntheticOrg]);
|
|
415
|
+
if (!currentOrgId) {
|
|
416
|
+
setCurrentOrgId(primaryOrg);
|
|
417
|
+
try {
|
|
418
|
+
localStorage.setItem(STORAGE_ORG_KEY, primaryOrg);
|
|
419
|
+
} catch {
|
|
420
|
+
/* ok */
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
// Invalid token format — skip JWT parsing
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 2. Try to fetch full org list from API (may fail for non-admin users)
|
|
430
|
+
try {
|
|
431
|
+
const client = new IamClient({
|
|
432
|
+
serverUrl: config.serverUrl,
|
|
433
|
+
clientId: config.clientId,
|
|
434
|
+
});
|
|
435
|
+
const orgs = await client.getOrganizations(accessToken);
|
|
436
|
+
if (!cancelled && orgs.length > 0) {
|
|
437
|
+
setOrganizations(orgs);
|
|
438
|
+
if (!currentOrgId && orgs.length > 0) {
|
|
439
|
+
const firstOrg = orgs[0].name;
|
|
440
|
+
setCurrentOrgId(firstOrg);
|
|
441
|
+
try {
|
|
442
|
+
localStorage.setItem(STORAGE_ORG_KEY, firstOrg);
|
|
443
|
+
} catch {
|
|
444
|
+
/* ok */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
// API call failed — keep JWT-derived org
|
|
450
|
+
} finally {
|
|
451
|
+
if (!cancelled) setIsLoading(false);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
fetchOrgs();
|
|
456
|
+
return () => {
|
|
457
|
+
cancelled = true;
|
|
458
|
+
};
|
|
459
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
460
|
+
}, [isAuthenticated, accessToken, config.serverUrl, config.clientId]);
|
|
461
|
+
|
|
462
|
+
const currentOrg = useMemo(
|
|
463
|
+
() => organizations.find((o) => o.name === currentOrgId) ?? null,
|
|
464
|
+
[organizations, currentOrgId],
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const switchOrg = useCallback((orgId: string) => {
|
|
468
|
+
setCurrentOrgId(orgId);
|
|
469
|
+
setCurrentProjectId(null);
|
|
470
|
+
try {
|
|
471
|
+
localStorage.setItem(STORAGE_ORG_KEY, orgId);
|
|
472
|
+
localStorage.removeItem(STORAGE_PROJECT_KEY);
|
|
473
|
+
} catch {
|
|
474
|
+
/* ok */
|
|
475
|
+
}
|
|
476
|
+
}, []);
|
|
477
|
+
|
|
478
|
+
const switchProject = useCallback((projectId: string | null) => {
|
|
479
|
+
setCurrentProjectId(projectId);
|
|
480
|
+
try {
|
|
481
|
+
if (projectId) {
|
|
482
|
+
localStorage.setItem(STORAGE_PROJECT_KEY, projectId);
|
|
483
|
+
} else {
|
|
484
|
+
localStorage.removeItem(STORAGE_PROJECT_KEY);
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
/* ok */
|
|
488
|
+
}
|
|
489
|
+
}, []);
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
organizations,
|
|
493
|
+
currentOrg,
|
|
494
|
+
currentOrgId,
|
|
495
|
+
switchOrg,
|
|
496
|
+
currentProjectId,
|
|
497
|
+
switchProject,
|
|
498
|
+
isLoading,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// useIamToken
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Hook that provides a valid access token with auto-refresh capability.
|
|
508
|
+
* Returns null while loading or if not authenticated.
|
|
509
|
+
*/
|
|
510
|
+
export function useIamToken(): {
|
|
511
|
+
token: string | null;
|
|
512
|
+
isValid: boolean;
|
|
513
|
+
refresh: () => Promise<string | null>;
|
|
514
|
+
} {
|
|
515
|
+
const { sdk, accessToken, isAuthenticated } = useIam();
|
|
516
|
+
|
|
517
|
+
const refresh = useCallback(async () => {
|
|
518
|
+
try {
|
|
519
|
+
return await sdk.getValidAccessToken();
|
|
520
|
+
} catch {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
}, [sdk]);
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
token: accessToken,
|
|
527
|
+
isValid: isAuthenticated && !!accessToken && !sdk.isTokenExpired(),
|
|
528
|
+
refresh,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Re-export context for advanced use
|
|
533
|
+
export { IamContext };
|