diff --git a/package.json b/package.json index 69ceda7bc256..aa76e8c186f5 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "react-apexcharts": "1.7.0", "react-beautiful-dnd": "13.1.1", "react-copy-to-clipboard": "^5.1.0", - "react-dom": "19.1.1", + "react-dom": "19.2.3", "react-dropzone": "14.3.8", "react-error-boundary": "^6.1.0", "react-grid-layout": "^1.5.0", diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx index 88e517a2139d..0818190ca1cc 100644 --- a/src/components/CippComponents/CIPPM365OAuthButton.jsx +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -1,7 +1,9 @@ import { useState, useEffect } from "react"; import { Alert, Button, Typography, CircularProgress, Box } from "@mui/material"; +import { Microsoft, Login, Refresh } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; +import { CippApiDialog } from "./CippApiDialog"; export const CIPPM365OAuthButton = ({ onAuthSuccess, @@ -14,12 +16,14 @@ export const CIPPM365OAuthButton = ({ applicationId = null, autoStartDeviceLogon = false, validateServiceAccount = true, + promptBeforeAuth = false, }) => { const [authInProgress, setAuthInProgress] = useState(false); const [authError, setAuthError] = useState(null); const [deviceCodeInfo, setDeviceCodeInfo] = useState(null); const [codeRetrievalInProgress, setCodeRetrievalInProgress] = useState(false); const [isServiceAccount, setIsServiceAccount] = useState(true); + const [promptDialog, setPromptDialog] = useState({ open: false }); const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null, @@ -32,13 +36,10 @@ export const CIPPM365OAuthButton = ({ const appIdInfo = ApiGetCall({ url: `/api/ExecListAppId`, + queryKey: "listAppId", waiting: true, }); - useEffect(() => { - appIdInfo.refetch(); - }, []); - const handleCloseError = () => { setAuthError(null); }; @@ -55,8 +56,10 @@ export const CIPPM365OAuthButton = ({ setCodeRetrievalInProgress(true); setAuthError(null); - // Refetch appId to ensure we have the latest - await appIdInfo.refetch(); + // Only refetch appId if not already present + if (!applicationId && !appIdInfo?.data?.applicationId) { + await appIdInfo.refetch(); + } try { // Get the application ID to use @@ -66,8 +69,8 @@ export const CIPPM365OAuthButton = ({ // Request device code from our API endpoint const deviceCodeResponse = await fetch( `/api/ExecDeviceCodeLogon?operation=getDeviceCode&clientId=${appId}&scope=${encodeURIComponent( - scope - )}` + scope, + )}`, ); const deviceCodeData = await deviceCodeResponse.json(); @@ -95,8 +98,10 @@ export const CIPPM365OAuthButton = ({ // Device code authentication function - opens popup and starts polling const handleDeviceCodeAuthentication = async () => { - // Refetch appId to ensure we have the latest - await appIdInfo.refetch(); + // Only refetch appId if not already present + if (!applicationId && !appIdInfo?.data?.applicationId) { + await appIdInfo.refetch(); + } if (!deviceCodeInfo) { // If we don't have a device code yet, retrieve it first @@ -129,7 +134,7 @@ export const CIPPM365OAuthButton = ({ const popup = window.open( "https://microsoft.com/devicelogin", "deviceLoginPopup", - `width=${width},height=${height},left=${left},top=${top}` + `width=${width},height=${height},left=${left},top=${top}`, ); // Start polling for token @@ -155,7 +160,7 @@ export const CIPPM365OAuthButton = ({ try { // Poll for token using our API endpoint const tokenResponse = await fetch( - `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}` + `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}`, ); const tokenData = await tokenResponse.json(); @@ -263,7 +268,9 @@ export const CIPPM365OAuthButton = ({ }; // MSAL-like authentication function - const handleMsalAuthentication = async () => { + const handleMsalAuthentication = async (retryCount = 0) => { + const maxRetries = 3; + // Clear previous authentication state when starting a new authentication setAuthInProgress(true); setAuthError(null); @@ -277,10 +284,12 @@ export const CIPPM365OAuthButton = ({ onmicrosoftDomain: null, }); - // Refetch app ID info to ensure we have the latest - await appIdInfo.refetch(); + // Only refetch app ID if not already present + if (!applicationId && !appIdInfo?.data?.applicationId) { + await appIdInfo.refetch(); + } - // Get the application ID to use - now we're sure to have the latest after the await + // Get the application ID to use const appId = applicationId || appIdInfo?.data?.applicationId; // Generate MSAL-like authentication parameters @@ -327,7 +336,7 @@ export const CIPPM365OAuthButton = ({ const popup = window.open( authUrl, "msalAuthPopup", - `width=${width},height=${height},left=${left},top=${top}` + `width=${width},height=${height},left=${left},top=${top}`, ); // Function to actually exchange the authorization code for tokens @@ -356,20 +365,43 @@ export const CIPPM365OAuthButton = ({ }; // Make the token request through our API proxy to avoid origin header issues - const tokenResponse = await fetch(`/api/ExecTokenExchange`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - tokenRequest, - tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", - tenantId: appId, // Pass the tenant ID to retrieve the correct client secret - }), - }); + // Retry logic for AADSTS650051 (service principal already exists) + let retryCount = 0; + const maxRetries = 3; + let tokenResponse; + let tokenData; + + while (retryCount <= maxRetries) { + tokenResponse = await fetch(`/api/ExecTokenExchange`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokenRequest, + tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + tenantId: appId, // Pass the tenant ID to retrieve the correct client secret + }), + }); - // Parse the token response - const tokenData = await tokenResponse.json(); + // Parse the token response + tokenData = await tokenResponse.json(); + + // Check if it's the AADSTS650051 error (service principal already exists) + if ( + tokenData.error === "invalid_client" && + tokenData.error_description?.includes("AADSTS650051") + ) { + retryCount++; + if (retryCount <= maxRetries) { + // Wait before retrying (exponential backoff) + await new Promise((resolve) => setTimeout(resolve, 2000 * retryCount)); + continue; + } + } + // If no error or different error, break out of retry loop + break; + } // Check if the response contains an error if (tokenData.error) { @@ -408,6 +440,9 @@ export const CIPPM365OAuthButton = ({ if (!refreshResponse.ok) { console.warn("Failed to store refresh token, but continuing with authentication"); + } else { + // Invalidate the listAppId and tenants-table queryKeys to refresh data + appIdInfo.refetch(); } } catch (error) { console.error("Failed to store refresh token:", error); @@ -502,7 +537,27 @@ export const CIPPM365OAuthButton = ({ const errorCode = urlParams.get("error"); const errorDescription = urlParams.get("error_description"); - // Set the error state + // Check if it's the AADSTS650051 error (service principal already exists during consent) + if ( + errorCode === "invalid_client" && + errorDescription?.includes("AADSTS650051") && + retryCount < maxRetries + ) { + // Close the popup + popup.close(); + setAuthInProgress(false); + + // Wait before retrying (exponential backoff) + setTimeout( + () => { + handleMsalAuthentication(retryCount + 1); + }, + 2000 * (retryCount + 1), + ); + return; + } + + // Set the error state for non-retryable errors const error = { errorCode: errorCode, errorMessage: errorDescription || "Unknown authentication error", @@ -550,9 +605,9 @@ export const CIPPM365OAuthButton = ({
{!applicationId && !appIdInfo.isLoading && - appIdInfo?.data && // Only check if data is available + appIdInfo?.data?.applicationId && // Only check if applicationId is present in data !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - appIdInfo?.data?.applicationId + appIdInfo?.data?.applicationId, ) && ( The Application ID is not valid. Please check your configuration. @@ -653,6 +708,30 @@ export const CIPPM365OAuthButton = ({ ) : null} )} + + {promptBeforeAuth !== false && ( + setPromptDialog({ open: false }), + }} + api={{ + type: "POST", + confirmText: promptBeforeAuth, + noConfirm: false, + customFunction: () => { + setPromptDialog({ open: false }); + const authFunction = useDeviceCode + ? handleDeviceCodeAuthentication + : handleMsalAuthentication; + authFunction(); + }, + }} + fields={[]} + /> + )} +
); diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index 99d46a6e5182..af96b19cd027 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -52,4 +52,11 @@ export const CippTranslations = { includeTenantId: "Include Tenant ID in Notifications", logsToInclude: "Logs to Include in notifications", assignmentFilterManagementType: "Filter Type", + microsoftSupport: "Microsoft Support", + syndicatePartner: "Syndicate Partner", + breadthPartner: "Breadth Partner", + breadthPartnerDelegatedAdmin: "Breadth Partner (Delegated)", + resellerPartnerDelegatedAdmin: "Direct Reseller", + valueAddedResellerPartnerDelegatedAdmin: "Indirect Reseller", + unknownFutureValue: "Unknown", }; diff --git a/src/components/CippSettings/CippGDAPResults.jsx b/src/components/CippSettings/CippGDAPResults.jsx index 5c381668c78a..89897fd278d0 100644 --- a/src/components/CippSettings/CippGDAPResults.jsx +++ b/src/components/CippSettings/CippGDAPResults.jsx @@ -149,10 +149,6 @@ export const CippGDAPResults = (props) => { }} extendedInfo={[]} > - - GDAP Details - - {results?.Results?.GDAPIssues?.length > 0 && ( <> { }} extendedInfo={[]} > - - Permission Details - {results?.Results?.Links.length > 0 && ( { const [tokens, setTokens] = useState(null); @@ -12,7 +13,7 @@ export const CIPPDeploymentUpdateTokens = ({ formControl }) => { // Get application ID information for the card header const appId = ApiGetCall({ url: `/api/ExecListAppId`, - queryKey: `ExecListAppId`, + queryKey: "listAppId", waiting: true, }); @@ -46,10 +47,79 @@ export const CIPPDeploymentUpdateTokens = ({ formControl }) => { /> } > - + Click the button to refresh the Graph token for your tenants using popup authentication. - This method opens a popup window where you can sign in to your Microsoft account. + Use this to update your refresh token or change the logged in user. This method opens a + popup window where you can sign in to your Microsoft account. + + + Current Tenant Info + + + {(appId.isLoading || appId.isFetching) && ( + + + + + + + + )} + {!appId.isLoading && !appId.isFetching && appId?.data?.orgName && ( + + + + + + + + {appId.data.orgName} + + + {appId.data.tenantId} + + + {appId.data.authenticatedUserDisplayName && ( + + + + + + {appId.data.authenticatedUserDisplayName} + + + {appId.data.authenticatedUserPrincipalName} + + + )} + + + {appId.data.isPartnerTenant ? ( + + ) : ( + + )} + + + )} + diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx index 8f8683af405e..52f9722e6254 100644 --- a/src/components/CippWizard/CippTenantModeDeploy.jsx +++ b/src/components/CippWizard/CippTenantModeDeploy.jsx @@ -1,10 +1,22 @@ import { useEffect } from "react"; -import { Stack, Box, Typography, Link } from "@mui/material"; +import { + Stack, + Box, + Typography, + Link, + Chip, + Skeleton, + SvgIcon, + IconButton, + Tooltip, +} from "@mui/material"; +import { Person, Apartment, Sync } from "@mui/icons-material"; import { CIPPM365OAuthButton } from "../CippComponents/CIPPM365OAuthButton"; import { CippApiResults } from "../CippComponents/CippApiResults"; -import { ApiPostCall } from "../../api/ApiCall"; +import { ApiPostCall, ApiGetCall } from "../../api/ApiCall"; import { CippWizardStepButtons } from "./CippWizardStepButtons"; import { CippTenantTable } from "./CippTenantTable"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; export const CippTenantModeDeploy = (props) => { const { formControl, currentStep, onPreviousStep, onNextStep } = props; @@ -13,8 +25,15 @@ export const CippTenantModeDeploy = (props) => { required: true, }); - const updateRefreshToken = ApiPostCall({ urlfromdata: true }); - const addTenant = ApiPostCall({ urlfromdata: true }); + const updateRefreshToken = ApiPostCall({ urlfromdata: true, relatedQueryKeys: ["listAppId"] }); + const addTenant = ApiPostCall({ urlfromdata: true, relatedQueryKeys: ["tenants-table"] }); + + // Get partner tenant info using the same API call as CIPPM365OAuthButton + const partnerTenantInfo = ApiGetCall({ + url: `/api/ExecListAppId`, + queryKey: "listAppId", + waiting: true, + }); useEffect(() => { if (updateRefreshToken.isSuccess) { @@ -30,18 +49,28 @@ export const CippTenantModeDeploy = (props) => { return ( - - {/* Partner Tenant (GDAP) */} - - Partner Tenant - + + + Partner Tenant + + + partnerTenantInfo.refetch()} + disabled={partnerTenantInfo.isLoading} + > + + + + Using GDAP is recommended for CIPP, however you can also authenticate to individual - tenants. It is still highly recommended to connect to your partner tenant first, even if - you are not a Microsoft CSP. This allows CIPP to send notifications, perform permission - checks, and update permissions when required. + tenants. It is required to connect to your partner tenant first, even if you are not a + Microsoft CSP. This is where the multi-tenant App Registration (CIPP-SAM) is installed. It + also allows CIPP to send notifications, perform permission checks, and update permissions + when required. Please remember to log onto a service account dedicated for CIPP. More info? Check out the{" "} @@ -55,26 +84,142 @@ export const CippTenantModeDeploy = (props) => { . - - - { - const updatedTokenData = { - ...tokenData, - tenantMode: "GDAP", - }; - updateRefreshToken.mutate({ - url: "/api/ExecUpdateRefreshToken", - data: updatedTokenData, - }); + {(partnerTenantInfo.isLoading || partnerTenantInfo.isFetching) && ( + + - + > + + + + + + + + + + )} + + {!partnerTenantInfo.isLoading && + !partnerTenantInfo.isFetching && + partnerTenantInfo?.data?.orgName && ( + + + + + + + + + + {partnerTenantInfo.data.orgName} + + + {partnerTenantInfo.data.tenantId} + + + {partnerTenantInfo.data.authenticatedUserDisplayName && ( + + + + + + {partnerTenantInfo.data.authenticatedUserDisplayName} + + + {partnerTenantInfo.data.authenticatedUserPrincipalName} + + + )} + + + {partnerTenantInfo.data.isPartnerTenant ? ( + + ) : ( + + )} + + + + + )} + + {!partnerTenantInfo.isLoading && + !partnerTenantInfo.isFetching && + !partnerTenantInfo?.data?.orgName && ( + + + + + No partner tenant connected. Click the button below to authenticate with your + partner tenant. + + + + + )} + + + { + const updatedTokenData = { + ...tokenData, + tenantMode: "GDAP", + }; + updateRefreshToken.mutate({ + url: "/api/ExecUpdateRefreshToken", + data: updatedTokenData, + }); + }} + buttonText={ + partnerTenantInfo?.data?.orgName + ? "Change Partner Tenant" + : "Connect to Partner Tenant" + } + showSuccessAlert={false} + promptBeforeAuth={ + partnerTenantInfo?.data?.orgName + ? `Are you sure you want to change the partner tenant from '${partnerTenantInfo?.data?.orgName}'? If you are trying to add another tenant, use the per-tenant authentication below.` + : false + } + scope="https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All https://graph.microsoft.com/Directory.ReadWrite.All https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All offline_access profile openid" + /> + + {/* Per-Tenant */} @@ -86,34 +231,65 @@ export const CippTenantModeDeploy = (props) => { wrong tenant? Use the table below to remove it. + {!partnerTenantInfo?.data?.orgName && ( + + + Please connect to your partner tenant first before adding separate tenants. + + + )} + - { - const updatedTokenData = { - ...tokenData, - tenantMode: "perTenant", - }; - addTenant.mutate({ - url: "/api/ExecAddTenant", - data: updatedTokenData, - }); - }} - buttonText="Connect to Separate Tenants" - showSuccessAlert={false} - /> + + {!partnerTenantInfo?.data?.orgName && ( + + )} + + { + if (!partnerTenantInfo?.data?.orgName) return; + const updatedTokenData = { + ...tokenData, + tenantMode: "perTenant", + }; + addTenant.mutate({ + url: "/api/ExecAddTenant", + data: updatedTokenData, + }); + }} + buttonText="Connect to Separate Tenants" + showSuccessAlert={false} + scope="https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All https://graph.microsoft.com/Directory.ReadWrite.All https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All offline_access profile openid" + /> + + - + + + + +