@authhero/react-admin 0.20.0 → 0.22.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/.env.example +27 -0
- package/CHANGELOG.md +18 -0
- package/README.md +25 -0
- package/package.json +1 -1
- package/src/auth0DataProvider.ts +83 -7
- package/src/authProvider.ts +56 -22
- package/src/components/flows/edit.tsx +10 -3
- package/src/components/hooks/list.tsx +1 -1
- package/src/components/users/edit.tsx +184 -0
- package/src/utils/domainUtils.ts +80 -46
- package/src/utils/tokenUtils.ts +4 -2
package/.env.example
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# AuthHero React Admin - Environment Configuration
|
|
2
|
+
|
|
3
|
+
# Default domain configuration (automatically added to domain list if not present)
|
|
4
|
+
# This is useful for development or single-instance deployments
|
|
5
|
+
|
|
6
|
+
# Auth domain for production (e.g., login.sesamy.com)
|
|
7
|
+
VITE_AUTH0_DOMAIN=login.sesamy.com
|
|
8
|
+
|
|
9
|
+
# For local development, use localhost:3000
|
|
10
|
+
# VITE_AUTH0_DOMAIN=localhost:3000
|
|
11
|
+
|
|
12
|
+
# Client ID to use for authentication
|
|
13
|
+
VITE_AUTH0_CLIENT_ID=auth-admin
|
|
14
|
+
|
|
15
|
+
# API URL for management endpoints (e.g., https://auth2.sesamy.com)
|
|
16
|
+
VITE_AUTH0_API_URL=https://auth2.sesamy.com
|
|
17
|
+
|
|
18
|
+
# For local development API
|
|
19
|
+
# VITE_AUTH0_API_URL=https://localhost:3000
|
|
20
|
+
|
|
21
|
+
# Optional: Management API audience (defaults to urn:authhero:management)
|
|
22
|
+
# VITE_AUTH0_AUDIENCE=urn:authhero:management
|
|
23
|
+
|
|
24
|
+
# Notes:
|
|
25
|
+
# - If VITE_AUTH0_DOMAIN is set, it will be automatically added to the domain list
|
|
26
|
+
# - Users can still add additional domains through the UI
|
|
27
|
+
# - For HTTPS on localhost, ensure your local server has a valid certificate
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @authhero/react-admin
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 00d2f83: Update versions to get latest build
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [00d2f83]
|
|
12
|
+
- @authhero/adapter-interfaces@0.120.0
|
|
13
|
+
- @authhero/widget@0.6.3
|
|
14
|
+
|
|
15
|
+
## 0.21.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 1423254: Fix casing in the ui for organizations
|
|
20
|
+
|
|
3
21
|
## 0.20.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -10,6 +10,31 @@ npm install
|
|
|
10
10
|
yarn install
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
### Environment Variables
|
|
16
|
+
|
|
17
|
+
You can configure the default domain connection using environment variables. Create a `.env` file in the `apps/react-admin` directory:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Production
|
|
21
|
+
VITE_AUTH0_DOMAIN=login.sesamy.com
|
|
22
|
+
VITE_AUTH0_CLIENT_ID=auth-admin
|
|
23
|
+
VITE_AUTH0_API_URL=https://auth2.sesamy.com
|
|
24
|
+
|
|
25
|
+
# Or for local development
|
|
26
|
+
VITE_AUTH0_DOMAIN=localhost:3000
|
|
27
|
+
VITE_AUTH0_CLIENT_ID=auth-admin
|
|
28
|
+
VITE_AUTH0_API_URL=https://localhost:3000
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
See `.env.example` for more details.
|
|
32
|
+
|
|
33
|
+
**Notes:**
|
|
34
|
+
|
|
35
|
+
- If `VITE_AUTH0_DOMAIN` is set, it will be automatically added to the domain list
|
|
36
|
+
- Users can still add additional domains through the UI
|
|
37
|
+
|
|
13
38
|
## Development
|
|
14
39
|
|
|
15
40
|
Start the application in development mode by running:
|
package/package.json
CHANGED
package/src/auth0DataProvider.ts
CHANGED
|
@@ -124,6 +124,11 @@ export default (
|
|
|
124
124
|
let managementClientPromise: Promise<ManagementClient> | null = null;
|
|
125
125
|
const getManagementClient = async () => {
|
|
126
126
|
if (!managementClientPromise) {
|
|
127
|
+
if (!apiUrl) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"API URL is not configured. Please set restApiUrl in domain configuration or VITE_SIMPLE_REST_URL environment variable.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
127
132
|
// Extract API domain from apiUrl
|
|
128
133
|
const apiDomain = apiUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
129
134
|
// Pass both API domain and OAuth domain for authentication
|
|
@@ -233,6 +238,11 @@ export default (
|
|
|
233
238
|
resourceKey: "custom_domains",
|
|
234
239
|
idKey: "custom_domain_id",
|
|
235
240
|
},
|
|
241
|
+
hooks: {
|
|
242
|
+
fetch: (client: any) => client.hooks.list(),
|
|
243
|
+
resourceKey: "hooks",
|
|
244
|
+
idKey: "hook_id",
|
|
245
|
+
},
|
|
236
246
|
};
|
|
237
247
|
|
|
238
248
|
// Handle SDK resources (only for top-level resources, not nested paths like users/{id}/roles)
|
|
@@ -317,6 +327,46 @@ export default (
|
|
|
317
327
|
}
|
|
318
328
|
}
|
|
319
329
|
|
|
330
|
+
// User organizations - when filtering by user_id
|
|
331
|
+
if (resource === "user-organizations" && params.filter?.user_id) {
|
|
332
|
+
const userId = params.filter.user_id;
|
|
333
|
+
const result = await managementClient.users.organizations.list(userId, {
|
|
334
|
+
page: page - 1,
|
|
335
|
+
per_page: perPage,
|
|
336
|
+
});
|
|
337
|
+
const response = (result as any).response || result;
|
|
338
|
+
|
|
339
|
+
let organizationsData: any[];
|
|
340
|
+
let total: number;
|
|
341
|
+
|
|
342
|
+
if (Array.isArray(response)) {
|
|
343
|
+
organizationsData = response;
|
|
344
|
+
total = response.length;
|
|
345
|
+
} else if (response.organizations) {
|
|
346
|
+
organizationsData = response.organizations;
|
|
347
|
+
total = response.total || organizationsData.length;
|
|
348
|
+
} else {
|
|
349
|
+
organizationsData = [];
|
|
350
|
+
total = 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
data: organizationsData.map((org: any) => ({
|
|
355
|
+
id: org.id || org.organization_id,
|
|
356
|
+
user_id: userId,
|
|
357
|
+
name: org.name,
|
|
358
|
+
display_name: org.display_name,
|
|
359
|
+
branding: org.branding,
|
|
360
|
+
metadata: org.metadata || {},
|
|
361
|
+
token_quota: org.token_quota,
|
|
362
|
+
created_at: org.created_at,
|
|
363
|
+
updated_at: org.updated_at,
|
|
364
|
+
...org,
|
|
365
|
+
})),
|
|
366
|
+
total,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
320
370
|
// Use HTTP client for all other list operations
|
|
321
371
|
const headers = createHeaders(tenantId);
|
|
322
372
|
|
|
@@ -340,7 +390,7 @@ export default (
|
|
|
340
390
|
if (Array.isArray(res.json)) {
|
|
341
391
|
return {
|
|
342
392
|
data: res.json.map((item) => ({
|
|
343
|
-
id: item.
|
|
393
|
+
id: item.id,
|
|
344
394
|
...item,
|
|
345
395
|
})),
|
|
346
396
|
total: res.json.length,
|
|
@@ -351,7 +401,7 @@ export default (
|
|
|
351
401
|
return {
|
|
352
402
|
data:
|
|
353
403
|
res.json[resource]?.map((item: any) => ({
|
|
354
|
-
id: item.
|
|
404
|
+
id: item.id,
|
|
355
405
|
...item,
|
|
356
406
|
})) || [],
|
|
357
407
|
total: res.json.total || res.json.length || 0,
|
|
@@ -382,15 +432,30 @@ export default (
|
|
|
382
432
|
fetch: (id) => (managementClient as any).customDomains.get(id),
|
|
383
433
|
idKey: "custom_domain_id",
|
|
384
434
|
},
|
|
435
|
+
flows: {
|
|
436
|
+
fetch: (id) => (managementClient as any).flows.get(id),
|
|
437
|
+
idKey: "id",
|
|
438
|
+
},
|
|
439
|
+
hooks: {
|
|
440
|
+
fetch: (id) => (managementClient as any).hooks.get(id),
|
|
441
|
+
idKey: "hook_id",
|
|
442
|
+
},
|
|
443
|
+
forms: {
|
|
444
|
+
fetch: (id) => managementClient.forms.get(id),
|
|
445
|
+
idKey: "id",
|
|
446
|
+
},
|
|
385
447
|
};
|
|
386
448
|
|
|
387
449
|
const handler = sdkGetHandlers[resource];
|
|
388
450
|
if (handler) {
|
|
389
451
|
const result = await handler.fetch(params.id as string);
|
|
452
|
+
// Unwrap SDK response wrapper if present
|
|
453
|
+
const data = (result as any).response || result;
|
|
454
|
+
|
|
390
455
|
return {
|
|
391
456
|
data: {
|
|
392
|
-
id:
|
|
393
|
-
...
|
|
457
|
+
id: data[handler.idKey] || data.id,
|
|
458
|
+
...data,
|
|
394
459
|
},
|
|
395
460
|
};
|
|
396
461
|
}
|
|
@@ -926,8 +991,12 @@ export default (
|
|
|
926
991
|
body: JSON.stringify(cleanParams.data),
|
|
927
992
|
}).then(({ json }) => {
|
|
928
993
|
if (!json.id) {
|
|
929
|
-
|
|
930
|
-
|
|
994
|
+
// Try singular form of resource name (e.g., hooks -> hook_id)
|
|
995
|
+
const singularResource = resource.endsWith("s")
|
|
996
|
+
? resource.slice(0, -1)
|
|
997
|
+
: resource;
|
|
998
|
+
json.id =
|
|
999
|
+
json[`${singularResource}_id`] || json[`${resource}_id`] || json.id;
|
|
931
1000
|
}
|
|
932
1001
|
return { data: json };
|
|
933
1002
|
});
|
|
@@ -1073,10 +1142,17 @@ export default (
|
|
|
1073
1142
|
|
|
1074
1143
|
// Default create (for endpoints not in SDK)
|
|
1075
1144
|
const res = await post(resource, params.data);
|
|
1145
|
+
// Try singular form of resource name (e.g., hooks -> hook_id)
|
|
1146
|
+
const singularResource = resource.endsWith("s")
|
|
1147
|
+
? resource.slice(0, -1)
|
|
1148
|
+
: resource;
|
|
1076
1149
|
return {
|
|
1077
1150
|
data: {
|
|
1078
1151
|
...res.json,
|
|
1079
|
-
id:
|
|
1152
|
+
id:
|
|
1153
|
+
res.json[`${singularResource}_id`] ||
|
|
1154
|
+
res.json[`${resource}_id`] ||
|
|
1155
|
+
res.json.id,
|
|
1080
1156
|
},
|
|
1081
1157
|
};
|
|
1082
1158
|
},
|
package/src/authProvider.ts
CHANGED
|
@@ -50,15 +50,23 @@ export const createAuth0Client = (domain: string) => {
|
|
|
50
50
|
const audience =
|
|
51
51
|
import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
|
|
52
52
|
|
|
53
|
+
const clientId = getClientIdFromStorage(domain);
|
|
54
|
+
console.log(
|
|
55
|
+
"[createAuth0Client] Creating client for domain:",
|
|
56
|
+
domain,
|
|
57
|
+
"with clientId:",
|
|
58
|
+
clientId,
|
|
59
|
+
);
|
|
60
|
+
|
|
53
61
|
const auth0Client = new Auth0Client({
|
|
54
62
|
domain: fullDomain,
|
|
55
|
-
clientId
|
|
63
|
+
clientId,
|
|
56
64
|
cacheLocation: "localstorage",
|
|
57
65
|
useRefreshTokens: false,
|
|
58
66
|
authorizationParams: {
|
|
59
67
|
audience,
|
|
60
68
|
redirect_uri: redirectUri,
|
|
61
|
-
scope: "openid profile email
|
|
69
|
+
scope: "openid profile email",
|
|
62
70
|
},
|
|
63
71
|
});
|
|
64
72
|
|
|
@@ -122,7 +130,9 @@ export const createAuth0ClientForOrg = (
|
|
|
122
130
|
domain: string,
|
|
123
131
|
organizationId: string,
|
|
124
132
|
) => {
|
|
125
|
-
|
|
133
|
+
// Normalize organization ID to lowercase to avoid casing mismatches
|
|
134
|
+
const normalizedOrgId = organizationId.toLowerCase();
|
|
135
|
+
const cacheKey = `${domain}:${normalizedOrgId}`;
|
|
126
136
|
|
|
127
137
|
// Check cache first
|
|
128
138
|
if (auth0OrgClientCache.has(cacheKey)) {
|
|
@@ -141,18 +151,28 @@ export const createAuth0ClientForOrg = (
|
|
|
141
151
|
const audience =
|
|
142
152
|
import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
|
|
143
153
|
|
|
154
|
+
const clientId = getClientIdFromStorage(domain);
|
|
155
|
+
console.log(
|
|
156
|
+
"[createAuth0ClientForOrg] Creating client for domain:",
|
|
157
|
+
domain,
|
|
158
|
+
"org:",
|
|
159
|
+
normalizedOrgId,
|
|
160
|
+
"with clientId:",
|
|
161
|
+
clientId,
|
|
162
|
+
);
|
|
163
|
+
|
|
144
164
|
const auth0Client = new Auth0Client({
|
|
145
165
|
domain: fullDomain,
|
|
146
|
-
clientId
|
|
166
|
+
clientId,
|
|
147
167
|
useRefreshTokens: false,
|
|
148
168
|
// Use organization-specific cache to isolate tokens
|
|
149
169
|
// Note: Don't use cacheLocation when providing a custom cache
|
|
150
|
-
cache: new OrgCache(
|
|
170
|
+
cache: new OrgCache(normalizedOrgId),
|
|
151
171
|
authorizationParams: {
|
|
152
172
|
audience,
|
|
153
173
|
redirect_uri: redirectUri,
|
|
154
|
-
scope: "openid profile email
|
|
155
|
-
organization:
|
|
174
|
+
scope: "openid profile email",
|
|
175
|
+
organization: normalizedOrgId,
|
|
156
176
|
},
|
|
157
177
|
});
|
|
158
178
|
|
|
@@ -167,7 +187,11 @@ export const createManagementClient = async (
|
|
|
167
187
|
tenantId?: string,
|
|
168
188
|
oauthDomain?: string,
|
|
169
189
|
): Promise<ManagementClient> => {
|
|
170
|
-
|
|
190
|
+
// Normalize tenant ID to lowercase to avoid casing mismatches
|
|
191
|
+
const normalizedTenantId = tenantId?.toLowerCase();
|
|
192
|
+
const cacheKey = normalizedTenantId
|
|
193
|
+
? `${apiDomain}:${normalizedTenantId}`
|
|
194
|
+
: apiDomain;
|
|
171
195
|
|
|
172
196
|
// Check cache first
|
|
173
197
|
if (managementClientCache.has(cacheKey)) {
|
|
@@ -193,18 +217,21 @@ export const createManagementClient = async (
|
|
|
193
217
|
const storedFlag = sessionStorage.getItem("isSingleTenant");
|
|
194
218
|
const isSingleTenant = storedFlag?.endsWith("|true") || storedFlag === "true";
|
|
195
219
|
|
|
196
|
-
if (
|
|
220
|
+
if (normalizedTenantId && !isSingleTenant) {
|
|
197
221
|
// When accessing tenant-specific resources in MULTI-TENANT mode, use org-scoped token
|
|
198
222
|
if (domainConfig.connectionMethod === "login") {
|
|
199
223
|
// For OAuth login, use organization-scoped client
|
|
200
|
-
const orgAuth0Client = createAuth0ClientForOrg(
|
|
224
|
+
const orgAuth0Client = createAuth0ClientForOrg(
|
|
225
|
+
domainForAuth,
|
|
226
|
+
normalizedTenantId,
|
|
227
|
+
);
|
|
201
228
|
const audience =
|
|
202
229
|
import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
|
|
203
230
|
try {
|
|
204
231
|
token = await orgAuth0Client.getTokenSilently({
|
|
205
232
|
authorizationParams: {
|
|
206
233
|
audience,
|
|
207
|
-
organization:
|
|
234
|
+
organization: normalizedTenantId,
|
|
208
235
|
},
|
|
209
236
|
});
|
|
210
237
|
} catch (error) {
|
|
@@ -216,7 +243,7 @@ export const createManagementClient = async (
|
|
|
216
243
|
// Redirect to login with organization
|
|
217
244
|
await orgAuth0Client.loginWithRedirect({
|
|
218
245
|
authorizationParams: {
|
|
219
|
-
organization:
|
|
246
|
+
organization: normalizedTenantId,
|
|
220
247
|
login_hint: user?.email,
|
|
221
248
|
},
|
|
222
249
|
appState: {
|
|
@@ -225,11 +252,13 @@ export const createManagementClient = async (
|
|
|
225
252
|
});
|
|
226
253
|
|
|
227
254
|
// This won't be reached as loginWithRedirect redirects the page
|
|
228
|
-
throw new Error(
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Redirecting to login for organization ${normalizedTenantId}`,
|
|
257
|
+
);
|
|
229
258
|
}
|
|
230
259
|
} else {
|
|
231
260
|
// For token/client_credentials, use getOrganizationToken
|
|
232
|
-
token = await getOrganizationToken(domainConfig,
|
|
261
|
+
token = await getOrganizationToken(domainConfig, normalizedTenantId);
|
|
233
262
|
}
|
|
234
263
|
} else {
|
|
235
264
|
// No tenantId - get non-org-scoped token for tenant management endpoints
|
|
@@ -244,7 +273,9 @@ export const createManagementClient = async (
|
|
|
244
273
|
const managementClient = new ManagementClient({
|
|
245
274
|
domain: apiDomain,
|
|
246
275
|
token,
|
|
247
|
-
headers:
|
|
276
|
+
headers: normalizedTenantId
|
|
277
|
+
? { "tenant-id": normalizedTenantId }
|
|
278
|
+
: undefined,
|
|
248
279
|
});
|
|
249
280
|
|
|
250
281
|
managementClientCache.set(cacheKey, managementClient);
|
|
@@ -688,8 +719,11 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
|
|
|
688
719
|
* @returns An HTTP client function that uses organization-scoped tokens
|
|
689
720
|
*/
|
|
690
721
|
export const createOrganizationHttpClient = (organizationId: string) => {
|
|
722
|
+
// Normalize organization ID to lowercase to avoid casing mismatches
|
|
723
|
+
const normalizedOrgId = organizationId.toLowerCase();
|
|
724
|
+
|
|
691
725
|
return (url: string, options: HttpOptions = {}) => {
|
|
692
|
-
const requestKey = `${
|
|
726
|
+
const requestKey = `${normalizedOrgId}:${url}-${JSON.stringify(options)}`;
|
|
693
727
|
|
|
694
728
|
// If there's already a pending request for this URL and options, return it
|
|
695
729
|
if (pendingRequests.has(requestKey)) {
|
|
@@ -728,7 +762,7 @@ export const createOrganizationHttpClient = (organizationId: string) => {
|
|
|
728
762
|
!activeSessions.has(formattedSelectedDomain)
|
|
729
763
|
) {
|
|
730
764
|
clearInterval(checkInterval);
|
|
731
|
-
createOrganizationHttpClient(
|
|
765
|
+
createOrganizationHttpClient(normalizedOrgId)(url, options)
|
|
732
766
|
.then(resolve)
|
|
733
767
|
.catch(reject);
|
|
734
768
|
}
|
|
@@ -768,7 +802,7 @@ export const createOrganizationHttpClient = (organizationId: string) => {
|
|
|
768
802
|
) {
|
|
769
803
|
// For token/client_credentials, use organization-scoped token
|
|
770
804
|
// This includes the org_id claim for accessing tenant-specific resources
|
|
771
|
-
request = getOrganizationToken(domainConfig,
|
|
805
|
+
request = getOrganizationToken(domainConfig, normalizedOrgId)
|
|
772
806
|
.catch((error) => {
|
|
773
807
|
throw new Error(
|
|
774
808
|
`Authentication failed: ${error.message}. Please configure your credentials in the domain selector.`,
|
|
@@ -838,7 +872,7 @@ export const createOrganizationHttpClient = (organizationId: string) => {
|
|
|
838
872
|
// For OAuth login, use an organization-specific client with isolated cache
|
|
839
873
|
const orgAuth0Client = createAuth0ClientForOrg(
|
|
840
874
|
selectedDomain,
|
|
841
|
-
|
|
875
|
+
normalizedOrgId,
|
|
842
876
|
);
|
|
843
877
|
|
|
844
878
|
// Use the management API audience for cross-tenant operations
|
|
@@ -851,7 +885,7 @@ export const createOrganizationHttpClient = (organizationId: string) => {
|
|
|
851
885
|
.getTokenSilently({
|
|
852
886
|
authorizationParams: {
|
|
853
887
|
audience,
|
|
854
|
-
organization:
|
|
888
|
+
organization: normalizedOrgId,
|
|
855
889
|
},
|
|
856
890
|
})
|
|
857
891
|
.catch(async (_error) => {
|
|
@@ -863,7 +897,7 @@ export const createOrganizationHttpClient = (organizationId: string) => {
|
|
|
863
897
|
// Redirect to login with organization
|
|
864
898
|
await orgAuth0Client.loginWithRedirect({
|
|
865
899
|
authorizationParams: {
|
|
866
|
-
organization:
|
|
900
|
+
organization: normalizedOrgId,
|
|
867
901
|
login_hint: user?.email,
|
|
868
902
|
},
|
|
869
903
|
appState: {
|
|
@@ -873,7 +907,7 @@ export const createOrganizationHttpClient = (organizationId: string) => {
|
|
|
873
907
|
|
|
874
908
|
// This won't be reached as loginWithRedirect redirects the page
|
|
875
909
|
throw new Error(
|
|
876
|
-
`Redirecting to login for organization ${
|
|
910
|
+
`Redirecting to login for organization ${normalizedOrgId}`,
|
|
877
911
|
);
|
|
878
912
|
})
|
|
879
913
|
.then((token) => {
|
|
@@ -89,9 +89,16 @@ export const FlowEdit = () => {
|
|
|
89
89
|
return (
|
|
90
90
|
<Edit
|
|
91
91
|
queryOptions={{
|
|
92
|
-
select: (
|
|
93
|
-
data
|
|
94
|
-
|
|
92
|
+
select: (response) => {
|
|
93
|
+
const data = response?.data as Record<string, unknown>;
|
|
94
|
+
if (!data) {
|
|
95
|
+
console.error("FlowEdit: No data in response", response);
|
|
96
|
+
return response;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
data: parseFlowData(data),
|
|
100
|
+
};
|
|
101
|
+
},
|
|
95
102
|
}}
|
|
96
103
|
transform={(data: Record<string, unknown>) => {
|
|
97
104
|
// Transform actions to include required fields
|
|
@@ -5,7 +5,7 @@ export function HookList() {
|
|
|
5
5
|
return (
|
|
6
6
|
<List actions={<PostListActions />}>
|
|
7
7
|
<Datagrid rowClick="edit" bulkActionButtons={false}>
|
|
8
|
-
<TextField source="
|
|
8
|
+
<TextField source="hook_id" />
|
|
9
9
|
<TextField source="trigger_id" />
|
|
10
10
|
<TextField source="url" />
|
|
11
11
|
<TextField source="form_id" label="Form" />
|
|
@@ -1119,6 +1119,189 @@ const UserRolesTable = ({
|
|
|
1119
1119
|
);
|
|
1120
1120
|
};
|
|
1121
1121
|
|
|
1122
|
+
// Add organization management: add user to organizations
|
|
1123
|
+
const AddOrganizationButton = () => {
|
|
1124
|
+
const [open, setOpen] = useState(false);
|
|
1125
|
+
const [availableOrganizations, setAvailableOrganizations] = useState<any[]>(
|
|
1126
|
+
[],
|
|
1127
|
+
);
|
|
1128
|
+
const [selectedOrganizations, setSelectedOrganizations] = useState<any[]>([]);
|
|
1129
|
+
const [loading, setLoading] = useState(false);
|
|
1130
|
+
const [searchText, setSearchText] = useState("");
|
|
1131
|
+
const dataProvider = useDataProvider();
|
|
1132
|
+
const notify = useNotify();
|
|
1133
|
+
const refresh = useRefresh();
|
|
1134
|
+
|
|
1135
|
+
const { id: userId } = useParams();
|
|
1136
|
+
|
|
1137
|
+
const handleOpen = async () => {
|
|
1138
|
+
setOpen(true);
|
|
1139
|
+
await loadAvailableOrganizations();
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const handleClose = () => {
|
|
1143
|
+
setOpen(false);
|
|
1144
|
+
setSelectedOrganizations([]);
|
|
1145
|
+
setAvailableOrganizations([]);
|
|
1146
|
+
setSearchText("");
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
const loadAvailableOrganizations = async () => {
|
|
1150
|
+
if (!userId) return;
|
|
1151
|
+
setLoading(true);
|
|
1152
|
+
try {
|
|
1153
|
+
// Load all organizations
|
|
1154
|
+
const allOrgsRes = await dataProvider.getList("organizations", {
|
|
1155
|
+
pagination: { page: 1, perPage: 1000 },
|
|
1156
|
+
sort: { field: "name", order: "ASC" },
|
|
1157
|
+
filter: {},
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
// Load organizations the user is already a member of
|
|
1161
|
+
const userOrgsRes = await dataProvider.getList("user-organizations", {
|
|
1162
|
+
pagination: { page: 1, perPage: 1000 },
|
|
1163
|
+
sort: { field: "name", order: "ASC" },
|
|
1164
|
+
filter: { user_id: userId },
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
const userOrgIds = new Set(userOrgsRes.data.map((org: any) => org.id));
|
|
1168
|
+
const available = allOrgsRes.data.filter(
|
|
1169
|
+
(org: any) => !userOrgIds.has(org.id),
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1172
|
+
setAvailableOrganizations(available);
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
console.error("Error loading organizations:", error);
|
|
1175
|
+
notify("Error loading organizations", { type: "error" });
|
|
1176
|
+
} finally {
|
|
1177
|
+
setLoading(false);
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const handleAddOrganizations = async () => {
|
|
1182
|
+
if (!userId || selectedOrganizations.length === 0) {
|
|
1183
|
+
notify("Please select at least one organization", { type: "warning" });
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
try {
|
|
1188
|
+
// Add user to each selected organization
|
|
1189
|
+
for (const org of selectedOrganizations) {
|
|
1190
|
+
await dataProvider.create("organization-members", {
|
|
1191
|
+
data: {
|
|
1192
|
+
organization_id: org.id,
|
|
1193
|
+
user_ids: [userId],
|
|
1194
|
+
},
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
notify(
|
|
1199
|
+
`Added user to ${selectedOrganizations.length} organization(s) successfully`,
|
|
1200
|
+
{ type: "success" },
|
|
1201
|
+
);
|
|
1202
|
+
handleClose();
|
|
1203
|
+
refresh();
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
console.error("Error adding user to organizations:", error);
|
|
1206
|
+
notify("Error adding user to organizations", { type: "error" });
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
if (!userId) return null;
|
|
1211
|
+
|
|
1212
|
+
const filteredOrganizations = availableOrganizations.filter(
|
|
1213
|
+
(org) =>
|
|
1214
|
+
!searchText ||
|
|
1215
|
+
org.name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
1216
|
+
org.display_name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
1217
|
+
org.id?.toLowerCase().includes(searchText.toLowerCase()),
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
return (
|
|
1221
|
+
<>
|
|
1222
|
+
<Button
|
|
1223
|
+
variant="contained"
|
|
1224
|
+
color="primary"
|
|
1225
|
+
startIcon={<AddIcon />}
|
|
1226
|
+
onClick={handleOpen}
|
|
1227
|
+
sx={{ mb: 2 }}
|
|
1228
|
+
>
|
|
1229
|
+
Add to Organization
|
|
1230
|
+
</Button>
|
|
1231
|
+
|
|
1232
|
+
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
|
1233
|
+
<DialogTitle>Add to Organizations</DialogTitle>
|
|
1234
|
+
<DialogContent>
|
|
1235
|
+
<Typography variant="body2" sx={{ mb: 3 }}>
|
|
1236
|
+
Select one or more organizations to add this user to
|
|
1237
|
+
</Typography>
|
|
1238
|
+
|
|
1239
|
+
<Autocomplete
|
|
1240
|
+
multiple
|
|
1241
|
+
options={filteredOrganizations}
|
|
1242
|
+
getOptionLabel={(option) =>
|
|
1243
|
+
option.display_name || option.name || option.id
|
|
1244
|
+
}
|
|
1245
|
+
value={selectedOrganizations}
|
|
1246
|
+
onChange={(_, value) => setSelectedOrganizations(value)}
|
|
1247
|
+
loading={loading}
|
|
1248
|
+
isOptionEqualToValue={(option, value) => option?.id === value?.id}
|
|
1249
|
+
onInputChange={(_, value) => setSearchText(value)}
|
|
1250
|
+
renderInput={(params) => (
|
|
1251
|
+
<MuiTextField
|
|
1252
|
+
{...params}
|
|
1253
|
+
label="Organizations"
|
|
1254
|
+
variant="outlined"
|
|
1255
|
+
fullWidth
|
|
1256
|
+
InputProps={{
|
|
1257
|
+
...params.InputProps,
|
|
1258
|
+
endAdornment: (
|
|
1259
|
+
<>
|
|
1260
|
+
{loading ? (
|
|
1261
|
+
<CircularProgress color="inherit" size={20} />
|
|
1262
|
+
) : null}
|
|
1263
|
+
{params.InputProps.endAdornment}
|
|
1264
|
+
</>
|
|
1265
|
+
),
|
|
1266
|
+
}}
|
|
1267
|
+
/>
|
|
1268
|
+
)}
|
|
1269
|
+
renderOption={(props, option) => (
|
|
1270
|
+
<li {...props} key={option.id}>
|
|
1271
|
+
<Box>
|
|
1272
|
+
<Typography variant="body2" fontWeight="medium">
|
|
1273
|
+
{option.display_name || option.name || option.id}
|
|
1274
|
+
</Typography>
|
|
1275
|
+
<Typography variant="caption" color="text.secondary">
|
|
1276
|
+
ID: {option.id}
|
|
1277
|
+
</Typography>
|
|
1278
|
+
</Box>
|
|
1279
|
+
</li>
|
|
1280
|
+
)}
|
|
1281
|
+
/>
|
|
1282
|
+
|
|
1283
|
+
{selectedOrganizations.length > 0 && (
|
|
1284
|
+
<Typography variant="caption" sx={{ mt: 2, display: "block" }}>
|
|
1285
|
+
{selectedOrganizations.length} organization(s) selected
|
|
1286
|
+
</Typography>
|
|
1287
|
+
)}
|
|
1288
|
+
</DialogContent>
|
|
1289
|
+
<DialogActions>
|
|
1290
|
+
<Button onClick={handleClose}>Cancel</Button>
|
|
1291
|
+
<Button
|
|
1292
|
+
onClick={handleAddOrganizations}
|
|
1293
|
+
variant="contained"
|
|
1294
|
+
color="primary"
|
|
1295
|
+
disabled={selectedOrganizations.length === 0 || loading}
|
|
1296
|
+
>
|
|
1297
|
+
Add to Organizations
|
|
1298
|
+
</Button>
|
|
1299
|
+
</DialogActions>
|
|
1300
|
+
</Dialog>
|
|
1301
|
+
</>
|
|
1302
|
+
);
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1122
1305
|
// Add roles management: add roles and remove roles for a user
|
|
1123
1306
|
const AddRoleButton = ({ onRolesChanged }: { onRolesChanged?: () => void }) => {
|
|
1124
1307
|
const [open, setOpen] = useState(false);
|
|
@@ -1672,6 +1855,7 @@ export function UserEdit() {
|
|
|
1672
1855
|
<RolesManagement />
|
|
1673
1856
|
</TabbedForm.Tab>
|
|
1674
1857
|
<TabbedForm.Tab label="organizations">
|
|
1858
|
+
<AddOrganizationButton />
|
|
1675
1859
|
<ReferenceManyField
|
|
1676
1860
|
reference="user-organizations"
|
|
1677
1861
|
target="user_id"
|
package/src/utils/domainUtils.ts
CHANGED
|
@@ -22,46 +22,98 @@ export interface DomainConfig {
|
|
|
22
22
|
clientSecret?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Formats a domain string (removes protocol if present)
|
|
27
|
+
*/
|
|
28
|
+
export const formatDomain = (domain: string): string => {
|
|
29
|
+
return domain.trim().replace(/^https?:\/\//, "");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gets default domain configuration from environment variables
|
|
34
|
+
*/
|
|
35
|
+
const getDefaultDomainFromEnv = (): DomainConfig | null => {
|
|
36
|
+
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
|
|
37
|
+
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
|
|
38
|
+
const apiUrl = import.meta.env.VITE_AUTH0_API_URL;
|
|
39
|
+
|
|
40
|
+
if (!domain) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
url: formatDomain(domain),
|
|
46
|
+
connectionMethod: "login",
|
|
47
|
+
clientId: clientId || undefined,
|
|
48
|
+
restApiUrl: apiUrl || undefined,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
25
52
|
/**
|
|
26
53
|
* Gets domains from localStorage
|
|
27
54
|
* Handles both formats (array of objects or array of strings) for backward compatibility
|
|
55
|
+
* Includes default domain from environment variables if not already in storage
|
|
28
56
|
*/
|
|
29
57
|
export const getDomainFromStorage = (): DomainConfig[] => {
|
|
30
58
|
try {
|
|
31
59
|
const storedValue = localStorage.getItem(DOMAINS_STORAGE_KEY);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
let domains: DomainConfig[] = [];
|
|
61
|
+
|
|
62
|
+
if (storedValue) {
|
|
63
|
+
const parsedData = JSON.parse(storedValue);
|
|
64
|
+
|
|
65
|
+
// Handle both formats: array of objects with url property or array of strings
|
|
66
|
+
if (Array.isArray(parsedData)) {
|
|
67
|
+
domains = parsedData
|
|
68
|
+
.filter((item) => item !== null && item !== undefined)
|
|
69
|
+
.map((item) => {
|
|
70
|
+
if (typeof item === "object" && item !== null && "url" in item) {
|
|
71
|
+
// Add connectionMethod if it doesn't exist (for backward compatibility)
|
|
72
|
+
if (!("connectionMethod" in item)) {
|
|
73
|
+
return {
|
|
74
|
+
...item,
|
|
75
|
+
connectionMethod: "login" as ConnectionMethod, // Assume login for existing entries
|
|
76
|
+
} as DomainConfig;
|
|
77
|
+
}
|
|
78
|
+
return item as DomainConfig;
|
|
79
|
+
} else {
|
|
80
|
+
// Convert string domains to DomainConfig format (backward compatibility)
|
|
44
81
|
return {
|
|
45
|
-
|
|
46
|
-
connectionMethod: "login" as ConnectionMethod,
|
|
47
|
-
|
|
82
|
+
url: String(item),
|
|
83
|
+
connectionMethod: "login" as ConnectionMethod,
|
|
84
|
+
clientId: "", // Empty clientId for legacy entries
|
|
85
|
+
};
|
|
48
86
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
url: String(item),
|
|
54
|
-
connectionMethod: "login" as ConnectionMethod,
|
|
55
|
-
clientId: "", // Empty clientId for legacy entries
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
|
-
.filter((domain) => domain.url.trim() !== ""); // Remove empty domains
|
|
87
|
+
})
|
|
88
|
+
.filter((domain) => domain.url.trim() !== ""); // Remove empty domains
|
|
89
|
+
}
|
|
60
90
|
}
|
|
61
|
-
|
|
91
|
+
|
|
92
|
+
// Add or update default domain from environment if configured
|
|
93
|
+
const defaultDomain = getDefaultDomainFromEnv();
|
|
94
|
+
if (defaultDomain) {
|
|
95
|
+
const existingIndex = domains.findIndex(
|
|
96
|
+
(d) => formatDomain(d.url) === defaultDomain.url,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (existingIndex >= 0) {
|
|
100
|
+
// Update existing domain with environment config (env takes precedence)
|
|
101
|
+
domains[existingIndex] = {
|
|
102
|
+
...domains[existingIndex],
|
|
103
|
+
...defaultDomain,
|
|
104
|
+
};
|
|
105
|
+
} else {
|
|
106
|
+
// Add new domain at the beginning
|
|
107
|
+
domains.unshift(defaultDomain);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return domains;
|
|
62
112
|
} catch (e) {
|
|
63
113
|
console.error("Failed to parse domains from localStorage", e);
|
|
64
|
-
|
|
114
|
+
// Return default domain from env if storage fails
|
|
115
|
+
const defaultDomain = getDefaultDomainFromEnv();
|
|
116
|
+
return defaultDomain ? [defaultDomain] : [];
|
|
65
117
|
}
|
|
66
118
|
};
|
|
67
119
|
|
|
@@ -70,7 +122,6 @@ export const getDomainFromStorage = (): DomainConfig[] => {
|
|
|
70
122
|
*/
|
|
71
123
|
export const saveDomainToStorage = (domains: DomainConfig[]): void => {
|
|
72
124
|
try {
|
|
73
|
-
console.log("Saving domains to localStorage:", domains);
|
|
74
125
|
localStorage.setItem(DOMAINS_STORAGE_KEY, JSON.stringify(domains));
|
|
75
126
|
} catch (e) {
|
|
76
127
|
console.error("Failed to save domains to localStorage", e);
|
|
@@ -100,7 +151,6 @@ export const getSelectedDomainFromStorage = (): string => {
|
|
|
100
151
|
*/
|
|
101
152
|
export const saveSelectedDomainToStorage = (domain: string): void => {
|
|
102
153
|
try {
|
|
103
|
-
console.log("Saving selected domain to localStorage:", domain);
|
|
104
154
|
localStorage.setItem(SELECTED_DOMAIN_STORAGE_KEY, domain);
|
|
105
155
|
} catch (e) {
|
|
106
156
|
console.error("Failed to save selected domain to localStorage", e);
|
|
@@ -129,16 +179,8 @@ export const getClientIdFromStorage = (domain: string): string => {
|
|
|
129
179
|
return fallbackClientId;
|
|
130
180
|
};
|
|
131
181
|
|
|
132
|
-
/**
|
|
133
|
-
* Formats a domain string (removes protocol if present)
|
|
134
|
-
*/
|
|
135
|
-
export const formatDomain = (domain: string): string => {
|
|
136
|
-
return domain.trim().replace(/^https?:\/\//, "");
|
|
137
|
-
};
|
|
138
|
-
|
|
139
182
|
/**
|
|
140
183
|
* Constructs a full URL with HTTPS protocol
|
|
141
|
-
* - If domain starts with "local.", connects to https://localhost:3000
|
|
142
184
|
* - Always uses https:// for all domains (including localhost with self-signed certs)
|
|
143
185
|
* - Preserves existing https:// protocol if already present
|
|
144
186
|
* - Converts http:// to https://
|
|
@@ -146,14 +188,6 @@ export const formatDomain = (domain: string): string => {
|
|
|
146
188
|
export const buildUrlWithProtocol = (domain: string): string => {
|
|
147
189
|
const trimmedDomain = domain.trim();
|
|
148
190
|
|
|
149
|
-
// Extract hostname without protocol for local. check
|
|
150
|
-
const hostnameOnly = trimmedDomain.replace(/^https?:\/\//, "");
|
|
151
|
-
|
|
152
|
-
// If hostname starts with "local.", redirect to local development server
|
|
153
|
-
if (hostnameOnly.startsWith("local.")) {
|
|
154
|
-
return "https://localhost:3000";
|
|
155
|
-
}
|
|
156
|
-
|
|
157
191
|
// Check if it already has a protocol
|
|
158
192
|
if (trimmedDomain.startsWith("https://")) {
|
|
159
193
|
return trimmedDomain;
|
package/src/utils/tokenUtils.ts
CHANGED
|
@@ -18,7 +18,8 @@ export class OrgCache implements ICache {
|
|
|
18
18
|
private orgId: string;
|
|
19
19
|
|
|
20
20
|
constructor(orgId: string) {
|
|
21
|
-
|
|
21
|
+
// Normalize organization ID to lowercase to avoid casing mismatches
|
|
22
|
+
this.orgId = orgId.toLowerCase();
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
public set<T = Cacheable>(key: string, entry: T) {
|
|
@@ -77,8 +78,9 @@ async function fetchTokenWithClientCredentials(
|
|
|
77
78
|
};
|
|
78
79
|
|
|
79
80
|
// Add organization if specified - this will include org_id in the token
|
|
81
|
+
// Normalize to lowercase to avoid casing mismatches
|
|
80
82
|
if (organizationId) {
|
|
81
|
-
body.organization = organizationId;
|
|
83
|
+
body.organization = organizationId.toLowerCase();
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
const response = await fetch(`https://proxy.authhe.ro/oauth/token`, {
|