Skip to content

Commit

Permalink
Show relevant errors when no login methods added
Browse files Browse the repository at this point in the history
  • Loading branch information
prateek3255 committed Mar 18, 2024
1 parent b4ffa8c commit ba74d6f
Show file tree
Hide file tree
Showing 14 changed files with 286 additions and 34 deletions.
8 changes: 4 additions & 4 deletions src/api/tenants/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export type TenantInfo = {
passwordless: {
enabled: boolean;
};
firstFactors?: Array<string>;
requiredSecondaryFactors?: Array<string>;
firstFactors?: Array<string> | null;
requiredSecondaryFactors?: Array<string> | null;
coreConfig: Record<string, unknown>;
userCount: number;
validFirstFactors: Array<string>;
Expand All @@ -64,8 +64,8 @@ export type UpdateTenant = {
emailPasswordEnabled?: boolean;
passwordlessEnabled?: boolean;
thirdPartyEnabled?: boolean;
firstFactors?: string[];
requiredSecondaryFactors?: string[];
firstFactors?: Array<string> | null;
requiredSecondaryFactors?: Array<string> | null;
coreConfig?: Record<string, unknown>;
};

Expand Down
20 changes: 20 additions & 0 deletions src/ui/components/dialog/dialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@
}
}

.dialog-title {
display: flex;
align-items: center;
gap: 10px;

& > svg {
height: 21px;
width: 23px;
}
}

.dialog-footer {
display: flex;
gap: 24px;
Expand Down Expand Up @@ -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%;
Expand Down
17 changes: 15 additions & 2 deletions src/ui/components/dialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -25,6 +27,7 @@ type DialogCommonProps = {
type DialogProps = DialogCommonProps & {
title: string;
closeOnOverlayClick?: boolean;
isError?: boolean;
onCloseDialog: () => void;
};

Expand All @@ -43,7 +46,11 @@ function Dialog(props: DialogProps) {
/>
<div className={`dialog-container ${className}`}>
<div className="dialog-header">
{title} <CloseIcon onClick={onCloseDialog} />
<div className="dialog-title">
{props.isError && <ErrorIcon />}
{title}
</div>
<CloseIcon onClick={onCloseDialog} />
</div>
{children}
</div>
Expand All @@ -57,6 +64,12 @@ function DialogContent(props: DialogCommonProps) {
return <div className={`dialog-content ${className}`}>{children}</div>;
}

function DialogConfirmText(props: DialogCommonProps) {
const { children, className = "" } = props;

return <p className={`dialog-confirm-text ${className}`}>{children}</p>;
}

type DialogFooterProps = DialogCommonProps & {
flexDirection?: "row" | "column";
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
Expand All @@ -75,4 +88,4 @@ function DialogFooter(props: DialogFooterProps) {
return <div className={`dialog-footer ${flexDirection} ${justifyContent} ${border} ${className}`}>{children}</div>;
}

export { Dialog, DialogContent, DialogFooter };
export { Dialog, DialogContent, DialogFooter, DialogConfirmText };
26 changes: 26 additions & 0 deletions src/ui/components/errorBlock/ErrorBlock.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`error-block ${className}`}>
<ErrorIcon />
<p className="error-block__error-message">{children}</p>
</div>
);
};
36 changes: 36 additions & 0 deletions src/ui/components/errorBlock/errorBlock.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
111 changes: 88 additions & 23 deletions src/ui/components/tenants/tenantDetail/LoginMethodsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,40 +15,25 @@
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();
const [selectedFactors, setSelectedFactors] = useState<{
firstFactors: Array<string>;
requiredSecondaryFactors: Array<string>;
}>({
firstFactors: getFirstFactorIds(tenantInfo),
firstFactors: tenantInfo.validFirstFactors ?? [],
requiredSecondaryFactors: tenantInfo.requiredSecondaryFactors ?? [],
});

Expand Down Expand Up @@ -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) => {
Expand All @@ -110,6 +102,7 @@ export const LoginMethodsSection = () => {
setTenantInfo({
...currentTenantInfo,
firstFactors: factors.firstFactors,
validFirstFactors: factors.firstFactors,
requiredSecondaryFactors: factors.requiredSecondaryFactors,
emailPassword: {
enabled: enabledLoginMethods.emailPasswordEnabled,
Expand All @@ -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 ?? [],
});

Expand Down Expand Up @@ -161,13 +154,21 @@ export const LoginMethodsSection = () => {
Enabled Login Methods
</PanelHeaderTitleWithTooltip>
</PanelHeader>

{selectedFactors.firstFactors.length === 0 && (
<ErrorBlock className="tenant-detail__factors-error-block">
At least one login method needs to be enabled for the user to log in to the tenant.
</ErrorBlock>
)}

<div className="tenant-detail__factors-container">
<div className="tenant-detail__factors-container__grid">
{FIRST_FACTOR_IDS.map((method) => (
<LoginFactor
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)}
Expand Down Expand Up @@ -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)}
Expand All @@ -218,14 +220,17 @@ const LoginFactor = ({
checked,
onChange,
fixedGap,
factorId,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: () => void;
fixedGap?: boolean;
factorId: string;
}) => {
const hasError = checked && !doesFactorHasRecipeInitialized(factorId);
return (
<div
className={`tenant-detail__factors-container__grid__factor${
Expand All @@ -235,8 +240,9 @@ const LoginFactor = ({
<TooltipContainer
tooltipWidth={200}
position="bottom"
tooltip={description}>
<InfoIcon />
tooltip={description}
error={hasError}>
{hasError ? <ErrorIcon style={{ transform: "translateY(-1px)" }} /> : <InfoIcon />}
</TooltipContainer>
<div className="tenant-detail__factors-container__grid__factor__label-container__label">{label}:</div>
</div>
Expand All @@ -248,3 +254,62 @@ const LoginFactor = ({
</div>
);
};

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;
};
Loading

0 comments on commit ba74d6f

Please sign in to comment.