@authhero/react-admin 0.13.0 → 0.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aaf0aa0: Fix paging issue for scopes
8
+
9
+ ## 0.14.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 63f9c89: Remove requirement for password users to have verified emails
14
+ - 63f9c89: Fix the listing of logs for a user
15
+
3
16
  ## 0.13.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,7 @@
26
26
  "react-admin": "^5.8.0",
27
27
  "react-admin-color-picker": "^1.0.3",
28
28
  "react-dom": "^19.1.0",
29
+ "react-hook-form": "^7.69.0",
29
30
  "react-router-dom": "^7.6.0",
30
31
  "recharts": "^2.15.0"
31
32
  },
@@ -205,29 +205,7 @@ export default (
205
205
  resourceKey: "organizations",
206
206
  idKey: "id",
207
207
  },
208
- logs: {
209
- fetch: (client) => {
210
- // Build the query string, combining search query and IP filter
211
- let query = params.filter?.q || "";
212
- if (params.filter?.ip) {
213
- const ipQuery = `ip:${params.filter.ip}`;
214
- query = query ? `${query} AND ${ipQuery}` : ipQuery;
215
- }
216
-
217
- return client.logs.list({
218
- page: page - 1,
219
- per_page: perPage,
220
- q: query || undefined,
221
- sort:
222
- field && order
223
- ? `${field}:${order === "DESC" ? "-1" : "1"}`
224
- : undefined,
225
- include_totals: true,
226
- });
227
- },
228
- resourceKey: "logs",
229
- idKey: "log_id",
230
- },
208
+ // Logs removed from SDK handlers - using HTTP directly for full control
231
209
  rules: {
232
210
  fetch: (client) => client.rules.list(),
233
211
  resourceKey: "rules",
@@ -257,9 +235,9 @@ export default (
257
235
  },
258
236
  };
259
237
 
260
- // Handle SDK resources
238
+ // Handle SDK resources (only for top-level resources, not nested paths like users/{id}/roles)
261
239
  const handler = sdkHandlers[resource];
