@authhero/react-admin 0.17.0 → 0.19.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,24 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 47fe928: Refactor create authhero
8
+ - f4b74e7: Add widget to react-admin
9
+
10
+ ### Patch Changes
11
+
12
+ - Updated dependencies [f4b74e7]
13
+ - Updated dependencies [b6d3411]
14
+ - @authhero/widget@0.5.0
15
+
16
+ ## 0.18.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 928d358: Add userinfo hook
21
+
3
22
  ## 0.17.0
4
23
 
5
24
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
+ "packageManager": "pnpm@10.20.0",
4
5
  "private": false,
5
6
  "repository": {
6
7
  "type": "git",
@@ -8,6 +9,8 @@
8
9
  },
9
10
  "dependencies": {
10
11
  "@auth0/auth0-spa-js": "^2.1.3",
12
+ "@authhero/adapter-interfaces": "^0.116.0",
13
+ "@authhero/widget": "^0.4.1",
11
14
  "@mui/icons-material": "^7.1.0",
12
15
  "@mui/material": "^7.1.0",
13
16
  "@tiptap/extension-link": "^3.13.0",
@@ -43,13 +46,11 @@
43
46
  "eslint-plugin-react": "^7.37.5",
44
47
  "eslint-plugin-react-hooks": "^5.2.0",
45
48
  "jsdom": "^26.1.0",
46
- "pnpm": "^8.15.3",
47
49
  "prettier": "^3.5.3",
48
50
  "typescript": "^5.8.3",
49
51
  "vite": "^6.3.5",
50
52
  "vitest": "^3.1.3"
51
53
  },
