@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 +25 -0
- package/package.json +3 -2
- package/src/authProvider.ts +2 -14
- package/src/components/TenantAppBar.tsx +3 -4
- package/src/components/branding/BrandingPreview.tsx +4 -2
- package/src/components/branding/UniversalLoginTab.tsx +357 -0
- package/src/components/branding/edit.tsx +4 -0
- package/src/components/branding/index.ts +1 -0
- package/src/components/clients/edit.tsx +15 -0
- package/src/components/resource-servers/edit.tsx +183 -93
- package/vite.config.ts +31 -2
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.
|
|
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.
|
|
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",
|
package/src/authProvider.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
35
|
-
|
|
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><head></code> section
|
|
247
|
+
</li>
|
|
248
|
+
<li>
|
|
249
|
+
<code>{"{%- auth0:widget -%}"}</code> - Must be placed in the{" "}
|
|
250
|
+
<code><body></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>
|
|
@@ -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,
|
|
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
|
-
|
|
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 [
|
|
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 =
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
55
|
-
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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((
|
|
96
|
+
const handleRemove = useCallback((originalIndex: number) => {
|
|
67
97
|
const currentScopes = getValues("scopes") || [];
|
|
68
|
-
const newScopes = currentScopes.filter((_: Scope, i: number) => i !==
|
|
98
|
+
const newScopes = currentScopes.filter((_: Scope, i: number) => i !== originalIndex);
|
|
69
99
|
setValue("scopes", newScopes, { shouldDirty: true });
|
|
70
|
-
|
|
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((
|
|
102
|
+
const handleScopeChange = useCallback((originalIndex: number, field: "value" | "description", newValue: string) => {
|
|
78
103
|
const currentScopes = getValues("scopes") || [];
|
|
79
104
|
const newScopes = [...currentScopes];
|
|
80
|
-
newScopes[
|
|
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
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
<Button
|
|
144
|
+
startIcon={<AddIcon />}
|
|
145
|
+
onClick={handleAdd}
|
|
146
|
+
size="small"
|
|
147
|
+
variant="contained"
|
|
133
148
|
>
|
|
134
|
-
|
|
135
|
-
</
|
|
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
|
-
<
|
|
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: [
|
|
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
|