@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "packageManager": "pnpm@10.20.0",
5
5
  "private": false,
6
6
  "repository": {
@@ -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.custom_domain_id || item.id,
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.custom_domain_id || item.id,
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: result[handler.idKey] || result.id,
393
- ...result,
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
- json.id = json[`${resource}_id`];
930
- delete json[`${resource}_id`];
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: res.json.id,
1152
+ id:
1153
+ res.json[`${singularResource}_id`] ||
1154
+ res.json[`${resource}_id`] ||
1155
+ res.json.id,
1080
1156
  },
1081
1157
  };
1082
1158
  },
@@ -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: getClientIdFromStorage(domain),
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 auth:read auth:write",
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
- const cacheKey = `${domain}:${organizationId}`;
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: getClientIdFromStorage(domain),
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(organizationId),
170
+ cache: new OrgCache(normalizedOrgId),
151
171
  authorizationParams: {
152
172
  audience,
153
173
  redirect_uri: redirectUri,
154
- scope: "openid profile email auth:read auth:write",
155
- organization: organizationId,
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
- const cacheKey = tenantId ? `${apiDomain}:${tenantId}` : apiDomain;
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 (tenantId && !isSingleTenant) {
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(domainForAuth, tenantId);
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: tenantId,
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: tenantId,
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(`Redirecting to login for organization ${tenantId}`);
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, tenantId);
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: tenantId ? { "tenant-id": tenantId } : undefined,
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 = `${organizationId}:${url}-${JSON.stringify(options)}`;
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(organizationId)(url, options)
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, organizationId)
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
- organizationId,
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: organizationId,
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: organizationId,
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 ${organizationId}`,
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: (data) => ({
93
- data: parseFlowData(data.data as Record<string, unknown>),
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="id" />
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"
@@ -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
- if (!storedValue) return [];
33
-
34
- const parsedData = JSON.parse(storedValue);
35
-
36
- // Handle both formats: array of objects with url property or array of strings
37
- if (Array.isArray(parsedData)) {
38
- return parsedData
39
- .filter((item) => item !== null && item !== undefined)
40
- .map((item) => {
41
- if (typeof item === "object" && item !== null && "url" in item) {
42
- // Add connectionMethod if it doesn't exist (for backward compatibility)
43
- if (!("connectionMethod" in item)) {
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
- ...item,
46
- connectionMethod: "login" as ConnectionMethod, // Assume login for existing entries
47
- } as DomainConfig;
82
+ url: String(item),
83
+ connectionMethod: "login" as ConnectionMethod,
84
+ clientId: "", // Empty clientId for legacy entries
85
+ };
48
86
  }
49
- return item as DomainConfig;
50
- } else {
51
- // Convert string domains to DomainConfig format (backward compatibility)
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
- return [];
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
- return [];
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;
@@ -18,7 +18,8 @@ export class OrgCache implements ICache {
18
18
  private orgId: string;
19
19
 
20
20
  constructor(orgId: string) {
21
- this.orgId = orgId;
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`, {