@authhero/react-admin 0.28.0 → 0.29.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.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - de7cb56: Use https for local dev
8
+ - 154993d: Improve react-admin experience by clearing caches and setting cores
9
+
10
+ ### Patch Changes
11
+
12
+ - Updated dependencies [154993d]
13
+ - @authhero/adapter-interfaces@0.126.0
14
+ - @authhero/widget@0.8.3
15
+
3
16
  ## 0.28.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.28.0",
3
+ "version": "0.29.0",
4
4
  "packageManager": "pnpm@10.20.0",
5
5
  "private": false,
6
6
  "repository": {
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "@auth0/auth0-spa-js": "^2.1.3",
12
12
  "@authhero/adapter-interfaces": "^0.116.0",
13
- "@authhero/widget": "^0.4.1",
13
+ "@authhero/widget": "^0.8.1",
14
14
  "@mui/icons-material": "^7.1.0",
15
15
  "@mui/material": "^7.1.0",
16
16
  "@tiptap/extension-link": "^3.13.0",
@@ -40,6 +40,7 @@
40
40
  "@types/react-dom": "^19.1.3",
41
41
  "@typescript-eslint/eslint-plugin": "^8.32.0",
42
42
  "@typescript-eslint/parser": "^8.32.0",
43
+ "@vitejs/plugin-basic-ssl": "^2.1.4",
43
44
  "@vitejs/plugin-react": "^4.4.1",
44
45
  "eslint": "^9.26.0",
45
46
  "eslint-config-prettier": "^10.1.5",
@@ -225,8 +225,10 @@ export const createManagementClient = async (
225
225
  domainForAuth,
226
226
  normalizedTenantId,
227
227
  );
228
+
228
229
  const audience =
229
230
  import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
231
+
230
232
  try {
231
233
  token = await orgAuth0Client.getTokenSilently({
232
234
  authorizationParams: {
@@ -2,7 +2,7 @@ import { AppBar, TitlePortal } from "react-admin";
2
2
  import { useEffect, useState, useMemo } from "react";
3
3
  import { Link, Box } from "@mui/material";
4
4
  import { getDataprovider } from "../dataProvider";
5
- import { getDomainFromStorage } from "../utils/domainUtils";
5
+ import { getSelectedDomainFromStorage } from "../utils/domainUtils";
6
6
 
7
7
  type TenantResponse = {
8
8
  audience: string;
@@ -31,9 +31,8 @@ export function TenantAppBar(props: TenantAppBarProps) {
31
31
 
32
32
  // Get the selected domain from storage or environment
33
33
  const selectedDomain = useMemo(() => {
34
- const domains = getDomainFromStorage();
35
- const selected = domains.find((d) => d.isSelected);
36
- return selected?.url || import.meta.env.VITE_AUTH0_DOMAIN || "";
34
+ const selected = getSelectedDomainFromStorage();
35
+ return selected || import.meta.env.VITE_AUTH0_DOMAIN || "";
37
36
  }, []);
38
37
 
39
38
  // Use the non-org data provider for fetching tenants list
@@ -10,9 +10,11 @@ import {
10
10
  import { useState } from "react";
11
11
  import { defineCustomElements } from "@authhero/widget/loader";
12
12
 
13
- // Initialize the widget custom elements
13
+ // Initialize the widget custom elements with CDN path for assets
14
14
  if (typeof window !== "undefined") {
15
- defineCustomElements(window);
15
+ defineCustomElements(window, {
16
+ resourcesUrl: "https://unpkg.com/@authhero/widget@latest/dist/authhero-widget/",
17
+ });
16
18
  }
17
19
 
18
20
  // Types for the widget screen configuration
@@ -1324,6 +1324,21 @@ export function ClientEdit() {
1324
1324
  <TabbedForm.Tab label="Connections">
1325
1325
  <ConnectionsTab />
1326
1326
  </TabbedForm.Tab>
1327
+ <TabbedForm.Tab label="Advanced">
1328
+ <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
1329
+ These settings control OAuth/OIDC protocol conformance behavior.
1330
+ </Typography>
1331
+ <BooleanInput
1332
+ source="oidc_conformant"
1333
+ label="OIDC Conformant"
1334
+ helperText="When enabled, the client will strictly follow the OIDC specification. This affects token claims, scopes, and other protocol behaviors."
1335
+ />
1336
+ <BooleanInput
1337
+ source="is_first_party"
1338
+ label="First Party Application"
1339
+ helperText="First party applications are trusted applications that don't require user consent for standard scopes."
1340
+ />
1341
+ </TabbedForm.Tab>
1327
1342
  <TabbedForm.Tab label="Raw JSON">
1328
1343
  <FunctionField
1329
1344
  source="date"
@@ -9,9 +9,10 @@ import {
9
9
  FormDataConsumer,
10
10
  useRecordContext,
11
11
  } from "react-admin";
12
- import { Stack, Alert, Pagination, Box, Typography, Button, IconButton, TextField as MuiTextField } from "@mui/material";
12
+ import { Stack, Alert, Box, Typography, Button, IconButton, TextField as MuiTextField, InputAdornment, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, TableSortLabel, Paper, TablePagination } from "@mui/material";
13
13
  import DeleteIcon from "@mui/icons-material/Delete";
14
14
  import AddIcon from "@mui/icons-material/Add";
15
+ import SearchIcon from "@mui/icons-material/Search";
15
16
  import { useState, useMemo, useCallback } from "react";
16
17
  import { useFormContext, useWatch } from "react-hook-form";
17
18
 
@@ -28,127 +29,216 @@ function SystemEntityAlert() {
28
29
  );
29
30
  }
30
31
 
31
- const SCOPES_PER_PAGE = 10;
32
-
33
32
  interface Scope {
34
33
  value: string;
35
34
  description?: string;
36
35
  }
37
36
 
38
- function PaginatedScopesInput({ disabled }: { disabled?: boolean }) {
37
+ type SortField = "value" | "description";
38
+ type SortOrder = "asc" | "desc";
39
+
40
+ function ScopesListInput({ disabled }: { disabled?: boolean }) {
39
41
  const { setValue, getValues } = useFormContext();
40
42
  const scopes: Scope[] = useWatch({ name: "scopes" }) || [];
41
- const [page, setPage] = useState(1);
43
+ const [searchQuery, setSearchQuery] = useState("");
44
+ const [sortField, setSortField] = useState<SortField>("value");
45
+ const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
46
+ const [page, setPage] = useState(0);
47
+ const [rowsPerPage, setRowsPerPage] = useState(25);
48
+
49
+ const filteredAndSortedScopes = useMemo(() => {
50
+ let result = scopes.map((scope, index) => ({ ...scope, originalIndex: index }));
51
+
52
+ // Filter by search query
53
+ if (searchQuery) {
54
+ const query = searchQuery.toLowerCase();
55
+ result = result.filter(
56
+ (scope) =>
57
+ scope.value?.toLowerCase().includes(query) ||
58
+ scope.description?.toLowerCase().includes(query)
59
+ );
60
+ }
61
+
62
+ // Sort
63
+ result.sort((a, b) => {
64
+ const aValue = (a[sortField] || "").toLowerCase();
65
+ const bValue = (b[sortField] || "").toLowerCase();
66
+ const comparison = aValue.localeCompare(bValue);
67
+ return sortOrder === "asc" ? comparison : -comparison;
68
+ });
69
+
70
+ return result;
71
+ }, [scopes, searchQuery, sortField, sortOrder]);
42
72
 
43
- const totalPages = Math.max(1, Math.ceil(scopes.length / SCOPES_PER_PAGE));
44
-
45
73
  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]);
74
+ const start = page * rowsPerPage;
75
+ return filteredAndSortedScopes.slice(start, start + rowsPerPage);
76
+ }, [filteredAndSortedScopes, page, rowsPerPage]);
53
77
 
54
- const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
55
- setPage(value);
78
+ const handleSort = (field: SortField) => {
79
+ if (sortField === field) {
80
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
81
+ } else {
82
+ setSortField(field);
83
+ setSortOrder("asc");
84
+ }
56
85
  };
57
86
 
58
87
  const handleAdd = useCallback(() => {
59
88
  const currentScopes = getValues("scopes") || [];
60
89
  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]);
90
+ // Clear search and go to last page to show the new item
91
+ setSearchQuery("");
92
+ const newTotal = currentScopes.length + 1;
93
+ setPage(Math.floor(newTotal / rowsPerPage));
94
+ }, [getValues, setValue, rowsPerPage]);
65
95
 
66
- const handleRemove = useCallback((actualIndex: number) => {
96
+ const handleRemove = useCallback((originalIndex: number) => {
67
97
  const currentScopes = getValues("scopes") || [];
68
- const newScopes = currentScopes.filter((_: Scope, i: number) => i !== actualIndex);
98
+ const newScopes = currentScopes.filter((_: Scope, i: number) => i !== originalIndex);
69
99
  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]);
100
+ }, [getValues, setValue]);
76
101
 
77
- const handleScopeChange = useCallback((actualIndex: number, field: "value" | "description", newValue: string) => {
102
+ const handleScopeChange = useCallback((originalIndex: number, field: "value" | "description", newValue: string) => {
78
103
  const currentScopes = getValues("scopes") || [];
79
104
  const newScopes = [...currentScopes];
80
- newScopes[actualIndex] = { ...newScopes[actualIndex], [field]: newValue };
105
+ newScopes[originalIndex] = { ...newScopes[originalIndex], [field]: newValue };
81
106
  setValue("scopes", newScopes, { shouldDirty: true });
82
107
  }, [getValues, setValue]);
83
108
 
109
+ const handleChangePage = (_event: unknown, newPage: number) => {
110
+ setPage(newPage);
111
+ };
112
+
113
+ const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
114
+ setRowsPerPage(parseInt(event.target.value, 10));
115
+ setPage(0);
116
+ };
117
+
84
118
  return (
85
119
  <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
- />
120
+ <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, gap: 2 }}>
121
+ <MuiTextField
122
+ placeholder="Search scopes..."
123
+ value={searchQuery}
124
+ onChange={(e) => {
125
+ setSearchQuery(e.target.value);
126
+ setPage(0);
127
+ }}
128
+ size="small"
129
+ sx={{ width: 300 }}
130
+ InputProps={{
131
+ startAdornment: (
132
+ <InputAdornment position="start">
133
+ <SearchIcon />
134
+ </InputAdornment>
135
+ ),
136
+ }}
137
+ />
138
+ <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
139
+ <Typography variant="body2" color="text.secondary">
140
+ {filteredAndSortedScopes.length} of {scopes.length} scope{scopes.length !== 1 ? "s" : ""}
141
+ </Typography>
128
142
  {!disabled && (
129
- <IconButton
130
- onClick={() => handleRemove(scope.actualIndex)}
131
- color="error"
132
- sx={{ mt: 1 }}
143
+ <Button
144
+ startIcon={<AddIcon />}
145
+ onClick={handleAdd}
146
+ size="small"
147
+ variant="contained"
133
148
  >
134
- <DeleteIcon />
135
- </IconButton>
149
+ Add Scope
150
+ </Button>
136
151
  )}
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
152
  </Box>
151
- )}
153
+ </Box>
154
+
155
+ <TableContainer component={Paper} variant="outlined">
156
+ <Table size="small">
157
+ <TableHead>
158
+ <TableRow>
159
+ <TableCell sx={{ width: "30%" }}>
160
+ <TableSortLabel
161
+ active={sortField === "value"}
162
+ direction={sortField === "value" ? sortOrder : "asc"}
163
+ onClick={() => handleSort("value")}
164
+ >
165
+ Scope Name
166
+ </TableSortLabel>
167
+ </TableCell>
168
+ <TableCell>
169
+ <TableSortLabel
170
+ active={sortField === "description"}
171
+ direction={sortField === "description" ? sortOrder : "asc"}
172
+ onClick={() => handleSort("description")}
173
+ >
174
+ Description
175
+ </TableSortLabel>
176
+ </TableCell>
177
+ {!disabled && <TableCell sx={{ width: 50 }} />}
178
+ </TableRow>
179
+ </TableHead>
180
+ <TableBody>
181
+ {paginatedScopes.length === 0 ? (
182
+ <TableRow>
183
+ <TableCell colSpan={disabled ? 2 : 3} align="center" sx={{ py: 4 }}>
184
+ <Typography color="text.secondary">
185
+ {searchQuery ? "No scopes match your search" : "No scopes defined"}
186
+ </Typography>
187
+ </TableCell>
188
+ </TableRow>
189
+ ) : (
190
+ paginatedScopes.map((scope) => (
191
+ <TableRow key={scope.originalIndex} hover>
192
+ <TableCell>
193
+ <MuiTextField
194
+ value={scope.value || ""}
195
+ onChange={(e) => handleScopeChange(scope.originalIndex, "value", e.target.value)}
196
+ size="small"
197
+ fullWidth
198
+ disabled={disabled}
199
+ variant="standard"
200
+ placeholder="e.g., read:users"
201
+ InputProps={{ disableUnderline: disabled }}
202
+ />
203
+ </TableCell>
204
+ <TableCell>
205
+ <MuiTextField
206
+ value={scope.description || ""}
207
+ onChange={(e) => handleScopeChange(scope.originalIndex, "description", e.target.value)}
208
+ size="small"
209
+ fullWidth
210
+ disabled={disabled}
211
+ variant="standard"
212
+ placeholder="What this scope allows"
213
+ InputProps={{ disableUnderline: disabled }}
214
+ />
215
+ </TableCell>
216
+ {!disabled && (
217
+ <TableCell>
218
+ <IconButton
219
+ onClick={() => handleRemove(scope.originalIndex)}
220
+ color="error"
221
+ size="small"
222
+ >
223
+ <DeleteIcon fontSize="small" />
224
+ </IconButton>
225
+ </TableCell>
226
+ )}
227
+ </TableRow>
228
+ ))
229
+ )}
230
+ </TableBody>
231
+ </Table>
232
+ <TablePagination
233
+ component="div"
234
+ count={filteredAndSortedScopes.length}
235
+ page={page}
236
+ onPageChange={handleChangePage}
237
+ rowsPerPage={rowsPerPage}
238
+ onRowsPerPageChange={handleChangeRowsPerPage}
239
+ rowsPerPageOptions={[10, 25, 50, 100]}
240
+ />
241
+ </TableContainer>
152
242
  </Box>
153
243
  );
154
244
  }
