@authhero/react-admin 0.21.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,17 @@
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
+
3
15
  ## 0.21.0
4
16
 
5
17
  ### 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.21.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
 
@@ -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
 
@@ -143,9 +151,19 @@ export const createAuth0ClientForOrg = (
143
151
  const audience =
144
152
  import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
145
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
+
146
164
  const auth0Client = new Auth0Client({
147
165
  domain: fullDomain,
148
- clientId: getClientIdFromStorage(domain),
166
+ clientId,
149
167
  useRefreshTokens: false,
150
168
  // Use organization-specific cache to isolate tokens
151
169
  // Note: Don't use cacheLocation when providing a custom cache
@@ -153,7 +171,7 @@ export const createAuth0ClientForOrg = (
153
171
  authorizationParams: {
154
172
  audience,
155
173
  redirect_uri: redirectUri,
156
- scope: "openid profile email auth:read auth:write",
174
+ scope: "openid profile email",
157
175
  organization: normalizedOrgId,
158
176
  },
159
177
  });
@@ -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;