@hanzo/iam 0.9.0 → 0.9.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/dist/auth.cjs +111 -0
- package/dist/auth.cjs.map +1 -0
- package/dist/auth.d.cts +19 -0
- package/dist/auth.d.ts +7 -4
- package/dist/auth.js +94 -121
- package/dist/auth.js.map +1 -1
- package/dist/betterauth.cjs +34 -0
- package/dist/betterauth.cjs.map +1 -0
- package/dist/betterauth.d.cts +64 -0
- package/dist/betterauth.d.ts +7 -10
- package/dist/betterauth.js +28 -62
- package/dist/betterauth.js.map +1 -1
- package/dist/billing.cjs +8 -0
- package/dist/billing.cjs.map +1 -0
- package/dist/billing.d.cts +2 -0
- package/dist/billing.d.ts +2 -16
- package/dist/billing.js +5 -17
- package/dist/billing.js.map +1 -1
- package/dist/browser.cjs +680 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +217 -0
- package/dist/browser.d.ts +10 -7
- package/dist/browser.js +645 -663
- package/dist/browser.js.map +1 -1
- package/dist/index.cjs +1087 -0
- package/dist/index.cjs.map +1 -0
- package/dist/{client.d.ts → index.d.cts} +23 -4
- package/dist/index.d.ts +86 -23
- package/dist/index.js +1077 -29
- package/dist/index.js.map +1 -1
- package/dist/nextauth.cjs +35 -0
- package/dist/nextauth.cjs.map +1 -0
- package/dist/nextauth.d.cts +55 -0
- package/dist/nextauth.d.ts +4 -7
- package/dist/nextauth.js +30 -66
- package/dist/nextauth.js.map +1 -1
- package/dist/passport.cjs +49 -0
- package/dist/passport.cjs.map +1 -0
- package/dist/passport.d.cts +47 -0
- package/dist/passport.d.ts +8 -5
- package/dist/passport.js +45 -65
- package/dist/passport.js.map +1 -1
- package/dist/react.cjs +1434 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +133 -0
- package/dist/react.d.ts +18 -50
- package/dist/react.js +1399 -494
- package/dist/react.js.map +1 -1
- package/dist/types.cjs +4 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.cts +219 -0
- package/dist/types.d.ts +25 -24
- package/dist/types.js +2 -5
- package/dist/types.js.map +1 -1
- package/package.json +24 -13
- package/dist/auth.d.ts.map +0 -1
- package/dist/betterauth.d.ts.map +0 -1
- package/dist/billing.d.ts.map +0 -1
- package/dist/browser.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -292
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/nextauth.d.ts.map +0 -1
- package/dist/passport.d.ts.map +0 -1
- package/dist/pkce.d.ts +0 -13
- package/dist/pkce.d.ts.map +0 -1
- package/dist/pkce.js +0 -36
- package/dist/pkce.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/dist/react.js
CHANGED
|
@@ -1,522 +1,1427 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
1
|
+
import { createContext, useMemo, useState, useRef, useCallback, useEffect, createElement, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/react.ts
|
|
4
|
+
|
|
5
|
+
// src/pkce.ts
|
|
6
|
+
function generateRandomString(length) {
|
|
7
|
+
const array = new Uint8Array(length);
|
|
8
|
+
crypto.getRandomValues(array);
|
|
9
|
+
return Array.from(array, (b) => b.toString(36).padStart(2, "0")).join("").slice(0, length);
|
|
10
|
+
}
|
|
11
|
+
async function sha256(plain) {
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
return crypto.subtle.digest("SHA-256", encoder.encode(plain));
|
|
14
|
+
}
|
|
15
|
+
function base64UrlEncode(buffer) {
|
|
16
|
+
const bytes = new Uint8Array(buffer);
|
|
17
|
+
let binary = "";
|
|
18
|
+
for (const byte of bytes) {
|
|
19
|
+
binary += String.fromCharCode(byte);
|
|
20
|
+
}
|
|
21
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
async function generatePKCEChallenge() {
|
|
24
|
+
const codeVerifier = generateRandomString(64);
|
|
25
|
+
const hash = await sha256(codeVerifier);
|
|
26
|
+
const codeChallenge = base64UrlEncode(hash);
|
|
27
|
+
return { codeVerifier, codeChallenge };
|
|
28
|
+
}
|
|
29
|
+
function generateState() {
|
|
30
|
+
return generateRandomString(32);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/browser.ts
|
|
34
|
+
var STORAGE_PREFIX = "hanzo_iam_";
|
|
35
|
+
var KEY_STATE = `${STORAGE_PREFIX}state`;
|
|
36
|
+
var KEY_CODE_VERIFIER = `${STORAGE_PREFIX}code_verifier`;
|
|
37
|
+
var KEY_ACCESS_TOKEN = `${STORAGE_PREFIX}access_token`;
|
|
38
|
+
var KEY_REFRESH_TOKEN = `${STORAGE_PREFIX}refresh_token`;
|
|
39
|
+
var KEY_ID_TOKEN = `${STORAGE_PREFIX}id_token`;
|
|
40
|
+
var KEY_EXPIRES_AT = `${STORAGE_PREFIX}expires_at`;
|
|
41
|
+
var IAM = class {
|
|
42
|
+
config;
|
|
43
|
+
storage;
|
|
44
|
+
discoveryCache = null;
|
|
45
|
+
constructor(config) {
|
|
46
|
+
this.config = config;
|
|
47
|
+
this.storage = config.storage ?? sessionStorage;
|
|
48
|
+
}
|
|
49
|
+
// -----------------------------------------------------------------------
|
|
50
|
+
// OIDC Discovery
|
|
51
|
+
// -----------------------------------------------------------------------
|
|
52
|
+
async getDiscovery() {
|
|
53
|
+
if (this.discoveryCache) return this.discoveryCache;
|
|
54
|
+
const baseUrl = this.config.serverUrl.replace(/\/+$/, "");
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${baseUrl}/.well-known/openid-configuration`, {
|
|
57
|
+
headers: { Accept: "application/json" }
|
|
58
|
+
});
|
|
59
|
+
if (res.ok) {
|
|
60
|
+
this.discoveryCache = await res.json();
|
|
61
|
+
return this.discoveryCache;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
this.discoveryCache = {
|
|
66
|
+
issuer: baseUrl,
|
|
67
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
68
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
69
|
+
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
|
|
70
|
+
jwks_uri: `${baseUrl}/.well-known/jwks`,
|
|
71
|
+
response_types_supported: ["code", "token", "id_token"],
|
|
72
|
+
grant_types_supported: ["authorization_code", "implicit", "refresh_token"],
|
|
73
|
+
scopes_supported: ["openid", "email", "profile"]
|
|
74
|
+
};
|
|
75
|
+
return this.discoveryCache;
|
|
76
|
+
}
|
|
77
|
+
// -----------------------------------------------------------------------
|
|
78
|
+
// Login redirect (PKCE)
|
|
79
|
+
// -----------------------------------------------------------------------
|
|
80
|
+
/**
|
|
81
|
+
* Start the OAuth2 PKCE login flow by redirecting to the IAM authorize endpoint.
|
|
82
|
+
*
|
|
83
|
+
* Generates PKCE challenge and state, stores them in session storage,
|
|
84
|
+
* then redirects the browser.
|
|
85
|
+
*/
|
|
86
|
+
async signinRedirect(params) {
|
|
87
|
+
const discovery = await this.getDiscovery();
|
|
88
|
+
const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
|
|
89
|
+
const state = generateState();
|
|
90
|
+
this.storage.setItem(KEY_STATE, state);
|
|
91
|
+
this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
|
|
92
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
93
|
+
url.searchParams.set("client_id", this.config.clientId);
|
|
94
|
+
url.searchParams.set("response_type", "code");
|
|
95
|
+
url.searchParams.set("redirect_uri", this.config.redirectUri);
|
|
96
|
+
url.searchParams.set("scope", this.config.scope ?? "openid profile email");
|
|
97
|
+
url.searchParams.set("state", state);
|
|
98
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
99
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
100
|
+
if (params?.additionalParams) {
|
|
101
|
+
for (const [k, v] of Object.entries(params.additionalParams)) {
|
|
102
|
+
url.searchParams.set(k, v);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
window.location.href = url.toString();
|
|
106
|
+
}
|
|
107
|
+
// -----------------------------------------------------------------------
|
|
108
|
+
// Callback handling
|
|
109
|
+
// -----------------------------------------------------------------------
|
|
110
|
+
/**
|
|
111
|
+
* Handle the OAuth2 callback after redirect. Exchanges the authorization code
|
|
112
|
+
* for tokens using PKCE.
|
|
113
|
+
*
|
|
114
|
+
* Call this on your callback page (e.g. /auth/callback).
|
|
115
|
+
* Returns the token response, or throws if the state doesn't match.
|
|
116
|
+
*/
|
|
117
|
+
async handleCallback(callbackUrl) {
|
|
118
|
+
const url = new URL(callbackUrl ?? window.location.href);
|
|
119
|
+
const error = url.searchParams.get("error");
|
|
120
|
+
if (error) {
|
|
121
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
122
|
+
throw new Error(`OAuth error: ${desc}`);
|
|
123
|
+
}
|
|
124
|
+
const state = url.searchParams.get("state");
|
|
125
|
+
const savedState = this.storage.getItem(KEY_STATE);
|
|
126
|
+
if (savedState && state !== savedState) {
|
|
127
|
+
throw new Error("OAuth state mismatch \u2014 possible CSRF attack");
|
|
128
|
+
}
|
|
129
|
+
const accessToken = url.searchParams.get("access_token");
|
|
130
|
+
if (accessToken) {
|
|
131
|
+
this.storage.removeItem(KEY_STATE);
|
|
132
|
+
this.storage.removeItem(KEY_CODE_VERIFIER);
|
|
133
|
+
const tokens2 = {
|
|
134
|
+
access_token: accessToken,
|
|
135
|
+
token_type: "Bearer",
|
|
136
|
+
refresh_token: url.searchParams.get("refresh_token") ?? void 0,
|
|
137
|
+
expires_in: 7200
|
|
138
|
+
};
|
|
139
|
+
this.storeTokens(tokens2);
|
|
140
|
+
return toIAMToken(tokens2);
|
|
141
|
+
}
|
|
142
|
+
const code = url.searchParams.get("code");
|
|
143
|
+
if (!code) {
|
|
144
|
+
throw new Error("Missing authorization code in callback URL");
|
|
145
|
+
}
|
|
146
|
+
const codeVerifier = this.storage.getItem(KEY_CODE_VERIFIER);
|
|
147
|
+
if (!codeVerifier) {
|
|
148
|
+
throw new Error("Missing PKCE code verifier \u2014 was signinRedirect() called?");
|
|
149
|
+
}
|
|
150
|
+
this.storage.removeItem(KEY_STATE);
|
|
151
|
+
this.storage.removeItem(KEY_CODE_VERIFIER);
|
|
152
|
+
const discovery = await this.getDiscovery();
|
|
153
|
+
const body = new URLSearchParams({
|
|
154
|
+
grant_type: "authorization_code",
|
|
155
|
+
client_id: this.config.clientId,
|
|
156
|
+
code,
|
|
157
|
+
redirect_uri: this.config.redirectUri,
|
|
158
|
+
code_verifier: codeVerifier
|
|
159
|
+
});
|
|
160
|
+
const tokenUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token` : discovery.token_endpoint;
|
|
161
|
+
const res = await fetch(tokenUrl, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
164
|
+
body: body.toString()
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
const text = await res.text().catch(() => "");
|
|
168
|
+
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
169
|
+
}
|
|
170
|
+
const tokens = await res.json();
|
|
171
|
+
this.storeTokens(tokens);
|
|
172
|
+
return toIAMToken(tokens);
|
|
173
|
+
}
|
|
174
|
+
// -----------------------------------------------------------------------
|
|
175
|
+
// Token refresh
|
|
176
|
+
// -----------------------------------------------------------------------
|
|
177
|
+
/** Refresh the access token using the stored refresh token. */
|
|
178
|
+
async refreshAccessToken() {
|
|
179
|
+
const refreshToken = this.storage.getItem(KEY_REFRESH_TOKEN);
|
|
180
|
+
if (!refreshToken) {
|
|
181
|
+
throw new Error("No refresh token available");
|
|
182
|
+
}
|
|
183
|
+
const discovery = await this.getDiscovery();
|
|
184
|
+
const body = new URLSearchParams({
|
|
185
|
+
grant_type: "refresh_token",
|
|
186
|
+
client_id: this.config.clientId,
|
|
187
|
+
refresh_token: refreshToken
|
|
188
|
+
});
|
|
189
|
+
const tokenUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token` : discovery.token_endpoint;
|
|
190
|
+
const res = await fetch(tokenUrl, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
193
|
+
body: body.toString()
|
|
194
|
+
});
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
const text = await res.text().catch(() => "");
|
|
197
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
198
|
+
}
|
|
199
|
+
const tokens = await res.json();
|
|
200
|
+
this.storeTokens(tokens);
|
|
201
|
+
return toIAMToken(tokens);
|
|
202
|
+
}
|
|
203
|
+
// -----------------------------------------------------------------------
|
|
204
|
+
// Popup signin
|
|
205
|
+
// -----------------------------------------------------------------------
|
|
206
|
+
/**
|
|
207
|
+
* Open the IAM login page in a popup window. Resolves when the popup
|
|
208
|
+
* completes the OAuth flow and returns tokens.
|
|
209
|
+
*/
|
|
210
|
+
async signinPopup(params) {
|
|
211
|
+
const discovery = await this.getDiscovery();
|
|
212
|
+
const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
|
|
213
|
+
const state = generateState();
|
|
214
|
+
this.storage.setItem(KEY_STATE, state);
|
|
215
|
+
this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
|
|
216
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
217
|
+
url.searchParams.set("client_id", this.config.clientId);
|
|
218
|
+
url.searchParams.set("response_type", "code");
|
|
219
|
+
url.searchParams.set("redirect_uri", this.config.redirectUri);
|
|
220
|
+
url.searchParams.set("scope", this.config.scope ?? "openid profile email");
|
|
221
|
+
url.searchParams.set("state", state);
|
|
222
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
223
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
224
|
+
if (params?.additionalParams) {
|
|
225
|
+
for (const [k, v] of Object.entries(params.additionalParams)) {
|
|
226
|
+
url.searchParams.set(k, v);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const width = params?.width ?? 600;
|
|
230
|
+
const height = params?.height ?? 700;
|
|
231
|
+
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
232
|
+
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const popup = window.open(
|
|
235
|
+
url.toString(),
|
|
236
|
+
"hanzo_iam_login",
|
|
237
|
+
`width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no`
|
|
238
|
+
);
|
|
239
|
+
if (!popup) {
|
|
240
|
+
reject(new Error("Failed to open login popup \u2014 blocked by browser?"));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const interval = setInterval(() => {
|
|
244
|
+
try {
|
|
245
|
+
if (popup.closed) {
|
|
246
|
+
clearInterval(interval);
|
|
247
|
+
reject(new Error("Login popup was closed before completing"));
|
|
108
248
|
return;
|
|
249
|
+
}
|
|
250
|
+
const popupUrl = popup.location.href;
|
|
251
|
+
if (popupUrl.startsWith(this.config.redirectUri)) {
|
|
252
|
+
clearInterval(interval);
|
|
253
|
+
popup.close();
|
|
254
|
+
this.handleCallback(popupUrl).then(resolve, reject);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
109
257
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}, [sdk, autoInit]);
|
|
151
|
-
// Cleanup refresh timer on unmount
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
return () => {
|
|
154
|
-
if (refreshTimerRef.current)
|
|
155
|
-
clearTimeout(refreshTimerRef.current);
|
|
156
|
-
};
|
|
157
|
-
}, []);
|
|
158
|
-
// Complete authentication after login/callback
|
|
159
|
-
const completeAuth = useCallback(async (tokens) => {
|
|
160
|
-
setAccessToken(tokens.accessToken);
|
|
161
|
-
setIsAuthenticated(true);
|
|
258
|
+
}, 200);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// -----------------------------------------------------------------------
|
|
262
|
+
// Silent signin (iframe)
|
|
263
|
+
// -----------------------------------------------------------------------
|
|
264
|
+
/**
|
|
265
|
+
* Attempt silent authentication via a hidden iframe.
|
|
266
|
+
* Useful for checking if the user has an active IAM session.
|
|
267
|
+
* Returns null if silent auth fails (user needs to log in interactively).
|
|
268
|
+
*/
|
|
269
|
+
async signinSilent(timeoutMs = 5e3) {
|
|
270
|
+
const discovery = await this.getDiscovery();
|
|
271
|
+
const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
|
|
272
|
+
const state = generateState();
|
|
273
|
+
this.storage.setItem(KEY_STATE, state);
|
|
274
|
+
this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
|
|
275
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
276
|
+
url.searchParams.set("client_id", this.config.clientId);
|
|
277
|
+
url.searchParams.set("response_type", "code");
|
|
278
|
+
url.searchParams.set("redirect_uri", this.config.redirectUri);
|
|
279
|
+
url.searchParams.set("scope", this.config.scope ?? "openid profile email");
|
|
280
|
+
url.searchParams.set("state", state);
|
|
281
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
282
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
283
|
+
url.searchParams.set("prompt", "none");
|
|
284
|
+
return new Promise((resolve) => {
|
|
285
|
+
const iframe = document.createElement("iframe");
|
|
286
|
+
iframe.style.display = "none";
|
|
287
|
+
const timeout = setTimeout(() => {
|
|
288
|
+
cleanup();
|
|
289
|
+
resolve(null);
|
|
290
|
+
}, timeoutMs);
|
|
291
|
+
const cleanup = () => {
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
iframe.remove();
|
|
294
|
+
this.storage.removeItem(KEY_STATE);
|
|
295
|
+
this.storage.removeItem(KEY_CODE_VERIFIER);
|
|
296
|
+
};
|
|
297
|
+
iframe.addEventListener("load", () => {
|
|
162
298
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
299
|
+
const iframeUrl = iframe.contentWindow?.location.href;
|
|
300
|
+
if (iframeUrl && iframeUrl.startsWith(this.config.redirectUri)) {
|
|
301
|
+
cleanup();
|
|
302
|
+
this.handleCallback(iframeUrl).then(
|
|
303
|
+
(tokens) => resolve(tokens),
|
|
304
|
+
() => resolve(null)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
cleanup();
|
|
309
|
+
resolve(null);
|
|
168
310
|
}
|
|
311
|
+
});
|
|
312
|
+
iframe.src = url.toString();
|
|
313
|
+
document.body.appendChild(iframe);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// -----------------------------------------------------------------------
|
|
317
|
+
// Token management
|
|
318
|
+
// -----------------------------------------------------------------------
|
|
319
|
+
storeTokens(tokens) {
|
|
320
|
+
this.storage.setItem(KEY_ACCESS_TOKEN, tokens.access_token);
|
|
321
|
+
if (tokens.refresh_token) {
|
|
322
|
+
this.storage.setItem(KEY_REFRESH_TOKEN, tokens.refresh_token);
|
|
323
|
+
}
|
|
324
|
+
if (tokens.id_token) {
|
|
325
|
+
this.storage.setItem(KEY_ID_TOKEN, tokens.id_token);
|
|
326
|
+
}
|
|
327
|
+
if (tokens.expires_in) {
|
|
328
|
+
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
|
329
|
+
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Get the stored access token (may be expired). */
|
|
333
|
+
getAccessToken() {
|
|
334
|
+
return this.storage.getItem(KEY_ACCESS_TOKEN);
|
|
335
|
+
}
|
|
336
|
+
/** Get the stored refresh token. */
|
|
337
|
+
getRefreshToken() {
|
|
338
|
+
return this.storage.getItem(KEY_REFRESH_TOKEN);
|
|
339
|
+
}
|
|
340
|
+
/** Get the stored ID token. */
|
|
341
|
+
getIdToken() {
|
|
342
|
+
return this.storage.getItem(KEY_ID_TOKEN);
|
|
343
|
+
}
|
|
344
|
+
/** Check if the stored access token is expired. */
|
|
345
|
+
isTokenExpired() {
|
|
346
|
+
const expiresAt = this.storage.getItem(KEY_EXPIRES_AT);
|
|
347
|
+
if (!expiresAt) return true;
|
|
348
|
+
return Date.now() >= Number(expiresAt);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Get a valid access token — refreshes automatically if expired.
|
|
352
|
+
* Returns null if no token and no refresh token available.
|
|
353
|
+
*/
|
|
354
|
+
async getValidAccessToken() {
|
|
355
|
+
const token = this.getAccessToken();
|
|
356
|
+
if (token && !this.isTokenExpired()) {
|
|
357
|
+
return token;
|
|
358
|
+
}
|
|
359
|
+
if (this.getRefreshToken()) {
|
|
360
|
+
try {
|
|
361
|
+
const tokens = await this.refreshAccessToken();
|
|
362
|
+
return tokens.accessToken;
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
/** Clear all stored tokens (logout). */
|
|
370
|
+
clearTokens() {
|
|
371
|
+
this.storage.removeItem(KEY_ACCESS_TOKEN);
|
|
372
|
+
this.storage.removeItem(KEY_REFRESH_TOKEN);
|
|
373
|
+
this.storage.removeItem(KEY_ID_TOKEN);
|
|
374
|
+
this.storage.removeItem(KEY_EXPIRES_AT);
|
|
375
|
+
this.storage.removeItem(KEY_STATE);
|
|
376
|
+
this.storage.removeItem(KEY_CODE_VERIFIER);
|
|
377
|
+
}
|
|
378
|
+
// -----------------------------------------------------------------------
|
|
379
|
+
// User info
|
|
380
|
+
// -----------------------------------------------------------------------
|
|
381
|
+
/** Fetch user info from the OIDC userinfo endpoint using the stored access token. */
|
|
382
|
+
async getUserInfo() {
|
|
383
|
+
const token = await this.getValidAccessToken();
|
|
384
|
+
if (!token) {
|
|
385
|
+
throw new Error("No valid access token \u2014 user must log in");
|
|
386
|
+
}
|
|
387
|
+
const discovery = await this.getDiscovery();
|
|
388
|
+
const userinfoUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/userinfo` : discovery.userinfo_endpoint;
|
|
389
|
+
const res = await fetch(userinfoUrl, {
|
|
390
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
391
|
+
});
|
|
392
|
+
if (!res.ok) {
|
|
393
|
+
throw new Error(`Userinfo fetch failed (${res.status})`);
|
|
394
|
+
}
|
|
395
|
+
return await res.json();
|
|
396
|
+
}
|
|
397
|
+
// -----------------------------------------------------------------------
|
|
398
|
+
// URL helpers
|
|
399
|
+
// -----------------------------------------------------------------------
|
|
400
|
+
/** Build the signup URL for the IAM server. */
|
|
401
|
+
getSignupUrl(params) {
|
|
402
|
+
const base = this.config.serverUrl.replace(/\/+$/, "");
|
|
403
|
+
const app = this.config.appName ?? "app";
|
|
404
|
+
this.config.orgName ?? "built-in";
|
|
405
|
+
let url = `${base}/signup/${app}`;
|
|
406
|
+
if (params?.enablePassword) {
|
|
407
|
+
url += "?enablePassword=true";
|
|
408
|
+
}
|
|
409
|
+
return url;
|
|
410
|
+
}
|
|
411
|
+
/** Build the user profile URL on the IAM server. */
|
|
412
|
+
getUserProfileUrl(username) {
|
|
413
|
+
const base = this.config.serverUrl.replace(/\/+$/, "");
|
|
414
|
+
const org = this.config.orgName ?? "built-in";
|
|
415
|
+
return `${base}/users/${org}/${username}`;
|
|
416
|
+
}
|
|
417
|
+
// -----------------------------------------------------------------------
|
|
418
|
+
// Casdoor REST surface (signup, OTP, REST login, phone lookup)
|
|
419
|
+
//
|
|
420
|
+
// The OIDC layer covers redirect/PKCE, token exchange, refresh, userinfo.
|
|
421
|
+
// These methods cover the Casdoor-native endpoints that don't have an OIDC
|
|
422
|
+
// analogue: phone/email OTP, custom signup with verification codes, and
|
|
423
|
+
// direct REST login that returns an authorization code in one round-trip
|
|
424
|
+
// (used as the bridge between OTP collection and `exchangeCode`).
|
|
425
|
+
//
|
|
426
|
+
// All paths are gateway-canonical (`/login`, `/signup`,
|
|
427
|
+
// `/send-verification-code`, `/get-phone-user`) — point `serverUrl` at the
|
|
428
|
+
// gateway prefix (e.g. `https://api.dev.satschel.com/v1/iam`) and the
|
|
429
|
+
// gateway proxies to Casdoor's `/api/*` internally.
|
|
430
|
+
// -----------------------------------------------------------------------
|
|
431
|
+
/**
|
|
432
|
+
* Send a verification code to a phone or email destination.
|
|
433
|
+
*
|
|
434
|
+
* @param contact `{ phone, countryCode }` for SMS, `{ email }` for email.
|
|
435
|
+
* @param method Casdoor method: `login`, `signup`, `forget`, `mfaSetup`, etc.
|
|
436
|
+
*/
|
|
437
|
+
async sendVerificationCode(contact, method = "login") {
|
|
438
|
+
if (method === "reset") method = "forget";
|
|
439
|
+
const isPhone = "phone" in contact;
|
|
440
|
+
const dest = isPhone ? contact.phone : contact.email;
|
|
441
|
+
const params = {
|
|
442
|
+
applicationId: `admin/${this.config.appName ?? "app"}`,
|
|
443
|
+
dest,
|
|
444
|
+
type: isPhone ? "phone" : "email",
|
|
445
|
+
method,
|
|
446
|
+
captchaType: "none",
|
|
447
|
+
captchaToken: ""
|
|
448
|
+
};
|
|
449
|
+
if (isPhone) params.countryCode = contact.countryCode;
|
|
450
|
+
const url = `${this.config.serverUrl.replace(/\/+$/, "")}/send-verification-code`;
|
|
451
|
+
const res = await fetch(url, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
454
|
+
body: new URLSearchParams(params).toString()
|
|
455
|
+
});
|
|
456
|
+
const data = await res.json().catch(() => ({}));
|
|
457
|
+
if (data.status === "ok") return { ok: true };
|
|
458
|
+
const msg = data.msg || `send-verification-code failed (${res.status})`;
|
|
459
|
+
if (msg.includes("SMS provider") || msg.includes("provider")) return { ok: true };
|
|
460
|
+
return { ok: false, error: msg };
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Look up whether a phone number is registered. Returns `{ exists: false }`
|
|
464
|
+
* on 404 or unknown numbers; `{ exists: true }` when Casdoor confirms a user.
|
|
465
|
+
*/
|
|
466
|
+
async lookupPhoneUser(phone, countryCode) {
|
|
467
|
+
const url = `${this.config.serverUrl.replace(/\/+$/, "")}/get-phone-user`;
|
|
468
|
+
const res = await fetch(url, {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: { "Content-Type": "application/json" },
|
|
471
|
+
body: JSON.stringify({
|
|
472
|
+
application: this.config.appName ?? "app",
|
|
473
|
+
phone,
|
|
474
|
+
countryCode
|
|
475
|
+
})
|
|
476
|
+
});
|
|
477
|
+
if (res.status === 404) return { exists: false };
|
|
478
|
+
if (!res.ok) return { exists: false, error: `lookupPhoneUser failed (${res.status})` };
|
|
479
|
+
return { exists: true };
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Casdoor REST signup. Returns the new user's id on success.
|
|
483
|
+
*
|
|
484
|
+
* Phone signup flow: send phoneCode via `sendVerificationCode`, then call
|
|
485
|
+
* this with the OTP in `phoneCode`. Casdoor verifies the code internally.
|
|
486
|
+
* Email signup flow: same with `email` + `emailCode`.
|
|
487
|
+
*/
|
|
488
|
+
async signup(params) {
|
|
489
|
+
const username = params.username ?? params.name;
|
|
490
|
+
const password = params.password ?? `Liq${Date.now()}!`;
|
|
491
|
+
const body = {
|
|
492
|
+
application: this.config.appName ?? "app",
|
|
493
|
+
organization: this.config.orgName ?? "built-in",
|
|
494
|
+
name: params.name,
|
|
495
|
+
username,
|
|
496
|
+
password,
|
|
497
|
+
confirm: password,
|
|
498
|
+
agreement: true
|
|
499
|
+
};
|
|
500
|
+
if (params.method === "email") {
|
|
501
|
+
body.email = params.email;
|
|
502
|
+
if (params.emailCode) body.emailCode = params.emailCode;
|
|
503
|
+
} else {
|
|
504
|
+
body.phone = params.phone;
|
|
505
|
+
body.countryCode = params.countryCode;
|
|
506
|
+
if (params.phoneCode) body.phoneCode = params.phoneCode;
|
|
507
|
+
if (params.email) body.email = params.email;
|
|
508
|
+
if (params.emailCode) body.emailCode = params.emailCode;
|
|
509
|
+
}
|
|
510
|
+
const url = `${this.config.serverUrl.replace(/\/+$/, "")}/signup`;
|
|
511
|
+
const res = await fetch(url, {
|
|
512
|
+
method: "POST",
|
|
513
|
+
headers: { "Content-Type": "application/json" },
|
|
514
|
+
body: JSON.stringify(body)
|
|
515
|
+
});
|
|
516
|
+
const data = await res.json().catch(() => ({}));
|
|
517
|
+
if (data.status === "ok") return { ok: true, id: data.data2 || data.data };
|
|
518
|
+
return { ok: false, error: data.msg || `signup failed (${res.status})` };
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* REST login that returns an authorization code (Casdoor `/login`).
|
|
522
|
+
*
|
|
523
|
+
* Use this when you want the caller to drive the PKCE flow without a
|
|
524
|
+
* full redirect — collect credentials in your own UI, get a code back,
|
|
525
|
+
* then call `exchangeCodeForToken` to land tokens.
|
|
526
|
+
*/
|
|
527
|
+
async loginWithCredentials(params) {
|
|
528
|
+
const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
|
|
529
|
+
this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
|
|
530
|
+
const url = new URL(`${this.config.serverUrl.replace(/\/+$/, "")}/login`);
|
|
531
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
532
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
533
|
+
const res = await fetch(url.toString(), {
|
|
534
|
+
method: "POST",
|
|
535
|
+
headers: { "Content-Type": "application/json" },
|
|
536
|
+
body: JSON.stringify({
|
|
537
|
+
application: this.config.appName ?? "app",
|
|
538
|
+
organization: this.config.orgName ?? "built-in",
|
|
539
|
+
username: params.username,
|
|
540
|
+
password: params.password,
|
|
541
|
+
type: params.type ?? "code",
|
|
542
|
+
clientId: this.config.clientId,
|
|
543
|
+
redirectUri: params.redirectUri ?? this.config.redirectUri,
|
|
544
|
+
codeChallenge,
|
|
545
|
+
codeChallengeMethod: "S256",
|
|
546
|
+
autoSignin: true
|
|
547
|
+
})
|
|
548
|
+
});
|
|
549
|
+
const data = await res.json().catch(() => ({}));
|
|
550
|
+
if (data.status === "ok" && data.data) return { ok: true, code: data.data };
|
|
551
|
+
return { ok: false, error: data.msg || `login failed (${res.status})` };
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Exchange an authorization code for tokens using the stored PKCE verifier.
|
|
555
|
+
* Pairs with `loginWithCredentials` for a code → tokens round-trip.
|
|
556
|
+
*/
|
|
557
|
+
async exchangeCodeForToken(code, redirectUri) {
|
|
558
|
+
const codeVerifier = this.storage.getItem(KEY_CODE_VERIFIER);
|
|
559
|
+
if (!codeVerifier) {
|
|
560
|
+
throw new Error("Missing PKCE verifier \u2014 call loginWithCredentials() first");
|
|
561
|
+
}
|
|
562
|
+
this.storage.removeItem(KEY_CODE_VERIFIER);
|
|
563
|
+
const discovery = await this.getDiscovery();
|
|
564
|
+
const tokenUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token` : discovery.token_endpoint;
|
|
565
|
+
const body = new URLSearchParams({
|
|
566
|
+
grant_type: "authorization_code",
|
|
567
|
+
client_id: this.config.clientId,
|
|
568
|
+
code,
|
|
569
|
+
redirect_uri: redirectUri ?? this.config.redirectUri,
|
|
570
|
+
code_verifier: codeVerifier
|
|
571
|
+
});
|
|
572
|
+
const res = await fetch(tokenUrl, {
|
|
573
|
+
method: "POST",
|
|
574
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
575
|
+
body: body.toString()
|
|
576
|
+
});
|
|
577
|
+
if (!res.ok) {
|
|
578
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
579
|
+
throw new Error(err.error_description || err.error || "Token exchange failed");
|
|
580
|
+
}
|
|
581
|
+
const tokens = await res.json();
|
|
582
|
+
this.storeTokens(tokens);
|
|
583
|
+
return toIAMToken(tokens);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Phone OTP login: tries the numbered username variants Casdoor accepts
|
|
587
|
+
* (`{phone}`, `{countryCode}{phone}`), exchanges the resulting code for
|
|
588
|
+
* tokens. Returns the token response, or throws on failure.
|
|
589
|
+
*/
|
|
590
|
+
async loginWithPhoneOTP(params) {
|
|
591
|
+
const usernames = [params.phone, `${params.countryCode}${params.phone}`];
|
|
592
|
+
let lastError = "";
|
|
593
|
+
for (const username of usernames) {
|
|
594
|
+
const result = await this.loginWithCredentials({
|
|
595
|
+
username,
|
|
596
|
+
password: params.code,
|
|
597
|
+
redirectUri: params.redirectUri
|
|
598
|
+
});
|
|
599
|
+
if (result.ok && result.code) {
|
|
600
|
+
return this.exchangeCodeForToken(result.code, params.redirectUri);
|
|
601
|
+
}
|
|
602
|
+
lastError = result.error ?? lastError;
|
|
603
|
+
}
|
|
604
|
+
throw new Error(lastError || "Phone OTP login failed");
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Logout via Casdoor REST `/logout` (clears server-side session) and
|
|
608
|
+
* the local storage.
|
|
609
|
+
*/
|
|
610
|
+
async logout() {
|
|
611
|
+
const token = this.storage.getItem(KEY_ACCESS_TOKEN);
|
|
612
|
+
try {
|
|
613
|
+
await fetch(`${this.config.serverUrl.replace(/\/+$/, "")}/oauth/logout`, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
616
|
+
});
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
this.clearTokens();
|
|
620
|
+
}
|
|
621
|
+
// -----------------------------------------------------------------------
|
|
622
|
+
// High-level helpers — normalize to ergonomic types so apps don't need
|
|
623
|
+
// their own adapters around the OIDC/Casdoor wire shapes.
|
|
624
|
+
// -----------------------------------------------------------------------
|
|
625
|
+
/**
|
|
626
|
+
* Build a social-login authorize URL. Used to navigate the user to
|
|
627
|
+
* Google/Apple/etc. — same as `signinRedirect` but returns the URL
|
|
628
|
+
* instead of issuing the redirect, so apps can `<a href="...">`.
|
|
629
|
+
*/
|
|
630
|
+
async getSocialLoginUrl(provider, scope = "openid profile email") {
|
|
631
|
+
const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
|
|
632
|
+
const state = generateState();
|
|
633
|
+
this.storage.setItem(KEY_STATE, state);
|
|
634
|
+
this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
|
|
635
|
+
const discovery = await this.getDiscovery();
|
|
636
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
637
|
+
url.searchParams.set("client_id", this.config.clientId);
|
|
638
|
+
url.searchParams.set("response_type", "code");
|
|
639
|
+
url.searchParams.set("redirect_uri", this.config.redirectUri);
|
|
640
|
+
url.searchParams.set("scope", scope);
|
|
641
|
+
url.searchParams.set("state", state);
|
|
642
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
643
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
644
|
+
url.searchParams.set("provider", provider);
|
|
645
|
+
return url.toString();
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Fetch the current user, shaped into the canonical `IAMUser` form
|
|
649
|
+
* (camelCase, no `_` keys). Returns null when no token is present.
|
|
650
|
+
*/
|
|
651
|
+
async getUser() {
|
|
652
|
+
const token = await this.getValidAccessToken();
|
|
653
|
+
if (!token) return null;
|
|
654
|
+
const u = await this.getUserInfo();
|
|
655
|
+
return {
|
|
656
|
+
sub: u.sub ?? "",
|
|
657
|
+
email: u.email,
|
|
658
|
+
name: u.name,
|
|
659
|
+
givenName: u.given_name,
|
|
660
|
+
familyName: u.family_name,
|
|
661
|
+
phoneNumber: u.phone_number,
|
|
662
|
+
emailVerified: u.email_verified,
|
|
663
|
+
picture: u.picture,
|
|
664
|
+
owner: u.owner
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
function toIAMToken(t) {
|
|
669
|
+
return {
|
|
670
|
+
accessToken: t.access_token,
|
|
671
|
+
refreshToken: t.refresh_token,
|
|
672
|
+
idToken: t.id_token,
|
|
673
|
+
expiresIn: t.expires_in,
|
|
674
|
+
tokenType: t.token_type,
|
|
675
|
+
scope: t.scope
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/client.ts
|
|
680
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
681
|
+
var IamClient = class {
|
|
682
|
+
baseUrl;
|
|
683
|
+
clientId;
|
|
684
|
+
clientSecret;
|
|
685
|
+
orgName;
|
|
686
|
+
appName;
|
|
687
|
+
discoveryCache = null;
|
|
688
|
+
constructor(config) {
|
|
689
|
+
this.baseUrl = config.serverUrl.replace(/\/+$/, "");
|
|
690
|
+
this.clientId = config.clientId;
|
|
691
|
+
this.clientSecret = config.clientSecret;
|
|
692
|
+
this.orgName = config.orgName;
|
|
693
|
+
this.appName = config.appName;
|
|
694
|
+
}
|
|
695
|
+
// -----------------------------------------------------------------------
|
|
696
|
+
// Internal HTTP helpers
|
|
697
|
+
// -----------------------------------------------------------------------
|
|
698
|
+
async request(path, opts) {
|
|
699
|
+
const url = new URL(path, this.baseUrl);
|
|
700
|
+
if (opts?.params) {
|
|
701
|
+
for (const [k, v] of Object.entries(opts.params)) {
|
|
702
|
+
url.searchParams.set(k, v);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const controller = new AbortController();
|
|
706
|
+
const timer = setTimeout(
|
|
707
|
+
() => controller.abort(),
|
|
708
|
+
opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
709
|
+
);
|
|
710
|
+
const headers = {
|
|
711
|
+
Accept: "application/json"
|
|
712
|
+
};
|
|
713
|
+
if (opts?.token) {
|
|
714
|
+
headers.Authorization = `Bearer ${opts.token}`;
|
|
715
|
+
}
|
|
716
|
+
if (opts?.body) {
|
|
717
|
+
headers["Content-Type"] = "application/json";
|
|
718
|
+
}
|
|
719
|
+
if (this.clientSecret && !opts?.token) {
|
|
720
|
+
const credentials = `${this.clientId}:${this.clientSecret}`;
|
|
721
|
+
const basic = typeof Buffer !== "undefined" ? Buffer.from(credentials).toString("base64") : btoa(credentials);
|
|
722
|
+
headers.Authorization = `Basic ${basic}`;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const res = await fetch(url.toString(), {
|
|
726
|
+
method: opts?.method ?? "GET",
|
|
727
|
+
headers,
|
|
728
|
+
body: opts?.body ? JSON.stringify(opts.body) : void 0,
|
|
729
|
+
signal: controller.signal
|
|
730
|
+
});
|
|
731
|
+
if (!res.ok) {
|
|
732
|
+
const text = await res.text().catch(() => "");
|
|
733
|
+
throw new IamApiError(res.status, `${res.statusText}: ${text}`.trim());
|
|
734
|
+
}
|
|
735
|
+
return await res.json();
|
|
736
|
+
} finally {
|
|
737
|
+
clearTimeout(timer);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// -----------------------------------------------------------------------
|
|
741
|
+
// OIDC Discovery
|
|
742
|
+
// -----------------------------------------------------------------------
|
|
743
|
+
async getDiscovery() {
|
|
744
|
+
const CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
745
|
+
if (this.discoveryCache && Date.now() - this.discoveryCache.fetchedAt < CACHE_TTL_MS) {
|
|
746
|
+
return this.discoveryCache.data;
|
|
747
|
+
}
|
|
748
|
+
const data = await this.request(
|
|
749
|
+
"/.well-known/openid-configuration"
|
|
750
|
+
);
|
|
751
|
+
this.discoveryCache = { data, fetchedAt: Date.now() };
|
|
752
|
+
return data;
|
|
753
|
+
}
|
|
754
|
+
/** Get JWKS URI from OIDC discovery (cached). */
|
|
755
|
+
async getJwksUri() {
|
|
756
|
+
const discovery = await this.getDiscovery();
|
|
757
|
+
return discovery.jwks_uri;
|
|
758
|
+
}
|
|
759
|
+
// -----------------------------------------------------------------------
|
|
760
|
+
// OAuth2 / Token
|
|
761
|
+
// -----------------------------------------------------------------------
|
|
762
|
+
/** Build the authorization URL for user login redirect. */
|
|
763
|
+
async getAuthorizationUrl(params) {
|
|
764
|
+
const discovery = await this.getDiscovery();
|
|
765
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
766
|
+
url.searchParams.set("client_id", this.clientId);
|
|
767
|
+
url.searchParams.set("response_type", "code");
|
|
768
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
769
|
+
url.searchParams.set("state", params.state);
|
|
770
|
+
url.searchParams.set("scope", params.scope ?? "openid profile email");
|
|
771
|
+
if (params.codeChallenge) {
|
|
772
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
773
|
+
url.searchParams.set("code_challenge_method", params.codeChallengeMethod ?? "S256");
|
|
774
|
+
}
|
|
775
|
+
return url.toString();
|
|
776
|
+
}
|
|
777
|
+
/** Exchange authorization code for tokens. */
|
|
778
|
+
async exchangeCode(params) {
|
|
779
|
+
const discovery = await this.getDiscovery();
|
|
780
|
+
const body = new URLSearchParams({
|
|
781
|
+
grant_type: "authorization_code",
|
|
782
|
+
client_id: this.clientId,
|
|
783
|
+
code: params.code,
|
|
784
|
+
redirect_uri: params.redirectUri
|
|
785
|
+
});
|
|
786
|
+
if (this.clientSecret) {
|
|
787
|
+
body.set("client_secret", this.clientSecret);
|
|
788
|
+
}
|
|
789
|
+
if (params.codeVerifier) {
|
|
790
|
+
body.set("code_verifier", params.codeVerifier);
|
|
791
|
+
}
|
|
792
|
+
const controller = new AbortController();
|
|
793
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
794
|
+
try {
|
|
795
|
+
const res = await fetch(discovery.token_endpoint, {
|
|
796
|
+
method: "POST",
|
|
797
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
798
|
+
body: body.toString(),
|
|
799
|
+
signal: controller.signal
|
|
800
|
+
});
|
|
801
|
+
if (!res.ok) {
|
|
802
|
+
const text = await res.text().catch(() => "");
|
|
803
|
+
throw new IamApiError(res.status, `Token exchange failed: ${text}`);
|
|
804
|
+
}
|
|
805
|
+
return await res.json();
|
|
806
|
+
} finally {
|
|
807
|
+
clearTimeout(timer);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Resource Owner Password Credentials grant.
|
|
812
|
+
* Used for service-to-service auth, CLI login, and e2e tests.
|
|
813
|
+
*/
|
|
814
|
+
async passwordGrant(params) {
|
|
815
|
+
const discovery = await this.getDiscovery();
|
|
816
|
+
const body = new URLSearchParams({
|
|
817
|
+
grant_type: "password",
|
|
818
|
+
client_id: this.clientId,
|
|
819
|
+
username: params.username,
|
|
820
|
+
password: params.password,
|
|
821
|
+
scope: params.scope ?? "openid profile email phone"
|
|
822
|
+
});
|
|
823
|
+
if (this.clientSecret) {
|
|
824
|
+
body.set("client_secret", this.clientSecret);
|
|
825
|
+
}
|
|
826
|
+
const controller = new AbortController();
|
|
827
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
828
|
+
try {
|
|
829
|
+
const res = await fetch(discovery.token_endpoint, {
|
|
830
|
+
method: "POST",
|
|
831
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
832
|
+
body: body.toString(),
|
|
833
|
+
signal: controller.signal
|
|
834
|
+
});
|
|
835
|
+
if (!res.ok) {
|
|
836
|
+
const text = await res.text().catch(() => "");
|
|
837
|
+
throw new IamApiError(res.status, `Password grant failed: ${text}`);
|
|
838
|
+
}
|
|
839
|
+
return await res.json();
|
|
840
|
+
} finally {
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/** Refresh an access token. */
|
|
845
|
+
async refreshToken(refreshToken) {
|
|
846
|
+
const discovery = await this.getDiscovery();
|
|
847
|
+
const body = new URLSearchParams({
|
|
848
|
+
grant_type: "refresh_token",
|
|
849
|
+
client_id: this.clientId,
|
|
850
|
+
refresh_token: refreshToken
|
|
851
|
+
});
|
|
852
|
+
if (this.clientSecret) {
|
|
853
|
+
body.set("client_secret", this.clientSecret);
|
|
854
|
+
}
|
|
855
|
+
const controller = new AbortController();
|
|
856
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
857
|
+
try {
|
|
858
|
+
const res = await fetch(discovery.token_endpoint, {
|
|
859
|
+
method: "POST",
|
|
860
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
861
|
+
body: body.toString(),
|
|
862
|
+
signal: controller.signal
|
|
863
|
+
});
|
|
864
|
+
if (!res.ok) {
|
|
865
|
+
const text = await res.text().catch(() => "");
|
|
866
|
+
throw new IamApiError(res.status, `Token refresh failed: ${text}`);
|
|
867
|
+
}
|
|
868
|
+
return await res.json();
|
|
869
|
+
} finally {
|
|
870
|
+
clearTimeout(timer);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// -----------------------------------------------------------------------
|
|
874
|
+
// User
|
|
875
|
+
// -----------------------------------------------------------------------
|
|
876
|
+
/** Get user info from access token (OIDC userinfo endpoint). */
|
|
877
|
+
async getUserInfo(accessToken) {
|
|
878
|
+
const discovery = await this.getDiscovery();
|
|
879
|
+
const controller = new AbortController();
|
|
880
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
881
|
+
try {
|
|
882
|
+
const res = await fetch(discovery.userinfo_endpoint, {
|
|
883
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
884
|
+
signal: controller.signal
|
|
885
|
+
});
|
|
886
|
+
if (!res.ok) {
|
|
887
|
+
throw new IamApiError(res.status, "Failed to fetch userinfo");
|
|
888
|
+
}
|
|
889
|
+
return await res.json();
|
|
890
|
+
} finally {
|
|
891
|
+
clearTimeout(timer);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/** Get a user by ID ("org/username" format). */
|
|
895
|
+
async getUser(userId, token) {
|
|
896
|
+
const resp = await this.request("/api/get-user", {
|
|
897
|
+
params: { id: userId },
|
|
898
|
+
token
|
|
899
|
+
});
|
|
900
|
+
return resp.data ?? null;
|
|
901
|
+
}
|
|
902
|
+
// -----------------------------------------------------------------------
|
|
903
|
+
// Organization
|
|
904
|
+
// -----------------------------------------------------------------------
|
|
905
|
+
/** List organizations (for the configured owner). */
|
|
906
|
+
async getOrganizations(token) {
|
|
907
|
+
const owner = this.orgName ?? "admin";
|
|
908
|
+
const resp = await this.request(
|
|
909
|
+
"/api/get-organizations",
|
|
910
|
+
{ params: { owner }, token }
|
|
911
|
+
);
|
|
912
|
+
return resp.data ?? [];
|
|
913
|
+
}
|
|
914
|
+
/** Get a specific organization. */
|
|
915
|
+
async getOrganization(id, token) {
|
|
916
|
+
const resp = await this.request(
|
|
917
|
+
"/api/get-organization",
|
|
918
|
+
{ params: { id }, token }
|
|
919
|
+
);
|
|
920
|
+
return resp.data ?? null;
|
|
921
|
+
}
|
|
922
|
+
/** Get organizations a user belongs to. */
|
|
923
|
+
async getUserOrganizations(userId, token) {
|
|
924
|
+
const user = await this.getUser(userId, token);
|
|
925
|
+
if (!user) return [];
|
|
926
|
+
const org = await this.getOrganization(
|
|
927
|
+
`admin/${user.owner}`,
|
|
928
|
+
token
|
|
929
|
+
);
|
|
930
|
+
return org ? [org] : [];
|
|
931
|
+
}
|
|
932
|
+
// -----------------------------------------------------------------------
|
|
933
|
+
// Project
|
|
934
|
+
// -----------------------------------------------------------------------
|
|
935
|
+
/** List projects (for the configured owner). */
|
|
936
|
+
async getProjects(token) {
|
|
937
|
+
const owner = this.orgName ?? "admin";
|
|
938
|
+
const resp = await this.request(
|
|
939
|
+
"/api/get-projects",
|
|
940
|
+
{ params: { owner }, token }
|
|
941
|
+
);
|
|
942
|
+
return resp.data ?? [];
|
|
943
|
+
}
|
|
944
|
+
/** Get a specific project by ID ("owner/name" format). */
|
|
945
|
+
async getProject(id, token) {
|
|
946
|
+
const resp = await this.request(
|
|
947
|
+
"/api/get-project",
|
|
948
|
+
{ params: { id }, token }
|
|
949
|
+
);
|
|
950
|
+
return resp.data ?? null;
|
|
951
|
+
}
|
|
952
|
+
/** Get all projects for an organization. */
|
|
953
|
+
async getOrganizationProjects(organization, token) {
|
|
954
|
+
const resp = await this.request(
|
|
955
|
+
"/api/get-organization-projects",
|
|
956
|
+
{ params: { organization }, token }
|
|
957
|
+
);
|
|
958
|
+
return resp.data ?? [];
|
|
959
|
+
}
|
|
960
|
+
// -----------------------------------------------------------------------
|
|
961
|
+
// Raw request (for extending)
|
|
962
|
+
// -----------------------------------------------------------------------
|
|
963
|
+
/** Make an arbitrary authenticated request to the IAM API. */
|
|
964
|
+
async apiRequest(path, opts) {
|
|
965
|
+
return this.request(path, opts);
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
var IamApiError = class extends Error {
|
|
969
|
+
status;
|
|
970
|
+
constructor(status, message) {
|
|
971
|
+
super(message);
|
|
972
|
+
this.name = "IamApiError";
|
|
973
|
+
this.status = status;
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// src/react.ts
|
|
978
|
+
var IamContext = createContext(null);
|
|
979
|
+
IamContext.displayName = "HanzoIamContext";
|
|
980
|
+
var STORAGE_ORG_KEY = "hanzo_iam_current_org";
|
|
981
|
+
var STORAGE_PROJECT_KEY = "hanzo_iam_current_project";
|
|
982
|
+
var STORAGE_EXPIRES_KEY = "hanzo_iam_expires_at";
|
|
983
|
+
function IamProvider(props) {
|
|
984
|
+
const { config, autoInit = true, onAuthChange, children } = props;
|
|
985
|
+
const sdk = useMemo(
|
|
986
|
+
() => new IAM(config),
|
|
987
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
988
|
+
[config.serverUrl, config.clientId, config.redirectUri]
|
|
989
|
+
);
|
|
990
|
+
const [user, setUser] = useState(null);
|
|
991
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
992
|
+
const [isLoading, setIsLoading] = useState(autoInit);
|
|
993
|
+
const [accessToken, setAccessToken] = useState(
|
|
994
|
+
sdk.getAccessToken()
|
|
995
|
+
);
|
|
996
|
+
const [error, setError] = useState(null);
|
|
997
|
+
const refreshTimerRef = useRef(null);
|
|
998
|
+
const scheduleRefresh = useCallback(() => {
|
|
999
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1000
|
+
if (sdk.isTokenExpired()) return;
|
|
1001
|
+
const storage = config.storage ?? sessionStorage;
|
|
1002
|
+
const expiresAtStr = storage.getItem(STORAGE_EXPIRES_KEY);
|
|
1003
|
+
if (!expiresAtStr) return;
|
|
1004
|
+
const msUntilRefresh = Number(expiresAtStr) - Date.now() - 6e4;
|
|
1005
|
+
if (msUntilRefresh <= 0) {
|
|
1006
|
+
sdk.refreshAccessToken().then((tokens) => {
|
|
1007
|
+
setAccessToken(tokens.accessToken);
|
|
169
1008
|
scheduleRefresh();
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const login = useCallback(async (params) => {
|
|
173
|
-
setError(null);
|
|
174
|
-
await sdk.signinRedirect(params);
|
|
175
|
-
}, [sdk]);
|
|
176
|
-
const loginPopup = useCallback(async (params) => {
|
|
177
|
-
setError(null);
|
|
178
|
-
try {
|
|
179
|
-
const tokens = await sdk.signinPopup(params);
|
|
180
|
-
await completeAuth(tokens);
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
184
|
-
setError(e);
|
|
185
|
-
throw e;
|
|
186
|
-
}
|
|
187
|
-
}, [sdk, completeAuth]);
|
|
188
|
-
const handleCallback = useCallback(async (callbackUrl) => {
|
|
189
|
-
setError(null);
|
|
190
|
-
try {
|
|
191
|
-
const tokens = await sdk.handleCallback(callbackUrl);
|
|
192
|
-
await completeAuth(tokens);
|
|
193
|
-
return tokens;
|
|
194
|
-
}
|
|
195
|
-
catch (err) {
|
|
196
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
197
|
-
setError(e);
|
|
198
|
-
throw e;
|
|
199
|
-
}
|
|
200
|
-
}, [sdk, completeAuth]);
|
|
201
|
-
const logout = useCallback(() => {
|
|
202
|
-
sdk.clearTokens();
|
|
1009
|
+
}).catch(() => {
|
|
1010
|
+
setIsAuthenticated(false);
|
|
203
1011
|
setUser(null);
|
|
1012
|
+
setAccessToken(null);
|
|
1013
|
+
});
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
refreshTimerRef.current = setTimeout(async () => {
|
|
1017
|
+
try {
|
|
1018
|
+
const tokens = await sdk.refreshAccessToken();
|
|
1019
|
+
setAccessToken(tokens.accessToken);
|
|
1020
|
+
scheduleRefresh();
|
|
1021
|
+
} catch {
|
|
204
1022
|
setIsAuthenticated(false);
|
|
1023
|
+
setUser(null);
|
|
205
1024
|
setAccessToken(null);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
1025
|
+
}
|
|
1026
|
+
}, msUntilRefresh);
|
|
1027
|
+
}, [sdk, config.storage]);
|
|
1028
|
+
useEffect(() => {
|
|
1029
|
+
if (!autoInit) {
|
|
1030
|
+
setIsLoading(false);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
let cancelled = false;
|
|
1034
|
+
const init = async () => {
|
|
1035
|
+
try {
|
|
1036
|
+
const token = await sdk.getValidAccessToken();
|
|
1037
|
+
if (cancelled) return;
|
|
1038
|
+
if (token) {
|
|
1039
|
+
setAccessToken(token);
|
|
1040
|
+
setIsAuthenticated(true);
|
|
1041
|
+
try {
|
|
1042
|
+
const info = await sdk.getUserInfo();
|
|
1043
|
+
if (!cancelled) setUser(info);
|
|
1044
|
+
} catch {
|
|
1045
|
+
}
|
|
1046
|
+
scheduleRefresh();
|
|
1047
|
+
onAuthChange?.(true);
|
|
1048
|
+
} else {
|
|
1049
|
+
onAuthChange?.(false);
|
|
212
1050
|
}
|
|
213
|
-
|
|
214
|
-
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
if (!cancelled) {
|
|
1053
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1054
|
+
onAuthChange?.(false);
|
|
215
1055
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
]
|
|
243
|
-
|
|
1056
|
+
} finally {
|
|
1057
|
+
if (!cancelled) setIsLoading(false);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
init();
|
|
1061
|
+
return () => {
|
|
1062
|
+
cancelled = true;
|
|
1063
|
+
};
|
|
1064
|
+
}, [sdk, autoInit]);
|
|
1065
|
+
useEffect(() => {
|
|
1066
|
+
return () => {
|
|
1067
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1068
|
+
};
|
|
1069
|
+
}, []);
|
|
1070
|
+
const completeAuth = useCallback(
|
|
1071
|
+
async (tokens) => {
|
|
1072
|
+
setAccessToken(tokens.accessToken);
|
|
1073
|
+
setIsAuthenticated(true);
|
|
1074
|
+
try {
|
|
1075
|
+
const info = await sdk.getUserInfo();
|
|
1076
|
+
setUser(info);
|
|
1077
|
+
} catch {
|
|
1078
|
+
}
|
|
1079
|
+
scheduleRefresh();
|
|
1080
|
+
onAuthChange?.(true);
|
|
1081
|
+
},
|
|
1082
|
+
[sdk, scheduleRefresh, onAuthChange]
|
|
1083
|
+
);
|
|
1084
|
+
const login = useCallback(
|
|
1085
|
+
async (params) => {
|
|
1086
|
+
setError(null);
|
|
1087
|
+
await sdk.signinRedirect(params);
|
|
1088
|
+
},
|
|
1089
|
+
[sdk]
|
|
1090
|
+
);
|
|
1091
|
+
const loginPopup = useCallback(
|
|
1092
|
+
async (params) => {
|
|
1093
|
+
setError(null);
|
|
1094
|
+
try {
|
|
1095
|
+
const tokens = await sdk.signinPopup(params);
|
|
1096
|
+
await completeAuth(tokens);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
1099
|
+
setError(e);
|
|
1100
|
+
throw e;
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
[sdk, completeAuth]
|
|
1104
|
+
);
|
|
1105
|
+
const handleCallback = useCallback(
|
|
1106
|
+
async (callbackUrl) => {
|
|
1107
|
+
setError(null);
|
|
1108
|
+
try {
|
|
1109
|
+
const tokens = await sdk.handleCallback(callbackUrl);
|
|
1110
|
+
await completeAuth(tokens);
|
|
1111
|
+
return tokens;
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
1114
|
+
setError(e);
|
|
1115
|
+
throw e;
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
[sdk, completeAuth]
|
|
1119
|
+
);
|
|
1120
|
+
const logout = useCallback(() => {
|
|
1121
|
+
sdk.clearTokens();
|
|
1122
|
+
setUser(null);
|
|
1123
|
+
setIsAuthenticated(false);
|
|
1124
|
+
setAccessToken(null);
|
|
1125
|
+
setError(null);
|
|
1126
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
1127
|
+
try {
|
|
1128
|
+
localStorage.removeItem(STORAGE_ORG_KEY);
|
|
1129
|
+
localStorage.removeItem(STORAGE_PROJECT_KEY);
|
|
1130
|
+
} catch {
|
|
1131
|
+
}
|
|
1132
|
+
onAuthChange?.(false);
|
|
1133
|
+
}, [sdk, onAuthChange]);
|
|
1134
|
+
const value = useMemo(
|
|
1135
|
+
() => ({
|
|
1136
|
+
sdk,
|
|
1137
|
+
config,
|
|
1138
|
+
user,
|
|
1139
|
+
isAuthenticated,
|
|
1140
|
+
isLoading,
|
|
1141
|
+
accessToken,
|
|
1142
|
+
login,
|
|
1143
|
+
loginPopup,
|
|
1144
|
+
handleCallback,
|
|
1145
|
+
logout,
|
|
1146
|
+
error
|
|
1147
|
+
}),
|
|
1148
|
+
[
|
|
1149
|
+
sdk,
|
|
1150
|
+
config,
|
|
1151
|
+
user,
|
|
1152
|
+
isAuthenticated,
|
|
1153
|
+
isLoading,
|
|
1154
|
+
accessToken,
|
|
1155
|
+
login,
|
|
1156
|
+
loginPopup,
|
|
1157
|
+
handleCallback,
|
|
1158
|
+
logout,
|
|
1159
|
+
error
|
|
1160
|
+
]
|
|
1161
|
+
);
|
|
1162
|
+
return createElement(IamContext.Provider, { value }, children);
|
|
244
1163
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
*/
|
|
252
|
-
export function useIam() {
|
|
253
|
-
const ctx = useContext(IamContext);
|
|
254
|
-
if (!ctx) {
|
|
255
|
-
throw new Error("useIam() must be used within an <IamProvider>");
|
|
256
|
-
}
|
|
257
|
-
return ctx;
|
|
1164
|
+
function useIam() {
|
|
1165
|
+
const ctx = useContext(IamContext);
|
|
1166
|
+
if (!ctx) {
|
|
1167
|
+
throw new Error("useIam() must be used within an <IamProvider>");
|
|
1168
|
+
}
|
|
1169
|
+
return ctx;
|
|
258
1170
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const syntheticOrg = {
|
|
308
|
-
owner: "admin",
|
|
309
|
-
name: primaryOrg,
|
|
310
|
-
displayName: primaryOrg,
|
|
311
|
-
};
|
|
312
|
-
setOrganizations([syntheticOrg]);
|
|
313
|
-
if (!currentOrgId) {
|
|
314
|
-
setCurrentOrgId(primaryOrg);
|
|
315
|
-
try {
|
|
316
|
-
localStorage.setItem(STORAGE_ORG_KEY, primaryOrg);
|
|
317
|
-
}
|
|
318
|
-
catch {
|
|
319
|
-
/* ok */
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
catch {
|
|
326
|
-
// Invalid token format — skip JWT parsing
|
|
327
|
-
}
|
|
328
|
-
// 2. Try to fetch full org list from API (may fail for non-admin users)
|
|
329
|
-
try {
|
|
330
|
-
const client = new IamClient({
|
|
331
|
-
serverUrl: config.serverUrl,
|
|
332
|
-
clientId: config.clientId,
|
|
333
|
-
});
|
|
334
|
-
const orgs = await client.getOrganizations(accessToken);
|
|
335
|
-
if (!cancelled && orgs.length > 0) {
|
|
336
|
-
setOrganizations(orgs);
|
|
337
|
-
if (!currentOrgId && orgs.length > 0) {
|
|
338
|
-
const firstOrg = orgs[0].name;
|
|
339
|
-
setCurrentOrgId(firstOrg);
|
|
340
|
-
try {
|
|
341
|
-
localStorage.setItem(STORAGE_ORG_KEY, firstOrg);
|
|
342
|
-
}
|
|
343
|
-
catch {
|
|
344
|
-
/* ok */
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
// API call failed — keep JWT-derived org
|
|
351
|
-
}
|
|
352
|
-
finally {
|
|
353
|
-
if (!cancelled)
|
|
354
|
-
setIsLoading(false);
|
|
1171
|
+
function useOrganizations() {
|
|
1172
|
+
const { config, isAuthenticated, accessToken } = useIam();
|
|
1173
|
+
const [organizations, setOrganizations] = useState([]);
|
|
1174
|
+
const [projects, setProjects] = useState([]);
|
|
1175
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
1176
|
+
const [currentOrgId, setCurrentOrgId] = useState(() => {
|
|
1177
|
+
try {
|
|
1178
|
+
return localStorage.getItem(STORAGE_ORG_KEY);
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
const [currentProjectId, setCurrentProjectId] = useState(
|
|
1184
|
+
() => {
|
|
1185
|
+
try {
|
|
1186
|
+
return localStorage.getItem(STORAGE_PROJECT_KEY);
|
|
1187
|
+
} catch {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
);
|
|
1192
|
+
useEffect(() => {
|
|
1193
|
+
if (!isAuthenticated || !accessToken) {
|
|
1194
|
+
setOrganizations([]);
|
|
1195
|
+
setProjects([]);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
let cancelled = false;
|
|
1199
|
+
const fetchOrgs = async () => {
|
|
1200
|
+
setIsLoading(true);
|
|
1201
|
+
try {
|
|
1202
|
+
const payload = JSON.parse(atob(accessToken.split(".")[1]));
|
|
1203
|
+
const sub = payload.sub;
|
|
1204
|
+
if (sub?.includes("/")) {
|
|
1205
|
+
const primaryOrg = sub.split("/")[0];
|
|
1206
|
+
if (!cancelled) {
|
|
1207
|
+
const syntheticOrg = {
|
|
1208
|
+
owner: "admin",
|
|
1209
|
+
name: primaryOrg,
|
|
1210
|
+
displayName: primaryOrg
|
|
1211
|
+
};
|
|
1212
|
+
setOrganizations([syntheticOrg]);
|
|
1213
|
+
if (!currentOrgId) {
|
|
1214
|
+
setCurrentOrgId(primaryOrg);
|
|
1215
|
+
try {
|
|
1216
|
+
localStorage.setItem(STORAGE_ORG_KEY, primaryOrg);
|
|
1217
|
+
} catch {
|
|
1218
|
+
}
|
|
355
1219
|
}
|
|
356
|
-
|
|
357
|
-
fetchOrgs();
|
|
358
|
-
return () => {
|
|
359
|
-
cancelled = true;
|
|
360
|
-
};
|
|
361
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
362
|
-
}, [isAuthenticated, accessToken, config.serverUrl, config.clientId]);
|
|
363
|
-
// Fetch projects when currentOrgId changes
|
|
364
|
-
useEffect(() => {
|
|
365
|
-
if (!isAuthenticated || !accessToken || !currentOrgId) {
|
|
366
|
-
setProjects([]);
|
|
367
|
-
return;
|
|
1220
|
+
}
|
|
368
1221
|
}
|
|
369
|
-
|
|
370
|
-
|
|
1222
|
+
} catch {
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
const client = new IamClient({
|
|
1226
|
+
serverUrl: config.serverUrl,
|
|
1227
|
+
clientId: config.clientId
|
|
1228
|
+
});
|
|
1229
|
+
const orgs = await client.getOrganizations(accessToken);
|
|
1230
|
+
if (!cancelled && orgs.length > 0) {
|
|
1231
|
+
setOrganizations(orgs);
|
|
1232
|
+
if (!currentOrgId && orgs.length > 0) {
|
|
1233
|
+
const firstOrg = orgs[0].name;
|
|
1234
|
+
setCurrentOrgId(firstOrg);
|
|
371
1235
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
clientId: config.clientId,
|
|
375
|
-
});
|
|
376
|
-
const orgProjects = await client.getOrganizationProjects(currentOrgId, accessToken);
|
|
377
|
-
if (!cancelled) {
|
|
378
|
-
setProjects(orgProjects);
|
|
379
|
-
// Auto-select default project if none selected
|
|
380
|
-
if (!currentProjectId && orgProjects.length > 0) {
|
|
381
|
-
const defaultProject = orgProjects.find((p) => p.isDefault) ?? orgProjects[0];
|
|
382
|
-
setCurrentProjectId(defaultProject.name);
|
|
383
|
-
try {
|
|
384
|
-
localStorage.setItem(STORAGE_PROJECT_KEY, defaultProject.name);
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
/* ok */
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
1236
|
+
localStorage.setItem(STORAGE_ORG_KEY, firstOrg);
|
|
1237
|
+
} catch {
|
|
391
1238
|
}
|
|
392
|
-
|
|
393
|
-
// Projects API may not be available yet — that's ok
|
|
394
|
-
if (!cancelled)
|
|
395
|
-
setProjects([]);
|
|
396
|
-
}
|
|
397
|
-
};
|
|
398
|
-
fetchProjects();
|
|
399
|
-
return () => {
|
|
400
|
-
cancelled = true;
|
|
401
|
-
};
|
|
402
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
403
|
-
}, [isAuthenticated, accessToken, currentOrgId, config.serverUrl, config.clientId]);
|
|
404
|
-
const currentOrg = useMemo(() => organizations.find((o) => o.name === currentOrgId) ?? null, [organizations, currentOrgId]);
|
|
405
|
-
const currentProject = useMemo(() => projects.find((p) => p.name === currentProjectId) ?? null, [projects, currentProjectId]);
|
|
406
|
-
const switchOrg = useCallback((orgId) => {
|
|
407
|
-
setCurrentOrgId(orgId);
|
|
408
|
-
setCurrentProjectId(null);
|
|
409
|
-
setProjects([]);
|
|
410
|
-
try {
|
|
411
|
-
localStorage.setItem(STORAGE_ORG_KEY, orgId);
|
|
412
|
-
localStorage.removeItem(STORAGE_PROJECT_KEY);
|
|
1239
|
+
}
|
|
413
1240
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1241
|
+
} catch {
|
|
1242
|
+
} finally {
|
|
1243
|
+
if (!cancelled) setIsLoading(false);
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
fetchOrgs();
|
|
1247
|
+
return () => {
|
|
1248
|
+
cancelled = true;
|
|
1249
|
+
};
|
|
1250
|
+
}, [isAuthenticated, accessToken, config.serverUrl, config.clientId]);
|
|
1251
|
+
useEffect(() => {
|
|
1252
|
+
if (!isAuthenticated || !accessToken || !currentOrgId) {
|
|
1253
|
+
setProjects([]);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
let cancelled = false;
|
|
1257
|
+
const fetchProjects = async () => {
|
|
1258
|
+
try {
|
|
1259
|
+
const client = new IamClient({
|
|
1260
|
+
serverUrl: config.serverUrl,
|
|
1261
|
+
clientId: config.clientId
|
|
1262
|
+
});
|
|
1263
|
+
const orgProjects = await client.getOrganizationProjects(
|
|
1264
|
+
currentOrgId,
|
|
1265
|
+
accessToken
|
|
1266
|
+
);
|
|
1267
|
+
if (!cancelled) {
|
|
1268
|
+
setProjects(orgProjects);
|
|
1269
|
+
if (!currentProjectId && orgProjects.length > 0) {
|
|
1270
|
+
const defaultProject = orgProjects.find((p) => p.isDefault) ?? orgProjects[0];
|
|
1271
|
+
setCurrentProjectId(defaultProject.name);
|
|
1272
|
+
try {
|
|
1273
|
+
localStorage.setItem(STORAGE_PROJECT_KEY, defaultProject.name);
|
|
1274
|
+
} catch {
|
|
426
1275
|
}
|
|
1276
|
+
}
|
|
427
1277
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}, []);
|
|
432
|
-
return {
|
|
433
|
-
organizations,
|
|
434
|
-
currentOrg,
|
|
435
|
-
currentOrgId,
|
|
436
|
-
switchOrg,
|
|
437
|
-
projects,
|
|
438
|
-
currentProject,
|
|
439
|
-
currentProjectId,
|
|
440
|
-
switchProject,
|
|
441
|
-
isLoading,
|
|
1278
|
+
} catch {
|
|
1279
|
+
if (!cancelled) setProjects([]);
|
|
1280
|
+
}
|
|
442
1281
|
};
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
// ---------------------------------------------------------------------------
|
|
447
|
-
/**
|
|
448
|
-
* Hook that provides a valid access token with auto-refresh capability.
|
|
449
|
-
* Returns null while loading or if not authenticated.
|
|
450
|
-
*/
|
|
451
|
-
export function useIamToken() {
|
|
452
|
-
const { sdk, accessToken, isAuthenticated } = useIam();
|
|
453
|
-
const refresh = useCallback(async () => {
|
|
454
|
-
try {
|
|
455
|
-
return await sdk.getValidAccessToken();
|
|
456
|
-
}
|
|
457
|
-
catch {
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
}, [sdk]);
|
|
461
|
-
return {
|
|
462
|
-
token: accessToken,
|
|
463
|
-
isValid: isAuthenticated && !!accessToken && !sdk.isTokenExpired(),
|
|
464
|
-
refresh,
|
|
1282
|
+
fetchProjects();
|
|
1283
|
+
return () => {
|
|
1284
|
+
cancelled = true;
|
|
465
1285
|
};
|
|
1286
|
+
}, [isAuthenticated, accessToken, currentOrgId, config.serverUrl, config.clientId]);
|
|
1287
|
+
const currentOrg = useMemo(
|
|
1288
|
+
() => organizations.find((o) => o.name === currentOrgId) ?? null,
|
|
1289
|
+
[organizations, currentOrgId]
|
|
1290
|
+
);
|
|
1291
|
+
const currentProject = useMemo(
|
|
1292
|
+
() => projects.find((p) => p.name === currentProjectId) ?? null,
|
|
1293
|
+
[projects, currentProjectId]
|
|
1294
|
+
);
|
|
1295
|
+
const switchOrg = useCallback((orgId) => {
|
|
1296
|
+
setCurrentOrgId(orgId);
|
|
1297
|
+
setCurrentProjectId(null);
|
|
1298
|
+
setProjects([]);
|
|
1299
|
+
try {
|
|
1300
|
+
localStorage.setItem(STORAGE_ORG_KEY, orgId);
|
|
1301
|
+
localStorage.removeItem(STORAGE_PROJECT_KEY);
|
|
1302
|
+
} catch {
|
|
1303
|
+
}
|
|
1304
|
+
}, []);
|
|
1305
|
+
const switchProject = useCallback((projectId) => {
|
|
1306
|
+
setCurrentProjectId(projectId);
|
|
1307
|
+
try {
|
|
1308
|
+
if (projectId) {
|
|
1309
|
+
localStorage.setItem(STORAGE_PROJECT_KEY, projectId);
|
|
1310
|
+
} else {
|
|
1311
|
+
localStorage.removeItem(STORAGE_PROJECT_KEY);
|
|
1312
|
+
}
|
|
1313
|
+
} catch {
|
|
1314
|
+
}
|
|
1315
|
+
}, []);
|
|
1316
|
+
return {
|
|
1317
|
+
organizations,
|
|
1318
|
+
currentOrg,
|
|
1319
|
+
currentOrgId,
|
|
1320
|
+
switchOrg,
|
|
1321
|
+
projects,
|
|
1322
|
+
currentProject,
|
|
1323
|
+
currentProjectId,
|
|
1324
|
+
switchProject,
|
|
1325
|
+
isLoading
|
|
1326
|
+
};
|
|
466
1327
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1328
|
+
function useIamToken() {
|
|
1329
|
+
const { sdk, accessToken, isAuthenticated } = useIam();
|
|
1330
|
+
const refresh = useCallback(async () => {
|
|
1331
|
+
try {
|
|
1332
|
+
return await sdk.getValidAccessToken();
|
|
1333
|
+
} catch {
|
|
1334
|
+
return null;
|
|
1335
|
+
}
|
|
1336
|
+
}, [sdk]);
|
|
1337
|
+
return {
|
|
1338
|
+
token: accessToken,
|
|
1339
|
+
isValid: isAuthenticated && !!accessToken && !sdk.isTokenExpired(),
|
|
1340
|
+
refresh
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
function OrgProjectSwitcher({
|
|
1344
|
+
organizations,
|
|
1345
|
+
currentOrgId,
|
|
1346
|
+
switchOrg,
|
|
1347
|
+
projects = [],
|
|
1348
|
+
currentProjectId = null,
|
|
1349
|
+
switchProject,
|
|
1350
|
+
onTenantChange,
|
|
1351
|
+
environment,
|
|
1352
|
+
className = "",
|
|
1353
|
+
alwaysShow = false
|
|
1354
|
+
}) {
|
|
1355
|
+
useEffect(() => {
|
|
1356
|
+
onTenantChange?.(currentOrgId, currentProjectId ?? null);
|
|
1357
|
+
}, [currentOrgId, currentProjectId, onTenantChange]);
|
|
1358
|
+
const handleOrgChange = useCallback(
|
|
1359
|
+
(e) => switchOrg(e.target.value),
|
|
1360
|
+
[switchOrg]
|
|
1361
|
+
);
|
|
1362
|
+
const handleProjectChange = useCallback(
|
|
1363
|
+
(e) => switchProject?.(e.target.value || null),
|
|
1364
|
+
[switchProject]
|
|
1365
|
+
);
|
|
1366
|
+
if (!alwaysShow && organizations.length <= 1 && projects.length <= 1) {
|
|
1367
|
+
if (organizations.length === 1) {
|
|
1368
|
+
const org = organizations[0];
|
|
1369
|
+
return createElement(
|
|
1370
|
+
"div",
|
|
1371
|
+
{ className: `flex items-center gap-2 text-sm ${className}` },
|
|
1372
|
+
createElement("span", { className: "font-medium" }, org.displayName || org.name),
|
|
1373
|
+
projects.length === 1 ? [
|
|
1374
|
+
createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
|
|
1375
|
+
createElement("span", { key: "proj" }, projects[0].displayName || projects[0].name)
|
|
1376
|
+
] : null,
|
|
1377
|
+
environment ? createElement(
|
|
1378
|
+
"span",
|
|
1379
|
+
{ className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
|
|
1380
|
+
environment
|
|
1381
|
+
) : null
|
|
1382
|
+
);
|
|
501
1383
|
}
|
|
502
|
-
return
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
return createElement(
|
|
1387
|
+
"div",
|
|
1388
|
+
{ className: `flex items-center gap-2 ${className}` },
|
|
1389
|
+
createElement(
|
|
1390
|
+
"select",
|
|
1391
|
+
{
|
|
503
1392
|
value: currentOrgId ?? "",
|
|
504
1393
|
onChange: handleOrgChange,
|
|
505
1394
|
className: "h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
|
|
506
|
-
"aria-label": "Switch organization"
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1395
|
+
"aria-label": "Switch organization"
|
|
1396
|
+
},
|
|
1397
|
+
...organizations.map(
|
|
1398
|
+
(org) => createElement("option", { key: org.name, value: org.name }, org.displayName || org.name)
|
|
1399
|
+
)
|
|
1400
|
+
),
|
|
1401
|
+
projects.length > 0 && switchProject ? [
|
|
1402
|
+
createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
|
|
1403
|
+
createElement(
|
|
1404
|
+
"select",
|
|
1405
|
+
{
|
|
1406
|
+
key: "proj-select",
|
|
1407
|
+
value: currentProjectId ?? "",
|
|
1408
|
+
onChange: handleProjectChange,
|
|
1409
|
+
className: "h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
|
|
1410
|
+
"aria-label": "Switch project"
|
|
1411
|
+
},
|
|
1412
|
+
...projects.map(
|
|
1413
|
+
(proj) => createElement("option", { key: proj.name, value: proj.name }, proj.displayName || proj.name)
|
|
1414
|
+
)
|
|
1415
|
+
)
|
|
1416
|
+
] : null,
|
|
1417
|
+
environment ? createElement(
|
|
1418
|
+
"span",
|
|
1419
|
+
{ className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
|
|
1420
|
+
environment
|
|
1421
|
+
) : null
|
|
1422
|
+
);
|
|
521
1423
|
}
|
|
1424
|
+
|
|
1425
|
+
export { IamContext, IamProvider, OrgProjectSwitcher, useIam, useIamToken, useOrganizations };
|
|
1426
|
+
//# sourceMappingURL=react.js.map
|
|
522
1427
|
//# sourceMappingURL=react.js.map
|