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-25 11:25:39 +00:00
committed by Copybot
parent 0d9fa6c0a6
commit 763bede00a
18 changed files with 364 additions and 44 deletions

View File

@@ -973,6 +973,7 @@
"let_us_know_how_we_can_help": "",
"let_us_know_what_you_think": "",
"lets_get_those_premium_features": "",
"lets_get_you_set_up": "",
"library": "",
"licenses": "",
"limited_document_history": "",
@@ -2109,6 +2110,7 @@
"value_must_be_at_least_x": "",
"vat": "",
"vat_number": "",
"verification_code": "",
"verify_email_address_before_enabling_managed_users": "",
"verify_your_email_address": "",
"view": "",
@@ -2250,6 +2252,7 @@
"your_current_plan_gives_you": "",
"your_current_plan_supports_up_to_x_licenses": "",
"your_current_project_will_revert_to_the_version_from_time": "",
"your_email_is_confirmed": "",
"your_feedback_matters_answer_two_quick_questions": "",
"your_git_access_info": "",
"your_git_access_info_bullet_1": "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ const meta: Meta<typeof DSButton> = {
},
parameters: {
controls: {
include: ['children', 'disabled', 'size', 'variant'],
include: ['children', 'disabled', 'isLoading', 'size', 'variant'],
},
...figmaDesignUrl(
'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=4565-2932&m=dev'

View File

@@ -0,0 +1,44 @@
import { ComponentProps, useEffect, useState } from 'react'
import { Meta } from '@storybook/react'
import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url'
import DSFormGroup from '@/shared/components/ds/ds-form-group'
import DSFormLabel from '@/shared/components/ds/ds-form-label'
import CIAMSixDigitsInput from '@/features/settings/components/emails/ciam-six-digits-input'
type Args = ComponentProps<typeof CIAMSixDigitsInput>
export const SixDigitsInput = ({ value, ...args }: Args) => {
const [state, setState] = useState(value)
useEffect(() => setState(value), [value])
return (
<div className="ciam-enabled">
<DSFormGroup controlId="form-control-id">
<DSFormLabel>Form input label</DSFormLabel>
<CIAMSixDigitsInput
id="form-control-id"
{...args}
value={state}
onChange={e => setState(e.target.value)}
/>
</DSFormGroup>
</div>
)
}
const meta: Meta<typeof SixDigitsInput> = {
title: 'Shared / DS Components',
component: SixDigitsInput,
argTypes: {
value: { control: 'text' },
},
parameters: {
controls: {
include: ['value'],
},
...figmaDesignUrl(
'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6318-428&t=pcx9KKzhlzpRmA4S-0'
),
},
}
export default meta

View File

@@ -2,3 +2,4 @@
@import 'ciam-layout';
@import 'ciam-variables';
@import 'ciam-register';
@import 'ciam-six-digits';

View File

@@ -76,6 +76,7 @@
border-radius: var(--ds-border-radius-400);
max-width: 464px;
margin: 0 auto;
min-height: 500px;
@include media-breakpoint-up(sm) {
padding: var(--ds-spacing-1300);

View File

@@ -0,0 +1,26 @@
@import '../ds/mixins';
.ciam-six-digits-container {
position: relative;
min-width: 180px;
.ciam-six-digits-input {
@include ds-heading-md-regular;
letter-spacing: 0.3em;
padding-left: calc(50% - 3.3em) !important;
font-variant-numeric: tabular-nums;
}
.ciam-six-digits-dash {
@include ds-heading-md-regular;
pointer-events: none;
color: var(--ds-color-neutral-400);
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View File

@@ -2,16 +2,10 @@
.ciam-enabled,
.website-redesign:not(.application-page) .ciam-enabled .notification {
// Links
// used in services/web/frontend/stylesheets/base/links.scss
// Links, used in services/web/frontend/stylesheets/base/links.scss
--link-color: var(--ds-color-text-primary);
--link-hover-color: var(--ds-color-text-secondary);
--link-visited-color: var(--ds-color-text-secondary);
--link-text-decoration: underline;
--link-hover-text-decoration: none;
// TODO: validate that this is correct
--link-color-dark: fuchsia;
--link-hover-color-dark: fuchsia;
--link-visited-color-dark: fuchsia;
}

View File

@@ -120,6 +120,13 @@
line-height: var(--line-height-02);
margin-top: var(--spacing-04);
}
&.marginless {
&,
.form-text-inner {
margin: 0;
}
}
}
.form-label {

View File

@@ -78,4 +78,37 @@
$disabled-background: var(--ds-color-neutral-100)
);
}
&.button-loading {
.spinner-container {
display: flex;
justify-content: center;
align-items: center;
.loading-spinner-small {
border-width: 0.25em;
height: 1.25rem;
width: 1.25rem;
}
.loading-spinner-large {
border-width: 0.25em;
height: 1.5rem;
width: 1.5rem;
}
.spinner-border {
border-right-color: color-mix(
in srgb,
var(--bs-btn-color) 32%,
var(--bs-btn-bg)
);
}
}
// Hide the text when the spinner is visible
& > [aria-hidden='true'] {
visibility: hidden;
}
}
}

View File

@@ -91,6 +91,13 @@ input.form-control.form-control-ds {
@include ds-body-sm-regular;
}
&.marginless {
&,
.form-text-inner-ds {
margin: 0;
}
}
.ciam-form-text-icon {
font-size: math.div(20em, 14);
}

View File

@@ -48,6 +48,7 @@
@import 'register';
@import 'plans';
@import 'onboarding-confirm-email';
@import 'onboarding-confirm-email-ciam';
@import 'secondary-confirm-email';
@import 'onboarding';
@import 'admin/admin';

View File

@@ -0,0 +1,46 @@
#onboarding-confirm-email .ciam-enabled {
.animated-tick {
height: var(--ds-spacing-1200);
width: var(--ds-spacing-1200);
}
.ciam-email-confirmed-title {
margin-top: var(--ds-spacing-800);
margin-bottom: var(--ds-spacing-600);
}
.confirm-email-success-form {
display: flex;
flex: 1 1 auto;
align-items: center;
flex-direction: column;
justify-content: center;
text-align: center;
}
.confirm-email-form .confirm-email-form-inner {
margin: auto;
max-width: 480px;
.notification {
margin-bottom: var(--spacing-05);
}
.text-danger {
display: flex;
gap: var(--spacing-03);
padding: var(--spacing-02);
}
.form-label {
font-weight: bold;
}
.form-actions {
margin-top: var(--ds-spacing-800);
display: flex;
flex-direction: column;
gap: var(--ds-spacing-400);
}
}
}

View File

@@ -1253,6 +1253,7 @@
"let_us_know_how_we_can_help": "Let us know how we can help",
"let_us_know_what_you_think": "Let us know what you think",
"lets_get_those_premium_features": "Lets get those premium features up and running for you straightaway. Youll be billed <0>__paymentAmount__</0> using the payment details we have for you.",
"lets_get_you_set_up": "Lets get you set up.",
"libraries": "Libraries",
"library": "Library",
"license": "License",
@@ -2651,6 +2652,7 @@
"value_must_be_at_least_x": "Value must be at least __value__",
"vat": "VAT",
"vat_number": "VAT Number",
"verification_code": "Verification code",
"verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
"verify_your_email_address": "Verify your email address",
"view": "View",
@@ -2808,6 +2810,7 @@
"your_current_plan_gives_you": "By pausing your subscription, youll be able to access your premium features faster when you need them again.",
"your_current_plan_supports_up_to_x_licenses": "Your current plan supports up to __users__ licenses.",
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
"your_email_is_confirmed": "Your email is confirmed.",
"your_feedback_matters_answer_two_quick_questions": "Your feedback matters! Answer two quick questions.",
"your_git_access_info": "Your Git authentication tokens should be entered whenever youre prompted for a password.",
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",