@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 +6 -0
- package/package.json +1 -1
- package/src/AuthCallback.tsx +41 -7
- package/src/authProvider.ts +360 -18
- package/src/components/TenantAppBar.tsx +22 -7
- package/src/components/clients/edit.tsx +40 -1
- package/src/components/organizations/edit.tsx +238 -2
- package/src/components/resource-servers/edit.tsx +130 -97
- package/src/dataProvider.ts +10 -3
- package/src/utils/tokenUtils.ts +72 -2
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/AuthCallback.tsx
CHANGED
|
@@ -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
|
-
//
|
|
70
|
-
forceNavigate(
|
|
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) {
|
package/src/authProvider.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Auth0AuthProvider
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
(
|
|
490
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
-
//
|
|
34
|
-
|
|
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,
|
|
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) =>
|
|
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
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
}
|
package/src/dataProvider.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { UpdateParams, withLifecycleCallbacks } from "react-admin";
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
98
|
+
organizationHttpClient,
|
|
92
99
|
tenantId,
|
|
93
100
|
auth0Domain,
|
|
94
101
|
);
|
package/src/utils/tokenUtils.ts
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
import { DomainConfig } from "./domainUtils";
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
130
|
+
// Get a regular token WITHOUT organization scope
|
|
61
131
|
try {
|
|
62
132
|
const token = await auth0Client.getTokenSilently();
|
|
63
133
|
return token;
|