Merge pull request #29654 from overleaf/ac-ciam-confirm-email-storybook
[web] CIAM design for Email confirmation form GitOrigin-RevId: 3e66c45fe20073eb0600b8243761dbe82d7dc6b2
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { Form, FormControlProps } from 'react-bootstrap'
|
||||
import classnames from 'classnames'
|
||||
|
||||
interface CIAMSixDigitsInputProps extends FormControlProps {
|
||||
value: string | undefined
|
||||
}
|
||||
|
||||
const separator = '\u2007' // figure space
|
||||
|
||||
const CIAMSixDigitsInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
CIAMSixDigitsInputProps
|
||||
>(({ className, onChange, value, ...props }, ref) => {
|
||||
const group1 = value?.slice(0, 3) || ''
|
||||
const group2 = value?.slice(3, 6) || ''
|
||||
const displayValue = group2 ? `${group1}${separator}${group2}` : group1
|
||||
return (
|
||||
<div className="ciam-six-digits-container">
|
||||
<Form.Control
|
||||
ref={ref}
|
||||
{...props}
|
||||
size="lg"
|
||||
onChange={v => {
|
||||
const inputValue = v.target.value
|
||||
const sanitizedValue = inputValue.replaceAll(/\D/g, '').slice(0, 6)
|
||||
onChange?.({
|
||||
...v,
|
||||
target: { ...v.target, value: sanitizedValue },
|
||||
currentTarget: { ...v.currentTarget, value: sanitizedValue },
|
||||
})
|
||||
}}
|
||||
value={displayValue}
|
||||
className={classnames(
|
||||
'form-control-ds ciam-six-digits-input',
|
||||
className
|
||||
)}
|
||||
maxLength={7}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<span className="ciam-six-digits-dash" aria-hidden>
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CIAMSixDigitsInput.displayName = 'CIAMSixDigitsInput'
|
||||
|
||||
export default CIAMSixDigitsInput
|
||||
+91
-29
@@ -2,14 +2,25 @@ import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { FormEvent, MouseEventHandler, useState } from 'react'
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
ComponentProps,
|
||||
FormEvent,
|
||||
MouseEventHandler,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import DSFormLabel from '@/shared/components/ds/ds-form-label'
|
||||
import DSButton from '@/shared/components/ds/ds-button'
|
||||
import CIAMSixDigitsInput from '@/features/settings/components/emails/ciam-six-digits-input'
|
||||
import OLFormText from '@/shared/components/ol/ol-form-text'
|
||||
import DSFormText from '@/shared/components/ds/ds-form-text'
|
||||
|
||||
type Feedback = {
|
||||
type: 'input' | 'alert'
|
||||
@@ -19,7 +30,7 @@ type Feedback = {
|
||||
|
||||
type ConfirmEmailFormProps = {
|
||||
confirmationEndpoint: string
|
||||
flow: string
|
||||
flow: 'registration' | 'resend' | 'secondary'
|
||||
resendEndpoint: string
|
||||
successMessage?: React.ReactNode
|
||||
successButtonText?: string
|
||||
@@ -32,6 +43,15 @@ type ConfirmEmailFormProps = {
|
||||
isCiam?: boolean
|
||||
}
|
||||
|
||||
const OLSixDigitsInput = (props: ComponentProps<'input'>) => (
|
||||
<input
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
className="form-control"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export function ConfirmEmailForm({
|
||||
confirmationEndpoint,
|
||||
flow,
|
||||
@@ -146,7 +166,7 @@ export function ConfirmEmailForm({
|
||||
})
|
||||
}
|
||||
|
||||
const changeHandler = (e: FormEvent<HTMLInputElement>) => {
|
||||
const changeHandler: ChangeEventHandler<HTMLInputElement> = e => {
|
||||
setConfirmationCode(e.currentTarget.value)
|
||||
setFeedback(null)
|
||||
}
|
||||
@@ -161,10 +181,21 @@ export function ConfirmEmailForm({
|
||||
successMessage={successMessage}
|
||||
successButtonText={successButtonText}
|
||||
redirectTo={successRedirectPath}
|
||||
autoRedirect={isCiam ? 8000 : false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const longLabel = isModal
|
||||
? t('enter_the_code', { email })
|
||||
: t('enter_the_confirmation_code', { email })
|
||||
|
||||
const Button = isCiam ? DSButton : OLButton
|
||||
const buttonSize = isCiam ? 'lg' : undefined
|
||||
|
||||
const SixDigits = isCiam ? CIAMSixDigitsInput : OLSixDigitsInput
|
||||
const FormText = isCiam ? DSFormText : OLFormText
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submitHandler}
|
||||
@@ -191,53 +222,54 @@ export function ConfirmEmailForm({
|
||||
outerErrorDisplay={outerErrorDisplay}
|
||||
/>
|
||||
|
||||
<OLFormLabel htmlFor="one-time-code">
|
||||
{isModal
|
||||
? t('enter_the_code', { email })
|
||||
: t('enter_the_confirmation_code', { email })}
|
||||
</OLFormLabel>
|
||||
<input
|
||||
{isCiam && <p>{longLabel}</p>}
|
||||
|
||||
{isCiam ? (
|
||||
<DSFormLabel htmlFor="one-time-code">
|
||||
{t('verification_code')}
|
||||
</DSFormLabel>
|
||||
) : (
|
||||
<OLFormLabel htmlFor="one-time-code">{longLabel}</OLFormLabel>
|
||||
)}
|
||||
|
||||
<SixDigits
|
||||
id="one-time-code"
|
||||
className="form-control"
|
||||
inputMode="numeric"
|
||||
required
|
||||
value={confirmationCode}
|
||||
onChange={changeHandler}
|
||||
data-ol-dirty={feedback ? 'true' : undefined}
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
disabled={!!outerErrorDisplay}
|
||||
/>
|
||||
<div aria-live="polite">
|
||||
{feedback?.type === 'input' && (
|
||||
<div className="small text-danger">
|
||||
<MaterialIcon className="icon" type="error" />
|
||||
<div>
|
||||
<ErrorMessage error={feedback.message} />
|
||||
</div>
|
||||
</div>
|
||||
<FormText type="error" marginless>
|
||||
<ErrorMessage error={feedback.message} />
|
||||
</FormText>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<OLButton
|
||||
<Button
|
||||
size={buttonSize}
|
||||
disabled={isResending || !!outerErrorDisplay}
|
||||
type="submit"
|
||||
isLoading={isConfirming}
|
||||
loadingLabel={t('confirming')}
|
||||
>
|
||||
{t('confirm')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={buttonSize}
|
||||
disabled={isConfirming}
|
||||
onClick={resendHandler}
|
||||
isLoading={isResending}
|
||||
loadingLabel={t('resending_confirmation_code')}
|
||||
>
|
||||
{t('resend_confirmation_code')}
|
||||
</OLButton>
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
@@ -248,6 +280,25 @@ export function ConfirmEmailForm({
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
{isCiam && flow === 'registration' && (
|
||||
<div className="mt-4 mb-2 text-center ">
|
||||
<Trans
|
||||
i18nKey="use_a_different_email"
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
|
||||
<a
|
||||
href="/register"
|
||||
onClick={() =>
|
||||
sendMB('email-verification-click', {
|
||||
button: 'change-email',
|
||||
flow,
|
||||
})
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
@@ -281,10 +332,12 @@ function ConfirmEmailSuccessfullForm({
|
||||
successMessage,
|
||||
successButtonText,
|
||||
redirectTo,
|
||||
autoRedirect = false,
|
||||
}: {
|
||||
successMessage: React.ReactNode
|
||||
successButtonText: string
|
||||
redirectTo: string
|
||||
autoRedirect?: number | false
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const submitHandler = (e: FormEvent<HTMLFormElement>) => {
|
||||
@@ -292,15 +345,24 @@ function ConfirmEmailSuccessfullForm({
|
||||
location.assign(redirectTo)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRedirect) {
|
||||
const timer = setTimeout(() => location.assign(redirectTo), autoRedirect)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [autoRedirect, location, redirectTo])
|
||||
|
||||
return (
|
||||
<form onSubmit={submitHandler}>
|
||||
<form onSubmit={submitHandler} className="confirm-email-success-form">
|
||||
<div aria-live="polite">{successMessage}</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<OLButton type="submit" variant="primary">
|
||||
{successButtonText}
|
||||
</OLButton>
|
||||
</div>
|
||||
{!autoRedirect && (
|
||||
<div className="form-actions">
|
||||
<OLButton type="submit" variant="primary">
|
||||
{successButtonText}
|
||||
</OLButton>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { forwardRef, ReactNode } from 'react'
|
||||
import { Button, ButtonProps } from 'react-bootstrap'
|
||||
import { Button, ButtonProps, Spinner } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
|
||||
type DSButtonProps = Pick<
|
||||
ButtonProps,
|
||||
@@ -22,6 +24,8 @@ type DSButtonProps = Pick<
|
||||
leadingIcon?: ReactNode
|
||||
trailingIcon?: ReactNode
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
|
||||
isLoading?: boolean
|
||||
loadingLabel?: string
|
||||
}
|
||||
|
||||
const DSButton = forwardRef<HTMLButtonElement, DSButtonProps>(
|
||||
@@ -29,6 +33,8 @@ const DSButton = forwardRef<HTMLButtonElement, DSButtonProps>(
|
||||
{
|
||||
children,
|
||||
leadingIcon,
|
||||
isLoading = false,
|
||||
loadingLabel,
|
||||
trailingIcon,
|
||||
variant = 'primary',
|
||||
size,
|
||||
@@ -36,16 +42,40 @@ const DSButton = forwardRef<HTMLButtonElement, DSButtonProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const buttonClassName = classNames('d-inline-grid btn-ds', {
|
||||
'button-loading': isLoading,
|
||||
})
|
||||
|
||||
const loadingSpinnerClassName =
|
||||
size === 'lg' ? 'loading-spinner-large' : 'loading-spinner-small'
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="d-inline-grid btn-ds"
|
||||
className={buttonClassName}
|
||||
variant={variant}
|
||||
size={size}
|
||||
{...props}
|
||||
ref={ref}
|
||||
disabled={isLoading || props.disabled}
|
||||
data-ol-loading={isLoading}
|
||||
role={undefined}
|
||||
>
|
||||
<span className="button-content">
|
||||
{isLoading && (
|
||||
<span className="spinner-container">
|
||||
<Spinner
|
||||
size="sm"
|
||||
animation="border"
|
||||
aria-hidden="true"
|
||||
className={loadingSpinnerClassName}
|
||||
/>
|
||||
<span className="visually-hidden">
|
||||
{loadingLabel ?? t('loading')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="button-content" aria-hidden={isLoading}>
|
||||
{leadingIcon}
|
||||
{children}
|
||||
{trailingIcon}
|
||||
|
||||
@@ -7,7 +7,10 @@ type TextType = 'success' | 'error'
|
||||
|
||||
export type FormTextProps = MergeAndOverride<
|
||||
BS5FormTextProps,
|
||||
{ type?: TextType }
|
||||
{
|
||||
type?: TextType
|
||||
marginless?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
const typeClasses = {
|
||||
@@ -31,10 +34,18 @@ function FormTextIcon({ type }: { type?: TextType }) {
|
||||
}
|
||||
}
|
||||
|
||||
function DSFormText({ type, children, className, ...rest }: FormTextProps) {
|
||||
function DSFormText({
|
||||
type,
|
||||
marginless,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: FormTextProps) {
|
||||
return (
|
||||
<Form.Text
|
||||
className={classnames('form-text-ds', className, getFormTextClass(type))}
|
||||
className={classnames('form-text-ds', className, getFormTextClass(type), {
|
||||
marginless,
|
||||
})}
|
||||
{...rest}
|
||||
>
|
||||
<span className="form-text-inner-ds">
|
||||
|
||||
@@ -9,6 +9,7 @@ export type FormTextProps = MergeAndOverride<
|
||||
BS5FormTextProps,
|
||||
{
|
||||
type?: TextType
|
||||
marginless?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
@@ -38,13 +39,14 @@ function FormTextIcon({ type }: { type?: TextType }) {
|
||||
|
||||
function FormText({
|
||||
type = 'default',
|
||||
marginless,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: FormTextProps) {
|
||||
return (
|
||||
<Form.Text
|
||||
className={classnames(className, getFormTextClass(type))}
|
||||
className={classnames(className, getFormTextClass(type), { marginless })}
|
||||
{...rest}
|
||||
>
|
||||
<span className="form-text-inner">
|
||||
|
||||
Reference in New Issue
Block a user