mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(experience): use button loading if possible
This commit is contained in:
parent
6060919a21
commit
942af078ba
29 changed files with 239 additions and 85 deletions
|
@ -1,18 +1,16 @@
|
|||
import { useContext } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useDebouncedLoader } from 'use-debounced-loader';
|
||||
|
||||
import PageContext from '@/Providers/PageContextProvider/PageContext';
|
||||
import LoadingLayer from '@/components/LoadingLayer';
|
||||
import LoadingMask from '@/components/LoadingMask';
|
||||
|
||||
const LoadingLayerProvider = () => {
|
||||
const { loading } = useContext(PageContext);
|
||||
const debouncedLoading = useDebouncedLoader(loading, 500);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
{debouncedLoading && <LoadingLayer />}
|
||||
{loading && <LoadingMask />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
3
packages/experience/src/assets/icons/ring.svg
Normal file
3
packages/experience/src/assets/icons/ring.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.3357 16.8714C11.441 17.4135 11.0865 17.9451 10.5355 17.9821C9.4048 18.0579 8.2669 17.8929 7.19834 17.4934C5.81639 16.9767 4.60425 16.0879 3.69591 14.9253C2.78758 13.7627 2.21844 12.3715 2.05143 10.9056C1.88441 9.43973 2.12602 7.9562 2.74954 6.61905C3.37306 5.28191 4.35421 4.14323 5.5845 3.32891C6.8148 2.5146 8.24632 2.05636 9.7208 2.00487C11.1953 1.95338 12.6553 2.31064 13.9394 3.03715C14.9323 3.59891 15.7901 4.36452 16.4588 5.27942C16.7847 5.72531 16.6054 6.33858 16.1223 6.60633C15.6393 6.87408 15.0366 6.69278 14.6924 6.26086C14.2154 5.66218 13.6262 5.15785 12.9545 4.77787C11.9915 4.23298 10.8965 3.96504 9.7906 4.00366C8.68474 4.04227 7.6111 4.38595 6.68838 4.99669C5.76565 5.60742 5.02979 6.46143 4.56215 7.46429C4.09451 8.46715 3.91331 9.5798 4.03857 10.6792C4.16383 11.7786 4.59069 12.822 5.27194 13.694C5.95319 14.5659 6.8623 15.2325 7.89876 15.62C8.62154 15.8903 9.38663 16.0175 10.1519 15.9981C10.704 15.9841 11.2303 16.3293 11.3357 16.8714Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -21,6 +21,11 @@
|
|||
line-height: normal;
|
||||
margin-right: _.unit(2);
|
||||
}
|
||||
|
||||
.loadingIcon {
|
||||
color: var(--color-white);
|
||||
animation: rotating 1s steps(60, end) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.large {
|
||||
|
@ -40,11 +45,16 @@
|
|||
&:disabled {
|
||||
background: var(--color-bg-state-disabled);
|
||||
color: var(--color-type-disable);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--color-brand-pressed);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
background: var(--color-brand-70);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
@ -74,7 +84,7 @@
|
|||
outline: 3px solid var(--color-overlay-brand-focused);
|
||||
}
|
||||
|
||||
&:not(:disabled):not(:active):hover {
|
||||
&:not(:disabled):not(:active):not(.loading):hover {
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
}
|
||||
|
@ -84,8 +94,18 @@
|
|||
outline: 3px solid var(--color-overlay-neutral-focused);
|
||||
}
|
||||
|
||||
&:not(:disabled):not(:active):hover {
|
||||
&:not(:disabled):not(:active):not(.loading):hover {
|
||||
background: var(--color-overlay-neutral-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import classNames from 'classnames';
|
|||
import type { TFuncKey } from 'i18next';
|
||||
import type { HTMLProps } from 'react';
|
||||
|
||||
import Ring from '@/assets/icons/ring.svg';
|
||||
|
||||
import DynamicT from '../DynamicT';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -15,6 +17,7 @@ type BaseProps = Omit<HTMLProps<HTMLButtonElement>, 'type' | 'size' | 'title'> &
|
|||
readonly isDisabled?: boolean;
|
||||
readonly className?: string;
|
||||
readonly onClick?: React.MouseEventHandler;
|
||||
readonly isLoading?: boolean;
|
||||
};
|
||||
|
||||
type Props = BaseProps & {
|
||||
|
@ -31,6 +34,7 @@ const Button = ({
|
|||
i18nProps,
|
||||
className,
|
||||
isDisabled = false,
|
||||
isLoading = false,
|
||||
icon,
|
||||
onClick,
|
||||
...rest
|
||||
|
@ -42,14 +46,20 @@ const Button = ({
|
|||
styles.button,
|
||||
styles[type],
|
||||
styles[size],
|
||||
isDisabled && styles.isDisabled,
|
||||
isDisabled && styles.disabled,
|
||||
isLoading && styles.loading,
|
||||
className
|
||||
)}
|
||||
type={htmlType}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <span className={styles.icon}>{icon}</span>}
|
||||
{icon && !isLoading && <span className={styles.icon}>{icon}</span>}
|
||||
{isLoading && (
|
||||
<span className={classNames(styles.icon, styles.loadingIcon)}>
|
||||
<Ring />
|
||||
</span>
|
||||
)}
|
||||
<DynamicT forKey={title} interpolation={i18nProps} />
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@include _.flex-column;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.loadingIcon {
|
||||
color: var(--color-type-primary);
|
||||
animation: rotating 1s steps(12, end) infinite;
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import LoadingMask from '../LoadingMask';
|
||||
|
||||
import LoadingIcon from './LoadingIcon';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export { default as LoadingIcon } from './LoadingIcon';
|
||||
|
||||
const LoadingLayer = () => (
|
||||
<div className={styles.overlay}>
|
||||
<LoadingMask>
|
||||
<div className={styles.container}>
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
</div>
|
||||
</LoadingMask>
|
||||
);
|
||||
|
||||
export default LoadingLayer;
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@include _.flex-column;
|
||||
z-index: 300;
|
||||
}
|
13
packages/experience/src/components/LoadingMask/index.tsx
Normal file
13
packages/experience/src/components/LoadingMask/index.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { type ReactNode } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
readonly children?: ReactNode;
|
||||
};
|
||||
|
||||
const LoadingMask = ({ children }: Props) => {
|
||||
return <div className={styles.overlay}>{children}</div>;
|
||||
};
|
||||
|
||||
export default LoadingMask;
|
|
@ -14,7 +14,7 @@ type Props = {
|
|||
readonly className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly autoFocus?: boolean;
|
||||
readonly onSubmit: (password: string) => void;
|
||||
readonly onSubmit: (password: string) => Promise<void>;
|
||||
readonly errorMessage?: string;
|
||||
readonly clearErrorMessage?: () => void;
|
||||
};
|
||||
|
@ -29,7 +29,7 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage
|
|||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FieldState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: { newPassword: '' },
|
||||
|
@ -42,11 +42,11 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage
|
|||
}, [clearErrorMessage, isValid]);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(event?: React.FormEvent<HTMLFormElement>) => {
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
clearErrorMessage?.();
|
||||
|
||||
void handleSubmit((data, event) => {
|
||||
onSubmit(data.newPassword);
|
||||
await handleSubmit(async (data) => {
|
||||
await onSubmit(data.newPassword);
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit]
|
||||
|
@ -70,7 +70,12 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage
|
|||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<Button name="submit" title="action.save_password" htmlType="submit" />
|
||||
<Button
|
||||
name="submit"
|
||||
title="action.save_password"
|
||||
htmlType="submit"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
|
|
|
@ -17,7 +17,7 @@ type Props = {
|
|||
readonly className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly autoFocus?: boolean;
|
||||
readonly onSubmit: (password: string) => void;
|
||||
readonly onSubmit: (password: string) => Promise<void>;
|
||||
readonly errorMessage?: string;
|
||||
readonly clearErrorMessage?: () => void;
|
||||
};
|
||||
|
@ -43,7 +43,7 @@ const SetPassword = ({
|
|||
watch,
|
||||
resetField,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FieldState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: { newPassword: '', confirmPassword: '' },
|
||||
|
@ -56,11 +56,11 @@ const SetPassword = ({
|
|||
}, [clearErrorMessage, isValid]);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(event?: React.FormEvent<HTMLFormElement>) => {
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
clearErrorMessage?.();
|
||||
|
||||
void handleSubmit((data, event) => {
|
||||
onSubmit(data.newPassword);
|
||||
await handleSubmit(async (data) => {
|
||||
await onSubmit(data.newPassword);
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit]
|
||||
|
@ -119,7 +119,12 @@ const SetPassword = ({
|
|||
|
||||
<TogglePassword isChecked={showPassword} onChange={setShowPassword} />
|
||||
|
||||
<Button name="submit" title="action.save_password" htmlType="submit" />
|
||||
<Button
|
||||
name="submit"
|
||||
title="action.save_password"
|
||||
htmlType="submit"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
|
|
|
@ -7,7 +7,7 @@ type Props = {
|
|||
readonly className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly autoFocus?: boolean;
|
||||
readonly onSubmit: (password: string) => void;
|
||||
readonly onSubmit: (password: string) => Promise<void>;
|
||||
readonly errorMessage?: string;
|
||||
readonly clearErrorMessage?: () => void;
|
||||
readonly maxLength?: number;
|
||||
|
|
|
@ -3,3 +3,7 @@
|
|||
.totpCodeInput {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
||||
.continueButton {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import VerificationCodeInput from '@/components/VerificationCode';
|
||||
import { type UserMfaFlow } from '@/types';
|
||||
|
||||
|
@ -8,32 +9,58 @@ import useTotpCodeVerification from './use-totp-code-verification';
|
|||
|
||||
const totpCodeLength = 6;
|
||||
|
||||
const isCodeReady = (code: string[]) => {
|
||||
return code.length === totpCodeLength && code.every(Boolean);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly flow: UserMfaFlow;
|
||||
};
|
||||
|
||||
const TotpCodeVerification = ({ flow }: Props) => {
|
||||
const [code, setCode] = useState<string[]>([]);
|
||||
const [codeInput, setCodeInput] = useState<string[]>([]);
|
||||
const errorCallback = useCallback(() => {
|
||||
setCode([]);
|
||||
setCodeInput([]);
|
||||
}, []);
|
||||
|
||||
const { errorMessage, onSubmit } = useTotpCodeVerification(flow, errorCallback);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (code: string[]) => {
|
||||
setIsSubmitting(true);
|
||||
await onSubmit(code.join(''));
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
[onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VerificationCodeInput
|
||||
name="totpCode"
|
||||
value={code}
|
||||
value={codeInput}
|
||||
className={styles.totpCodeInput}
|
||||
error={errorMessage}
|
||||
onChange={(code) => {
|
||||
setCode(code);
|
||||
|
||||
if (code.length === totpCodeLength && code.every(Boolean)) {
|
||||
onSubmit(code.join(''));
|
||||
onChange={async (code) => {
|
||||
setCodeInput(code);
|
||||
if (isCodeReady(code)) {
|
||||
await handleSubmit(code);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title="action.continue"
|
||||
type="primary"
|
||||
className={styles.continueButton}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isCodeReady(codeInput)}
|
||||
onClick={async () => {
|
||||
await handleSubmit(codeInput);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -19,8 +19,8 @@ const useTotpCodeVerification = (flow: UserMfaFlow, errorCallback?: () => void)
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(code: string) => {
|
||||
void sendMfaPayload(
|
||||
async (code: string) => {
|
||||
await sendMfaPayload(
|
||||
{ flow, payload: { type: MfaFactor.TOTP, code } },
|
||||
invalidCodeErrorHandlers,
|
||||
errorCallback
|
||||
|
|
|
@ -27,7 +27,8 @@
|
|||
align-self: start;
|
||||
}
|
||||
|
||||
.switch {
|
||||
.switch,
|
||||
.continueButton {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import classNames from 'classnames';
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import VerificationCodeInput, { defaultLength } from '@/components/VerificationCode';
|
||||
import { UserFlow } from '@/types';
|
||||
|
@ -24,6 +25,8 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
|
|||
const [code, setCode] = useState<string[]>([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isCodeReady = code.length === defaultLength && code.every(Boolean);
|
||||
|
||||
const useVerificationCode = getCodeVerificationHookByFlow(flow);
|
||||
|
||||
const errorCallback = useCallback(() => {
|
||||
|
@ -42,15 +45,27 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
|
|||
target
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (code.length === defaultLength && code.every(Boolean)) {
|
||||
const payload =
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!isCodeReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
await onSubmit(
|
||||
identifier === SignInIdentifier.Email
|
||||
? { email: target, verificationCode: code.join('') }
|
||||
: { phone: target, verificationCode: code.join('') };
|
||||
void onSubmit(payload);
|
||||
}
|
||||
}, [code, identifier, onSubmit, target]);
|
||||
: { phone: target, verificationCode: code.join('') }
|
||||
);
|
||||
|
||||
setIsSubmitting(false);
|
||||
}, [code, identifier, isCodeReady, onSubmit, target]);
|
||||
|
||||
useEffect(() => {
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)}>
|
||||
|
@ -88,6 +103,14 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
|
|||
{flow === UserFlow.SignIn && hasPasswordButton && (
|
||||
<PasswordSignInLink className={styles.switch} />
|
||||
)}
|
||||
<Button
|
||||
title="action.continue"
|
||||
type="primary"
|
||||
isDisabled={!isCodeReady}
|
||||
isLoading={isSubmitting}
|
||||
className={styles.continueButton}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -27,10 +27,14 @@ const Consent = () => {
|
|||
const [consentData, setConsentData] = useState<ConsentInfoResponse>();
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<Organization>();
|
||||
|
||||
const [isConsentLoading, setIsConsentLoading] = useState(false);
|
||||
|
||||
const asyncGetConsentInfo = useApi(getConsentInfo);
|
||||
|
||||
const consentHandler = useCallback(async () => {
|
||||
setIsConsentLoading(true);
|
||||
const [error, result] = await asyncConsent(selectedOrganization?.id);
|
||||
setIsConsentLoading(false);
|
||||
|
||||
if (error) {
|
||||
await handleError(error);
|
||||
|
@ -113,7 +117,7 @@ const Consent = () => {
|
|||
window.location.replace(consentData.redirectUri);
|
||||
}}
|
||||
/>
|
||||
<Button title="action.authorize" onClick={consentHandler} />
|
||||
<Button title="action.authorize" isLoading={isConsentLoading} onClick={consentHandler} />
|
||||
</div>
|
||||
{!showTerms && (
|
||||
<div className={styles.redirectUri}>
|
||||
|
|
|
@ -43,7 +43,7 @@ const IdentifierProfileForm = ({
|
|||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FormState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
|
@ -64,7 +64,7 @@ const IdentifierProfileForm = ({
|
|||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
clearErrorMessage?.();
|
||||
|
||||
void handleSubmit(async ({ identifier: { type, value } }) => {
|
||||
await handleSubmit(async ({ identifier: { type, value } }) => {
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ const IdentifierProfileForm = ({
|
|||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<Button title="action.continue" htmlType="submit" />
|
||||
<Button title="action.continue" htmlType="submit" isLoading={isSubmitting} />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
|
|
|
@ -42,12 +42,13 @@ const ForgotPasswordForm = ({
|
|||
UserFlow.ForgotPassword
|
||||
);
|
||||
|
||||
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
|
||||
const { setForgotPasswordIdentifierInputValue, setIdentifierInputValue } =
|
||||
useContext(UserInteractionContext);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FormState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
|
@ -76,10 +77,19 @@ const ForgotPasswordForm = ({
|
|||
// Cache or update the forgot password identifier input value
|
||||
setForgotPasswordIdentifierInputValue({ type, value });
|
||||
|
||||
// Set current identifier input value for code verification
|
||||
setIdentifierInputValue({ type, value });
|
||||
|
||||
await onSubmit({ identifier: type, value });
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit, setForgotPasswordIdentifierInputValue]
|
||||
[
|
||||
clearErrorMessage,
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
setForgotPasswordIdentifierInputValue,
|
||||
setIdentifierInputValue,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -122,7 +132,7 @@ const ForgotPasswordForm = ({
|
|||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<Button title="action.continue" htmlType="submit" />
|
||||
<Button title="action.continue" htmlType="submit" isLoading={isSubmitting} />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { t } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
|
@ -18,6 +19,7 @@ import * as styles from './index.module.scss';
|
|||
const BackupCodeBinding = () => {
|
||||
const { copyText, downloadText } = useTextHandler();
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { state } = useLocation();
|
||||
const [, backupCodeBindingState] = validate(state, backupCodeBindingStateGuard);
|
||||
|
@ -46,7 +48,7 @@ const BackupCodeBinding = () => {
|
|||
<Button
|
||||
title="action.download"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
downloadText(backupCodeTextContent, 'backup_code.txt');
|
||||
}}
|
||||
/>
|
||||
|
@ -54,7 +56,7 @@ const BackupCodeBinding = () => {
|
|||
<Button
|
||||
title="action.copy"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
void copyText(backupCodeTextContent, t('mfa.backup_code_copied'));
|
||||
}}
|
||||
/>
|
||||
|
@ -64,11 +66,14 @@ const BackupCodeBinding = () => {
|
|||
</div>
|
||||
<Button
|
||||
title="action.continue"
|
||||
onClick={() => {
|
||||
void sendMfaPayload({
|
||||
isLoading={isSubmitting}
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true);
|
||||
await sendMfaPayload({
|
||||
flow: UserMfaFlow.MfaBinding,
|
||||
payload: { type: MfaFactor.BackupCode },
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
|
@ -19,6 +20,7 @@ const WebAuthnBinding = () => {
|
|||
const [, webAuthnState] = validate(state, webAuthnStateGuard);
|
||||
const handleWebAuthn = useWebAuthnOperation();
|
||||
const skipMfa = useSkipMfa();
|
||||
const [isCreatingPasskey, setIsCreatingPasskey] = useState(false);
|
||||
|
||||
if (!webAuthnState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
|
@ -38,8 +40,11 @@ const WebAuthnBinding = () => {
|
|||
>
|
||||
<Button
|
||||
title="mfa.create_a_passkey"
|
||||
onClick={() => {
|
||||
void handleWebAuthn(options);
|
||||
isLoading={isCreatingPasskey}
|
||||
onClick={async () => {
|
||||
setIsCreatingPasskey(true);
|
||||
await handleWebAuthn(options);
|
||||
setIsCreatingPasskey(false);
|
||||
}}
|
||||
/>
|
||||
<SwitchMfaFactorsLink
|
||||
|
|
|
@ -22,12 +22,16 @@ type FormState = {
|
|||
const BackupCodeVerification = () => {
|
||||
const flowState = useMfaFlowState();
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
const { register, handleSubmit } = useForm<FormState>({ defaultValues: { code: '' } });
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormState>({ defaultValues: { code: '' } });
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(event?: FormEvent<HTMLFormElement>) => {
|
||||
void handleSubmit(async ({ code }) => {
|
||||
void sendMfaPayload({
|
||||
async (event?: FormEvent<HTMLFormElement>) => {
|
||||
await handleSubmit(async ({ code }) => {
|
||||
await sendMfaPayload({
|
||||
flow: UserMfaFlow.MfaVerification,
|
||||
payload: { type: MfaFactor.BackupCode, code },
|
||||
});
|
||||
|
@ -53,7 +57,7 @@ const BackupCodeVerification = () => {
|
|||
className={styles.backupCodeInput}
|
||||
{...register('code')}
|
||||
/>
|
||||
<Button title="action.continue" htmlType="submit" />
|
||||
<Button title="action.continue" htmlType="submit" isLoading={isSubmitting} />
|
||||
</form>
|
||||
</SectionLayout>
|
||||
<SwitchMfaFactorsLink
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
|
@ -17,6 +18,7 @@ const WebAuthnVerification = () => {
|
|||
const { state } = useLocation();
|
||||
const [, webAuthnState] = validate(state, webAuthnStateGuard);
|
||||
const handleWebAuthn = useWebAuthnOperation();
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
if (!webAuthnState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
|
@ -37,8 +39,11 @@ const WebAuthnVerification = () => {
|
|||
<Button
|
||||
title="action.verify_via_passkey"
|
||||
className={styles.verifyButton}
|
||||
onClick={() => {
|
||||
void handleWebAuthn(options);
|
||||
isLoading={isVerifying}
|
||||
onClick={async () => {
|
||||
setIsVerifying(true);
|
||||
await handleWebAuthn(options);
|
||||
setIsVerifying(false);
|
||||
}}
|
||||
/>
|
||||
</SectionLayout>
|
||||
|
|
|
@ -40,7 +40,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
|
|||
const {
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
control,
|
||||
} = useForm<FormState>({
|
||||
reValidateMode: 'onBlur',
|
||||
|
@ -154,6 +154,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
|
|||
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.create_account'}
|
||||
icon={showSingleSignOnForm ? <LockIcon /> : undefined}
|
||||
htmlType="submit"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
|
|
|
@ -44,7 +44,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
|||
watch,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FormState>({
|
||||
reValidateMode: 'onBlur',
|
||||
});
|
||||
|
@ -153,6 +153,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
|||
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.sign_in'}
|
||||
icon={showSingleSignOnForm ? <LockIcon /> : undefined}
|
||||
htmlType="submit"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
|
|
|
@ -45,7 +45,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FormState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
|
@ -174,6 +174,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.sign_in'}
|
||||
icon={showSingleSignOnForm ? <LockIcon /> : undefined}
|
||||
htmlType="submit"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
|
|
|
@ -47,7 +47,7 @@ const PasswordForm = ({
|
|||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FormState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
|
@ -69,7 +69,7 @@ const PasswordForm = ({
|
|||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
clearErrorMessage();
|
||||
|
||||
void handleSubmit(async ({ identifier: { type, value }, password }) => {
|
||||
await handleSubmit(async ({ identifier: { type, value }, password }) => {
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
@ -114,7 +114,7 @@ const PasswordForm = ({
|
|||
<ForgotPasswordLink className={styles.link} identifier={identifier} value={value} />
|
||||
)}
|
||||
|
||||
<Button title="action.continue" name="submit" htmlType="submit" />
|
||||
<Button title="action.continue" name="submit" htmlType="submit" isLoading={isSubmitting} />
|
||||
|
||||
{identifier !== SignInIdentifier.Username && isVerificationCodeEnabled && (
|
||||
<VerificationCodeLink className={styles.switch} identifier={identifier} value={value} />
|
||||
|
|
|
@ -26,7 +26,7 @@ const SingleSignOnEmail = () => {
|
|||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<FormState>({
|
||||
reValidateMode: 'onBlur',
|
||||
});
|
||||
|
@ -82,7 +82,12 @@ const SingleSignOnEmail = () => {
|
|||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<Button title="action.single_sign_on" htmlType="submit" icon={<LockIcon />} />
|
||||
<Button
|
||||
title="action.single_sign_on"
|
||||
htmlType="submit"
|
||||
icon={<LockIcon />}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
--color-brand-30: #4300da;
|
||||
--color-brand-40: #5d34f2;
|
||||
--color-brand-50: #7958ff;
|
||||
--color-brand-70: #af9eff;
|
||||
|
||||
--color-alert-60: #ca8000;
|
||||
--color-alert-70: #eb9918;
|
||||
|
|
Loading…
Reference in a new issue