@authhero/react-admin 0.28.0 → 0.30.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,30 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6585906: Move universal login templates to separate adapter
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [6585906]
12
+ - @authhero/adapter-interfaces@0.128.0
13
+ - @authhero/widget@0.8.5
14
+
15
+ ## 0.29.0
16
+
17
+ ### Minor Changes
18
+
19
+ - de7cb56: Use https for local dev
20
+ - 154993d: Improve react-admin experience by clearing caches and setting cores
21
+
22
+ ### Patch Changes
23
+
24
+ - Updated dependencies [154993d]
25
+ - @authhero/adapter-interfaces@0.126.0
26
+ - @authhero/widget@0.8.3
27
+
3
28
  ## 0.28.0
4
29
 
5
30
  ### 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.30.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",
@@ -51,12 +51,6 @@ export const createAuth0Client = (domain: string) => {
51
51
  import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
52
52
 
53
53
  const clientId = getClientIdFromStorage(domain);
54
- console.log(
55
- "[createAuth0Client] Creating client for domain:",
56
- domain,
57
- "with clientId:",
58
- clientId,
59
- );
60
54
 
61
55
  const auth0Client = new Auth0Client({
62
56
  domain: fullDomain,
@@ -152,14 +146,6 @@ export const createAuth0ClientForOrg = (
152
146
  import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
153
147
 
154
148
  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
149
 
164
150
  const auth0Client = new Auth0Client({
165
151
  domain: fullDomain,
@@ -225,8 +211,10 @@ export const createManagementClient = async (
225
211
  domainForAuth,
226
212
  normalizedTenantId,
227
213
  );
214
+
228
215
  const audience =
229
216
  import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
217
+
230
218
  try {
231
219
  token = await orgAuth0Client.getTokenSilently({
232
220
  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
@@ -0,0 +1,357 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import {
3
+ Box,
4
+ Typography,
5
+ TextField,
6
+ Button,
7
+ Alert,
8
+ CircularProgress,
9
+ Paper,
10
+ Divider,
11
+ Link,
12
+ } from "@mui/material";
13
+ import { useNotify } from "react-admin";
14
+ import {
15
+ authorizedHttpClient,
16
+ createOrganizationHttpClient,
17
+ } from "../../authProvider";
18
+ import {
19
+ getDomainFromStorage,
20
+ buildUrlWithProtocol,
21
+ formatDomain,
22
+ getSelectedDomainFromStorage,
23
+ } from "../../utils/domainUtils";
24
+
25
+ // Get tenantId from the URL path (e.g., /breakit/branding -> breakit)
26
+ function getTenantIdFromPath(): string {
27
+ const pathSegments = window.location.pathname.split("/").filter(Boolean);
28
+ return pathSegments[0] || "";
29
+ }
30
+
31
+ // Default template with required Liquid tags
32
+ const DEFAULT_TEMPLATE = `<!DOCTYPE html>
33
+ <html>
34
+ <head>
35
+ {%- auth0:head -%}
36
+ </head>
37
+ <body>
38
+ {%- auth0:widget -%}
39
+ </body>
40
+ </html>`;
41
+
42
+ function getApiUrl(): string {
43
+ const domains = getDomainFromStorage();
44
+ const selectedDomain = getSelectedDomainFromStorage();
45
+ const formattedSelectedDomain = formatDomain(selectedDomain);
46
+ const domainConfig = domains.find(
47
+ (d) => formatDomain(d.url) === formattedSelectedDomain
48
+ );
49
+
50
+ let apiUrl: string;
51
+
52
+ if (domainConfig?.restApiUrl) {
53
+ apiUrl = buildUrlWithProtocol(domainConfig.restApiUrl);
54
+ } else if (selectedDomain) {
55
+ apiUrl = buildUrlWithProtocol(selectedDomain);
56
+ } else {
57
+ apiUrl = buildUrlWithProtocol(import.meta.env.VITE_AUTH0_API_URL || "");
58
+ }
59
+
60
+ return apiUrl.replace(/\/$/, "");
61
+ }
62
+
63
+ function getHttpClient(tenantId: string) {
64
+ // Check single-tenant mode at request time
65
+ const storedFlag = sessionStorage.getItem("isSingleTenant");
66
+ const isSingleTenant =
67
+ storedFlag?.endsWith("|true") || storedFlag === "true";
68
+
69
+ // In single-tenant mode, use the regular authorized client without organization scope
70
+ // In multi-tenant mode, use organization-scoped client for proper access control
71
+ if (isSingleTenant) {
72
+ return authorizedHttpClient;
73
+ } else {
74
+ return createOrganizationHttpClient(tenantId);
75
+ }
76
+ }
77
+
78
+ export function UniversalLoginTab() {
79
+ const notify = useNotify();
80
+ const tenantId = getTenantIdFromPath();
81
+
82
+ const [template, setTemplate] = useState<string>("");
83
+ const [originalTemplate, setOriginalTemplate] = useState<string>("");
84
+ const [loading, setLoading] = useState(true);
85
+ const [saving, setSaving] = useState(false);
86
+ const [error, setError] = useState<string | null>(null);
87
+ const [hasTemplate, setHasTemplate] = useState(false);
88
+
89
+ const fetchTemplate = useCallback(async () => {
90
+ if (!tenantId) return;
91
+
92
+ setLoading(true);
93
+ setError(null);
94
+
95
+ try {
96
+ const apiUrl = getApiUrl();
97
+ const httpClient = getHttpClient(tenantId);
98
+ const url = `${apiUrl}/api/v2/branding/templates/universal-login`;
99
+
100
+ const response = await httpClient(url, {
101
+ headers: new Headers({
102
+ "tenant-id": tenantId,
103
+ }),
104
+ });
105
+
106
+ if (response.json?.body) {
107
+ setTemplate(response.json.body);
108
+ setOriginalTemplate(response.json.body);
109
+ setHasTemplate(true);
110
+ }
111
+ } catch (err: any) {
112
+ if (err.status === 404) {
113
+ // No template exists yet, that's fine
114
+ setTemplate("");
115
+ setOriginalTemplate("");
116
+ setHasTemplate(false);
117
+ } else {
118
+ setError("Failed to load template");
119
+ console.error("Error fetching template:", err);
120
+ }
121
+ } finally {
122
+ setLoading(false);
123
+ }
124
+ }, [tenantId]);
125
+
126
+ useEffect(() => {
127
+ fetchTemplate();
128
+ }, [fetchTemplate]);
129
+
130
+ const handleSave = async () => {
131
+ if (!tenantId) return;
132
+
133
+ // Validate template
134
+ if (!template.includes("{%- auth0:head -%}")) {
135
+ notify("Template must contain {%- auth0:head -%} tag", { type: "error" });
136
+ return;
137
+ }
138
+ if (!template.includes("{%- auth0:widget -%}")) {
139
+ notify("Template must contain {%- auth0:widget -%} tag", {
140
+ type: "error",
141
+ });
142
+ return;
143
+ }
144
+
145
+ setSaving(true);
146
+ setError(null);
147
+
148
+ try {
149
+ const apiUrl = getApiUrl();
150
+ const httpClient = getHttpClient(tenantId);
151
+ await httpClient(
152
+ `${apiUrl}/api/v2/branding/templates/universal-login`,
153
+ {
154
+ method: "PUT",
155
+ headers: new Headers({
156
+ "tenant-id": tenantId,
157
+ "Content-Type": "application/json",
158
+ }),
159
+ body: JSON.stringify({ body: template }),
160
+ }
161
+ );
162
+
163
+ setOriginalTemplate(template);
164
+ setHasTemplate(true);
165
+ notify("Template saved successfully", { type: "success" });
166
+ } catch (err: any) {
167
+ setError(err.message || "Failed to save template");
168
+ notify("Failed to save template", { type: "error" });
169
+ } finally {
170
+ setSaving(false);
171
+ }
172
+ };
173
+
174
+ const handleDelete = async () => {
175
+ if (!tenantId) return;
176
+
177
+ if (
178
+ !window.confirm(
179
+ "Are you sure you want to delete the custom template? The default template will be used instead."
180
+ )
181
+ ) {
182
+ return;
183
+ }
184
+
185
+ setSaving(true);
186
+ setError(null);
187
+
188
+ try {
189
+ const apiUrl = getApiUrl();
190
+ const httpClient = getHttpClient(tenantId);
191
+ await httpClient(
192
+ `${apiUrl}/api/v2/branding/templates/universal-login`,
193
+ {
194
+ method: "DELETE",
195
+ headers: new Headers({
196
+ "tenant-id": tenantId,
197
+ }),
198
+ }
199
+ );
200
+
201
+ setTemplate("");
202
+ setOriginalTemplate("");
203
+ setHasTemplate(false);
204
+ notify("Template deleted successfully", { type: "success" });
205
+ } catch (err: any) {
206
+ setError(err.message || "Failed to delete template");
207
+ notify("Failed to delete template", { type: "error" });
208
+ } finally {
209
+ setSaving(false);
210
+ }
211
+ };
212
+
213
+ const handleUseDefault = () => {
214
+ setTemplate(DEFAULT_TEMPLATE);
215
+ };
216
+
217
+ const hasChanges = template !== originalTemplate;
218
+
219
+ if (loading) {
220
+ return (
221
+ <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
222
+ <CircularProgress />
223
+ </Box>
224
+ );
225
+ }
226
+
227
+ return (
228
+ <Box sx={{ maxWidth: 1000, p: 2 }}>
229
+ <Typography variant="h6" sx={{ mb: 2 }}>
230
+ Universal Login Page Template
231
+ </Typography>
232
+
233
+ <Typography variant="body2" sx={{ mb: 3, color: "text.secondary" }}>
234
+ Customize the HTML template for your Universal Login page. The template
235
+ uses Liquid templating syntax and must include the required{" "}
236
+ <code>{"{%- auth0:head -%}"}</code> and{" "}
237
+ <code>{"{%- auth0:widget -%}"}</code> tags.
238
+ </Typography>
239
+
240
+ <Alert severity="info" sx={{ mb: 3 }}>
241
+ <Typography variant="body2">
242
+ <strong>Required tags:</strong>
243
+ <ul style={{ margin: "8px 0", paddingLeft: "20px" }}>
244
+ <li>
245
+ <code>{"{%- auth0:head -%}"}</code> - Must be placed in the{" "}
246
+ <code>&lt;head&gt;</code> section
247
+ </li>
248
+ <li>
249
+ <code>{"{%- auth0:widget -%}"}</code> - Must be placed in the{" "}
250
+ <code>&lt;body&gt;</code> section where you want the login widget
251
+ </li>
252
+ </ul>
253
+ <Link
254
+ href="https://auth0.com/docs/authenticate/login/auth0-universal-login/new-experience/universal-login-page-templates"
255
+ target="_blank"
256
+ rel="noopener noreferrer"
257
+ >
258
+ Learn more about page templates
259
+ </Link>
260
+ </Typography>
261
+ </Alert>
262
+
263
+ {error && (
264
+ <Alert severity="error" sx={{ mb: 2 }}>
265
+ {error}
266
+ </Alert>
267
+ )}
268
+
269
+ <Paper variant="outlined" sx={{ mb: 2 }}>
270
+ <TextField
271
+ multiline
272
+ fullWidth
273
+ minRows={15}
274
+ maxRows={30}
275
+ value={template}
276
+ onChange={(e) => setTemplate(e.target.value)}
277
+ placeholder="Enter your custom HTML template..."
278
+ sx={{
279
+ "& .MuiInputBase-root": {
280
+ fontFamily: "monospace",
281
+ fontSize: "13px",
282
+ },
283
+ "& .MuiOutlinedInput-notchedOutline": {
284
+ border: "none",
285
+ },
286
+ }}
287
+ />
288
+ </Paper>
289
+
290
+ <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
291
+ <Button
292
+ variant="contained"
293
+ onClick={handleSave}
294
+ disabled={saving || !template || !hasChanges}
295
+ >
296
+ {saving ? <CircularProgress size={20} /> : "Save Template"}
297
+ </Button>
298
+
299
+ {!template && (
300
+ <Button variant="outlined" onClick={handleUseDefault}>
301
+ Use Default Template
302
+ </Button>
303
+ )}
304
+
305
+ {hasTemplate && (
306
+ <Button
307
+ variant="outlined"
308
+ color="error"
309
+ onClick={handleDelete}
310
+ disabled={saving}
311
+ >
312
+ Delete Template
313
+ </Button>
314
+ )}
315
+
316
+ {hasChanges && (
317
+ <Button
318
+ variant="text"
319
+ onClick={() => setTemplate(originalTemplate)}
320
+ disabled={saving}
321
+ >
322
+ Discard Changes
323
+ </Button>
324
+ )}
325
+ </Box>
326
+
327
+ {hasChanges && (
328
+ <Typography
329
+ variant="caption"
330
+ sx={{ display: "block", mt: 1, color: "warning.main" }}
331
+ >
332
+ You have unsaved changes
333
+ </Typography>
334
+ )}
335
+
336
+ <Divider sx={{ my: 4 }} />
337
+
338
+ <Typography variant="subtitle2" sx={{ mb: 1, color: "text.secondary" }}>
339
+ Template Variables
340
+ </Typography>
341
+ <Typography variant="body2" sx={{ color: "text.secondary" }}>
342
+ You can use Liquid variables to customize your template:
343
+ <ul style={{ margin: "8px 0", paddingLeft: "20px" }}>
344
+ <li>
345
+ <code>{"{{ branding.logo_url }}"}</code> - Your logo URL
346
+ </li>
347
+ <li>
348
+ <code>{"{{ branding.colors.primary }}"}</code> - Primary color
349
+ </li>
350
+ <li>
351
+ <code>{"{{ prompt.screen.name }}"}</code> - Current screen name
352
+ </li>
353
+ </ul>
354
+ </Typography>
355
+ </Box>
356
+ );
357
+ }
@@ -5,6 +5,7 @@ import { useState, useEffect } from "react";
5
5
  import { Box } from "@mui/material";
6
6
  import { ThemesTab } from "./ThemesTab";
7
7
  import { BrandingPreview } from "./BrandingPreview";
8
+ import { UniversalLoginTab } from "./UniversalLoginTab";
8
9
 
9
10
  // Helper to recursively remove null values and empty objects from data
10
11
  // This is needed because react-admin sends null for empty form fields,
@@ -207,6 +208,9 @@ function BrandingFormContent() {
207
208
  <BrandingPreview />
208
209
  </Box>
209
210
  </TabbedForm.Tab>
211
+ <TabbedForm.Tab label="Universal Login">
212
+ <UniversalLoginTab />
213
+ </TabbedForm.Tab>
210
214
  </TabbedForm>
211
215
  </Box>
212
216
  </Box>
@@ -1,2 +1,3 @@
1
1
  export * from "./edit";
2
2
  export * from "./list";
3
+ export * from "./UniversalLoginTab";
@@ -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
  );
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