@authhero/react-admin 0.29.0 → 0.31.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 +1 -1
- package/src/App.tsx +10 -0
- package/src/auth0DataProvider.ts +187 -0
- package/src/authProvider.ts +0 -14
- 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/prompts/edit.tsx +554 -0
- package/src/components/prompts/index.ts +2 -0
- package/src/components/prompts/list.tsx +14 -0
- package/src/dataProvider.ts +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @authhero/react-admin
|
|
2
2
|
|
|
3
|
+
## 0.31.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ac8af37: Add custom text support
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [88a03cd]
|
|
12
|
+
- Updated dependencies [ac8af37]
|
|
13
|
+
- @authhero/widget@0.9.0
|
|
14
|
+
- @authhero/adapter-interfaces@0.130.0
|
|
15
|
+
|
|
16
|
+
## 0.30.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- 6585906: Move universal login templates to separate adapter
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- Updated dependencies [6585906]
|
|
25
|
+
- @authhero/adapter-interfaces@0.128.0
|
|
26
|
+
- @authhero/widget@0.8.5
|
|
27
|
+
|
|
3
28
|
## 0.29.0
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
DomainList,
|
|
26
26
|
} from "./components/custom-domains";
|
|
27
27
|
import { BrandingList, BrandingEdit } from "./components/branding";
|
|
28
|
+
import { PromptsList, PromptsEdit } from "./components/prompts";
|
|
28
29
|
import { LogsList, LogShow } from "./components/logs";
|
|
29
30
|
import { HookEdit, HookList, HooksCreate } from "./components/hooks";
|
|
30
31
|
import { SessionEdit } from "./components/sessions";
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
import WebhookIcon from "@mui/icons-material/Webhook";
|
|
42
43
|
import DnsIcon from "@mui/icons-material/Dns";
|
|
43
44
|
import PaletteIcon from "@mui/icons-material/Palette";
|
|
45
|
+
import TextFieldsIcon from "@mui/icons-material/TextFields";
|
|
44
46
|
import StorageIcon from "@mui/icons-material/Storage";
|
|
45
47
|
import AccountTreeIcon from "@mui/icons-material/AccountTree";
|
|
46
48
|
import { useMemo, useState, useEffect } from "react";
|
|
@@ -251,6 +253,14 @@ export function App(props: AppProps) {
|
|
|
251
253
|
edit={BrandingEdit}
|
|
252
254
|
show={ShowGuesser}
|
|
253
255
|
/>
|
|
256
|
+
<Resource
|
|
257
|
+
icon={TextFieldsIcon}
|
|
258
|
+
name="prompts"
|
|
259
|
+
options={{ hasSingle: true }}
|
|
260
|
+
list={PromptsList}
|
|
261
|
+
edit={PromptsEdit}
|
|
262
|
+
show={ShowGuesser}
|
|
263
|
+
/>
|
|
254
264
|
<Resource
|
|
255
265
|
icon={StorageIcon}
|
|
256
266
|
name="resource-servers"
|
package/src/auth0DataProvider.ts
CHANGED
|
@@ -346,6 +346,61 @@ export default (
|
|
|
346
346
|
);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
// Handle prompts singleton resource
|
|
350
|
+
if (resource === "prompts") {
|
|
351
|
+
const headers = createHeaders(tenantId);
|
|
352
|
+
try {
|
|
353
|
+
const res = await httpClient(`${apiUrl}/api/v2/prompts`, { headers });
|
|
354
|
+
// Also fetch custom text entries list
|
|
355
|
+
let customTextEntries: Array<{ prompt: string; language: string }> =
|
|
356
|
+
[];
|
|
357
|
+
try {
|
|
358
|
+
const customTextRes = await httpClient(
|
|
359
|
+
`${apiUrl}/api/v2/prompts/custom-text`,
|
|
360
|
+
{ headers },
|
|
361
|
+
);
|
|
362
|
+
customTextEntries = customTextRes.json || [];
|
|
363
|
+
} catch {
|
|
364
|
+
// Custom text list might not exist yet
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
data: [{ ...res.json, customTextEntries, id: resource }],
|
|
368
|
+
total: 1,
|
|
369
|
+
};
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error("Error fetching prompts:", error);
|
|
372
|
+
return {
|
|
373
|
+
data: [{ id: resource, customTextEntries: [] }],
|
|
374
|
+
total: 1,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Handle custom-text resource (for individual custom text entries)
|
|
380
|
+
if (resource === "custom-text") {
|
|
381
|
+
const headers = createHeaders(tenantId);
|
|
382
|
+
try {
|
|
383
|
+
const res = await httpClient(
|
|
384
|
+
`${apiUrl}/api/v2/prompts/custom-text`,
|
|
385
|
+
{ headers },
|
|
386
|
+
);
|
|
387
|
+
const entries = res.json || [];
|
|
388
|
+
return {
|
|
389
|
+
data: entries.map(
|
|
390
|
+
(e: { prompt: string; language: string }, idx: number) => ({
|
|
391
|
+
id: `${e.prompt}:${e.language}`,
|
|
392
|
+
prompt: e.prompt,
|
|
393
|
+
language: e.language,
|
|
394
|
+
}),
|
|
395
|
+
),
|
|
396
|
+
total: entries.length,
|
|
397
|
+
};
|
|
398
|
+
} catch (error) {
|
|
399
|
+
console.error("Error fetching custom-text list:", error);
|
|
400
|
+
return { data: [], total: 0 };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
349
404
|
// Handle organizations with client-side paging and search (fetch 500, filter locally)
|
|
350
405
|
if (resource === "organizations" && !resourcePath.includes("/")) {
|
|
351
406
|
const result = await managementClient.organizations.list({
|
|
@@ -587,6 +642,62 @@ export default (
|
|
|
587
642
|
};
|
|
588
643
|
}
|
|
589
644
|
|
|
645
|
+
// Handle prompts singleton resource
|
|
646
|
+
if (resource === "prompts") {
|
|
647
|
+
const headers = createHeaders(tenantId);
|
|
648
|
+
try {
|
|
649
|
+
const res = await httpClient(`${apiUrl}/api/v2/prompts`, { headers });
|
|
650
|
+
// Also fetch custom text entries list
|
|
651
|
+
let customTextEntries: Array<{ prompt: string; language: string }> =
|
|
652
|
+
[];
|
|
653
|
+
try {
|
|
654
|
+
const customTextRes = await httpClient(
|
|
655
|
+
`${apiUrl}/api/v2/prompts/custom-text`,
|
|
656
|
+
{ headers },
|
|
657
|
+
);
|
|
658
|
+
customTextEntries = customTextRes.json || [];
|
|
659
|
+
} catch {
|
|
660
|
+
// Custom text list might not exist yet
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
data: { ...res.json, customTextEntries, id: resource },
|
|
664
|
+
};
|
|
665
|
+
} catch (error) {
|
|
666
|
+
console.error("Error fetching prompts:", error);
|
|
667
|
+
return {
|
|
668
|
+
data: { id: resource, customTextEntries: [] },
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Handle custom-text resource (individual entries)
|
|
674
|
+
if (resource === "custom-text") {
|
|
675
|
+
// ID format is "prompt:language"
|
|
676
|
+
const [prompt, language] = params.id.split(":");
|
|
677
|
+
if (!prompt || !language) {
|
|
678
|
+
throw new Error("Invalid custom-text ID format");
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
const result = await managementClient.prompts.customText.get(
|
|
682
|
+
prompt as any,
|
|
683
|
+
language as any,
|
|
684
|
+
);
|
|
685
|
+
return {
|
|
686
|
+
data: {
|
|
687
|
+
id: params.id,
|
|
688
|
+
prompt,
|
|
689
|
+
language,
|
|
690
|
+
texts: result || {},
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.error("Error fetching custom-text:", error);
|
|
695
|
+
return {
|
|
696
|
+
data: { id: params.id, prompt, language, texts: {} },
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
590
701
|
// Handle stats/active-users endpoint
|
|
591
702
|
if (resource === "stats/active-users") {
|
|
592
703
|
const headers = createHeaders(tenantId);
|
|
@@ -948,6 +1059,44 @@ export default (
|
|
|
948
1059
|
};
|
|
949
1060
|
}
|
|
950
1061
|
|
|
1062
|
+
// Handle prompts singleton resource
|
|
1063
|
+
if (resource === "prompts") {
|
|
1064
|
+
const headers = createHeaders(tenantId);
|
|
1065
|
+
headers.set("Content-Type", "application/json");
|
|
1066
|
+
// Don't send customTextEntries to the settings endpoint
|
|
1067
|
+
const { customTextEntries, ...promptsData } = cleanParams.data;
|
|
1068
|
+
const res = await httpClient(`${apiUrl}/api/v2/prompts`, {
|
|
1069
|
+
method: "PATCH",
|
|
1070
|
+
headers,
|
|
1071
|
+
body: JSON.stringify(promptsData),
|
|
1072
|
+
});
|
|
1073
|
+
return {
|
|
1074
|
+
data: { ...res.json, customTextEntries, id: resource },
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Handle custom-text resource
|
|
1079
|
+
if (resource === "custom-text") {
|
|
1080
|
+
// ID format is "prompt:language"
|
|
1081
|
+
const [prompt, language] = params.id.split(":");
|
|
1082
|
+
if (!prompt || !language) {
|
|
1083
|
+
throw new Error("Invalid custom-text ID format");
|
|
1084
|
+
}
|
|
1085
|
+
await managementClient.prompts.customText.set(
|
|
1086
|
+
prompt as any,
|
|
1087
|
+
language as any,
|
|
1088
|
+
cleanParams.data.texts || {},
|
|
1089
|
+
);
|
|
1090
|
+
return {
|
|
1091
|
+
data: {
|
|
1092
|
+
id: params.id,
|
|
1093
|
+
prompt,
|
|
1094
|
+
language,
|
|
1095
|
+
texts: cleanParams.data.texts || {},
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
951
1100
|
// Special handling for branding to update theme data separately
|
|
952
1101
|
if (resource === "branding") {
|
|
953
1102
|
// Extract themes from the payload - it's updated via a separate endpoint
|
|
@@ -1123,6 +1272,27 @@ export default (
|
|
|
1123
1272
|
if (tenantId) headers.set("tenant-id", tenantId);
|
|
1124
1273
|
const managementClient = await getManagementClient();
|
|
1125
1274
|
|
|
1275
|
+
// Handle custom-text resource
|
|
1276
|
+
if (resource === "custom-text") {
|
|
1277
|
+
const { prompt, language, texts } = params.data;
|
|
1278
|
+
if (!prompt || !language) {
|
|
1279
|
+
throw new Error("prompt and language are required");
|
|
1280
|
+
}
|
|
1281
|
+
await managementClient.prompts.customText.set(
|
|
1282
|
+
prompt as any,
|
|
1283
|
+
language as any,
|
|
1284
|
+
texts || {},
|
|
1285
|
+
);
|
|
1286
|
+
return {
|
|
1287
|
+
data: {
|
|
1288
|
+
id: `${prompt}:${language}`,
|
|
1289
|
+
prompt,
|
|
1290
|
+
language,
|
|
1291
|
+
texts: texts || {},
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1126
1296
|
// Helper for POST requests
|
|
1127
1297
|
const post = async (endpoint: string, body: any) =>
|
|
1128
1298
|
httpClient(`${apiUrl}/api/v2/${endpoint}`, {
|
|
@@ -1276,6 +1446,23 @@ export default (
|
|
|
1276
1446
|
const headers = new Headers({ "content-type": "application/json" });
|
|
1277
1447
|
if (tenantId) headers.set("tenant-id", tenantId);
|
|
1278
1448
|
|
|
1449
|
+
// Handle custom-text resource
|
|
1450
|
+
if (resource === "custom-text") {
|
|
1451
|
+
// ID format is "prompt:language"
|
|
1452
|
+
const [prompt, language] = String(params.id).split(":");
|
|
1453
|
+
if (!prompt || !language) {
|
|
1454
|
+
throw new Error("Invalid custom-text ID format");
|
|
1455
|
+
}
|
|
1456
|
+
// Auth0 SDK doesn't have delete, so we set to empty object
|
|
1457
|
+
// Our backend also supports DELETE, but using set({}) is compatible with Auth0
|
|
1458
|
+
await managementClient.prompts.customText.set(
|
|
1459
|
+
prompt as any,
|
|
1460
|
+
language as any,
|
|
1461
|
+
{},
|
|
1462
|
+
);
|
|
1463
|
+
return { data: { id: params.id } };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1279
1466
|
// Helper for DELETE requests
|
|
1280
1467
|
const del = async (endpoint: string, body?: any) =>
|
|
1281
1468
|
httpClient(`${apiUrl}/api/v2/${endpoint}`, {
|
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,
|
|
@@ -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>
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Edit,
|
|
3
|
+
TabbedForm,
|
|
4
|
+
SelectInput,
|
|
5
|
+
BooleanInput,
|
|
6
|
+
useRecordContext,
|
|
7
|
+
useDataProvider,
|
|
8
|
+
useNotify,
|
|
9
|
+
useRefresh,
|
|
10
|
+
} from "react-admin";
|
|
11
|
+
import {
|
|
12
|
+
Stack,
|
|
13
|
+
Typography,
|
|
14
|
+
Box,
|
|
15
|
+
Button,
|
|
16
|
+
Table,
|
|
17
|
+
TableBody,
|
|
18
|
+
TableCell,
|
|
19
|
+
TableContainer,
|
|
20
|
+
TableHead,
|
|
21
|
+
TableRow,
|
|
22
|
+
Paper,
|
|
23
|
+
IconButton,
|
|
24
|
+
Dialog,
|
|
25
|
+
DialogTitle,
|
|
26
|
+
DialogContent,
|
|
27
|
+
DialogActions,
|
|
28
|
+
TextField,
|
|
29
|
+
MenuItem,
|
|
30
|
+
} from "@mui/material";
|
|
31
|
+
import EditIcon from "@mui/icons-material/Edit";
|
|
32
|
+
import DeleteIcon from "@mui/icons-material/Delete";
|
|
33
|
+
import AddIcon from "@mui/icons-material/Add";
|
|
34
|
+
import { useState, useCallback } from "react";
|
|
35
|
+
|
|
36
|
+
// Available prompt screens
|
|
37
|
+
const PROMPT_SCREENS = [
|
|
38
|
+
{ id: "login", name: "Login" },
|
|
39
|
+
{ id: "login-id", name: "Login - Identifier" },
|
|
40
|
+
{ id: "login-password", name: "Login - Password" },
|
|
41
|
+
{ id: "signup", name: "Sign Up" },
|
|
42
|
+
{ id: "signup-id", name: "Sign Up - Identifier" },
|
|
43
|
+
{ id: "signup-password", name: "Sign Up - Password" },
|
|
44
|
+
{ id: "reset-password", name: "Reset Password" },
|
|
45
|
+
{ id: "consent", name: "Consent" },
|
|
46
|
+
{ id: "mfa", name: "MFA" },
|
|
47
|
+
{ id: "mfa-push", name: "MFA - Push" },
|
|
48
|
+
{ id: "mfa-otp", name: "MFA - OTP" },
|
|
49
|
+
{ id: "mfa-voice", name: "MFA - Voice" },
|
|
50
|
+
{ id: "mfa-phone", name: "MFA - Phone" },
|
|
51
|
+
{ id: "mfa-webauthn", name: "MFA - WebAuthn" },
|
|
52
|
+
{ id: "mfa-sms", name: "MFA - SMS" },
|
|
53
|
+
{ id: "mfa-email", name: "MFA - Email" },
|
|
54
|
+
{ id: "mfa-recovery-code", name: "MFA - Recovery Code" },
|
|
55
|
+
{ id: "status", name: "Status" },
|
|
56
|
+
{ id: "device-flow", name: "Device Flow" },
|
|
57
|
+
{ id: "email-verification", name: "Email Verification" },
|
|
58
|
+
{ id: "email-otp-challenge", name: "Email OTP Challenge" },
|
|
59
|
+
{ id: "organizations", name: "Organizations" },
|
|
60
|
+
{ id: "invitation", name: "Invitation" },
|
|
61
|
+
{ id: "common", name: "Common" },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Common languages
|
|
65
|
+
const LANGUAGES = [
|
|
66
|
+
{ id: "en", name: "English" },
|
|
67
|
+
{ id: "es", name: "Spanish" },
|
|
68
|
+
{ id: "fr", name: "French" },
|
|
69
|
+
{ id: "de", name: "German" },
|
|
70
|
+
{ id: "it", name: "Italian" },
|
|
71
|
+
{ id: "pt", name: "Portuguese" },
|
|
72
|
+
{ id: "nl", name: "Dutch" },
|
|
73
|
+
{ id: "ja", name: "Japanese" },
|
|
74
|
+
{ id: "ko", name: "Korean" },
|
|
75
|
+
{ id: "zh", name: "Chinese" },
|
|
76
|
+
{ id: "sv", name: "Swedish" },
|
|
77
|
+
{ id: "nb", name: "Norwegian" },
|
|
78
|
+
{ id: "fi", name: "Finnish" },
|
|
79
|
+
{ id: "da", name: "Danish" },
|
|
80
|
+
{ id: "pl", name: "Polish" },
|
|
81
|
+
{ id: "cs", name: "Czech" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// Default text keys for each screen type
|
|
85
|
+
const DEFAULT_TEXT_KEYS: Record<string, string[]> = {
|
|
86
|
+
login: [
|
|
87
|
+
"pageTitle",
|
|
88
|
+
"title",
|
|
89
|
+
"description",
|
|
90
|
+
"buttonText",
|
|
91
|
+
"signupActionLinkText",
|
|
92
|
+
"signupActionText",
|
|
93
|
+
"forgotPasswordText",
|
|
94
|
+
"usernameLabel",
|
|
95
|
+
"usernameOrEmailLabel",
|
|
96
|
+
"emailLabel",
|
|
97
|
+
"phoneLabel",
|
|
98
|
+
"passwordLabel",
|
|
99
|
+
"separatorText",
|
|
100
|
+
"continueWithText",
|
|
101
|
+
"invitationTitle",
|
|
102
|
+
"invitationDescription",
|
|
103
|
+
"alertListTitle",
|
|
104
|
+
"wrongEmailOrPasswordErrorText",
|
|
105
|
+
"invalidEmailErrorText",
|
|
106
|
+
"passwordRequiredErrorText",
|
|
107
|
+
"captchaErrorText",
|
|
108
|
+
],
|
|
109
|
+
signup: [
|
|
110
|
+
"pageTitle",
|
|
111
|
+
"title",
|
|
112
|
+
"description",
|
|
113
|
+
"buttonText",
|
|
114
|
+
"loginActionLinkText",
|
|
115
|
+
"loginActionText",
|
|
116
|
+
"usernameLabel",
|
|
117
|
+
"emailLabel",
|
|
118
|
+
"phoneLabel",
|
|
119
|
+
"passwordLabel",
|
|
120
|
+
"confirmPasswordLabel",
|
|
121
|
+
"termsText",
|
|
122
|
+
"privacyPolicyText",
|
|
123
|
+
"separatorText",
|
|
124
|
+
"continueWithText",
|
|
125
|
+
],
|
|
126
|
+
"reset-password": [
|
|
127
|
+
"pageTitle",
|
|
128
|
+
"title",
|
|
129
|
+
"description",
|
|
130
|
+
"buttonText",
|
|
131
|
+
"backToLoginText",
|
|
132
|
+
"emailLabel",
|
|
133
|
+
"successTitle",
|
|
134
|
+
"successDescription",
|
|
135
|
+
],
|
|
136
|
+
common: [
|
|
137
|
+
"alertListTitle",
|
|
138
|
+
"showPasswordText",
|
|
139
|
+
"hidePasswordText",
|
|
140
|
+
"continueText",
|
|
141
|
+
"orText",
|
|
142
|
+
"termsOfServiceText",
|
|
143
|
+
"privacyPolicyText",
|
|
144
|
+
"contactSupportText",
|
|
145
|
+
"copyrightText",
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Remove null/undefined values from an object
|
|
150
|
+
function removeNullValues(
|
|
151
|
+
obj: Record<string, unknown>,
|
|
152
|
+
): Record<string, unknown> {
|
|
153
|
+
const result: Record<string, unknown> = {};
|
|
154
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
155
|
+
if (value === null || value === undefined) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
159
|
+
const cleaned = removeNullValues(value as Record<string, unknown>);
|
|
160
|
+
if (Object.keys(cleaned).length > 0) {
|
|
161
|
+
result[key] = cleaned;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
result[key] = value;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface CustomTextEntry {
|
|
171
|
+
prompt: string;
|
|
172
|
+
language: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function CustomTextTab() {
|
|
176
|
+
const record = useRecordContext();
|
|
177
|
+
const dataProvider = useDataProvider();
|
|
178
|
+
const notify = useNotify();
|
|
179
|
+
const refresh = useRefresh();
|
|
180
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
181
|
+
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
182
|
+
const [selectedEntry, setSelectedEntry] = useState<CustomTextEntry | null>(
|
|
183
|
+
null,
|
|
184
|
+
);
|
|
185
|
+
const [editingTexts, setEditingTexts] = useState<Record<string, string>>({});
|
|
186
|
+
const [newPrompt, setNewPrompt] = useState("");
|
|
187
|
+
const [newLanguage, setNewLanguage] = useState("en");
|
|
188
|
+
const [loading, setLoading] = useState(false);
|
|
189
|
+
|
|
190
|
+
const customTextEntries: CustomTextEntry[] = record?.customTextEntries || [];
|
|
191
|
+
|
|
192
|
+
const handleAdd = useCallback(() => {
|
|
193
|
+
setNewPrompt("");
|
|
194
|
+
setNewLanguage("en");
|
|
195
|
+
setDialogOpen(true);
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
const handleCreate = useCallback(async () => {
|
|
199
|
+
if (!newPrompt || !newLanguage) {
|
|
200
|
+
notify("Please select a screen and language", { type: "warning" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setLoading(true);
|
|
205
|
+
try {
|
|
206
|
+
// Get default text keys for this screen
|
|
207
|
+
const defaultKeys = DEFAULT_TEXT_KEYS[newPrompt] || [];
|
|
208
|
+
const initialTexts: Record<string, string> = {};
|
|
209
|
+
defaultKeys.forEach((key) => {
|
|
210
|
+
initialTexts[key] = "";
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await dataProvider.create("custom-text", {
|
|
214
|
+
data: {
|
|
215
|
+
prompt: newPrompt,
|
|
216
|
+
language: newLanguage,
|
|
217
|
+
texts: initialTexts,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
notify("Custom text created successfully", { type: "success" });
|
|
221
|
+
setDialogOpen(false);
|
|
222
|
+
refresh();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
notify("Error creating custom text", { type: "error" });
|
|
225
|
+
} finally {
|
|
226
|
+
setLoading(false);
|
|
227
|
+
}
|
|
228
|
+
}, [dataProvider, newPrompt, newLanguage, notify, refresh]);
|
|
229
|
+
|
|
230
|
+
const handleEdit = useCallback(
|
|
231
|
+
async (entry: CustomTextEntry) => {
|
|
232
|
+
setLoading(true);
|
|
233
|
+
try {
|
|
234
|
+
const result = await dataProvider.getOne("custom-text", {
|
|
235
|
+
id: `${entry.prompt}:${entry.language}`,
|
|
236
|
+
});
|
|
237
|
+
setSelectedEntry(entry);
|
|
238
|
+
setEditingTexts(result.data.texts || {});
|
|
239
|
+
setEditDialogOpen(true);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
notify("Error loading custom text", { type: "error" });
|
|
242
|
+
} finally {
|
|
243
|
+
setLoading(false);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
[dataProvider, notify],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const handleSave = useCallback(async () => {
|
|
250
|
+
if (!selectedEntry) return;
|
|
251
|
+
|
|
252
|
+
setLoading(true);
|
|
253
|
+
try {
|
|
254
|
+
await dataProvider.update("custom-text", {
|
|
255
|
+
id: `${selectedEntry.prompt}:${selectedEntry.language}`,
|
|
256
|
+
data: { texts: editingTexts },
|
|
257
|
+
previousData: {},
|
|
258
|
+
});
|
|
259
|
+
notify("Custom text updated successfully", { type: "success" });
|
|
260
|
+
setEditDialogOpen(false);
|
|
261
|
+
refresh();
|
|
262
|
+
} catch (error) {
|
|
263
|
+
notify("Error updating custom text", { type: "error" });
|
|
264
|
+
} finally {
|
|
265
|
+
setLoading(false);
|
|
266
|
+
}
|
|
267
|
+
}, [dataProvider, selectedEntry, editingTexts, notify, refresh]);
|
|
268
|
+
|
|
269
|
+
const handleDelete = useCallback(
|
|
270
|
+
async (entry: CustomTextEntry) => {
|
|
271
|
+
if (
|
|
272
|
+
!window.confirm(
|
|
273
|
+
`Delete custom text for ${entry.prompt} (${entry.language})?`,
|
|
274
|
+
)
|
|
275
|
+
) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setLoading(true);
|
|
280
|
+
try {
|
|
281
|
+
await dataProvider.delete("custom-text", {
|
|
282
|
+
id: `${entry.prompt}:${entry.language}`,
|
|
283
|
+
});
|
|
284
|
+
notify("Custom text deleted successfully", { type: "success" });
|
|
285
|
+
refresh();
|
|
286
|
+
} catch (error) {
|
|
287
|
+
notify("Error deleting custom text", { type: "error" });
|
|
288
|
+
} finally {
|
|
289
|
+
setLoading(false);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[dataProvider, notify, refresh],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const handleTextChange = useCallback((key: string, value: string) => {
|
|
296
|
+
setEditingTexts((prev) => ({ ...prev, [key]: value }));
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
299
|
+
const handleAddTextKey = useCallback(() => {
|
|
300
|
+
const key = window.prompt("Enter new text key:");
|
|
301
|
+
if (key && !editingTexts[key]) {
|
|
302
|
+
setEditingTexts((prev) => ({ ...prev, [key]: "" }));
|
|
303
|
+
}
|
|
304
|
+
}, [editingTexts]);
|
|
305
|
+
|
|
306
|
+
const handleRemoveTextKey = useCallback((key: string) => {
|
|
307
|
+
setEditingTexts((prev) => {
|
|
308
|
+
const next = { ...prev };
|
|
309
|
+
delete next[key];
|
|
310
|
+
return next;
|
|
311
|
+
});
|
|
312
|
+
}, []);
|
|
313
|
+
|
|
314
|
+
const getScreenName = (prompt: string) => {
|
|
315
|
+
return PROMPT_SCREENS.find((s) => s.id === prompt)?.name || prompt;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const getLanguageName = (lang: string) => {
|
|
319
|
+
return LANGUAGES.find((l) => l.id === lang)?.name || lang;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<Box>
|
|
324
|
+
<Box
|
|
325
|
+
display="flex"
|
|
326
|
+
justifyContent="space-between"
|
|
327
|
+
alignItems="center"
|
|
328
|
+
mb={2}
|
|
329
|
+
>
|
|
330
|
+
<Typography variant="h6">Custom Text</Typography>
|
|
331
|
+
<Button
|
|
332
|
+
variant="contained"
|
|
333
|
+
startIcon={<AddIcon />}
|
|
334
|
+
onClick={handleAdd}
|
|
335
|
+
disabled={loading}
|
|
336
|
+
>
|
|
337
|
+
Add Custom Text
|
|
338
|
+
</Button>
|
|
339
|
+
</Box>
|
|
340
|
+
|
|
341
|
+
<Typography variant="body2" color="textSecondary" paragraph>
|
|
342
|
+
Customize button labels, messages, and screen texts in different
|
|
343
|
+
languages. Custom text applies only to Universal Login screens.
|
|
344
|
+
</Typography>
|
|
345
|
+
|
|
346
|
+
{customTextEntries.length === 0 ? (
|
|
347
|
+
<Typography color="textSecondary">
|
|
348
|
+
No custom text configured yet. Click "Add Custom Text" to create your
|
|
349
|
+
first customization.
|
|
350
|
+
</Typography>
|
|
351
|
+
) : (
|
|
352
|
+
<TableContainer component={Paper}>
|
|
353
|
+
<Table>
|
|
354
|
+
<TableHead>
|
|
355
|
+
<TableRow>
|
|
356
|
+
<TableCell>Screen</TableCell>
|
|
357
|
+
<TableCell>Language</TableCell>
|
|
358
|
+
<TableCell align="right">Actions</TableCell>
|
|
359
|
+
</TableRow>
|
|
360
|
+
</TableHead>
|
|
361
|
+
<TableBody>
|
|
362
|
+
{customTextEntries.map((entry) => (
|
|
363
|
+
<TableRow key={`${entry.prompt}:${entry.language}`}>
|
|
364
|
+
<TableCell>{getScreenName(entry.prompt)}</TableCell>
|
|
365
|
+
<TableCell>{getLanguageName(entry.language)}</TableCell>
|
|
366
|
+
<TableCell align="right">
|
|
367
|
+
<IconButton
|
|
368
|
+
onClick={() => handleEdit(entry)}
|
|
369
|
+
disabled={loading}
|
|
370
|
+
size="small"
|
|
371
|
+
>
|
|
372
|
+
<EditIcon />
|
|
373
|
+
</IconButton>
|
|
374
|
+
<IconButton
|
|
375
|
+
onClick={() => handleDelete(entry)}
|
|
376
|
+
disabled={loading}
|
|
377
|
+
size="small"
|
|
378
|
+
color="error"
|
|
379
|
+
>
|
|
380
|
+
<DeleteIcon />
|
|
381
|
+
</IconButton>
|
|
382
|
+
</TableCell>
|
|
383
|
+
</TableRow>
|
|
384
|
+
))}
|
|
385
|
+
</TableBody>
|
|
386
|
+
</Table>
|
|
387
|
+
</TableContainer>
|
|
388
|
+
)}
|
|
389
|
+
|
|
390
|
+
{/* Create Dialog */}
|
|
391
|
+
<Dialog
|
|
392
|
+
open={dialogOpen}
|
|
393
|
+
onClose={() => setDialogOpen(false)}
|
|
394
|
+
maxWidth="sm"
|
|
395
|
+
fullWidth
|
|
396
|
+
>
|
|
397
|
+
<DialogTitle>Add Custom Text</DialogTitle>
|
|
398
|
+
<DialogContent>
|
|
399
|
+
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
400
|
+
<TextField
|
|
401
|
+
select
|
|
402
|
+
label="Screen"
|
|
403
|
+
value={newPrompt}
|
|
404
|
+
onChange={(e) => setNewPrompt(e.target.value)}
|
|
405
|
+
fullWidth
|
|
406
|
+
>
|
|
407
|
+
{PROMPT_SCREENS.map((screen) => (
|
|
408
|
+
<MenuItem key={screen.id} value={screen.id}>
|
|
409
|
+
{screen.name}
|
|
410
|
+
</MenuItem>
|
|
411
|
+
))}
|
|
412
|
+
</TextField>
|
|
413
|
+
<TextField
|
|
414
|
+
select
|
|
415
|
+
label="Language"
|
|
416
|
+
value={newLanguage}
|
|
417
|
+
onChange={(e) => setNewLanguage(e.target.value)}
|
|
418
|
+
fullWidth
|
|
419
|
+
>
|
|
420
|
+
{LANGUAGES.map((lang) => (
|
|
421
|
+
<MenuItem key={lang.id} value={lang.id}>
|
|
422
|
+
{lang.name}
|
|
423
|
+
</MenuItem>
|
|
424
|
+
))}
|
|
425
|
+
</TextField>
|
|
426
|
+
</Stack>
|
|
427
|
+
</DialogContent>
|
|
428
|
+
<DialogActions>
|
|
429
|
+
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
|
430
|
+
<Button onClick={handleCreate} variant="contained" disabled={loading}>
|
|
431
|
+
Create
|
|
432
|
+
</Button>
|
|
433
|
+
</DialogActions>
|
|
434
|
+
</Dialog>
|
|
435
|
+
|
|
436
|
+
{/* Edit Dialog */}
|
|
437
|
+
<Dialog
|
|
438
|
+
open={editDialogOpen}
|
|
439
|
+
onClose={() => setEditDialogOpen(false)}
|
|
440
|
+
maxWidth="md"
|
|
441
|
+
fullWidth
|
|
442
|
+
>
|
|
443
|
+
<DialogTitle>
|
|
444
|
+
Edit Custom Text -{" "}
|
|
445
|
+
{selectedEntry && getScreenName(selectedEntry.prompt)} (
|
|
446
|
+
{selectedEntry && getLanguageName(selectedEntry.language)})
|
|
447
|
+
</DialogTitle>
|
|
448
|
+
<DialogContent>
|
|
449
|
+
<Box sx={{ mt: 1 }}>
|
|
450
|
+
<Box display="flex" justifyContent="flex-end" mb={2}>
|
|
451
|
+
<Button
|
|
452
|
+
size="small"
|
|
453
|
+
startIcon={<AddIcon />}
|
|
454
|
+
onClick={handleAddTextKey}
|
|
455
|
+
>
|
|
456
|
+
Add Text Key
|
|
457
|
+
</Button>
|
|
458
|
+
</Box>
|
|
459
|
+
<Stack spacing={2}>
|
|
460
|
+
{Object.entries(editingTexts).map(([key, value]) => (
|
|
461
|
+
<Box key={key} display="flex" alignItems="flex-start" gap={1}>
|
|
462
|
+
<TextField
|
|
463
|
+
label={key}
|
|
464
|
+
value={value}
|
|
465
|
+
onChange={(e) => handleTextChange(key, e.target.value)}
|
|
466
|
+
fullWidth
|
|
467
|
+
multiline
|
|
468
|
+
minRows={1}
|
|
469
|
+
maxRows={4}
|
|
470
|
+
/>
|
|
471
|
+
<IconButton
|
|
472
|
+
onClick={() => handleRemoveTextKey(key)}
|
|
473
|
+
size="small"
|
|
474
|
+
color="error"
|
|
475
|
+
>
|
|
476
|
+
<DeleteIcon />
|
|
477
|
+
</IconButton>
|
|
478
|
+
</Box>
|
|
479
|
+
))}
|
|
480
|
+
{Object.keys(editingTexts).length === 0 && (
|
|
481
|
+
<Typography color="textSecondary">
|
|
482
|
+
No text keys configured. Click "Add Text Key" to add
|
|
483
|
+
customizations.
|
|
484
|
+
</Typography>
|
|
485
|
+
)}
|
|
486
|
+
</Stack>
|
|
487
|
+
</Box>
|
|
488
|
+
</DialogContent>
|
|
489
|
+
<DialogActions>
|
|
490
|
+
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
|
|
491
|
+
<Button onClick={handleSave} variant="contained" disabled={loading}>
|
|
492
|
+
Save
|
|
493
|
+
</Button>
|
|
494
|
+
</DialogActions>
|
|
495
|
+
</Dialog>
|
|
496
|
+
</Box>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function PromptsEdit() {
|
|
501
|
+
const transform = (data: Record<string, unknown>) => {
|
|
502
|
+
return removeNullValues(data);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<Edit transform={transform}>
|
|
507
|
+
<TabbedForm>
|
|
508
|
+
<TabbedForm.Tab label="Settings">
|
|
509
|
+
<Typography variant="h6" gutterBottom>
|
|
510
|
+
Prompt Settings
|
|
511
|
+
</Typography>
|
|
512
|
+
<Typography variant="body2" color="textSecondary" paragraph>
|
|
513
|
+
Configure how the login prompts behave for your users.
|
|
514
|
+
</Typography>
|
|
515
|
+
|
|
516
|
+
<Stack spacing={2} sx={{ maxWidth: 600 }}>
|
|
517
|
+
<SelectInput
|
|
518
|
+
source="universal_login_experience"
|
|
519
|
+
label="Universal Login Experience"
|
|
520
|
+
choices={[
|
|
521
|
+
{ id: "new", name: "New Universal Login" },
|
|
522
|
+
{ id: "classic", name: "Classic Universal Login" },
|
|
523
|
+
]}
|
|
524
|
+
helperText="Choose between the new or classic Universal Login experience"
|
|
525
|
+
fullWidth
|
|
526
|
+
/>
|
|
527
|
+
|
|
528
|
+
<BooleanInput
|
|
529
|
+
source="identifier_first"
|
|
530
|
+
label="Identifier First"
|
|
531
|
+
helperText="Show identifier (email/username) field first, then password on a separate screen"
|
|
532
|
+
/>
|
|
533
|
+
|
|
534
|
+
<BooleanInput
|
|
535
|
+
source="password_first"
|
|
536
|
+
label="Password First"
|
|
537
|
+
helperText="Show password field on the first screen along with the identifier"
|
|
538
|
+
/>
|
|
539
|
+
|
|
540
|
+
<BooleanInput
|
|
541
|
+
source="webauthn_platform_first_factor"
|
|
542
|
+
label="WebAuthn Platform First Factor"
|
|
543
|
+
helperText="Enable WebAuthn (passkeys, biometrics) as a first factor authentication option"
|
|
544
|
+
/>
|
|
545
|
+
</Stack>
|
|
546
|
+
</TabbedForm.Tab>
|
|
547
|
+
|
|
548
|
+
<TabbedForm.Tab label="Custom Text">
|
|
549
|
+
<CustomTextTab />
|
|
550
|
+
</TabbedForm.Tab>
|
|
551
|
+
</TabbedForm>
|
|
552
|
+
</Edit>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useRedirect, useBasename } from "react-admin";
|
|
3
|
+
|
|
4
|
+
export function PromptsList() {
|
|
5
|
+
const redirect = useRedirect();
|
|
6
|
+
const basename = useBasename();
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
// For singleton resources, redirect to edit with "prompts" as the ID
|
|
10
|
+
redirect(`${basename}/prompts/prompts`);
|
|
11
|
+
}, [redirect, basename]);
|
|
12
|
+
|
|
13
|
+
return null;
|
|
14
|
+
}
|
package/src/dataProvider.ts
CHANGED
|
@@ -30,8 +30,6 @@ 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
|
-
|
|
35
33
|
if (auth0Domain) {
|
|
36
34
|
// Check if there's a custom REST API URL configured for this domain
|
|
37
35
|
const domains = getDomainFromStorage();
|
|
@@ -40,8 +38,6 @@ export function getDataprovider(auth0Domain?: string) {
|
|
|
40
38
|
(d) => formatDomain(d.url) === formattedAuth0Domain,
|
|
41
39
|
);
|
|
42
40
|
|
|
43
|
-
console.log("[getDataprovider] domains:", domains, "domainConfig:", domainConfig);
|
|
44
|
-
|
|
45
41
|
if (domainConfig?.restApiUrl) {
|
|
46
42
|
// Use the custom REST API URL if configured (ensure HTTPS)
|
|
47
43
|
baseUrl = buildUrlWithProtocol(domainConfig.restApiUrl);
|
|
@@ -54,8 +50,6 @@ export function getDataprovider(auth0Domain?: string) {
|
|
|
54
50
|
baseUrl = buildUrlWithProtocol(baseUrl);
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
console.log("[getDataprovider] final baseUrl:", baseUrl);
|
|
58
|
-
|
|
59
53
|
// TODO - duplicate auth0DataProvider to tenantsDataProvider
|
|
60
54
|
// we are introducing non-auth0 endpoints AND we don't require the tenants-id header
|
|
61
55
|
const provider = auth0DataProvider(
|
|
@@ -80,8 +74,6 @@ export function getDataproviderForTenant(
|
|
|
80
74
|
// Start with a default API URL
|
|
81
75
|
let apiUrl;
|
|
82
76
|
|
|
83
|
-
console.log("[getDataproviderForTenant] tenantId:", tenantId, "auth0Domain:", auth0Domain);
|
|
84
|
-
|
|
85
77
|
if (auth0Domain) {
|
|
86
78
|
// Check if there's a custom REST API URL configured for this domain
|
|
87
79
|
const domains = getDomainFromStorage();
|
|
@@ -90,8 +82,6 @@ export function getDataproviderForTenant(
|
|
|
90
82
|
(d) => formatDomain(d.url) === formattedAuth0Domain,
|
|
91
83
|
);
|
|
92
84
|
|
|
93
|
-
console.log("[getDataproviderForTenant] domains:", domains, "domainConfig:", domainConfig);
|
|
94
|
-
|
|
95
85
|
if (domainConfig?.restApiUrl) {
|
|
96
86
|
// Use the custom REST API URL if configured (ensure HTTPS)
|
|
97
87
|
apiUrl = buildUrlWithProtocol(domainConfig.restApiUrl);
|
|
@@ -107,8 +97,6 @@ export function getDataproviderForTenant(
|
|
|
107
97
|
// Ensure apiUrl doesn't end with a slash
|
|
108
98
|
apiUrl = apiUrl.replace(/\/$/, "");
|
|
109
99
|
|
|
110
|
-
console.log("[getDataproviderForTenant] final apiUrl:", apiUrl);
|
|
111
|
-
|
|
112
100
|
// Create a dynamic httpClient that checks single-tenant mode at REQUEST TIME
|
|
113
101
|
// This is important because the mode may not be known when the dataProvider is created
|
|
114
102
|
const dynamicHttpClient = (url: string, options?: any) => {
|