262
- if (handler) {
240
+ if (handler && !resourcePath.includes("/")) {
263
241
  const result = await handler.fetch(managementClient);
264
242
  const { data, total } = normalizeSDKResponse(
265
243
  result,
@@ -285,6 +263,35 @@ export default (
285
263
  );
286
264
  }
287
265
 
266
+ // Handle logs with direct HTTP for full control over query params
267
+ if (resource === "logs") {
268
+ const headers = createHeaders(tenantId);
269
+ const query: any = {
270
+ include_totals: true,
271
+ page: page - 1,
272
+ per_page: perPage,
273
+ sort:
274
+ field && order
275
+ ? `${field}:${order === "DESC" ? "-1" : "1"}`
276
+ : undefined,
277
+ ...params.filter, // Pass all filter params directly (q, from, etc.)
278
+ };
279
+ const url = `${apiUrl}/api/v2/logs?${stringify(query)}`;
280
+
281
+ const res = await httpClient(url, { headers });
282
+ const response = res.json;
283
+ const logsData = response.logs || response || [];
284
+ const logsArray = Array.isArray(logsData) ? logsData : [];
285
+
286
+ return {
287
+ data: logsArray.map((log: any) => ({
288
+ id: log.log_id || log.id,
289
+ ...log,
290
+ })),
291
+ total: response.total || logsArray.length,
292
+ };
293
+ }
294
+
288
295
  // Handle stats/daily endpoint
289
296
  if (resourcePath === "stats/daily") {
290
297
  const headers = createHeaders(tenantId);
@@ -704,9 +711,10 @@ export default (
704
711
  };
705
712
  }
706
713
 
707
- // Logs filtered by user_id
714
+ // Logs filtered by user_id - use direct HTTP for full control
708
715
  if (resource === "logs" && params.target === "user_id") {
709
- const result = await managementClient.logs.list({
716
+ const headers = createHeaders(tenantId);
717
+ const query = {
710
718
  page: page - 1,
711
719
  per_page: perPage,
712
720
  q: `user_id:${params.id}`,
@@ -715,12 +723,21 @@ export default (
715
723
  ? `${field}:${order === "DESC" ? "-1" : "1"}`
716
724
  : undefined,
717
725
  include_totals: true,
718
- });
726
+ ...params.filter, // Allow additional filters to be passed through
727
+ };
728
+ const url = `${apiUrl}/api/v2/logs?${stringify(query)}`;
729
+
730
+ const res = await httpClient(url, { headers });
731
+ const response = res.json;
732
+ const logsData = response.logs || response || [];
733
+ const logsArray = Array.isArray(logsData) ? logsData : [];
719
734
 
720
- const normalized = normalizeSDKResponse(result, "logs");
721
735
  return {
722
- data: normalized.data.map((log: any) => ({ id: log.log_id, ...log })),
723
- total: normalized.total,
736
+ data: logsArray.map((log: any) => ({
737
+ id: log.log_id || log.id,
738
+ ...log,
739
+ })),
740
+ total: response.total || logsArray.length,
724
741
  };
725
742
  }
726
743
 
@@ -196,12 +196,35 @@ export const createManagementClient = async (
196
196
  const orgAuth0Client = createAuth0ClientForOrg(domainForAuth, tenantId);
197
197
  const audience =
198
198
  import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
199
- token = await orgAuth0Client.getTokenSilently({
200
- authorizationParams: {
201
- audience,
202
- organization: tenantId,
203
- },
204
- });
199
+ try {
200
+ token = await orgAuth0Client.getTokenSilently({
201
+ authorizationParams: {
202
+ audience,
203
+ organization: tenantId,
204
+ },
205
+ });
206
+ } catch (error) {
207
+ // If silent token acquisition fails, redirect to login with org
208
+ // Get the base auth0 client to get the user's email for login hint
209
+ const baseClient = createAuth0Client(domainForAuth);
210
+ const user = await baseClient.getUser().catch(() => null);
211
+
212
+ // Redirect to login with organization
213
+ await orgAuth0Client.loginWithRedirect({
214
+ authorizationParams: {
215
+ organization: tenantId,
216
+ login_hint: user?.email,
217
+ },
218
+ appState: {
219
+ returnTo: window.location.pathname,
220
+ },
221
+ });
222
+
223
+ // This won't be reached as loginWithRedirect redirects the page
224
+ throw new Error(
225
+ `Redirecting to login for organization ${tenantId}`,
226
+ );
227
+ }
205
228
  } else {
206
229
  // For token/client_credentials, use getOrganizationToken
207
230
  token = await getOrganizationToken(domainConfig, tenantId);
@@ -829,7 +852,7 @@ export const createOrganizationHttpClient = (organizationId: string) => {
829
852
  organization: organizationId,
830
853
  },
831
854
  })
832
- .catch(async (error) => {
855
+ .catch(async (_error) => {
833
856
  // If silent token acquisition fails, we need to redirect to login with org
834
857
  // Get the base auth0 client to get the user's email for login hint
835
858
  const baseClient = createAuth0Client(selectedDomain);
@@ -55,7 +55,7 @@ import EditIcon from "@mui/icons-material/Edit";
55
55
  import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
56
56
  import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
57
57
  import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
58
- import { authorizedHttpClient } from "../../authProvider";
58
+ import { createOrganizationHttpClient } from "../../authProvider";
59
59
  import {
60
60
  getDomainFromStorage,
61
61
  getSelectedDomainFromStorage,
@@ -864,8 +864,10 @@ const ConnectionsTab = () => {
864
864
  setLoading(true);
865
865
  try {
866
866
  // Fetch enabled connections from the new API endpoint
867
+ // Use organization-scoped HTTP client to ensure proper org_id in token
867
868
  const baseUrl = getApiBaseUrl();
868
- const response = await authorizedHttpClient(
869
+ const orgHttpClient = createOrganizationHttpClient(tenantId);
870
+ const response = await orgHttpClient(
869
871
  `${baseUrl}/api/v2/clients/${clientId}/connections`,
870
872
  {
871
873
  method: "GET",
@@ -926,8 +928,10 @@ const ConnectionsTab = () => {
926
928
 
927
929
  setSaving(true);
928
930
  try {
931
+ // Use organization-scoped HTTP client to ensure proper org_id in token
929
932
  const baseUrl = getApiBaseUrl();
930
- await authorizedHttpClient(
933
+ const orgHttpClient = createOrganizationHttpClient(tenantId);
934
+ await orgHttpClient(
931
935
  `${baseUrl}/api/v2/clients/${clientId}/connections`,
932
936
  {
933
937
  method: "PATCH",
@@ -2,8 +2,6 @@ import {
2
2
  Edit,
3
3
  TextInput,
4
4
  BooleanInput,
5
- ArrayInput,
6
- SimpleFormIterator,
7
5
  TextField,
8
6
  TabbedForm,
9
7
  required,
@@ -11,7 +9,11 @@ import {
11
9
  FormDataConsumer,
12
10
  useRecordContext,
13
11
  } from "react-admin";
14
- import { Stack, Alert } from "@mui/material";
12
+ import { Stack, Alert, Pagination, Box, Typography, Button, IconButton, TextField as MuiTextField } from "@mui/material";
13
+ import DeleteIcon from "@mui/icons-material/Delete";
14
+ import AddIcon from "@mui/icons-material/Add";
15
+ import { useState, useMemo, useCallback } from "react";
16
+ import { useFormContext, useWatch } from "react-hook-form";
15
17
 
16
18
  function SystemEntityAlert() {
17
19
  const record = useRecordContext();
@@ -26,6 +28,131 @@ function SystemEntityAlert() {
26
28
  );
27
29
  }
28
30
 
31
+ const SCOPES_PER_PAGE = 10;
32
+
33
+ interface Scope {
34
+ value: string;
35
+ description?: string;
36
+ }
37
+
38
+ function PaginatedScopesInput({ disabled }: { disabled?: boolean }) {
39
+ const { setValue, getValues } = useFormContext();
40
+ const scopes: Scope[] = useWatch({ name: "scopes" }) || [];
41
+ const [page, setPage] = useState(1);
42
+
43
+ const totalPages = Math.max(1, Math.ceil(scopes.length / SCOPES_PER_PAGE));
44
+
45
+ const paginatedScopes = useMemo(() => {
46
+ const start = (page - 1) * SCOPES_PER_PAGE;
47
+ const end = start + SCOPES_PER_PAGE;
48
+ return scopes.slice(start, end).map((scope, index) => ({
49
+ ...scope,
50
+ actualIndex: start + index,
51
+ }));
52
+ }, [scopes, page]);
53
+
54
+ const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
55
+ setPage(value);
56
+ };
57
+
58
+ const handleAdd = useCallback(() => {
59
+ const currentScopes = getValues("scopes") || [];
60
+ setValue("scopes", [...currentScopes, { value: "", description: "" }], { shouldDirty: true });
61
+ // Navigate to the last page where the new item will be
62
+ const newTotalPages = Math.ceil((currentScopes.length + 1) / SCOPES_PER_PAGE);
63
+ setPage(newTotalPages);
64
+ }, [getValues, setValue]);
65
+
66
+ const handleRemove = useCallback((actualIndex: number) => {
67
+ const currentScopes = getValues("scopes") || [];
68
+ const newScopes = currentScopes.filter((_: Scope, i: number) => i !== actualIndex);
69
+ setValue("scopes", newScopes, { shouldDirty: true });
70
+ // Adjust page if we removed the last item on current page
71
+ const newTotalPages = Math.ceil(newScopes.length / SCOPES_PER_PAGE);
72
+ if (page > newTotalPages && newTotalPages > 0) {
73
+ setPage(newTotalPages);
74
+ }
75
+ }, [getValues, setValue, page]);
76
+
77
+ const handleScopeChange = useCallback((actualIndex: number, field: "value" | "description", newValue: string) => {
78
+ const currentScopes = getValues("scopes") || [];
79
+ const newScopes = [...currentScopes];
80
+ newScopes[actualIndex] = { ...newScopes[actualIndex], [field]: newValue };
81
+ setValue("scopes", newScopes, { shouldDirty: true });
82
+ }, [getValues, setValue]);
83
+
84
+ return (
85
+ <Box>
86
+ <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
87
+ <Typography variant="body2" color="text.secondary">
88
+ {scopes.length} scope{scopes.length !== 1 ? "s" : ""} total
89
+ </Typography>
90
+ {!disabled && (
91
+ <Button
92
+ startIcon={<AddIcon />}
93
+ onClick={handleAdd}
94
+ size="small"
95
+ variant="outlined"
96
+ >
97
+ Add Scope
98
+ </Button>
99
+ )}
100
+ </Box>
101
+
102
+ {paginatedScopes.map((scope) => (
103
+ <Stack
104
+ key={scope.actualIndex}
105
+ spacing={2}
106
+ direction="row"
107
+ sx={{ width: "100%", alignItems: "flex-start", mb: 2 }}
108
+ >
109
+ <MuiTextField
110
+ value={scope.value || ""}
111
+ onChange={(e) => handleScopeChange(scope.actualIndex, "value", e.target.value)}
112
+ label="Scope Name"
113
+ helperText="e.g., read:users, write:posts"
114
+ sx={{ flex: 1 }}
115
+ disabled={disabled}
116
+ size="small"
117
+ required
118
+ />
119
+ <MuiTextField
120
+ value={scope.description || ""}
121
+ onChange={(e) => handleScopeChange(scope.actualIndex, "description", e.target.value)}
122
+ label="Description"
123
+ helperText="What this scope allows"
124
+ sx={{ flex: 2 }}
125
+ disabled={disabled}
126
+ size="small"
127
+ />
128
+ {!disabled && (
129
+ <IconButton
130
+ onClick={() => handleRemove(scope.actualIndex)}
131
+ color="error"
132
+ sx={{ mt: 1 }}
133
+ >
134
+ <DeleteIcon />
135
+ </IconButton>
136
+ )}
137
+ </Stack>
138
+ ))}
139
+
140
+ {totalPages > 1 && (
141
+ <Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
142
+ <Pagination
143
+ count={totalPages}
144
+ page={page}
145
+ onChange={handlePageChange}
146
+ color="primary"
147
+ showFirstButton
148
+ showLastButton
149
+ />
150
+ </Box>
151
+ )}
152
+ </Box>
153
+ );
154
+ }
155
+
29
156
  function ResourceServerForm() {
30
157
  const record = useRecordContext();
31
158
  const isSystem = record?.is_system;
@@ -115,31 +242,7 @@ function ResourceServerForm() {
115
242
  </TabbedForm.Tab>
116
243
 
117
244
  <TabbedForm.Tab label="Scopes">
118
- <ArrayInput source="scopes" label="" disabled={isSystem}>
119
- <SimpleFormIterator disableAdd={isSystem} disableRemove={isSystem} disableReordering={isSystem}>
120
- <Stack
121
- spacing={2}
122
- direction="row"
123
- sx={{ width: "100%", alignItems: "flex-start" }}
124
- >
125
- <TextInput
126
- source="value"
127
- validate={[required()]}
128
- label="Scope Name"
129
- helperText="e.g., read:users, write:posts"
130
- sx={{ flex: 1 }}
131
- disabled={isSystem}
132
- />
133
- <TextInput
134
- source="description"
135
- label="Description"
136
- helperText="What this scope allows"
137
- sx={{ flex: 2 }}
138
- disabled={isSystem}
139
- />
140
- </Stack>
141
- </SimpleFormIterator>
142
- </ArrayInput>
245
+ <PaginatedScopesInput disabled={isSystem} />
143
246
  </TabbedForm.Tab>
144
247
  </TabbedForm>
145
248
  );
@@ -6,6 +6,7 @@ import {
6
6
  SelectInput,
7
7
  useGetList,
8
8
  FormDataConsumer,
9
+ BooleanInput,
9
10
  } from "react-admin";
10
11
  import { useState } from "react";
11
12
 
@@ -101,6 +102,11 @@ export function UserCreate() {
101
102
  validate={[required()]}
102
103
  helperText="Password for the user account"
103
104
  />
105
+ <BooleanInput
106
+ source="email_verified"
107
+ label="Email Verified"
108
+ defaultValue={false}
109
+ />
104
110
  </>
105
111
  );
106
112
  }
@@ -12,6 +12,7 @@ import {
12
12
  TextInput,
13
13
  FunctionField,
14
14
  BooleanField,
15
+ BooleanInput,
15
16
  ArrayField,
16
17
  SimpleShowLayout,
17
18
  useNotify,
@@ -1444,6 +1445,7 @@ export function UserEdit() {
1444
1445
  >
1445
1446
  <TextField source="connection" />
1446
1447
  </Labeled>
1448
+ <BooleanInput source="email_verified" label="Email Verified" />
1447
1449
  </Stack>
1448
1450
  <TextInput source="picture" />
1449
1451
  <ArrayField source="identities">