@@ -242,7 +332,7 @@ function ResourceServerForm() {
242
332
  </TabbedForm.Tab>
243
333
 
244
334
  <TabbedForm.Tab label="Scopes">
245
- <PaginatedScopesInput disabled={isSystem} />
335
+ <ScopesListInput disabled={isSystem} />
246
336
  </TabbedForm.Tab>
247
337
  </TabbedForm>
248
338
  );
@@ -30,6 +30,8 @@ export function getDataprovider(auth0Domain?: string) {
30
30
  // Create the complete base URL using the selected domain
31
31
  let baseUrl = import.meta.env.VITE_SIMPLE_REST_URL;
32
32
 
33
+ console.log("[getDataprovider] auth0Domain:", auth0Domain, "VITE_SIMPLE_REST_URL:", import.meta.env.VITE_SIMPLE_REST_URL);
34
+
33
35
  if (auth0Domain) {
34
36
  // Check if there's a custom REST API URL configured for this domain
35
37
  const domains = getDomainFromStorage();
@@ -38,6 +40,8 @@ export function getDataprovider(auth0Domain?: string) {
38
40
  (d) => formatDomain(d.url) === formattedAuth0Domain,
39
41
  );
40
42
 
43
+ console.log("[getDataprovider] domains:", domains, "domainConfig:", domainConfig);
44
+
41
45
  if (domainConfig?.restApiUrl) {
42
46
  // Use the custom REST API URL if configured (ensure HTTPS)
43
47
  baseUrl = buildUrlWithProtocol(domainConfig.restApiUrl);
@@ -50,6 +54,8 @@ export function getDataprovider(auth0Domain?: string) {
50
54
  baseUrl = buildUrlWithProtocol(baseUrl);
51
55
  }
52
56
 
57
+ console.log("[getDataprovider] final baseUrl:", baseUrl);
58
+
53
59
  // TODO - duplicate auth0DataProvider to tenantsDataProvider
54
60
  // we are introducing non-auth0 endpoints AND we don't require the tenants-id header
55
61
  const provider = auth0DataProvider(
@@ -74,6 +80,8 @@ export function getDataproviderForTenant(
74
80
  // Start with a default API URL
75
81
  let apiUrl;
76
82
 
83
+ console.log("[getDataproviderForTenant] tenantId:", tenantId, "auth0Domain:", auth0Domain);
84
+
77
85
  if (auth0Domain) {
78
86
  // Check if there's a custom REST API URL configured for this domain
79
87
  const domains = getDomainFromStorage();
@@ -82,6 +90,8 @@ export function getDataproviderForTenant(
82
90
  (d) => formatDomain(d.url) === formattedAuth0Domain,
83
91
  );
84
92
 
93
+ console.log("[getDataproviderForTenant] domains:", domains, "domainConfig:", domainConfig);
94
+
85
95
  if (domainConfig?.restApiUrl) {
86
96
  // Use the custom REST API URL if configured (ensure HTTPS)
87
97
  apiUrl = buildUrlWithProtocol(domainConfig.restApiUrl);
@@ -97,6 +107,8 @@ export function getDataproviderForTenant(
97
107
  // Ensure apiUrl doesn't end with a slash
98
108
  apiUrl = apiUrl.replace(/\/$/, "");
99
109
 
110
+ console.log("[getDataproviderForTenant] final apiUrl:", apiUrl);
111
+
100
112
  // Create a dynamic httpClient that checks single-tenant mode at REQUEST TIME
101
113
  // This is important because the mode may not be known when the dataProvider is created
102
114
  const dynamicHttpClient = (url: string, options?: any) => {
package/vite.config.ts CHANGED
@@ -1,18 +1,47 @@
1
1
  /// <reference types="vitest" />
2
2
  import { defineConfig } from "vite";
3
3
  import react from "@vitejs/plugin-react";
4
+ import basicSsl from "@vitejs/plugin-basic-ssl";
5
+ import http from "http";
6
+
7
+ // Redirect HTTP to HTTPS
8
+ const HTTPS_PORT = 5173;
9
+ const HTTP_PORT = 5172;
10
+
11
+ function startHttpRedirectServer() {
12
+ const server = http.createServer((req, res) => {
13
+ const host = req.headers.host?.replace(`:${HTTP_PORT}`, `:${HTTPS_PORT}`);
14
+ res.writeHead(301, { Location: `https://${host}${req.url}` });
15
+ res.end();
16
+ });
17
+ server.listen(HTTP_PORT, () => {
18
+ console.log(
19
+ `HTTP redirect server running on http://localhost:${HTTP_PORT} -> https://localhost:${HTTPS_PORT}`,
20
+ );
21
+ });
22
+ }
4
23
 
5
24
  // https://vitejs.dev/config/
6
25
  export default defineConfig({
7
- plugins: [react()],
26
+ plugins: [
27
+ react(),
28
+ basicSsl(),
29
+ {
30
+ name: "http-redirect",
31
+ configureServer() {
32
+ startHttpRedirectServer();
33
+ },
34
+ },
35
+ ],
8
36
  define: {
9
37
  "process.env": process.env,
10
38
  },
11
39
  server: {
12
40
  host: true,
41
+ https: {},
42
+ port: HTTPS_PORT,
13
43
  },
14
44
  base: "./",
15
- // @ts-expect-error
16
45
  test: {
17
46
  environment: "jsdom", // Set JSDOM as the default test environment
18
47
  globals: true, // Make test globals available