52
- "packageManager": "pnpm@8.15.3",
53
54
  "scripts": {
54
55
  "dev": "vite",
55
56
  "build": "pnpm install --no-frozen-lockfile && vite build",
package/src/App.tsx CHANGED
@@ -43,12 +43,13 @@ import DnsIcon from "@mui/icons-material/Dns";
43
43
  import PaletteIcon from "@mui/icons-material/Palette";
44
44
  import StorageIcon from "@mui/icons-material/Storage";
45
45
  import AccountTreeIcon from "@mui/icons-material/AccountTree";
46
- import { useMemo, useState } from "react";
46
+ import { useMemo, useState, useEffect } from "react";
47
47
  import { RoleCreate, RoleEdit, RoleList } from "./components/roles";
48
48
  import SecurityIcon from "@mui/icons-material/Security";
49
49
  import { SettingsList, SettingsEdit } from "./components/settings";
50
50
  import { CertificateErrorDialog } from "./components/CertificateErrorDialog";
51
51
  import { ActivityDashboard } from "./components/activity";
52
+ import { buildUrlWithProtocol } from "./utils/domainUtils";
52
53
 
53
54
  interface AppProps {
54
55
  tenantId: string;
@@ -58,11 +59,61 @@ interface AppProps {
58
59
 
59
60
  export function App(props: AppProps) {
60
61
  const [certErrorUrl, setCertErrorUrl] = useState<string | null>(null);
61
-
62
+
62
63
  // Use a default domain for now - in the working project, domain selection might be handled differently
63
64
  const selectedDomain =
64
65
  props.initialDomain || import.meta.env.VITE_AUTH0_DOMAIN || "";
65
66
 
67
+ // Check if we've already verified single-tenant mode for THIS domain
68
+ // The flag is stored as "domain|true" or "domain|false" to ensure we re-check when domain changes
69
+ // Using | as separator since domains can contain : (e.g., localhost:3000)
70
+ const storedFlag = sessionStorage.getItem('isSingleTenant');
71
+ const separatorIndex = storedFlag?.lastIndexOf('|') ?? -1;
72
+ const [storedDomain, storedValue] = separatorIndex > -1
73
+ ? [storedFlag!.substring(0, separatorIndex), storedFlag!.substring(separatorIndex + 1)]
74
+ : [null, storedFlag];
75
+
76
+ const [isSingleTenantChecked, setIsSingleTenantChecked] = useState<boolean>(
77
+ // Only skip check if we have a flag for the SAME domain
78
+ storedDomain === selectedDomain && storedValue !== null
79
+ );
80
+
81
+ // Check for single-tenant mode on mount if not already checked
82
+ // This handles the case where user navigates directly to /{tenantId} without going through /tenants
83
+ useEffect(() => {
84
+ if (isSingleTenantChecked || !selectedDomain) {
85
+ return;
86
+ }
87
+
88
+ const checkSingleTenant = async () => {
89
+ const apiUrl = buildUrlWithProtocol(selectedDomain);
90
+ try {
91
+ // Try to fetch the tenants endpoint - if it exists, we're in multi-tenant mode
92
+ const response = await fetch(`${apiUrl}/api/v2/tenants?per_page=1`, {
93
+ method: 'GET',
94
+ headers: {
95
+ 'Content-Type': 'application/json',
96
+ },
97
+ });
98
+
99
+ // If we get a 401/403 (auth required) or 200, the endpoint exists -> multi-tenant
100
+ // If we get a 404, the endpoint doesn't exist -> single-tenant
101
+ if (response.status === 404) {
102
+ // Store domain|value so we know which domain was checked (using | since domain can contain :)
103
+ sessionStorage.setItem('isSingleTenant', `${selectedDomain}|true`);
104
+ } else {
105
+ sessionStorage.setItem('isSingleTenant', `${selectedDomain}|false`);
106
+ }
107
+ } catch {
108
+ // Network error or endpoint doesn't exist -> assume single-tenant
109
+ sessionStorage.setItem('isSingleTenant', `${selectedDomain}|true`);
110
+ }
111
+ setIsSingleTenantChecked(true);
112
+ };
113
+
114
+ checkSingleTenant();
115
+ }, [selectedDomain, isSingleTenantChecked]);
116
+
66
117
  // Use memoization for creating the auth provider to prevent re-authentication
67
118
  const authProvider = useMemo(() => {
68
119
  if (!selectedDomain) return null;
@@ -71,6 +122,7 @@ export function App(props: AppProps) {
71
122
 
72
123
  // Memoize the data provider to prevent unnecessary re-creations
73
124
  // Wrap it to catch certificate errors
125
+ // Re-create when isSingleTenantChecked changes to pick up the new sessionStorage value
74
126
  const dataProvider = useMemo(() => {
75
127
  const baseProvider = getDataproviderForTenant(
76
128
  props.tenantId,
@@ -97,12 +149,17 @@ export function App(props: AppProps) {
97
149
  }
98
150
  }
99
151
  return wrappedProvider;
100
- }, [props.tenantId, selectedDomain]);
152
+ }, [props.tenantId, selectedDomain, isSingleTenantChecked]);
101
153
 
102
154
  const handleCloseCertError = () => {
103
155
  setCertErrorUrl(null);
104
156
  };
105
157
 
158
+ // If not done checking single-tenant mode, show loading
159
+ if (!isSingleTenantChecked) {
160
+ return <div>Checking tenant mode...</div>;
161
+ }
162
+
106
163
  // If no domain is selected, show a loading state
107
164
  if (!authProvider || !selectedDomain) {
108
165
  return <div>Loading...</div>;
@@ -1,9 +1,9 @@
1
1
  import { Admin, Resource } from "react-admin";
2
2
  import { getDataprovider } from "./dataProvider";
3
- import { getAuthProvider } from "./authProvider";
3
+ import { getAuthProvider, createAuth0Client } from "./authProvider";
4
4
  import { TenantsList } from "./components/tenants/list";
5
5
  import { TenantsCreate } from "./components/tenants/create";
6
- import { useMemo, useState } from "react";
6
+ import { useMemo, useState, useEffect } from "react";
7
7
  import { Button } from "@mui/material";
8
8
  import { DomainSelector } from "./components/DomainSelector";
9
9
  import { saveSelectedDomainToStorage } from "./utils/domainUtils";
@@ -24,6 +24,8 @@ export function TenantsApp(props: TenantsAppProps = {}) {
24
24
  );
25
25
  const [showDomainDialog, setShowDomainDialog] = useState<boolean>(false);
26
26
  const [certErrorUrl, setCertErrorUrl] = useState<string | null>(null);
27
+ const [isCheckingSingleTenant, setIsCheckingSingleTenant] =
28
+ useState<boolean>(true);
27
29
 
28
30
  // Use useMemo to prevent recreating the auth provider on every render
29
31
  const authProvider = useMemo(
@@ -60,6 +62,70 @@ export function TenantsApp(props: TenantsAppProps = {}) {
60
62
  return wrappedProvider;
61
63
  }, [selectedDomain]);
62
64
 
65
+ // Check for single tenant mode on mount
66
+ useEffect(() => {
67
+ if (!selectedDomain) {
68
+ setIsCheckingSingleTenant(false);
69
+ return;
70
+ }
71
+
72
+ // Try to fetch tenants list
73
+ dataProvider
74
+ .getList("tenants", {
75
+ pagination: { page: 1, perPage: 2 }, // Only need to know if there's 1 or more
76
+ sort: { field: "id", order: "ASC" },
77
+ filter: {},
78
+ })
79
+ .then((result) => {
80
+ // Multi-tenant mode - tenants endpoint exists
81
+ // Mark as multi-tenant and show the tenants list (don't auto-redirect)
82
+ sessionStorage.setItem("isSingleTenant", `${selectedDomain}|false`);
83
+ setIsCheckingSingleTenant(false);
84
+ })
85
+ .catch(async (error) => {
86
+ console.log("Tenants endpoint check:", error);
87
+ // If we get a 404 or any error, the tenants endpoint doesn't exist
88
+ // In single-tenant mode without multi-tenancy package, the endpoint won't exist
89
+
90
+ // Mark as single-tenant mode immediately (before trying to fetch settings)
91
+ // This ensures subsequent requests won't try to use organization tokens
92
+ sessionStorage.setItem("isSingleTenant", `${selectedDomain}|true`);
93
+
94
+ // Try to use the /tenants/settings endpoint which works in single-tenant mode
95
+ // We need to get a token and make a direct fetch to avoid organization logic
96
+ try {
97
+ const apiUrl = selectedDomain.startsWith("http")
98
+ ? selectedDomain
99
+ : `https://${selectedDomain}`;
100
+
101
+ // Get a non-org token
102
+ const auth0Client = createAuth0Client(selectedDomain);
103
+ const token = await auth0Client.getTokenSilently();
104
+
105
+ const response = await fetch(`${apiUrl}/api/v2/tenants/settings`, {
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Authorization: `Bearer ${token}`,
109
+ },
110
+ });
111
+
112
+ if (response.ok) {
113
+ const settings = await response.json();
114
+ if (settings?.id) {
115
+ window.location.href = `/${settings.id}`;
116
+ return;
117
+ }
118
+ }
119
+ } catch (settingsError) {
120
+ console.log("Settings endpoint also failed:", settingsError);
121
+ }
122
+
123
+ // If both endpoints fail, clear the flag and show the tenants list (which will show an error)
124
+ sessionStorage.removeItem("isSingleTenant");
125
+ setIsCheckingSingleTenant(false);
126
+ });
127
+ }, [selectedDomain, dataProvider]);
128
+
63
129
  const openDomainManager = () => {
64
130
  setShowDomainDialog(true);
65
131
  };
@@ -74,6 +140,22 @@ export function TenantsApp(props: TenantsAppProps = {}) {
74
140
  setCertErrorUrl(null);
75
141
  };
76
142
 
143
+ // Show loading while checking for single tenant
144
+ if (isCheckingSingleTenant) {
145
+ return (
146
+ <div
147
+ style={{
148
+ display: "flex",
149
+ justifyContent: "center",
150
+ alignItems: "center",
151
+ height: "100vh",
152
+ }}
153
+ >
154
+ Checking tenant configuration...
155
+ </div>
156
+ );
157
+ }
158
+
77
159
  // Create the domain selector button that will be passed to the AppBar
78
160
  const DomainSelectorButton = (
79
161
  <Button
@@ -1254,6 +1254,22 @@ export default (
1254
1254
  return { data: res.json };
1255
1255
  },
1256
1256
 
1257
- deleteMany: () => Promise.reject("not supporting deleteMany"),
1257
+ deleteMany: async (resource, params) => {
1258
+ const headers = new Headers({ "content-type": "text/plain" });
1259
+ if (tenantId) headers.set("tenant-id", tenantId);
1260
+
1261
+ const deletedIds: typeof params.ids = [];
1262
+
1263
+ for (const id of params.ids) {
1264
+ const resourceUrl = `${resource}/${encodeURIComponent(String(id))}`;
1265
+ await httpClient(`${apiUrl}/api/v2/${resourceUrl}`, {
1266
+ method: "DELETE",
1267
+ headers,
1268
+ });
1269
+ deletedIds.push(id);
1270
+ }
1271
+
1272
+ return { data: deletedIds };
1273
+ },
1258
1274
  };
1259
1275
  };
@@ -189,8 +189,12 @@ export const createManagementClient = async (
189
189
 
190
190
  let token: string;
191
191
 
192
- if (tenantId) {
193
- // When accessing tenant-specific resources, use org-scoped token
192
+ // Check if we're in single-tenant mode
193
+ const storedFlag = sessionStorage.getItem("isSingleTenant");
194
+ const isSingleTenant = storedFlag?.endsWith("|true") || storedFlag === "true";
195
+
196
+ if (tenantId && !isSingleTenant) {
197
+ // When accessing tenant-specific resources in MULTI-TENANT mode, use org-scoped token
194
198
  if (domainConfig.connectionMethod === "login") {
195
199
  // For OAuth login, use organization-scoped client
196
200
  const orgAuth0Client = createAuth0ClientForOrg(domainForAuth, tenantId);
@@ -221,9 +225,7 @@ export const createManagementClient = async (
221
225
  });
222
226
 
223
227
  // This won't be reached as loginWithRedirect redirects the page
224
- throw new Error(
225
- `Redirecting to login for organization ${tenantId}`,
226
- );
228
+ throw new Error(`Redirecting to login for organization ${tenantId}`);
227
229
  }
228
230
  } else {
229
231
  // For token/client_credentials, use getOrganizationToken
@@ -66,8 +66,24 @@ export function TenantAppBar(props: TenantAppBarProps) {
66
66
  } as TenantResponse);
67
67
  }
68
68
  })
69
- .catch((error) => {
70
- console.error("Failed to fetch tenant:", error);
69
+ .catch(async (error) => {
70
+ console.error("Failed to fetch tenant list:", error);
71
+
72
+ // In single-tenant mode, the tenants list endpoint might not exist
73
+ // Try to fetch from the settings endpoint instead
74
+ try {
75
+ const settings = await tenantsDataProvider.getOne("tenants", {
76
+ id: "settings",
77
+ });
78
+
79
+ if (settings?.data && settings.data.id === tenantId) {
80
+ setTenant(settings.data as TenantResponse);
81
+ return;
82
+ }
83
+ } catch (settingsError) {
84
+ console.error("Failed to fetch tenant settings:", settingsError);
85
+ }
86
+
71
87
  // Set a minimal tenant object on error
72
88
  setTenant({
73
89
  id: tenantId,
@@ -0,0 +1,421 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useWatch } from "react-hook-form";
3
+ import {
4
+ Box,
5
+ Typography,
6
+ Paper,
7
+ ToggleButtonGroup,
8
+ ToggleButton,
9
+ } from "@mui/material";
10
+ import { useState } from "react";
11
+ import { defineCustomElements } from "@authhero/widget/loader";
12
+
13
+ // Initialize the widget custom elements
14
+ if (typeof window !== "undefined") {
15
+ defineCustomElements(window);
16
+ }
17
+
18
+ // Types for the widget screen configuration
19
+ interface FormComponent {
20
+ id: string;
21
+ type: string;
22
+ category: "FIELD" | "BLOCK" | "WIDGET";
23
+ visible: boolean;
24
+ label?: string;
25
+ config?: Record<string, unknown>;
26
+ required?: boolean;
27
+ sensitive?: boolean;
28
+ order: number;
29
+ messages?: Array<{ text: string; type: "error" | "success" }>;
30
+ }
31
+
32
+ interface ScreenLink {
33
+ id?: string;
34
+ text: string;
35
+ linkText?: string;
36
+ href: string;
37
+ }
38
+
39
+ interface UiScreen {
40
+ action: string;
41
+ method: string;
42
+ title?: string;
43
+ description?: string;
44
+ components: FormComponent[];
45
+ links?: ScreenLink[];
46
+ messages?: Array<{ text: string; type: "error" | "success" }>;
47
+ }
48
+
49
+ // Sample screen to preview
50
+ const sampleScreen: UiScreen = {
51
+ action: "#",
52
+ method: "POST",
53
+ title: "Welcome",
54
+ description: "Sign in to continue",
55
+ components: [
56
+ {
57
+ id: "social-buttons",
58
+ type: "SOCIAL",
59
+ category: "FIELD",
60
+ visible: true,
61
+ config: {
62
+ providers: ["google-oauth2"],
63
+ },
64
+ order: 0,
65
+ },
66
+ {
67
+ id: "divider",
68
+ type: "DIVIDER",
69
+ category: "BLOCK",
70
+ visible: true,
71
+ order: 1,
72
+ },
73
+ {
74
+ id: "username",
75
+ type: "EMAIL",
76
+ category: "FIELD",
77
+ visible: true,
78
+ label: "Email address",
79
+ config: {
80
+ placeholder: "name@example.com",
81
+ },
82
+ required: true,
83
+ order: 2,
84
+ },
85
+ {
86
+ id: "submit",
87
+ type: "NEXT_BUTTON",
88
+ category: "BLOCK",
89
+ visible: true,
90
+ config: {
91
+ text: "Continue",
92
+ },
93
+ order: 3,
94
+ },
95
+ ],
96
+ links: [
97
+ {
98
+ id: "signup",
99
+ text: "Don't have an account?",
100
+ linkText: "Sign up",
101
+ href: "#",
102
+ },
103
+ ],
104
+ };
105
+
106
+ type PreviewScreen = "login" | "signup" | "password";
107
+
108
+ const screenConfigs: Record<PreviewScreen, UiScreen> = {
109
+ login: sampleScreen,
110
+ signup: {
111
+ ...sampleScreen,
112
+ title: "Create account",
113
+ description: "Sign up to get started",
114
+ components: [
115
+ ...sampleScreen.components.slice(0, 3),
116
+ {
117
+ id: "password",
118
+ type: "PASSWORD",
119
+ category: "FIELD",
120
+ visible: true,
121
+ label: "Password",
122
+ config: {
123
+ placeholder: "Enter your password",
124
+ },
125
+ required: true,
126
+ sensitive: true,
127
+ order: 3,
128
+ },
129
+ {
130
+ id: "submit",
131
+ type: "NEXT_BUTTON",
132
+ category: "BLOCK",
133
+ visible: true,
134
+ config: {
135
+ text: "Sign up",
136
+ },
137
+ order: 4,
138
+ },
139
+ ],
140
+ links: [
141
+ {
142
+ id: "login",
143
+ text: "Already have an account?",
144
+ linkText: "Sign in",
145
+ href: "#",
146
+ },
147
+ ],
148
+ },
149
+ password: {
150
+ action: "#",
151
+ method: "POST",
152
+ title: "Enter your password",
153
+ components: [
154
+ {
155
+ id: "email-display",
156
+ type: "RICH_TEXT",
157
+ category: "BLOCK",
158
+ visible: true,
159
+ config: {
160
+ content: "Signing in as <strong>user@example.com</strong>",
161
+ },
162
+ order: 0,
163
+ },
164
+ {
165
+ id: "password",
166
+ type: "PASSWORD",
167
+ category: "FIELD",
168
+ visible: true,
169
+ label: "Password",
170
+ config: {
171
+ placeholder: "Enter your password",
172
+ },
173
+ required: true,
174
+ sensitive: true,
175
+ order: 1,
176
+ },
177
+ {
178
+ id: "submit",
179
+ type: "NEXT_BUTTON",
180
+ category: "BLOCK",
181
+ visible: true,
182
+ config: {
183
+ text: "Continue",
184
+ },
185
+ order: 2,
186
+ },
187
+ ],
188
+ links: [
189
+ {
190
+ id: "forgot",
191
+ text: "Forgot your password?",
192
+ linkText: "Reset it",
193
+ href: "#",
194
+ },
195
+ ],
196
+ },
197
+ };
198
+
199
+ interface WidgetBranding {
200
+ colors?: {
201
+ primary?: string;
202
+ page_background?:
203
+ | {
204
+ type?: string;
205
+ start?: string;
206
+ end?: string;
207
+ angle_deg?: number;
208
+ }
209
+ | string;
210
+ };
211
+ logo_url?: string;
212
+ favicon_url?: string;
213
+ font?: {
214
+ url?: string;
215
+ };
216
+ }
217
+
218
+ interface WidgetTheme {
219
+ borders?: {
220
+ button_border_radius?: number;
221
+ button_border_weight?: number;
222
+ buttons_style?: "pill" | "rounded" | "sharp";
223
+ input_border_radius?: number;
224
+ input_border_weight?: number;
225
+ inputs_style?: "pill" | "rounded" | "sharp";
226
+ show_widget_shadow?: boolean;
227
+ widget_border_weight?: number;
228
+ widget_corner_radius?: number;
229
+ };
230
+ colors?: {
231
+ base_focus_color?: string;
232
+ base_hover_color?: string;
233
+ body_text?: string;
234
+ error?: string;
235
+ header?: string;
236
+ icons?: string;
237
+ input_background?: string;
238
+ input_border?: string;
239
+ input_filled_text?: string;
240
+ input_labels_placeholders?: string;
241
+ links_focused_components?: string;
242
+ primary_button?: string;
243
+ primary_button_label?: string;
244
+ secondary_button_border?: string;
245
+ secondary_button_label?: string;
246
+ success?: string;
247
+ widget_background?: string;
248
+ widget_border?: string;
249
+ };
250
+ fonts?: {
251
+ body_text?: { bold?: boolean; size?: number };
252
+ buttons_text?: { bold?: boolean; size?: number };
253
+ font_url?: string;
254
+ input_labels?: { bold?: boolean; size?: number };
255
+ links?: { bold?: boolean; size?: number };
256
+ links_style?: "normal" | "underlined";
257
+ reference_text_size?: number;
258
+ subtitle?: { bold?: boolean; size?: number };
259
+ title?: { bold?: boolean; size?: number };
260
+ };
261
+ page_background?: {
262
+ background_color?: string;
263
+ background_image_url?: string;
264
+ page_layout?: "center" | "left" | "right";
265
+ };
266
+ widget?: {
267
+ header_text_alignment?: "center" | "left" | "right";
268
+ logo_height?: number;
269
+ logo_position?: "center" | "left" | "none" | "right";
270
+ logo_url?: string;
271
+ social_buttons_layout?: "bottom" | "top";
272
+ };
273
+ }
274
+
275
+ export function BrandingPreview() {
276
+ const widgetRef = useRef<HTMLElement>(null);
277
+ const [previewScreen, setPreviewScreen] = useState<PreviewScreen>("login");
278
+
279
+ // Watch for form changes
280
+ const colors = useWatch({ name: "colors" });
281
+ const logoUrl = useWatch({ name: "logo_url" });
282
+ const faviconUrl = useWatch({ name: "favicon_url" });
283
+ const font = useWatch({ name: "font" });
284
+ const themes = useWatch({ name: "themes" });
285
+
286
+ // Convert form values to widget branding format
287
+ const branding: WidgetBranding = {
288
+ colors: {
289
+ primary: colors?.primary,
290
+ page_background:
291
+ typeof colors?.page_background === "string"
292
+ ? { type: "solid", start: colors.page_background }
293
+ : colors?.page_background,
294
+ },
295
+ logo_url: logoUrl,
296
+ favicon_url: faviconUrl,
297
+ font: font,
298
+ };
299
+
300
+ // Convert themes to widget theme format
301
+ const theme: WidgetTheme | undefined = themes
302
+ ? {
303
+ borders: themes.borders,
304
+ colors: themes.colors,
305
+ fonts: themes.fonts,
306
+ page_background: themes.page_background,
307
+ widget: themes.widget,
308
+ }
309
+ : undefined;
310
+
311
+ // Get background style for the preview container
312
+ const getBackgroundStyle = () => {
313
+ // Check theme page_background first
314
+ if (theme?.page_background?.background_color) {
315
+ return { background: theme.page_background.background_color };
316
+ }
317
+
318
+ // Fall back to branding page_background
319
+ const bg = branding.colors?.page_background;
320
+ if (!bg) return { background: "#f5f5f5" };
321
+
322
+ if (typeof bg === "string") {
323
+ return { background: bg };
324
+ }
325
+
326
+ if (bg.type === "linear-gradient" && bg.start && bg.end) {
327
+ const angle = bg.angle_deg ?? 180;
328
+ return {
329
+ background: `linear-gradient(${angle}deg, ${bg.start}, ${bg.end})`,
330
+ };
331
+ }
332
+
333
+ if (bg.start) {
334
+ return { background: bg.start };
335
+ }
336
+
337
+ return { background: "#f5f5f5" };
338
+ };
339
+
340
+ // Update widget props when values change
341
+ useEffect(() => {
342
+ if (widgetRef.current) {
343
+ const widget = widgetRef.current.querySelector(
344
+ "authhero-widget",
345
+ ) as HTMLElement & {
346
+ screen?: UiScreen;
347
+ branding?: WidgetBranding;
348
+ theme?: WidgetTheme;
349
+ };
350
+ if (widget) {
351
+ widget.screen = screenConfigs[previewScreen];
352
+ widget.branding = branding;
353
+ if (theme) {
354
+ widget.theme = theme;
355
+ }
356
+ }
357
+ }
358
+ }, [branding, theme, previewScreen]);
359
+
360
+ return (
361
+ <Paper
362
+ elevation={0}
363
+ sx={{
364
+ p: 2,
365
+ height: "100%",
366
+ display: "flex",
367
+ flexDirection: "column",
368
+ bgcolor: "grey.100",
369
+ borderRadius: 2,
370
+ }}
371
+ >
372
+ <Box
373
+ sx={{
374
+ display: "flex",
375
+ justifyContent: "space-between",
376
+ alignItems: "center",
377
+ mb: 2,
378
+ }}
379
+ >
380
+ <Typography variant="subtitle2" color="text.secondary">
381
+ Preview
382
+ </Typography>
383
+ <ToggleButtonGroup
384
+ size="small"
385
+ value={previewScreen}
386
+ exclusive
387
+ onChange={(_, value) => value && setPreviewScreen(value)}
388
+ >
389
+ <ToggleButton value="login">Login</ToggleButton>
390
+ <ToggleButton value="password">Password</ToggleButton>
391
+ <ToggleButton value="signup">Signup</ToggleButton>
392
+ </ToggleButtonGroup>
393
+ </Box>
394
+
395
+ <Box
396
+ sx={{
397
+ flex: 1,
398
+ borderRadius: 1,
399
+ overflow: "hidden",
400
+ display: "flex",
401
+ alignItems: "center",
402
+ justifyContent: "center",
403
+ minHeight: 500,
404
+ ...getBackgroundStyle(),
405
+ }}
406
+ >
407
+ {/* Using dangerouslySetInnerHTML to bypass JSX type issues with web components */}
408
+ <div
409
+ ref={widgetRef as React.RefObject<HTMLDivElement>}
410
+ dangerouslySetInnerHTML={{
411
+ __html: `<authhero-widget
412
+ screen='${JSON.stringify(screenConfigs[previewScreen]).replace(/'/g, "&apos;")}'
413
+ branding='${JSON.stringify(branding).replace(/'/g, "&apos;")}'
414
+ ${theme ? `theme='${JSON.stringify(theme).replace(/'/g, "&apos;")}'` : ""}
415
+ ></authhero-widget>`,
416
+ }}
417
+ />
418
+ </Box>
419
+ </Paper>
420
+ );
421
+ }
@@ -9,7 +9,9 @@ import {
9
9
  import { ColorInput } from "react-admin-color-picker";
10
10
  import { useInput, useRecordContext } from "react-admin";
11
11
  import { useState, useEffect } from "react";
12
+ import { Box } from "@mui/material";
12
13
  import { ThemesTab } from "./ThemesTab";
14
+ import { BrandingPreview } from "./BrandingPreview";
13
15
 
14
16
  function PageBackgroundInput(props) {
15
17
  const { field } = useInput(props);
@@ -56,7 +58,7 @@ function PageBackgroundInput(props) {
56
58
  {mode === "color" ? (
57
59
  <>
58
60
  <ColorInput
59
- key={color}
61
+ key="page-background-solid"
60
62
  source={props.source}
61
63
  label="Solid Color"
62
64
  // No value prop, uncontrolled
@@ -79,7 +81,7 @@ function PageBackgroundInput(props) {
79
81
  }
80
82
  />
81
83
  <ColorInput
82
- key={gradient.start}
84
+ key="page-background-start"
83
85
  source="colors.page_background.start"
84
86
  label="Start Color"
85
87
  />
@@ -92,7 +94,7 @@ function PageBackgroundInput(props) {
92
94
  }
93
95
  />
94
96
  <ColorInput
95
- key={gradient.end}
97
+ key="page-background-end"
96
98
  source="colors.page_background.end"
97
99
  label="End Color"
98
100
  />
@@ -119,31 +121,71 @@ function PageBackgroundInput(props) {
119
121
  );
120
122
  }
121
123
 
124
+ // Wrapper component that provides the preview inside the form context
125
+ function BrandingFormContent() {
126
+ return (
127
+ <Box sx={{ display: "flex", gap: 3, p: 0 }}>
128
+ {/* Form Section */}
129
+ <Box sx={{ flex: "1 1 60%", minWidth: 0 }}>
130
+ <TabbedForm>
131
+ <TabbedForm.Tab label="Info">
132
+ <TextInput source="id" />
133
+ <TextInput source="name" />
134
+ <Labeled label={<FieldTitle source="created_at" />}>
135
+ <DateField source="created_at" showTime={true} />
136
+ </Labeled>
137
+ <Labeled label={<FieldTitle source="updated_at" />}>
138
+ <DateField source="updated_at" showTime={true} />
139
+ </Labeled>
140
+ </TabbedForm.Tab>
141
+ <TabbedForm.Tab label="Style">
142
+ <ColorInput source="colors.primary" label="Primary Color" />
143
+ <PageBackgroundInput source="colors.page_background" />
144
+ <TextInput source="favicon_url" label="Favicon URL" />
145
+ <TextInput source="logo_url" label="Logo URL" />
146
+ <TextInput source="font.url" label="Font URL" />
147
+ {/* Preview inside the form context */}
148
+ <Box
149
+ sx={{
150
+ position: "fixed",
151
+ right: 24,
152
+ top: 80,
153
+ width: 400,
154
+ height: "calc(100vh - 120px)",
155
+ display: { xs: "none", lg: "block" },
156
+ zIndex: 1000,
157
+ }}
158
+ >
159
+ <BrandingPreview />
160
+ </Box>
161
+ </TabbedForm.Tab>
162
+ <TabbedForm.Tab label="Themes">
163
+ <ThemesTab />
164
+ {/* Preview inside the form context */}
165
+ <Box
166
+ sx={{
167
+ position: "fixed",
168
+ right: 24,
169
+ top: 80,
170
+ width: 400,
171
+ height: "calc(100vh - 120px)",
172
+ display: { xs: "none", lg: "block" },
173
+ zIndex: 1000,
174
+ }}
175
+ >
176
+ <BrandingPreview />
177
+ </Box>
178
+ </TabbedForm.Tab>
179
+ </TabbedForm>
180
+ </Box>
181
+ </Box>
182
+ );
183
+ }
184
+
122
185
  export function BrandingEdit() {
123
186
  return (
124
187
  <Edit>
125
- <TabbedForm>
126
- <TabbedForm.Tab label="Info">
127
- <TextInput source="id" />
128
- <TextInput source="name" />
129
- <Labeled label={<FieldTitle source="created_at" />}>
130
- <DateField source="created_at" showTime={true} />
131
- </Labeled>
132
- <Labeled label={<FieldTitle source="updated_at" />}>
133
- <DateField source="updated_at" showTime={true} />
134
- </Labeled>
135
- </TabbedForm.Tab>
136
- <TabbedForm.Tab label="Style">
137
- <ColorInput source="colors.primary" label="Primary Color" />
138
- <PageBackgroundInput source="colors.page_background" />
139
- <TextInput source="favicon_url" label="Favicon URL" />
140
- <TextInput source="logo_url" label="Logo URL" />
141
- <TextInput source="font.url" label="Font URL" />
142
- </TabbedForm.Tab>
143
- <TabbedForm.Tab label="Themes">
144
- <ThemesTab />
145
- </TabbedForm.Tab>
146
- </TabbedForm>
188
+ <BrandingFormContent />
147
189
  </Edit>
148
190
  );
149
191
  }
@@ -34,15 +34,20 @@ export function getDataprovider(auth0Domain?: string) {
34
34
  // Check if there's a custom REST API URL configured for this domain
35
35
  const domains = getDomainFromStorage();
36
36
  const formattedAuth0Domain = formatDomain(auth0Domain);
37
- const domainConfig = domains.find((d) => formatDomain(d.url) === formattedAuth0Domain);
37
+ const domainConfig = domains.find(
38
+ (d) => formatDomain(d.url) === formattedAuth0Domain,
39
+ );
38
40
 
39
41
  if (domainConfig?.restApiUrl) {
40
- // Use the custom REST API URL if configured
41
- baseUrl = domainConfig.restApiUrl;
42
+ // Use the custom REST API URL if configured (ensure HTTPS)
43
+ baseUrl = buildUrlWithProtocol(domainConfig.restApiUrl);
42
44
  } else {
43
45
  // Otherwise use the auth domain with HTTPS
44
46
  baseUrl = buildUrlWithProtocol(auth0Domain);
45
47
  }
48
+ } else if (baseUrl) {
49
+ // Ensure env variable URL also uses HTTPS
50
+ baseUrl = buildUrlWithProtocol(baseUrl);
46
51
  }
47
52
 
48
53
  // TODO - duplicate auth0DataProvider to tenantsDataProvider
@@ -73,31 +78,46 @@ export function getDataproviderForTenant(
73
78
  // Check if there's a custom REST API URL configured for this domain
74
79
  const domains = getDomainFromStorage();
75
80
  const formattedAuth0Domain = formatDomain(auth0Domain);
76
- const domainConfig = domains.find((d) => formatDomain(d.url) === formattedAuth0Domain);
81
+ const domainConfig = domains.find(
82
+ (d) => formatDomain(d.url) === formattedAuth0Domain,
83
+ );
77
84
 
78
85
  if (domainConfig?.restApiUrl) {
79
- // Use the custom REST API URL if configured
80
- apiUrl = domainConfig.restApiUrl;
86
+ // Use the custom REST API URL if configured (ensure HTTPS)
87
+ apiUrl = buildUrlWithProtocol(domainConfig.restApiUrl);
81
88
  } else {
82
89
  // Otherwise construct an API URL using the auth0Domain with HTTPS
83
90
  apiUrl = buildUrlWithProtocol(auth0Domain);
84
91
  }
85
92
  } else {
86
- // Fallback to the environment variable
87
- apiUrl = import.meta.env.VITE_AUTH0_API_URL;
93
+ // Fallback to the environment variable (ensure HTTPS)
94
+ apiUrl = buildUrlWithProtocol(import.meta.env.VITE_AUTH0_API_URL || "");
88
95
  }
89
96
 
90
97
  // Ensure apiUrl doesn't end with a slash
91
98
  apiUrl = apiUrl.replace(/\/$/, "");
92
99
 
93
- // Create an organization-scoped HTTP client for this tenant
94
- // This ensures the user has the correct permissions for accessing tenant resources
95
- const organizationHttpClient = createOrganizationHttpClient(tenantId);
100
+ // Create a dynamic httpClient that checks single-tenant mode at REQUEST TIME
101
+ // This is important because the mode may not be known when the dataProvider is created
102
+ const dynamicHttpClient = (url: string, options?: any) => {
103
+ // Check single-tenant mode at request time, not at creation time
104
+ const storedFlag = sessionStorage.getItem("isSingleTenant");
105
+ const isSingleTenant =
106
+ storedFlag?.endsWith("|true") || storedFlag === "true";
96
107
 
97
- // Create the auth0Provider with the API URL, tenant ID, domain, and org-scoped client
108
+ // In single-tenant mode, use the regular authorized client without organization scope
109
+ // In multi-tenant mode, use organization-scoped client for proper access control
110
+ if (isSingleTenant) {
111
+ return authorizedHttpClient(url, options);
112
+ } else {
113
+ return createOrganizationHttpClient(tenantId)(url, options);
114
+ }
115
+ };
116
+
117
+ // Create the auth0Provider with the API URL, tenant ID, domain, and dynamic client
98
118
  const auth0Provider = auth0DataProvider(
99
119
  apiUrl,
100
- organizationHttpClient,
120
+ dynamicHttpClient,
101
121
  tenantId,
102
122
  auth0Domain,
103
123
  );
package/vercel.json CHANGED
@@ -14,4 +14,4 @@
14
14
  }
15
15
  ],
16
16
  "installCommand": "pnpm install --no-frozen-lockfile"
17
- }
17
+ }