@authhero/react-admin 0.29.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,17 @@
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
+
3
15
  ## 0.29.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "packageManager": "pnpm@10.20.0",
5
5
  "private": false,
6
6
  "repository": {
@@ -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>&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";
@@ -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) => {