From 3f7ed1f10d44f7af88535fe4c12efb6a743c390b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 8 May 2026 17:09:56 -0400 Subject: [PATCH] feat: add Indirect Reseller Link component and integrate into onboarding wizard implements #5963 --- .../CippWizard/CippAddTenantTypeSelection.jsx | 94 +++++++----- .../CippWizard/CippIndirectResellerLink.jsx | 141 ++++++++++++++++++ .../CippWizard/OnboardingWizardPage.jsx | 7 + 3 files changed, 202 insertions(+), 40 deletions(-) create mode 100644 src/components/CippWizard/CippIndirectResellerLink.jsx diff --git a/src/components/CippWizard/CippAddTenantTypeSelection.jsx b/src/components/CippWizard/CippAddTenantTypeSelection.jsx index f8ba6a3848848..57e0b2ebac870 100644 --- a/src/components/CippWizard/CippAddTenantTypeSelection.jsx +++ b/src/components/CippWizard/CippAddTenantTypeSelection.jsx @@ -1,68 +1,82 @@ -import { Avatar, Card, CardContent, Stack, SvgIcon, Typography } from "@mui/material"; -import { useState, useEffect } from "react"; -import { CippWizardStepButtons } from "./CippWizardStepButtons"; -import { BuildingOfficeIcon, CloudIcon } from "@heroicons/react/24/outline"; +import { Avatar, Card, CardContent, Stack, SvgIcon, Typography } from '@mui/material' +import { useState, useEffect } from 'react' +import { CippWizardStepButtons } from './CippWizardStepButtons' +import { BuildingOfficeIcon, CloudIcon, LinkIcon } from '@heroicons/react/24/outline' export const CippAddTenantTypeSelection = (props) => { - const { onNextStep, formControl, currentStep, onPreviousStep } = props; + const { onNextStep, formControl, currentStep, onPreviousStep } = props - const [selectedOption, setSelectedOption] = useState(null); + const [selectedOption, setSelectedOption] = useState(null) // Register the tenantType field in react-hook-form - formControl.register("tenantType", { + formControl.register('tenantType', { required: true, - }); + }) // Restore selection if already set (when navigating back) useEffect(() => { - const currentValue = formControl.getValues("tenantType"); + const currentValue = formControl.getValues('tenantType') if (currentValue) { - setSelectedOption(currentValue); + setSelectedOption(currentValue) } // Restore the form's selectedOption state if navigating back - const selectedOptionValue = formControl.getValues("selectedOption"); + const selectedOptionValue = formControl.getValues('selectedOption') if (selectedOptionValue) { - formControl.setValue("selectedOption", selectedOptionValue); + formControl.setValue('selectedOption', selectedOptionValue) } - }, [formControl]); + }, [formControl]) const handleOptionClick = (value) => { - setSelectedOption(value); - formControl.setValue("tenantType", value); + setSelectedOption(value) + formControl.setValue('tenantType', value) // Clear validation fields from other paths when changing selection // This ensures going back and choosing a different option doesn't keep old validations - if (value === "GDAP") { + if (value === 'GDAP') { // Clear Direct tenant fields - formControl.unregister("DirectTenantAuth"); - } else if (value === "Direct") { + formControl.unregister('DirectTenantAuth') + } else if (value === 'Direct') { // Clear GDAP fields - formControl.unregister("GDAPTemplate"); - formControl.unregister("GDAPInviteAccepted"); - formControl.unregister("GDAPRelationshipId"); - formControl.unregister("GDAPOnboardingComplete"); + formControl.unregister('GDAPTemplate') + formControl.unregister('GDAPInviteAccepted') + formControl.unregister('GDAPRelationshipId') + formControl.unregister('GDAPOnboardingComplete') + } else if (value === 'IndirectReseller') { + // Clear other paths + formControl.unregister('DirectTenantAuth') + formControl.unregister('GDAPTemplate') + formControl.unregister('GDAPInviteAccepted') + formControl.unregister('GDAPRelationshipId') + formControl.unregister('GDAPOnboardingComplete') } // Trigger validation only for the tenantType field - formControl.trigger("tenantType"); - }; + formControl.trigger('tenantType') + } const options = [ { - value: "GDAP", - label: "Add GDAP Tenant", + value: 'GDAP', + label: 'Add GDAP Tenant', description: "Select this option to add a new tenant to your Microsoft Partner center environment. We'll walk you through the steps of setting up GDAP.", icon: , }, { - value: "Direct", - label: "Add Direct Tenant", + value: 'Direct', + label: 'Add Direct Tenant', description: - "Select this option if you are not a Microsoft partner, or want to add a tenant outside of the scope of your partner center.", + 'Select this option if you are not a Microsoft partner, or want to add a tenant outside of the scope of your partner center.', icon: , }, - ]; + { + value: 'IndirectReseller', + label: 'Get Indirect Reseller Invite Link', + description: + 'Generate a reseller relationship invite link to send to a customer. This does not add the tenant to CIPP, but may be used by other vendors to populate their customer list.', + icon: , + }, + ] return ( @@ -74,7 +88,7 @@ export const CippAddTenantTypeSelection = (props) => { {options.map((option) => { - const isSelected = selectedOption === option.value; + const isSelected = selectedOption === option.value return ( { onClick={() => handleOptionClick(option.value)} variant="outlined" sx={{ - cursor: "pointer", + cursor: 'pointer', ...(isSelected && { boxShadow: (theme) => `0px 0px 0px 2px ${theme.palette.primary.main}`, }), - "&:hover": { + '&:hover': { ...(isSelected ? {} : { boxShadow: 8 }), }, }} @@ -96,9 +110,9 @@ export const CippAddTenantTypeSelection = (props) => { @@ -111,7 +125,7 @@ export const CippAddTenantTypeSelection = (props) => { - ); + ) })} { formControl={formControl} /> - ); -}; + ) +} -export default CippAddTenantTypeSelection; +export default CippAddTenantTypeSelection diff --git a/src/components/CippWizard/CippIndirectResellerLink.jsx b/src/components/CippWizard/CippIndirectResellerLink.jsx new file mode 100644 index 0000000000000..82a83dcae989a --- /dev/null +++ b/src/components/CippWizard/CippIndirectResellerLink.jsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useState } from 'react' +import { Alert, Autocomplete, Box, Skeleton, Stack, TextField, Typography } from '@mui/material' +import { ApiGetCall } from '../../api/ApiCall' +import { CippWizardStepButtons } from './CippWizardStepButtons' +import { CippCopyToClipBoard } from '../CippComponents/CippCopyToClipboard' + +export const CippIndirectResellerLink = (props) => { + const { formControl, currentStep, onPreviousStep, onNextStep } = props + const [selectedProvider, setSelectedProvider] = useState(null) + + const linkData = ApiGetCall({ + url: '/api/ListResellerRelationshipLink', + queryKey: 'ListResellerRelationshipLink', + }) + + const inviteUrl = linkData.data?.inviteUrl ?? null + const indirectProviders = linkData.data?.indirectProviders ?? [] + const inviteUrlError = linkData.data?.inviteUrlError ?? null + + const finalUrl = useMemo(() => { + if (!inviteUrl) return null + if (!selectedProvider) return inviteUrl + // Append the indirect provider ID before the # fragment + const hashIndex = inviteUrl.indexOf('#') + const base = hashIndex !== -1 ? inviteUrl.slice(0, hashIndex) : inviteUrl + const hash = hashIndex !== -1 ? inviteUrl.slice(hashIndex) : '' + return `${base}&indirectCSPId=${selectedProvider.id}${hash}` + }, [inviteUrl, selectedProvider]) + + const providerOptions = useMemo( + () => + indirectProviders.map((p) => ({ + label: p.name, + id: p.id, + mpnId: p.mpnId, + location: p.location, + })), + [indirectProviders] + ) + + return ( + + + + Indirect Reseller Relationship Link + + + Generate an invite link to send to a customer so they can authorize you as their indirect + reseller. This does not add the tenant to CIPP — it only provides the + Microsoft Admin Portal invitation link. + + + + {linkData.isFetching && ( + + {/* Indirect provider dropdown skeleton */} + + {/* Link field skeleton */} + + + + + + + )} + + {linkData.isError && ( + + Failed to load relationship link from the Partner Center API. Ensure your CIPP application + has the required Partner Center permissions. + + )} + + {inviteUrlError && !linkData.isError && {inviteUrlError}} + + {!linkData.isFetching && !linkData.isError && inviteUrl && ( + <> + {indirectProviders.length > 0 && ( + setSelectedProvider(value)} + getOptionLabel={(option) => option.label} + renderOption={(renderProps, option) => ( +
  • + + {option.label} + + MPN ID: {option.mpnId} · {option.location} + + +
  • + )} + renderInput={(params) => ( + + )} + /> + )} + + + + Invite Link + + + + + + + Send this link to your customer. When they follow it, they will be linked to your + reseller account in the Microsoft Admin Portal. + + + + + There is no automatic confirmation when the customer accepts this invite. You can verify + the relationship in Partner Center once the customer has completed the process. + + + )} + + +
    + ) +} diff --git a/src/components/CippWizard/OnboardingWizardPage.jsx b/src/components/CippWizard/OnboardingWizardPage.jsx index c80416dc8622d..6b0876ad9a580 100644 --- a/src/components/CippWizard/OnboardingWizardPage.jsx +++ b/src/components/CippWizard/OnboardingWizardPage.jsx @@ -10,6 +10,7 @@ import { CippAlertsStep } from './CippAlertsStep.jsx' import { CippAddTenantTypeSelection } from './CippAddTenantTypeSelection.jsx' import { CippDirectTenantDeploy } from './CippDirectTenantDeploy.jsx' import { CippGDAPTenantSetup } from './CippGDAPTenantSetup.jsx' +import { CippIndirectResellerLink } from './CippIndirectResellerLink.jsx' import { CippGDAPTenantOnboarding } from './CippGDAPTenantOnboarding.jsx' import { BuildingOfficeIcon, CloudIcon, CpuChipIcon } from '@heroicons/react/24/outline' import { useRouter } from 'next/router' @@ -103,6 +104,12 @@ const OnboardingWizardPage = () => { showStepWhen: (values) => values?.selectedOption === 'AddTenant' && values?.tenantType === 'GDAP', }, + { + description: 'Reseller Link', + component: CippIndirectResellerLink, + showStepWhen: (values) => + values?.selectedOption === 'AddTenant' && values?.tenantType === 'IndirectReseller', + }, { description: 'GDAP Onboarding', component: CippGDAPTenantOnboarding,