Browse Source

Merge pull request #29654 from overleaf/ac-ciam-confirm-email-storybook

[web] CIAM design for Email confirmation form

GitOrigin-RevId: 3e66c45fe20073eb0600b8243761dbe82d7dc6b2
Tim Down 10 months ago
parent
commit
763bede00a

+ 3 - 0
services/web/frontend/extracted-translations.json

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

+ 49 - 0
services/web/frontend/js/features/settings/components/emails/ciam-six-digits-input.tsx

@@ -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
services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx

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

+ 33 - 3
services/web/frontend/js/shared/components/ds/ds-button.tsx

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

+ 14 - 3
services/web/frontend/js/shared/components/ds/ds-form-text.tsx

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

+ 3 - 1
services/web/frontend/js/shared/components/form/form-text.tsx

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

+ 1 - 1
services/web/frontend/stories/shared/ds-button.stories.tsx

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

+ 44 - 0
services/web/frontend/stories/shared/ds-six-digits-input.stories.tsx

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

+ 1 - 0
services/web/frontend/stylesheets/ciam/all.scss

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

+ 1 - 0
services/web/frontend/stylesheets/ciam/ciam-layout.scss

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

+ 26 - 0
services/web/frontend/stylesheets/ciam/ciam-six-digits.scss

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

+ 1 - 7
services/web/frontend/stylesheets/ciam/ciam-variables.scss

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

+ 7 - 0
services/web/frontend/stylesheets/components/form.scss

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

+ 33 - 0
services/web/frontend/stylesheets/ds/components/button.scss

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

+ 7 - 0
services/web/frontend/stylesheets/ds/components/form-control.scss

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

+ 1 - 0
services/web/frontend/stylesheets/pages/all.scss

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

+ 46 - 0
services/web/frontend/stylesheets/pages/onboarding-confirm-email-ciam.scss

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

+ 3 - 0
services/web/locales/en.json

@@ -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": "Let’s get those premium features up and running for you straightaway. You’ll be billed <0>__paymentAmount__</0> using the payment details we have for you.",
+  "lets_get_you_set_up": "Let’s 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, you’ll 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 you’re prompted for a password.",
   "your_git_access_info_bullet_1": "You can have up to 10 tokens.",