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:
Tim Down
2025-11-26 09:05:47 +00:00
committed by Copybot
parent 0d9fa6c0a6
commit 763bede00a
18 changed files with 364 additions and 44 deletions
@@ -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
@@ -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">