From ba74d6fcd71421e5b642f161b59b8b6ce0224be1 Mon Sep 17 00:00:00 2001 From: Prateek Surana Date: Mon, 18 Mar 2024 11:19:51 +0530 Subject: [PATCH] Show relevant errors when no login methods added --- src/api/tenants/types.ts | 8 +- src/ui/components/dialog/dialog.scss | 20 ++++ src/ui/components/dialog/index.tsx | 17 ++- src/ui/components/errorBlock/ErrorBlock.tsx | 26 ++++ src/ui/components/errorBlock/errorBlock.scss | 36 ++++++ .../tenantDetail/LoginMethodsSection.tsx | 111 ++++++++++++++---- .../tenants/tenantDetail/TenantDetail.tsx | 14 +++ .../NoLoginMethodsAddedDialog.tsx | 39 ++++++ .../tenants/tenantDetail/tenantDetail.scss | 4 + .../BuiltInProviderInfo.tsx | 4 +- src/ui/components/tooltip/tooltip.tsx | 17 ++- src/ui/styles/uikit.scss | 21 ++++ src/ui/styles/variables.css | 2 + src/utils/index.ts | 1 + 14 files changed, 286 insertions(+), 34 deletions(-) create mode 100644 src/ui/components/errorBlock/ErrorBlock.tsx create mode 100644 src/ui/components/errorBlock/errorBlock.scss create mode 100644 src/ui/components/tenants/tenantDetail/noLoginMethodsAddedDialog/NoLoginMethodsAddedDialog.tsx diff --git a/src/api/tenants/types.ts b/src/api/tenants/types.ts index 8489c576..1e829149 100644 --- a/src/api/tenants/types.ts +++ b/src/api/tenants/types.ts @@ -52,8 +52,8 @@ export type TenantInfo = { passwordless: { enabled: boolean; }; - firstFactors?: Array; - requiredSecondaryFactors?: Array; + firstFactors?: Array | null; + requiredSecondaryFactors?: Array | null; coreConfig: Record; userCount: number; validFirstFactors: Array; @@ -64,8 +64,8 @@ export type UpdateTenant = { emailPasswordEnabled?: boolean; passwordlessEnabled?: boolean; thirdPartyEnabled?: boolean; - firstFactors?: string[]; - requiredSecondaryFactors?: string[]; + firstFactors?: Array | null; + requiredSecondaryFactors?: Array | null; coreConfig?: Record; }; diff --git a/src/ui/components/dialog/dialog.scss b/src/ui/components/dialog/dialog.scss index bef4ada4..6169d83e 100644 --- a/src/ui/components/dialog/dialog.scss +++ b/src/ui/components/dialog/dialog.scss @@ -71,6 +71,17 @@ } } +.dialog-title { + display: flex; + align-items: center; + gap: 10px; + + & > svg { + height: 21px; + width: 23px; + } +} + .dialog-footer { display: flex; gap: 24px; @@ -117,6 +128,15 @@ } } +.dialog-confirm-text { + color: black; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 23px; + padding-top: 24px; +} + @media (max-width: 485px) { .dialog-container { width: 95%; diff --git a/src/ui/components/dialog/index.tsx b/src/ui/components/dialog/index.tsx index 78b9b72c..0c28815c 100644 --- a/src/ui/components/dialog/index.tsx +++ b/src/ui/components/dialog/index.tsx @@ -15,6 +15,8 @@ import React from "react"; import { ReactComponent as CloseIcon } from "../../../assets/close.svg"; +import { ReactComponent as ErrorIcon } from "../../../assets/form-field-error-icon.svg"; + import "./dialog.scss"; type DialogCommonProps = { @@ -25,6 +27,7 @@ type DialogCommonProps = { type DialogProps = DialogCommonProps & { title: string; closeOnOverlayClick?: boolean; + isError?: boolean; onCloseDialog: () => void; }; @@ -43,7 +46,11 @@ function Dialog(props: DialogProps) { />
- {title} +
+ {props.isError && } + {title} +
+
{children}
@@ -57,6 +64,12 @@ function DialogContent(props: DialogCommonProps) { return
{children}
; } +function DialogConfirmText(props: DialogCommonProps) { + const { children, className = "" } = props; + + return

{children}

; +} + type DialogFooterProps = DialogCommonProps & { flexDirection?: "row" | "column"; justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly"; @@ -75,4 +88,4 @@ function DialogFooter(props: DialogFooterProps) { return
{children}
; } -export { Dialog, DialogContent, DialogFooter }; +export { Dialog, DialogContent, DialogFooter, DialogConfirmText }; diff --git a/src/ui/components/errorBlock/ErrorBlock.tsx b/src/ui/components/errorBlock/ErrorBlock.tsx new file mode 100644 index 00000000..cb32c6e0 --- /dev/null +++ b/src/ui/components/errorBlock/ErrorBlock.tsx @@ -0,0 +1,26 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ReactNode } from "react"; +import { ReactComponent as ErrorIcon } from "../../../assets/form-field-error-icon.svg"; +import "./errorBlock.scss"; + +export const ErrorBlock = ({ children, className }: { children: ReactNode; className?: string }) => { + return ( +
+ +

{children}

+
+ ); +}; diff --git a/src/ui/components/errorBlock/errorBlock.scss b/src/ui/components/errorBlock/errorBlock.scss new file mode 100644 index 00000000..027e0f3e --- /dev/null +++ b/src/ui/components/errorBlock/errorBlock.scss @@ -0,0 +1,36 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +.error-block { + border: 1px solid var(--color-border-error-block); + background-color: var(--color-error-block-bg); + border-radius: 6px; + padding: 14px 18px; + display: flex; + gap: 10px; + + & > svg { + width: 19px; + height: 17px; + transform: translateY(1px); + } + + &__error-message { + font-family: inherit; + font-size: 14px; + line-height: 23px; + color: var(--color-black); + } +} diff --git a/src/ui/components/tenants/tenantDetail/LoginMethodsSection.tsx b/src/ui/components/tenants/tenantDetail/LoginMethodsSection.tsx index de92b73c..db0b4072 100644 --- a/src/ui/components/tenants/tenantDetail/LoginMethodsSection.tsx +++ b/src/ui/components/tenants/tenantDetail/LoginMethodsSection.tsx @@ -15,32 +15,17 @@ import { useCallback, useContext, useState } from "react"; import { useTenantService } from "../../../../api/tenants"; import { TenantInfo } from "../../../../api/tenants/types"; +import { ReactComponent as ErrorIcon } from "../../../../assets/form-field-error-icon.svg"; import { ReactComponent as InfoIcon } from "../../../../assets/info-icon.svg"; import { FIRST_FACTOR_IDS, SECONDARY_FACTOR_IDS } from "../../../../constants"; -import { debounce, getImageUrl } from "../../../../utils"; +import { debounce, getImageUrl, getInitializedRecipes } from "../../../../utils"; import { PopupContentContext } from "../../../contexts/PopupContentContext"; +import { ErrorBlock } from "../../errorBlock/ErrorBlock"; import { Toggle } from "../../toggle/Toggle"; import TooltipContainer from "../../tooltip/tooltip"; import { useTenantDetailContext } from "./TenantDetailContext"; import { PanelHeader, PanelHeaderTitleWithTooltip, PanelRoot } from "./tenantDetailPanel/TenantDetailPanel"; -const getFirstFactorIds = (tenant: TenantInfo) => { - if (!tenant.firstFactors) { - const firstFactors = []; - if (tenant.emailPassword.enabled) { - firstFactors.push("emailpassword"); - } - if (tenant.passwordless.enabled) { - firstFactors.push("otp-email", "otp-phone", "link-email", "link-phone"); - } - if (tenant.thirdParty.enabled) { - firstFactors.push("thirdparty"); - } - return firstFactors; - } - return tenant.firstFactors; -}; - export const LoginMethodsSection = () => { const { tenantInfo, setTenantInfo } = useTenantDetailContext(); const { updateTenant } = useTenantService(); @@ -48,7 +33,7 @@ export const LoginMethodsSection = () => { firstFactors: Array; requiredSecondaryFactors: Array; }>({ - firstFactors: getFirstFactorIds(tenantInfo), + firstFactors: tenantInfo.validFirstFactors ?? [], requiredSecondaryFactors: tenantInfo.requiredSecondaryFactors ?? [], }); @@ -100,7 +85,14 @@ export const LoginMethodsSection = () => { }; updateTenant(tenantId, { - ...factors, + firstFactors: + Array.isArray(factors.firstFactors) && factors.firstFactors.length > 0 + ? factors.firstFactors + : null, + requiredSecondaryFactors: + Array.isArray(factors.requiredSecondaryFactors) && factors.requiredSecondaryFactors.length > 0 + ? factors.requiredSecondaryFactors + : null, ...enabledLoginMethods, }) .then((res) => { @@ -110,6 +102,7 @@ export const LoginMethodsSection = () => { setTenantInfo({ ...currentTenantInfo, firstFactors: factors.firstFactors, + validFirstFactors: factors.firstFactors, requiredSecondaryFactors: factors.requiredSecondaryFactors, emailPassword: { enabled: enabledLoginMethods.emailPasswordEnabled, @@ -126,7 +119,7 @@ export const LoginMethodsSection = () => { .catch((_) => { // Revert the state back to the original state in case of error setSelectedFactors({ - firstFactors: currentTenantInfo.firstFactors ?? [], + firstFactors: currentTenantInfo.validFirstFactors ?? [], requiredSecondaryFactors: currentTenantInfo.requiredSecondaryFactors ?? [], }); @@ -161,6 +154,13 @@ export const LoginMethodsSection = () => { Enabled Login Methods + + {selectedFactors.firstFactors.length === 0 && ( + + At least one login method needs to be enabled for the user to log in to the tenant. + + )} +
{FIRST_FACTOR_IDS.map((method) => ( @@ -168,6 +168,7 @@ export const LoginMethodsSection = () => { id={`first-factor-${method.id}`} key={`first-factor-${method.id}`} label={method.label} + factorId={method.id} description={method.description} checked={selectedFactors.firstFactors.includes(method.id)} onChange={() => handleFactorChange("firstFactors", method.id)} @@ -198,6 +199,7 @@ export const LoginMethodsSection = () => { id={`secondary-factor-${method.id}`} key={`secondary-factor-${method.id}`} label={method.label} + factorId={method.id} fixedGap description={method.description} checked={selectedFactors.requiredSecondaryFactors.includes(method.id)} @@ -218,6 +220,7 @@ const LoginFactor = ({ checked, onChange, fixedGap, + factorId, }: { id: string; label: string; @@ -225,7 +228,9 @@ const LoginFactor = ({ checked: boolean; onChange: () => void; fixedGap?: boolean; + factorId: string; }) => { + const hasError = checked && !doesFactorHasRecipeInitialized(factorId); return (
- + tooltip={description} + error={hasError}> + {hasError ? : }
{label}:
@@ -248,3 +254,62 @@ const LoginFactor = ({
); }; + +const doesFactorHasRecipeInitialized = (factorId: string) => { + const initializedRecipes = getInitializedRecipes(); + + if (factorId === "emailpassword") { + return initializedRecipes.emailPassword; + } + + if (factorId === "thirdparty") { + return initializedRecipes.thirdParty; + } + + if (["otp-email", "otp-phone", "link-email", "link-phone"].includes(factorId)) { + if (!initializedRecipes.passwordless) { + return false; + } + if (factorId === "otp-email") { + return ( + (initializedRecipes.passwordless.contactMethod === "EMAIL" || + initializedRecipes.passwordless.contactMethod === "EMAIL_OR_PHONE") && + (initializedRecipes.passwordless.flowType === "USER_INPUT_CODE" || + initializedRecipes.passwordless.flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") + ); + } + + if (factorId === "otp-phone") { + return ( + (initializedRecipes.passwordless.contactMethod === "PHONE" || + initializedRecipes.passwordless.contactMethod === "EMAIL_OR_PHONE") && + (initializedRecipes.passwordless.flowType === "USER_INPUT_CODE" || + initializedRecipes.passwordless.flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") + ); + } + + if (factorId === "link-email") { + return ( + (initializedRecipes.passwordless.contactMethod === "EMAIL" || + initializedRecipes.passwordless.contactMethod === "EMAIL_OR_PHONE") && + (initializedRecipes.passwordless.flowType === "MAGIC_LINK" || + initializedRecipes.passwordless.flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") + ); + } + + if (factorId === "link-phone") { + return ( + (initializedRecipes.passwordless.contactMethod === "PHONE" || + initializedRecipes.passwordless.contactMethod === "EMAIL_OR_PHONE") && + (initializedRecipes.passwordless.flowType === "MAGIC_LINK" || + initializedRecipes.passwordless.flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") + ); + } + } + + if (factorId === "totp") { + return initializedRecipes.totp; + } + + return false; +}; diff --git a/src/ui/components/tenants/tenantDetail/TenantDetail.tsx b/src/ui/components/tenants/tenantDetail/TenantDetail.tsx index d7f36499..de4e2217 100644 --- a/src/ui/components/tenants/tenantDetail/TenantDetail.tsx +++ b/src/ui/components/tenants/tenantDetail/TenantDetail.tsx @@ -23,6 +23,7 @@ import { Loader, LoaderOverlay } from "../../loader/Loader"; import { CoreConfigSection } from "./CoreConfigSection"; import { DeleteTenantDialog } from "./deleteTenant/DeleteTenant"; import { LoginMethodsSection } from "./LoginMethodsSection"; +import { NoLoginMethodsAddedDialog } from "./noLoginMethodsAddedDialog/NoLoginMethodsAddedDialog"; import "./tenantDetail.scss"; import { TenantDetailContextProvider } from "./TenantDetailContext"; import { TenantDetailHeader } from "./TenantDetailHeader"; @@ -38,6 +39,7 @@ export const TenantDetail = ({ }) => { const { getTenantInfo } = useTenantService(); const { getCoreConfigOptions } = useCoreConfigService(); + const [isNoLoginMethodsDialogVisible, setIsNoLoginMethodsDialogVisible] = useState(false); const [tenant, setTenant] = useState(undefined); const [configOptions, setConfigOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -77,6 +79,15 @@ export const TenantDetail = ({ void fetchData(); }, [tenantId]); + useEffect(() => { + if ( + typeof tenant?.tenantId === "string" && + (!Array.isArray(tenant?.validFirstFactors) || tenant?.validFirstFactors.length === 0) + ) { + setIsNoLoginMethodsDialogVisible(true); + } + }, [tenant?.validFirstFactors]); + const refetchTenant = async () => { setShowLoadingOverlay(true); await getTenant(); @@ -189,6 +200,9 @@ export const TenantDetail = ({ coreConfigOptions={configOptions} refetchTenant={refetchTenant}> {renderView()} + {isNoLoginMethodsDialogVisible && ( + setIsNoLoginMethodsDialogVisible(false)} /> + )} ); }; diff --git a/src/ui/components/tenants/tenantDetail/noLoginMethodsAddedDialog/NoLoginMethodsAddedDialog.tsx b/src/ui/components/tenants/tenantDetail/noLoginMethodsAddedDialog/NoLoginMethodsAddedDialog.tsx new file mode 100644 index 00000000..7565a77d --- /dev/null +++ b/src/ui/components/tenants/tenantDetail/noLoginMethodsAddedDialog/NoLoginMethodsAddedDialog.tsx @@ -0,0 +1,39 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import Button from "../../../button"; +import { Dialog, DialogConfirmText, DialogContent, DialogFooter } from "../../../dialog"; + +export const NoLoginMethodsAddedDialog = ({ onCloseDialog }: { onCloseDialog: () => void }) => { + return ( + + + + At least one login method needs to be enabled for the user to log in to the tenant. + + + + + + + + ); +}; diff --git a/src/ui/components/tenants/tenantDetail/tenantDetail.scss b/src/ui/components/tenants/tenantDetail/tenantDetail.scss index 82e35865..e92ec0e7 100644 --- a/src/ui/components/tenants/tenantDetail/tenantDetail.scss +++ b/src/ui/components/tenants/tenantDetail/tenantDetail.scss @@ -347,4 +347,8 @@ gap: 20px; flex-wrap: wrap; } + + &__factors-error-block { + margin-bottom: 10px; + } } diff --git a/src/ui/components/tenants/tenantDetail/thirdPartyProviderConfig/BuiltInProviderInfo.tsx b/src/ui/components/tenants/tenantDetail/thirdPartyProviderConfig/BuiltInProviderInfo.tsx index 98063f77..5fab3fdb 100644 --- a/src/ui/components/tenants/tenantDetail/thirdPartyProviderConfig/BuiltInProviderInfo.tsx +++ b/src/ui/components/tenants/tenantDetail/thirdPartyProviderConfig/BuiltInProviderInfo.tsx @@ -417,7 +417,7 @@ const IN_BUILT_PROVIDERS_CUSTOM_FIELDS: BuiltInProvidersCustomFields = { tooltip: "The id of the Microsoft Entra tenant, this is required if OIDC discovery endpoint is not provided.", type: "text", - required: false, + required: true, }, ], defaultScopes: ["openid", "email"], @@ -448,7 +448,7 @@ const IN_BUILT_PROVIDERS_CUSTOM_FIELDS: BuiltInProvidersCustomFields = { tooltip: "The domain of your Okta account, this is required if OIDC discovery endpoint is not provided.", type: "text", - required: false, + required: true, }, ], defaultScopes: ["openid", "email"], diff --git a/src/ui/components/tooltip/tooltip.tsx b/src/ui/components/tooltip/tooltip.tsx index fbc0257c..b916e089 100644 --- a/src/ui/components/tooltip/tooltip.tsx +++ b/src/ui/components/tooltip/tooltip.tsx @@ -1,13 +1,15 @@ import { FC, ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { isMobile } from "../../../utils"; import Toast, { ToastProps } from "../toast/toast"; -import { PopUpPositionProperties, PopUpPositionType, getPopupPosition } from "./tooltip-util"; +import { getPopupPosition, PopUpPositionProperties, PopUpPositionType } from "./tooltip-util"; const DEFAULT_TOOLTIP_WIDTH = 380; type TooltipBaseProps = Pick & { /** tooltip width in pixel, will get `DEFAULT_TOOLTIP_WIDTH` by default */ tooltipWidth?: number; + /** Render the error tooltip if true */ + error?: boolean; }; type TooltipPopupProps = TooltipBaseProps & { properties?: PopUpPositionProperties }; @@ -26,7 +28,14 @@ type TooltipProps = TooltipBaseProps & { }; /** The element that will pop out when the `TooltipContainer` is receiving `TooltipTrigger`*/ -export const TooltipPopup: FC = ({ duration, children, onDisappear, properties, tooltipWidth }) => { +export const TooltipPopup: FC = ({ + duration, + children, + onDisappear, + properties, + tooltipWidth, + error, +}) => { // if positioned left/right, then use the `tooltipWidth` value because the there is enough space const width = properties?.positionType === "left" || properties?.positionType === "right" ? `${tooltipWidth}px` : "auto"; @@ -36,7 +45,9 @@ export const TooltipPopup: FC = ({ duration, children, onDisa duration={duration} onDisappear={onDisappear}>
{children} diff --git a/src/ui/styles/uikit.scss b/src/ui/styles/uikit.scss index 0b9b5f4b..077b13f1 100644 --- a/src/ui/styles/uikit.scss +++ b/src/ui/styles/uikit.scss @@ -454,6 +454,7 @@ button.link, border: $arrow-width solid transparent; position: absolute; } + p:not(:last-child) { margin-bottom: 6px; } @@ -490,6 +491,26 @@ button.link, left: 50%; transform: translateY(-100%) translateX(-50%); } + + &--error { + background-color: var(--color-black); + color: var(--color-button-error); + box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.16); + border: 1px solid var(--color-border-command); + + &.popup_left::before { + border-left-color: var(--color-black); + } + &.popup_right::before { + border-right-color: var(--color-black); + } + &.popup_top::before { + border-top-color: var(--color-black); + } + &.popup_bottom::before { + border-bottom-color: var(--color-black); + } + } } } diff --git a/src/ui/styles/variables.css b/src/ui/styles/variables.css index f025b854..f113ecae 100644 --- a/src/ui/styles/variables.css +++ b/src/ui/styles/variables.css @@ -52,6 +52,7 @@ body { --color-third-party-button-bg: rgb(222, 228, 232); --color-client-config-header-bg: rgb(250, 250, 250); --color-input-field-prefix-bg: rgb(250, 250, 250); + --color-error-block-bg: rgba(255, 213, 213); /* Border Colors */ --color-border: rgb(229, 229, 229); @@ -60,6 +61,7 @@ body { --color-border-warn: rgb(255, 183, 29); --color-border-success: rgb(73, 200, 153); --color-border-command: rgb(221, 221, 221); + --color-border-error-block: rgba(255, 18, 18, 1); /* Text Colors */ --color-secondary-text: rgb(110, 106, 101); /* Below title, table headers, placeholders etc */ diff --git a/src/utils/index.ts b/src/utils/index.ts index 105d59d7..79d99d48 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -50,6 +50,7 @@ type InitializedRecipes = { }; thirdParty: boolean; mfa: boolean; + totp: boolean; }; export function getInitializedRecipes(): InitializedRecipes {