@authhero/react-admin 0.10.0 → 0.11.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,11 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1c36752: Use org tokens for tenants in admin
8
+
3
9
  ## 0.10.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from "react";
2
2
  import { useNavigate, useLocation } from "react-router-dom";
3
3
  import { CircularProgress, Box, Typography, Alert } from "@mui/material";
4
4
  import { getSelectedDomainFromStorage } from "./utils/domainUtils";
5
- import { createAuth0Client } from "./authProvider";
5
+ import { createAuth0Client, createAuth0ClientForOrg } from "./authProvider";
6
6
 
7
7
  interface AuthCallbackProps {
8
8
  onAuthComplete?: () => void;
@@ -55,19 +55,53 @@ export function AuthCallback({ onAuthComplete }: AuthCallbackProps) {
55
55
  return;
56
56
  }
57
57
 
58
- // Get the auth0 client for the selected domain
59
- const auth0 = createAuth0Client(selectedDomain);
60
-
61
58
  // Mark that we've processed this callback to prevent duplicate processing
62
59
  callbackProcessedRef.current = true;
63
60
 
61
+ // First, try with the non-org client to handle the callback
62
+ // The Auth0 SDK will extract the token, and we can then check if it has org_id
63
+ const auth0 = createAuth0Client(selectedDomain);
64
+
64
65
  // Process the redirect callback
65
66
  // This is critical - it extracts the auth info from the URL and stores it
66
- await auth0.handleRedirectCallback();
67
+ const result = await auth0.handleRedirectCallback();
68
+
69
+ // Get the return URL from appState, defaulting to /tenants
70
+ const returnTo = result?.appState?.returnTo || "/tenants";
71
+
72
+ // Check if the token has an organization by decoding the ID token
73
+ // If returnTo is a tenant path (not /tenants), we need to also cache for that org
74
+ const pathSegments = returnTo.split("/").filter(Boolean);
75
+ const possibleOrgId = pathSegments[0];
76
+
77
+ // If it looks like a tenant path (not 'tenants' itself), create an org client
78
+ // to ensure the token is also cached in the org-specific cache
79
+ if (possibleOrgId && possibleOrgId !== "tenants") {
80
+ try {
81
+ // Get the token from the non-org client and check if it has org_id
82
+ const token = await auth0.getIdTokenClaims();
83
+ if (token?.org_id === possibleOrgId) {
84
+ // Create the org client - this will also cache the token in the org-specific cache
85
+ const orgAuth0 = createAuth0ClientForOrg(
86
+ selectedDomain,
87
+ possibleOrgId,
88
+ );
89
+ // Try to get a token to populate the org cache
90
+ await orgAuth0.getTokenSilently().catch(() => {
91
+ // It's ok if this fails - the main callback was processed
92
+ console.log(
93
+ `[AuthCallback] Org token caching for ${possibleOrgId} deferred`,
94
+ );
95
+ });
96
+ }
97
+ } catch {
98
+ // Non-critical - continue with navigation
99
+ }
100
+ }
67
101
 
68
102
  // We're being redirected back from the authentication provider
69
- // Force redirect to the main application
70
- forceNavigate("/tenants");
103
+ // Navigate to the original URL or /tenants
104
+ forceNavigate(returnTo);
71
105
  } catch (err) {
72
106
  // Only set error for real errors, not for "already processed" errors
73
107
  if (err instanceof Error) {
@@ -1,4 +1,4 @@
1
- import { Auth0AuthProvider, httpClient } from "ra-auth-auth0";
1
+ import { Auth0AuthProvider } from "ra-auth-auth0";
2
2
  import { Auth0Client } from "@auth0/auth0-spa-js";
3
3
  import { ManagementClient } from "auth0";
4
4
  import {
@@ -7,7 +7,10 @@ import {
7
7
  getDomainFromStorage,
8
8
  buildUrlWithProtocol,
9
9
  } from "./utils/domainUtils";
10
- import getToken from "./utils/tokenUtils";
10
+ import getToken, {
11
+ clearOrganizationTokenCache,
12
+ OrgCache,
13
+ } from "./utils/tokenUtils";
11
14
 
12
15
  // Track auth requests globally
13
16
  let authRequestInProgress = false;
@@ -17,13 +20,16 @@ const AUTH_REQUEST_DEBOUNCE_MS = 1000; // Debounce time between auth requests
17
20
  // Store active sessions by domain
18
21
  const activeSessions = new Map<string, boolean>();
19
22
 
20
- // Cache for auth0 clients
23
+ // Cache for auth0 clients (domain only, no org)
21
24
  const auth0ClientCache = new Map<string, Auth0Client>();
22
25
 
26
+ // Cache for organization-specific auth0 clients (domain:orgId)
27
+ const auth0OrgClientCache = new Map<string, Auth0Client>();
28
+
23
29
  // Cache for management clients
24
30
  const managementClientCache = new Map<string, ManagementClient>();
25
31
 
26
- // Create a function to get Auth0Client with the specified domain
32
+ // Create a function to get Auth0Client with the specified domain (no organization)
27
33
  export const createAuth0Client = (domain: string) => {
28
34
  // Check cache first to avoid creating multiple clients for the same domain
29
35
  if (auth0ClientCache.has(domain)) {
@@ -37,13 +43,17 @@ export const createAuth0Client = (domain: string) => {
37
43
  const currentUrl = new URL(window.location.href);
38
44
  const redirectUri = `${currentUrl.protocol}//${currentUrl.host}/auth-callback`;
39
45
 
46
+ // Use explicit audience or fallback to the domain's API
47
+ const audience =
48
+ import.meta.env.VITE_AUTH0_AUDIENCE || `https://${domain}/api/v2/`;
49
+
40
50
  const auth0Client = new Auth0Client({
41
51
  domain: fullDomain,
42
52
  clientId: getClientIdFromStorage(domain),
43
53
  cacheLocation: "localstorage",
44
54
  useRefreshTokens: true,
45
55
  authorizationParams: {
46
- audience: import.meta.env.VITE_AUTH0_AUDIENCE,
56
+ audience,
47
57
  redirect_uri: redirectUri,
48
58
  scope: "openid profile email auth:read auth:write",
49
59
  },
@@ -101,6 +111,52 @@ export const createAuth0Client = (domain: string) => {
101
111
  return auth0Client;
102
112
  };
103
113
 
114
+ /**
115
+ * Create an Auth0Client for a specific organization with isolated cache.
116
+ * This ensures tokens for different organizations don't interfere with each other.
117
+ */
118
+ export const createAuth0ClientForOrg = (
119
+ domain: string,
120
+ organizationId: string,
121
+ ) => {
122
+ const cacheKey = `${domain}:${organizationId}`;
123
+
124
+ // Check cache first
125
+ if (auth0OrgClientCache.has(cacheKey)) {
126
+ return auth0OrgClientCache.get(cacheKey)!;
127
+ }
128
+
129
+ // Build full domain URL with HTTPS
130
+ const fullDomain = buildUrlWithProtocol(domain);
131
+
132
+ // Get redirect URI from current app URL
133
+ const currentUrl = new URL(window.location.href);
134
+ const redirectUri = `${currentUrl.protocol}//${currentUrl.host}/auth-callback`;
135
+
136
+ // Use explicit audience or fallback to the domain's API
137
+ const audience =
138
+ import.meta.env.VITE_AUTH0_AUDIENCE || `https://${domain}/api/v2/`;
139
+
140
+ const auth0Client = new Auth0Client({
141
+ domain: fullDomain,
142
+ clientId: getClientIdFromStorage(domain),
143
+ useRefreshTokens: true,
144
+ // Use organization-specific cache to isolate tokens
145
+ // Note: Don't use cacheLocation when providing a custom cache
146
+ cache: new OrgCache(organizationId),
147
+ authorizationParams: {
148
+ audience,
149
+ redirect_uri: redirectUri,
150
+ scope: "openid profile email auth:read auth:write offline_access",
151
+ organization: organizationId,
152
+ },
153
+ });
154
+
155
+ // Store in cache
156
+ auth0OrgClientCache.set(cacheKey, auth0Client);
157
+ return auth0Client;
158
+ };
159
+
104
160
  // Create a Management API client
105
161
  export const createManagementClient = async (
106
162
  apiDomain: string,
@@ -149,6 +205,7 @@ export const createManagementClient = async (
149
205
  // but callers typically only have access to the OAuth domain
150
206
  export const clearManagementClientCache = () => {
151
207
  managementClientCache.clear();
208
+ clearOrganizationTokenCache();
152
209
  };
153
210
 
154
211
  // Create a function to get the auth provider with the specified domain
@@ -478,16 +535,62 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
478
535
  throw error;
479
536
  });
480
537
  } else {
481
- // For Auth0 login method, create a client for the current domain
538
+ // For Auth0 login method, get a token WITHOUT organization scope
539
+ // This ensures we don't accidentally use an org-scoped token when listing tenants
482
540
  const currentAuth0Client = createAuth0Client(selectedDomain);
483
- // Ensure headers is a Headers instance (ra-auth-auth0 expects this)
484
- const normalizedOptions = {
485
- ...options,
486
- headers: new Headers(options.headers || {}),
487
- };
488
- request = httpClient(currentAuth0Client)(url, normalizedOptions).catch(
489
- (error) => {
490
- // Check for certificate errors
541
+ request = getToken(domainConfig, currentAuth0Client)
542
+ .catch((error) => {
543
+ throw new Error(
544
+ `Authentication failed: ${error.message}. Please log in again.`,
545
+ );
546
+ })
547
+ .then((token) => {
548
+ const headersObj = new Headers();
549
+ headersObj.set("Authorization", `Bearer ${token}`);
550
+ const method = (options.method || "GET").toUpperCase();
551
+ if (method === "POST" || method === "PATCH") {
552
+ headersObj.set("content-type", "application/json");
553
+ }
554
+ return fetch(url, { ...options, headers: headersObj });
555
+ })
556
+ .then(async (response) => {
557
+ if (response.status < 200 || response.status >= 300) {
558
+ const text = await response.text();
559
+
560
+ // Check for 401 with "Bad audience" message on /v2/tenants endpoint
561
+ if (
562
+ response.status === 401 &&
563
+ text.includes("Bad audience") &&
564
+ url.includes("/v2/tenants")
565
+ ) {
566
+ console.warn(
567
+ "Auth0 server detected without multi-tenant support. Navigating to Auth0 page",
568
+ );
569
+ pendingRequests.delete(requestKey);
570
+ window.history.pushState({}, "", "/auth0");
571
+ window.dispatchEvent(new PopStateEvent("popstate"));
572
+ throw new Error("Navigating to Auth0 configuration");
573
+ }
574
+
575
+ throw new Error(text || response.statusText);
576
+ }
577
+
578
+ const text = await response.text();
579
+ let json;
580
+ try {
581
+ json = JSON.parse(text);
582
+ } catch (e) {
583
+ json = text;
584
+ }
585
+
586
+ return {
587
+ json,
588
+ status: response.status,
589
+ headers: response.headers,
590
+ body: text,
591
+ };
592
+ })
593
+ .catch((error) => {
491
594
  if (error.message === "Failed to fetch" || error.name === "TypeError") {
492
595
  const urlObj = new URL(url);
493
596
  if (
@@ -495,8 +598,7 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
495
598
  urlObj.hostname === "127.0.0.1"
496
599
  ) {
497
600
  const certError = new Error(
498
- `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.\n\n` +
499
- `Please visit ${urlObj.origin} in your browser and accept the security warning to trust the certificate, then refresh this page.`,
601
+ `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.`,
500
602
  );
501
603
  (certError as any).isCertificateError = true;
502
604
  (certError as any).serverUrl = urlObj.origin;
@@ -504,8 +606,7 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
504
606
  }
505
607
  }
506
608
  throw error;
507
- },
508
- );
609
+ });
509
610
  }
510
611
 
511
612
  // Handle cleanup when request is done
@@ -518,4 +619,245 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
518
619
  return request;
519
620
  };
520
621
 
622
+ /**
623
+ * Creates an HTTP client that uses organization-scoped tokens.
624
+ * This is used when accessing tenant-specific resources to ensure
625
+ * the user has the correct permissions for that tenant.
626
+ *
627
+ * @param organizationId - The organization/tenant ID to scope tokens to
628
+ * @returns An HTTP client function that uses organization-scoped tokens
629
+ */
630
+ export const createOrganizationHttpClient = (organizationId: string) => {
631
+ return (url: string, options: HttpOptions = {}) => {
632
+ const requestKey = `${organizationId}:${url}-${JSON.stringify(options)}`;
633
+
634
+ // If there's already a pending request for this URL and options, return it
635
+ if (pendingRequests.has(requestKey)) {
636
+ return pendingRequests.get(requestKey)!;
637
+ }
638
+
639
+ // Prevent API calls while on the auth callback page
640
+ if (window.location.pathname === "/auth-callback") {
641
+ return Promise.reject({
642
+ json: {
643
+ message: "Authentication in progress. Please wait...",
644
+ },
645
+ status: 401,
646
+ headers: new Headers(),
647
+ body: JSON.stringify({ message: "Authentication in progress" }),
648
+ });
649
+ }
650
+
651
+ // Check if we're using token-based auth or client credentials
652
+ const domains = getDomainFromStorage();
653
+ const selectedDomain = getSelectedDomainFromStorage();
654
+ const domainConfig = domains.find((d) => d.url === selectedDomain);
655
+
656
+ // If using login method and auth request is in progress, delay the request
657
+ if (
658
+ domainConfig?.connectionMethod === "login" &&
659
+ (authRequestInProgress || activeSessions.has(selectedDomain))
660
+ ) {
661
+ const delayedRequest = new Promise((resolve, reject) => {
662
+ const checkInterval = setInterval(() => {
663
+ if (!authRequestInProgress && !activeSessions.has(selectedDomain)) {
664
+ clearInterval(checkInterval);
665
+ createOrganizationHttpClient(organizationId)(url, options)
666
+ .then(resolve)
667
+ .catch(reject);
668
+ }
669
+ }, 100);
670
+
671
+ setTimeout(() => {
672
+ clearInterval(checkInterval);
673
+ reject(new Error("Authentication timeout"));
674
+ }, 30000);
675
+ });
676
+
677
+ pendingRequests.set(requestKey, delayedRequest);
678
+ delayedRequest.finally(() => {
679
+ pendingRequests.delete(requestKey);
680
+ });
681
+
682
+ return delayedRequest;
683
+ }
684
+
685
+ if (!domainConfig) {
686
+ return Promise.reject({
687
+ json: {
688
+ message:
689
+ "No domain configuration found. Please select a domain and configure authentication.",
690
+ },
691
+ status: 401,
692
+ headers: new Headers(),
693
+ body: JSON.stringify({ message: "No domain configuration found" }),
694
+ });
695
+ }
696
+
697
+ let request;
698
+ if (
699
+ ["token", "client_credentials"].includes(
700
+ domainConfig.connectionMethod || "",
701
+ )
702
+ ) {
703
+ // For token/client_credentials, use the standard token (no org scoping)
704
+ request = getToken(domainConfig)
705
+ .catch((error) => {
706
+ throw new Error(
707
+ `Authentication failed: ${error.message}. Please configure your credentials in the domain selector.`,
708
+ );
709
+ })
710
+ .then((token) => {
711
+ const headersObj = new Headers();
712
+ headersObj.set("Authorization", `Bearer ${token}`);
713
+ const method = (options.method || "GET").toUpperCase();
714
+ if (method === "POST" || method === "PATCH") {
715
+ headersObj.set("content-type", "application/json");
716
+ }
717
+ return fetch(url, { ...options, headers: headersObj });
718
+ })
719
+ .then(async (response) => {
720
+ if (response.status < 200 || response.status >= 300) {
721
+ const text = await response.text();
722
+ throw new Error(text || response.statusText);
723
+ }
724
+
725
+ const text = await response.text();
726
+ let json;
727
+ try {
728
+ json = JSON.parse(text);
729
+ } catch (e) {
730
+ json = text;
731
+ }
732
+
733
+ return {
734
+ json,
735
+ status: response.status,
736
+ headers: response.headers,
737
+ body: text,
738
+ };
739
+ })
740
+ .catch((error) => {
741
+ if (
742
+ error.message === "Failed to fetch" ||
743
+ error.name === "TypeError"
744
+ ) {
745
+ const urlObj = new URL(url);
746
+ if (
747
+ urlObj.hostname === "localhost" ||
748
+ urlObj.hostname === "127.0.0.1"
749
+ ) {
750
+ const certError = new Error(
751
+ `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.`,
752
+ );
753
+ (certError as any).isCertificateError = true;
754
+ (certError as any).serverUrl = urlObj.origin;
755
+ throw certError;
756
+ }
757
+ }
758
+ throw error;
759
+ });
760
+ } else {
761
+ // For OAuth login, use an organization-specific client with isolated cache
762
+ const orgAuth0Client = createAuth0ClientForOrg(
763
+ selectedDomain,
764
+ organizationId,
765
+ );
766
+
767
+ // Get the audience for token requests
768
+ const audience =
769
+ import.meta.env.VITE_AUTH0_AUDIENCE ||
770
+ `https://${selectedDomain}/api/v2/`;
771
+
772
+ // First, check if we have a valid session for this organization
773
+ request = orgAuth0Client
774
+ .getTokenSilently({
775
+ authorizationParams: {
776
+ audience,
777
+ organization: organizationId,
778
+ },
779
+ })
780
+ .catch(async (error) => {
781
+ // If silent token acquisition fails, we need to redirect to login with org
782
+ // Get the base auth0 client to get the user's email for login hint
783
+ const baseClient = createAuth0Client(selectedDomain);
784
+ const user = await baseClient.getUser().catch(() => null);
785
+
786
+ // Redirect to login with organization
787
+ await orgAuth0Client.loginWithRedirect({
788
+ authorizationParams: {
789
+ organization: organizationId,
790
+ login_hint: user?.email,
791
+ },
792
+ appState: {
793
+ returnTo: window.location.pathname,
794
+ },
795
+ });
796
+
797
+ // This won't be reached as loginWithRedirect redirects the page
798
+ throw new Error(
799
+ `Redirecting to login for organization ${organizationId}`,
800
+ );
801
+ })
802
+ .then((token) => {
803
+ const headersObj = new Headers();
804
+ headersObj.set("Authorization", `Bearer ${token}`);
805
+ const method = (options.method || "GET").toUpperCase();
806
+ if (method === "POST" || method === "PATCH") {
807
+ headersObj.set("content-type", "application/json");
808
+ }
809
+ return fetch(url, { ...options, headers: headersObj });
810
+ })
811
+ .then(async (response) => {
812
+ if (response.status < 200 || response.status >= 300) {
813
+ const text = await response.text();
814
+ throw new Error(text || response.statusText);
815
+ }
816
+
817
+ const text = await response.text();
818
+ let json;
819
+ try {
820
+ json = JSON.parse(text);
821
+ } catch (e) {
822
+ json = text;
823
+ }
824
+
825
+ return {
826
+ json,
827
+ status: response.status,
828
+ headers: response.headers,
829
+ body: text,
830
+ };
831
+ })
832
+ .catch((error) => {
833
+ if (
834
+ error.message === "Failed to fetch" ||
835
+ error.name === "TypeError"
836
+ ) {
837
+ const urlObj = new URL(url);
838
+ if (
839
+ urlObj.hostname === "localhost" ||
840
+ urlObj.hostname === "127.0.0.1"
841
+ ) {
842
+ const certError = new Error(
843
+ `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.`,
844
+ );
845
+ (certError as any).isCertificateError = true;
846
+ (certError as any).serverUrl = urlObj.origin;
847
+ throw certError;
848
+ }
849
+ }
850
+ throw error;
851
+ });
852
+ }
853
+
854
+ request.finally(() => {
855
+ pendingRequests.delete(requestKey);
856
+ });
857
+
858
+ pendingRequests.set(requestKey, request);
859
+ return request;
860
+ };
861
+ };
862
+
521
863
  export { authProvider, authorizedHttpClient };
@@ -1,6 +1,8 @@
1
- import { AppBar, TitlePortal, useDataProvider } from "react-admin";
2
- import { useEffect, useState } from "react";
1
+ import { AppBar, TitlePortal } from "react-admin";
2
+ import { useEffect, useState, useMemo } from "react";
3
3
  import { Link, Box } from "@mui/material";
4
+ import { getDataprovider } from "../dataProvider";
5
+ import { getDomainFromStorage } from "../utils/domainUtils";
4
6
 
5
7
  type TenantResponse = {
6
8
  audience: string;
@@ -26,12 +28,25 @@ export function TenantAppBar(props: TenantAppBarProps) {
26
28
  const pathSegments = location.pathname.split("/").filter(Boolean);
27
29
  const tenantId = pathSegments[0];
28
30
  const [tenant, setTenant] = useState<TenantResponse>();
29
- const dataProvider = useDataProvider();
31
+
32
+ // Get the selected domain from storage or environment
33
+ const selectedDomain = useMemo(() => {
34
+ const domains = getDomainFromStorage();
35
+ const selected = domains.find((d) => d.isSelected);
36
+ return selected?.url || import.meta.env.VITE_AUTH0_DOMAIN || "";
37
+ }, []);
38
+
39
+ // Use the non-org data provider for fetching tenants list
40
+ // This is necessary because tenants list requires a non-org token
41
+ const tenantsDataProvider = useMemo(
42
+ () => getDataprovider(selectedDomain),
43
+ [selectedDomain],
44
+ );
30
45
 
31
46
  useEffect(() => {
32
- // Use the dataProvider to fetch tenants list and find the matching one
33
- // This ensures we use the correct API URL configured in the app
34
- dataProvider
47
+ // Use the non-org dataProvider to fetch tenants list
48
+ // The tenants endpoint requires non-org scoped tokens
49
+ tenantsDataProvider
35
50
  .getList("tenants", {
36
51
  pagination: { page: 1, perPage: 100 },
37
52
  sort: { field: "id", order: "ASC" },
@@ -59,7 +74,7 @@ export function TenantAppBar(props: TenantAppBarProps) {
59
74
  name: tenantId,
60
75
  } as TenantResponse);
61
76
  });
62
- }, [tenantId, dataProvider]);
77
+ }, [tenantId, tenantsDataProvider]);
63
78
 
64
79
  const isDefaultSettings = tenantId === "DEFAULT_SETTINGS";
65
80
 
@@ -746,7 +746,20 @@ const ClientMetadataInput = ({ source }: { source: string }) => {
746
746
  };
747
747
 
748
748
  const updateFormData = (array: Array<{ key: string; value: string }>) => {
749
+ // Fields managed by other inputs (BooleanInput, SelectInput, etc.)
750
+ const preservedFields = ["disable_sign_ups", "email_validation"];
751
+
752
+ // Start with preserved fields from current value
749
753
  const newObject: Record<string, any> = {};
754
+ if (value && typeof value === "object") {
755
+ preservedFields.forEach((field) => {
756
+ if (field in value) {
757
+ newObject[field] = value[field];
758
+ }
759
+ });
760
+ }
761
+
762
+ // Add the metadata array values
750
763
  array.forEach((item) => {
751
764
  if (item.key && item.key.trim()) {
752
765
  newObject[item.key.trim()] = item.value;
@@ -1157,8 +1170,34 @@ const ConnectionsTab = () => {
1157
1170
  };
1158
1171
 
1159
1172
  export function ClientEdit() {
1173
+ // Transform data before submission to ensure client_metadata values are strings
1174
+ const transformClientData = (data: Record<string, unknown>) => {
1175
+ const transformed = { ...data };
1176
+
1177
+ // Ensure client_metadata values are strings (Auth0 requirement)
1178
+ if (
1179
+ transformed.client_metadata &&
1180
+ typeof transformed.client_metadata === "object"
1181
+ ) {
1182
+ const metadata = transformed.client_metadata as Record<string, unknown>;
1183
+ const stringifiedMetadata: Record<string, string> = {};
1184
+
1185
+ for (const [key, value] of Object.entries(metadata)) {
1186
+ if (typeof value === "boolean") {
1187
+ stringifiedMetadata[key] = value ? "true" : "false";
1188
+ } else if (value !== null && value !== undefined) {
1189
+ stringifiedMetadata[key] = String(value);
1190
+ }
1191
+ }
1192
+
1193
+ transformed.client_metadata = stringifiedMetadata;
1194
+ }
1195
+
1196
+ return transformed;
1197
+ };
1198
+
1160
1199
  return (
1161
- <Edit>
1200
+ <Edit transform={transformClientData}>
1162
1201
  <SimpleShowLayout>
1163
1202
  <TextField source="name" />
1164
1203
  <TextField source="id" />
@@ -14,7 +14,7 @@ import {
14
14
  useRecordContext,
15
15
  useRedirect,
16
16
  } from "react-admin";
17
- import { useState } from "react";
17
+ import { useState, useEffect } from "react";
18
18
  import {
19
19
  Box,
20
20
  Button,
@@ -34,9 +34,15 @@ import {
34
34
  Checkbox,
35
35
  Chip,
36
36
  Stack,
37
+ FormControl,
38
+ InputLabel,
39
+ Select,
40
+ MenuItem,
41
+ OutlinedInput,
37
42
  } from "@mui/material";
38
43
  import AddIcon from "@mui/icons-material/Add";
39
44
  import DeleteIcon from "@mui/icons-material/Delete";
45
+ import EditIcon from "@mui/icons-material/Edit";
40
46
  import { useParams } from "react-router-dom";
41
47
 
42
48
  const AddOrganizationMemberButton = () => {
@@ -401,6 +407,227 @@ const RemoveMemberButton = ({ record }: { record: any }) => {
401
407
  );
402
408
  };
403
409
 
410
+ const ManageMemberRolesButton = ({ record }: { record: any }) => {
411
+ const [open, setOpen] = useState(false);
412
+ const [roles, setRoles] = useState<any[]>([]);
413
+ const [memberRoles, setMemberRoles] = useState<string[]>([]);
414
+ const [loading, setLoading] = useState(false);
415
+ const dataProvider = useDataProvider();
416
+ const notify = useNotify();
417
+ const refresh = useRefresh();
418
+ const { id: organizationId } = useParams();
419
+
420
+ const handleOpen = async () => {
421
+ setOpen(true);
422
+ setLoading(true);
423
+ try {
424
+ // Fetch all available roles
425
+ const { data: allRoles } = await dataProvider.getList("roles", {
426
+ pagination: { page: 1, perPage: 100 },
427
+ sort: { field: "name", order: "ASC" },
428
+ filter: {},
429
+ });
430
+ setRoles(allRoles);
431
+
432
+ // Fetch member's current roles in this organization
433
+ if (organizationId && record?.user_id) {
434
+ const response = await dataProvider.getList(
435
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
436
+ {
437
+ pagination: { page: 1, perPage: 100 },
438
+ sort: { field: "name", order: "ASC" },
439
+ filter: {},
440
+ },
441
+ );
442
+ setMemberRoles(response.data.map((r: any) => r.id));
443
+ }
444
+ } catch (error) {
445
+ notify("Error loading roles", { type: "error" });
446
+ } finally {
447
+ setLoading(false);
448
+ }
449
+ };
450
+
451
+ const handleClose = () => {
452
+ setOpen(false);
453
+ setMemberRoles([]);
454
+ setRoles([]);
455
+ };
456
+
457
+ const handleSaveRoles = async () => {
458
+ if (!organizationId || !record?.user_id) return;
459
+
460
+ setLoading(true);
461
+ try {
462
+ // Get current roles to determine what to add/remove
463
+ const currentResponse = await dataProvider.getList(
464
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
465
+ {
466
+ pagination: { page: 1, perPage: 100 },
467
+ sort: { field: "name", order: "ASC" },
468
+ filter: {},
469
+ },
470
+ );
471
+ const currentRoleIds = currentResponse.data.map((r: any) => r.id);
472
+
473
+ const rolesToAdd = memberRoles.filter((r) => !currentRoleIds.includes(r));
474
+ const rolesToRemove = currentRoleIds.filter(
475
+ (r: string) => !memberRoles.includes(r),
476
+ );
477
+
478
+ // Add new roles
479
+ if (rolesToAdd.length > 0) {
480
+ await dataProvider.create(
481
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
482
+ {
483
+ data: { roles: rolesToAdd },
484
+ },
485
+ );
486
+ }
487
+
488
+ // Remove old roles
489
+ if (rolesToRemove.length > 0) {
490
+ await dataProvider.delete(
491
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
492
+ {
493
+ id: "",
494
+ previousData: { roles: rolesToRemove },
495
+ },
496
+ );
497
+ }
498
+
499
+ notify("Member roles updated successfully", { type: "success" });
500
+ refresh();
501
+ handleClose();
502
+ } catch (error) {
503
+ notify("Error updating member roles", { type: "error" });
504
+ } finally {
505
+ setLoading(false);
506
+ }
507
+ };
508
+
509
+ return (
510
+ <>
511
+ <IconButton
512
+ onClick={handleOpen}
513
+ color="primary"
514
+ size="small"
515
+ title="Manage roles"
516
+ >
517
+ <EditIcon />
518
+ </IconButton>
519
+
520
+ <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
521
+ <DialogTitle>
522
+ Manage Roles for {record?.email || record?.user_id}
523
+ </DialogTitle>
524
+ <DialogContent>
525
+ {loading ? (
526
+ <Box display="flex" justifyContent="center" p={3}>
527
+ <CircularProgress />
528
+ </Box>
529
+ ) : (
530
+ <>
531
+ <DialogContentText sx={{ mb: 2 }}>
532
+ Select roles to assign to this member in this organization.
533
+ </DialogContentText>
534
+
535
+ <FormControl fullWidth>
536
+ <InputLabel id="member-roles-label">Roles</InputLabel>
537
+ <Select
538
+ labelId="member-roles-label"
539
+ multiple
540
+ value={memberRoles}
541
+ onChange={(e) => setMemberRoles(e.target.value as string[])}
542
+ input={<OutlinedInput label="Roles" />}
543
+ renderValue={(selected) => (
544
+ <Stack direction="row" spacing={0.5} flexWrap="wrap">
545
+ {selected.map((roleId) => {
546
+ const role = roles.find((r) => r.id === roleId);
547
+ return (
548
+ <Chip
549
+ key={roleId}
550
+ label={role?.name || roleId}
551
+ size="small"
552
+ />
553
+ );
554
+ })}
555
+ </Stack>
556
+ )}
557
+ >
558
+ {roles.map((role) => (
559
+ <MenuItem key={role.id} value={role.id}>
560
+ <Checkbox checked={memberRoles.includes(role.id)} />
561
+ <ListItemText
562
+ primary={role.name}
563
+ secondary={role.description}
564
+ />
565
+ </MenuItem>
566
+ ))}
567
+ </Select>
568
+ </FormControl>
569
+ </>
570
+ )}
571
+ </DialogContent>
572
+ <DialogActions>
573
+ <Button onClick={handleClose}>Cancel</Button>
574
+ <Button onClick={handleSaveRoles} variant="contained" disabled={loading}>
575
+ Save Roles
576
+ </Button>
577
+ </DialogActions>
578
+ </Dialog>
579
+ </>
580
+ );
581
+ };
582
+
583
+ const MemberRolesDisplay = ({ record }: { record: any }) => {
584
+ const [roles, setRoles] = useState<any[]>([]);
585
+ const [loading, setLoading] = useState(true);
586
+ const dataProvider = useDataProvider();
587
+ const { id: organizationId } = useParams();
588
+
589
+ useEffect(() => {
590
+ const fetchRoles = async () => {
591
+ if (!organizationId || !record?.user_id) {
592
+ setLoading(false);
593
+ return;
594
+ }
595
+ try {
596
+ const response = await dataProvider.getList(
597
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
598
+ {
599
+ pagination: { page: 1, perPage: 100 },
600
+ sort: { field: "name", order: "ASC" },
601
+ filter: {},
602
+ },
603
+ );
604
+ setRoles(response.data);
605
+ } catch (error) {
606
+ console.error("Error fetching member roles:", error);
607
+ } finally {
608
+ setLoading(false);
609
+ }
610
+ };
611
+ fetchRoles();
612
+ }, [dataProvider, organizationId, record?.user_id]);
613
+
614
+ if (loading) {
615
+ return <CircularProgress size={16} />;
616
+ }
617
+
618
+ if (roles.length === 0) {
619
+ return <Typography color="text.secondary">No roles</Typography>;
620
+ }
621
+
622
+ return (
623
+ <Stack direction="row" spacing={0.5} flexWrap="wrap">
624
+ {roles.map((role) => (
625
+ <Chip key={role.id} label={role.name} size="small" variant="outlined" />
626
+ ))}
627
+ </Stack>
628
+ );
629
+ };
630
+
404
631
  const OrganizationGeneralTab = () => (
405
632
  <Box>
406
633
  <TextInput source="name" validate={[required()]} fullWidth />
@@ -456,9 +683,18 @@ const OrganizationMembersTab = () => {
456
683
  )}
457
684
  />
458
685
  <TextField source="email" label="Email" />
686
+ <FunctionField
687
+ label="Roles"
688
+ render={(record) => <MemberRolesDisplay record={record} />}
689
+ />
459
690
  <FunctionField
460
691
  label="Actions"
461
- render={(record) => <RemoveMemberButton record={record} />}
692
+ render={(record) => (
693
+ <Box sx={{ display: "flex", gap: 0.5 }}>
694
+ <ManageMemberRolesButton record={record} />
695
+ <RemoveMemberButton record={record} />
696
+ </Box>
697
+ )}
462
698
  />
463
699
  </Datagrid>
464
700
  </ReferenceManyField>
@@ -9,113 +9,146 @@ import {
9
9
  required,
10
10
  NumberInput,
11
11
  FormDataConsumer,
12
+ useRecordContext,
12
13
  } from "react-admin";
13
- import { Stack } from "@mui/material";
14
+ import { Stack, Alert } from "@mui/material";
15
+
16
+ function SystemEntityAlert() {
17
+ const record = useRecordContext();
18
+ if (!record?.is_system) return null;
14
19
 
15
- export function ResourceServerEdit() {
16
20
  return (
17
- <Edit>
18
- <TabbedForm>
19
- <TabbedForm.Tab label="Details">
20
- <Stack spacing={2}>
21
- <TextInput source="name" validate={[required()]} />
22
- <TextInput
23
- source="identifier"
24
- validate={[required()]}
25
- helperText="Unique identifier for this resource server"
26
- />
27
- </Stack>
21
+ <Alert severity="info" sx={{ mb: 2 }}>
22
+ This Resource Server represents a system entity and cannot be modified or
23
+ deleted. You can still authorize applications to consume this resource
24
+ server.
25
+ </Alert>
26
+ );
27
+ }
28
+
29
+ function ResourceServerForm() {
30
+ const record = useRecordContext();
31
+ const isSystem = record?.is_system;
32
+
33
+ return (
34
+ <TabbedForm>
35
+ <TabbedForm.Tab label="Details">
36
+ <SystemEntityAlert />
37
+ <Stack spacing={2}>
38
+ <TextInput source="name" validate={[required()]} disabled={isSystem} />
39
+ <TextInput
40
+ source="identifier"
41
+ validate={[required()]}
42
+ helperText="Unique identifier for this resource server"
43
+ disabled={isSystem}
44
+ />
45
+ </Stack>
46
+
47
+ <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
48
+ <BooleanInput
49
+ source="signing_alg_values_supported"
50
+ defaultValue={true}
51
+ disabled={isSystem}
52
+ />
53
+ <BooleanInput
54
+ source="skip_consent_for_verifiable_first_party_clients"
55
+ defaultValue={true}
56
+ disabled={isSystem}
57
+ />
58
+ <BooleanInput source="allow_offline_access" defaultValue={true} disabled={isSystem} />
59
+ </Stack>
28
60
 
29
- <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
30
- <BooleanInput
31
- source="signing_alg_values_supported"
32
- defaultValue={true}
33
- />
34
- <BooleanInput
35
- source="skip_consent_for_verifiable_first_party_clients"
36
- defaultValue={true}
37
- />
38
- <BooleanInput source="allow_offline_access" defaultValue={true} />
39
- </Stack>
61
+ <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
62
+ <TextInput
63
+ source="signing_alg"
64
+ defaultValue="RS256"
65
+ helperText="Signing algorithm for tokens"
66
+ disabled={isSystem}
67
+ />
68
+ </Stack>
40
69
 
41
- <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
42
- <TextInput
43
- source="signing_alg"
44
- defaultValue="RS256"
45
- helperText="Signing algorithm for tokens"
46
- />
47
- </Stack>
70
+ <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
71
+ <NumberInput
72
+ source="token_lifetime"
73
+ defaultValue={1209600}
74
+ helperText="Token lifetime in seconds (default: 14 days)"
75
+ disabled={isSystem}
76
+ />
77
+ <NumberInput
78
+ source="token_lifetime_for_web"
79
+ defaultValue={7200}
80
+ helperText="Web token lifetime in seconds (default: 2 hours)"
81
+ disabled={isSystem}
82
+ />
83
+ </Stack>
48
84
 
49
- <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
50
- <NumberInput
51
- source="token_lifetime"
52
- defaultValue={1209600}
53
- helperText="Token lifetime in seconds (default: 14 days)"
54
- />
55
- <NumberInput
56
- source="token_lifetime_for_web"
57
- defaultValue={7200}
58
- helperText="Web token lifetime in seconds (default: 2 hours)"
59
- />
60
- </Stack>
85
+ <Stack spacing={2} direction="row" sx={{ mt: 4 }}>
86
+ <TextField source="created_at" />
87
+ <TextField source="updated_at" />
88
+ </Stack>
89
+ </TabbedForm.Tab>
61
90
 
62
- <Stack spacing={2} direction="row" sx={{ mt: 4 }}>
63
- <TextField source="created_at" />
64
- <TextField source="updated_at" />
65
- </Stack>
66
- </TabbedForm.Tab>
91
+ <TabbedForm.Tab label="RBAC">
92
+ <Stack spacing={3}>
93
+ <BooleanInput
94
+ source="options.enforce_policies"
95
+ label="Enable RBAC"
96
+ helperText="Enable Role-Based Access Control for this resource server"
97
+ disabled={isSystem}
98
+ />
67
99
 
68
- <TabbedForm.Tab label="RBAC">
69
- <Stack spacing={3}>
70
- <BooleanInput
71
- source="options.enforce_policies"
72
- label="Enable RBAC"
73
- helperText="Enable Role-Based Access Control for this resource server"
74
- />
100
+ <FormDataConsumer>
101
+ {({ formData }) => (
102
+ <BooleanInput
103
+ source="options.token_dialect"
104
+ label="Add permissions in token"
105
+ helperText="Include permissions directly in the access token"
106
+ disabled={isSystem || !formData?.options?.enforce_policies}
107
+ format={(value) => value === "access_token_authz"}
108
+ parse={(checked) =>
109
+ checked ? "access_token_authz" : "access_token"
110
+ }
111
+ />
112
+ )}
113
+ </FormDataConsumer>
114
+ </Stack>
115
+ </TabbedForm.Tab>
75
116
 
76
- <FormDataConsumer>
77
- {({ formData }) => (
78
- <BooleanInput
79
- source="options.token_dialect"
80
- label="Add permissions in token"
81
- helperText="Include permissions directly in the access token"
82
- disabled={!formData?.options?.enforce_policies}
83
- format={(value) => value === "access_token_authz"}
84
- parse={(checked) =>
85
- checked ? "access_token_authz" : "access_token"
86
- }
87
- />
88
- )}
89
- </FormDataConsumer>
90
- </Stack>
91
- </TabbedForm.Tab>
117
+ <TabbedForm.Tab label="Scopes">
118
+ <ArrayInput source="scopes" label="" disabled={isSystem}>
119
+ <SimpleFormIterator disableAdd={isSystem} disableRemove={isSystem} disableReordering={isSystem}>
120
+ <Stack
121
+ spacing={2}
122
+ direction="row"
123
+ sx={{ width: "100%", alignItems: "flex-start" }}
124
+ >
125
+ <TextInput
126
+ source="value"
127
+ validate={[required()]}
128
+ label="Scope Name"
129
+ helperText="e.g., read:users, write:posts"
130
+ sx={{ flex: 1 }}
131
+ disabled={isSystem}
132
+ />
133
+ <TextInput
134
+ source="description"
135
+ label="Description"
136
+ helperText="What this scope allows"
137
+ sx={{ flex: 2 }}
138
+ disabled={isSystem}
139
+ />
140
+ </Stack>
141
+ </SimpleFormIterator>
142
+ </ArrayInput>
143
+ </TabbedForm.Tab>
144
+ </TabbedForm>
145
+ );
146
+ }
92
147
 
93
- <TabbedForm.Tab label="Scopes">
94
- <ArrayInput source="scopes" label="">
95
- <SimpleFormIterator>
96
- <Stack
97
- spacing={2}
98
- direction="row"
99
- sx={{ width: "100%", alignItems: "flex-start" }}
100
- >
101
- <TextInput
102
- source="value"
103
- validate={[required()]}
104
- label="Scope Name"
105
- helperText="e.g., read:users, write:posts"
106
- sx={{ flex: 1 }}
107
- />
108
- <TextInput
109
- source="description"
110
- label="Description"
111
- helperText="What this scope allows"
112
- sx={{ flex: 2 }}
113
- />
114
- </Stack>
115
- </SimpleFormIterator>
116
- </ArrayInput>
117
- </TabbedForm.Tab>
118
- </TabbedForm>
148
+ export function ResourceServerEdit() {
149
+ return (
150
+ <Edit>
151
+ <ResourceServerForm />
119
152
  </Edit>
120
153
  );
121
154
  }
@@ -1,5 +1,8 @@
1
1
  import { UpdateParams, withLifecycleCallbacks } from "react-admin";
2
- import { authorizedHttpClient } from "./authProvider";
2
+ import {
3
+ authorizedHttpClient,
4
+ createOrganizationHttpClient,
5
+ } from "./authProvider";
3
6
  import auth0DataProvider from "./auth0DataProvider";
4
7
  import {
5
8
  getDomainFromStorage,
@@ -85,10 +88,14 @@ export function getDataproviderForTenant(
85
88
  // Ensure apiUrl doesn't end with a slash
86
89
  apiUrl = apiUrl.replace(/\/$/, "");
87
90
 
88
- // Create the auth0Provider with the API URL, tenant ID, and domain
91
+ // Create an organization-scoped HTTP client for this tenant
92
+ // This ensures the user has the correct permissions for accessing tenant resources
93
+ const organizationHttpClient = createOrganizationHttpClient(tenantId);
94
+
95
+ // Create the auth0Provider with the API URL, tenant ID, domain, and org-scoped client
89
96
  const auth0Provider = auth0DataProvider(
90
97
  apiUrl,
91
- authorizedHttpClient,
98
+ organizationHttpClient,
92
99
  tenantId,
93
100
  auth0Domain,
94
101
  );
@@ -1,5 +1,57 @@
1
1
  import { DomainConfig } from "./domainUtils";
2
- import { Auth0Client } from "@auth0/auth0-spa-js";
2
+ import {
3
+ Auth0Client,
4
+ ICache,
5
+ Cacheable,
6
+ MaybePromise,
7
+ } from "@auth0/auth0-spa-js";
8
+
9
+ // Import the CACHE_KEY_PREFIX constant for consistency with the Auth0 SDK
10
+ const CACHE_KEY_PREFIX = "@@auth0spajs@@";
11
+
12
+ /**
13
+ * Custom cache provider that wraps localStorage and adds organization isolation.
14
+ * This ensures that tokens and auth state are kept separate between organizations.
15
+ * Based on the original LocalStorageCache implementation from auth0-spa-js.
16
+ */
17
+ export class OrgCache implements ICache {
18
+ private orgId: string;
19
+
20
+ constructor(orgId: string) {
21
+ this.orgId = orgId;
22
+ }
23
+
24
+ public set<T = Cacheable>(key: string, entry: T) {
25
+ const orgKey = `${key}:${this.orgId}`;
26
+ localStorage.setItem(orgKey, JSON.stringify(entry));
27
+ }
28
+
29
+ public get<T = Cacheable>(key: string): MaybePromise<T | undefined> {
30
+ const orgKey = `${key}:${this.orgId}`;
31
+ const json = window.localStorage.getItem(orgKey);
32
+
33
+ if (!json) return;
34
+
35
+ try {
36
+ const payload = JSON.parse(json) as T;
37
+ return payload;
38
+ } catch (e) {
39
+ return;
40
+ }
41
+ }
42
+
43
+ public remove(key: string) {
44
+ const orgKey = `${key}:${this.orgId}`;
45
+ localStorage.removeItem(orgKey);
46
+ }
47
+
48
+ public allKeys() {
49
+ const orgSuffix = `:${this.orgId}`;
50
+ return Object.keys(window.localStorage).filter(
51
+ (key) => key.startsWith(CACHE_KEY_PREFIX) && key.endsWith(orgSuffix),
52
+ );
53
+ }
54
+ }
3
55
 
4
56
  async function fetchTokenWithClientCredentials(
5
57
  domain: string,
@@ -37,6 +89,24 @@ async function fetchTokenWithClientCredentials(
37
89
  return data.access_token;
38
90
  }
39
91
 
92
+ /**
93
+ * Clear the organization token cache (e.g., on logout).
94
+ * This clears all organization-specific localStorage entries.
95
+ */
96
+ export function clearOrganizationTokenCache(): void {
97
+ // Clear all org-cached tokens from localStorage
98
+ const keysToRemove = Object.keys(window.localStorage).filter(
99
+ (key) =>
100
+ key.startsWith(CACHE_KEY_PREFIX) && key.match(/:[^:]+$/),
101
+ );
102
+ keysToRemove.forEach((key) => localStorage.removeItem(key));
103
+ }
104
+
105
+ /**
106
+ * Get a token for the given domain configuration.
107
+ * For OAuth login, this gets a token WITHOUT organization scope.
108
+ * Use createAuth0ClientForOrg for organization-scoped tokens.
109
+ */
40
110
  export default async function getToken(
41
111
  domainConfig: DomainConfig,
42
112
  auth0Client?: Auth0Client,
@@ -57,7 +127,7 @@ export default async function getToken(
57
127
  );
58
128
  return token;
59
129
  } else if (domainConfig.connectionMethod === "login" && auth0Client) {
60
- // If using OAuth login, get token from auth0Client
130
+ // Get a regular token WITHOUT organization scope
61
131
  try {
62
132
  const token = await auth0Client.getTokenSilently();
63
133
  return token;