0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console): use button loading in experience flow if possible (#6234)

This commit is contained in:
Xiao Yijun 2024-07-17 11:08:01 +08:00 committed by GitHub
parent a84389da13
commit bc2ccf671e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 194 additions and 77 deletions

View file

@ -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 />}
</>
);
};

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,8 @@
@use '@/scss/underscore' as _;
.overlay {
position: fixed;
inset: 0;
@include _.flex-column;
z-index: 300;
}

View 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;

View file

@ -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: '' },
@ -45,8 +45,8 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage
(event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage?.();
void handleSubmit((data, event) => {
onSubmit(data.newPassword);
void 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>

View file

@ -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: '' },
@ -59,8 +59,8 @@ const SetPassword = ({
(event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage?.();
void handleSubmit((data, event) => {
onSubmit(data.newPassword);
void 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>

View file

@ -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;

View file

@ -3,3 +3,7 @@
.totpCodeInput {
margin-top: _.unit(4);
}
.continueButton {
margin-top: _.unit(6);
}

View file

@ -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);
return (
<VerificationCodeInput
name="totpCode"
value={code}
className={styles.totpCodeInput}
error={errorMessage}
onChange={(code) => {
setCode(code);
const [isSubmitting, setIsSubmitting] = useState(false);
if (code.length === totpCodeLength && code.every(Boolean)) {
onSubmit(code.join(''));
}
}}
/>
const handleSubmit = useCallback(
async (code: string[]) => {
setIsSubmitting(true);
await onSubmit(code.join(''));
setIsSubmitting(false);
},
[onSubmit]
);
return (
<>
<VerificationCodeInput
name="totpCode"
value={codeInput}
className={styles.totpCodeInput}
error={errorMessage}
onChange={(code) => {
setCodeInput(code);
if (isCodeReady(code)) {
void handleSubmit(code);
}
}}
/>
<Button
title="action.continue"
type="primary"
className={styles.continueButton}
isLoading={isSubmitting}
isDisabled={!isCodeReady(codeInput)}
onClick={() => {
void handleSubmit(codeInput);
}}
/>
</>
);
};

View file

@ -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

View file

@ -27,7 +27,8 @@
align-self: start;
}
.switch {
.switch,
.continueButton {
margin-top: _.unit(6);
}
}

View file

@ -1,8 +1,9 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } 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';
@ -21,13 +22,18 @@ type Props = {
};
const VerificationCode = ({ flow, identifier, className, hasPasswordButton, target }: Props) => {
const [code, setCode] = useState<string[]>([]);
const [codeInput, setCodeInput] = useState<string[]>([]);
const { t } = useTranslation();
const isCodeInputReady = useMemo(
() => codeInput.length === defaultLength && codeInput.every(Boolean),
[codeInput]
);
const useVerificationCode = getCodeVerificationHookByFlow(flow);
const errorCallback = useCallback(() => {
setCode([]);
setCodeInput([]);
}, []);
const { errorMessage, clearErrorMessage, onSubmit } = useVerificationCode(
@ -42,24 +48,37 @@ 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 (code: string[]) => {
setIsSubmitting(true);
await onSubmit(
identifier === SignInIdentifier.Email
? { email: target, verificationCode: code.join('') }
: { phone: target, verificationCode: code.join('') };
void onSubmit(payload);
: { phone: target, verificationCode: code.join('') }
);
setIsSubmitting(false);
},
[identifier, onSubmit, target]
);
useEffect(() => {
if (isCodeInputReady) {
void handleSubmit(codeInput);
}
}, [code, identifier, onSubmit, target]);
}, [codeInput, handleSubmit, isCodeInputReady]);
return (
<form className={classNames(styles.form, className)}>
<VerificationCodeInput
name="passcode"
className={classNames(styles.inputField, errorMessage && styles.withError)}
value={code}
value={codeInput}
error={errorMessage}
onChange={setCode}
onChange={setCodeInput}
/>
<div className={styles.message}>
{isRunning ? (
@ -75,7 +94,7 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
onClick={async () => {
clearErrorMessage();
await onResendVerificationCode();
setCode([]);
setCodeInput([]);
}}
/>
),
@ -88,6 +107,16 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
{flow === UserFlow.SignIn && hasPasswordButton && (
<PasswordSignInLink className={styles.switch} />
)}
<Button
title="action.continue"
type="primary"
isDisabled={!isCodeInputReady}
isLoading={isSubmitting}
className={styles.continueButton}
onClick={() => {
void handleSubmit(codeInput);
}}
/>
</form>
);
};

View file

@ -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}>

View file

@ -43,7 +43,7 @@ const IdentifierProfileForm = ({
const {
handleSubmit,
control,
formState: { errors, isValid },
formState: { errors, isValid, isSubmitting },
} = useForm<FormState>({
reValidateMode: 'onBlur',
defaultValues: {
@ -61,7 +61,7 @@ const IdentifierProfileForm = ({
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
(event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage?.();
void handleSubmit(async ({ identifier: { type, value } }) => {
@ -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>

View file

@ -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: {
@ -122,7 +123,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>

View file

@ -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);
@ -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>

View file

@ -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

View file

@ -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({
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

View file

@ -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>

View file

@ -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" />

View file

@ -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" />

View file

@ -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" />

View file

@ -47,7 +47,7 @@ const PasswordForm = ({
register,
handleSubmit,
control,
formState: { errors, isValid },
formState: { errors, isValid, isSubmitting },
} = useForm<FormState>({
reValidateMode: 'onBlur',
defaultValues: {
@ -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} />

View file

@ -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>