Skip to content
v2.5.0
English
Русский
Documentation
All templates

Sign in

Email and password with errors, then a two-factor code step.

Code

docs/examples/templates/sign-in.tsx
import { useEffect, useId, useState, type FormEvent } from 'react';
import {
  Alert,
  AlertContent,
  AlertIcon,
  AlertMain,
  AlertTitle,
  Button,
  Card,
  Checkbox,
  CheckboxCheckedIcon,
  CheckboxIndicator,
  CheckboxInput,
  Heading,
  HelperText,
  Label,
  PasswordField,
  PasswordFieldHiddenIcon,
  PasswordFieldInput,
  PasswordFieldShownIcon,
  PasswordFieldToggleIconButton,
  PinField,
  PinFieldInput,
  PinFieldSlot,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';

const DEMO_PASSWORD = 'northwind';
const DEMO_CODE = '246810';
const RESEND_SECONDS = 30;

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const card = { alignItems: 'stretch', gap: 'var(--spacing-12)', width: '100%', maxWidth: 400, marginInline: 'auto' } as const;

type Step = 'credentials' | 'code' | 'done';

interface CredentialErrors {
  email?: string
  password?: string
  form?: string
}

const maskEmail = (email: string) => {
  const [name, domain] = email.split('@');

  return `${name.slice(0, 2)}${'•'.repeat(Math.max(1, name.length - 2))}@${domain}`;
};

export default function SignIn() {
  const id = useId();
  const [step, setStep] = useState<Step>('credentials');
  const [email, setEmail] = useState('');
  const [shown, setShown] = useState(false);
  const [errors, setErrors] = useState<CredentialErrors>({});
  const [code, setCode] = useState('');
  const [codeError, setCodeError] = useState('');
  const [cooldown, setCooldown] = useState(0);

  useEffect(() => {
    if (cooldown === 0) return;

    const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);

    return () => clearTimeout(timer);
  }, [cooldown]);

  const signIn = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    const password = String(data.get('password'));
    const next: CredentialErrors = {};

    if (email.trim() === '') next.email = 'Enter the email you signed up with.';
    if (email.trim() !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) next.email = 'Use the full address, like ada@northwind.io.';
    if (password === '') next.password = 'Enter your password.';
    if (Object.keys(next).length === 0 && password !== DEMO_PASSWORD) next.form = 'That email and password don’t match. Check both and try again.';

    setErrors(next);
    setShown(false);

    const invalid = ['email', 'password'].find((name) => next[name as keyof CredentialErrors]);

    if (invalid) {
      const element = form.elements.namedItem(invalid);
      if (element instanceof HTMLElement) element.focus();
      return;
    }

    if (next.form) return;

    setCode('');
    setCodeError('');
    setCooldown(RESEND_SECONDS);
    setStep('code');
  };

  const changeCode = (next: string) => {
    setCode(next);
    setCodeError('');
  };

  const verify = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (code.length < 6) {
      setCodeError('Enter all 6 digits of the code.');
      return;
    }

    if (code !== DEMO_CODE) {
      setCodeError('This code is wrong or has expired. Try again or send a new one.');
      return;
    }

    setStep('done');
  };

  const resend = () => {
    setCode('');
    setCodeError('');
    setCooldown(RESEND_SECONDS);
  };

  const restart = () => {
    setStep('credentials');
    setErrors({});
  };

  if (step === 'done') {
    return (
      <Card style={card}>
        <Alert variant='success'>
          <AlertIcon />
          <AlertMain>
            <AlertTitle>You’re signed in</AlertTitle>
            <AlertContent>{`Welcome back. Signed in as ${email}.`}</AlertContent>
          </AlertMain>
        </Alert>
        <Button size='xs' variant='secondary' appearance='ghost' style={{ alignSelf: 'flex-start' }} onClick={restart}>Sign out</Button>
      </Card>
    );
  }

  if (step === 'code') {
    return (
      <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
        <Card asChild style={card}>
          <form noValidate aria-labelledby={`${id}-code-title`} onSubmit={verify}>
            <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
              <Heading id={`${id}-code-title`} as='h1' size={6} fontWeight={700}>Two-factor authentication</Heading>
              <Text size={3} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>
                {`Enter the 6-digit code from your authenticator app for ${maskEmail(email)}.`}
              </Text>
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-code`}>Verification code</Label>
              <PinField maxLength={6} value={code} onChangeValue={changeCode} error={codeError !== ''}>
                <PinFieldInput
                  id={`${id}-code`}
                  autoComplete='one-time-code'
                  aria-invalid={codeError !== ''}
                  aria-describedby={codeError === '' ? undefined : `${id}-code-error`}
                />
                {Array.from({ length: 6 }, (_, index) => <PinFieldSlot key={index} index={index} />)}
              </PinField>
              {codeError !== '' && <HelperText id={`${id}-code-error`} error>{codeError}</HelperText>}
            </div>
            <Checkbox style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
              <CheckboxInput name='trust' />
              <CheckboxIndicator>
                <CheckboxCheckedIcon />
              </CheckboxIndicator>
              Don’t ask again on this device for 30 days
            </Checkbox>
            <div style={{ display: 'grid', gap: 'var(--spacing-4)' }}>
              <Button size='xs' type='submit' fullWidth>Verify</Button>
              <Button size='xs' type='button' variant='secondary' appearance='ghost' fullWidth disabled={cooldown > 0} onClick={resend}>
                <span style={{ fontVariantNumeric: 'tabular-nums' }}>{cooldown > 0 ? `Send a new code in ${cooldown}s` : 'Send a new code'}</span>
              </Button>
            </div>
          </form>
        </Card>
        <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', gap: 'var(--spacing-4)' }}>
          <Button size='xs' variant='secondary' appearance='ghost' onClick={restart}>Use another account</Button>
          <Text size={2} style={{ color: 'var(--label-tertiary)' }}>{`Demo code: ${DEMO_CODE}`}</Text>
        </div>
      </div>
    );
  }

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <Card asChild style={card}>
        <form noValidate aria-labelledby={`${id}-title`} onSubmit={signIn}>
          <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
            <Heading id={`${id}-title`} as='h1' size={6} fontWeight={700}>Sign in to Northwind</Heading>
            <Text size={3} style={{ color: 'var(--label-secondary)' }}>Welcome back. Use your work email.</Text>
          </div>

          {errors.form && (
            <Alert variant='error' role='alert'>
              <AlertIcon />
              <AlertMain>
                <AlertTitle>Couldn’t sign in</AlertTitle>
                <AlertContent>{errors.form}</AlertContent>
              </AlertMain>
            </Alert>
          )}

          <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
            <div style={field}>
              <Label htmlFor={`${id}-email`}>Email</Label>
              <TextField error={errors.email !== undefined}>
                <TextFieldInput
                  id={`${id}-email`}
                  name='email'
                  type='email'
                  autoComplete='username'
                  value={email}
                  aria-invalid={errors.email !== undefined}
                  aria-describedby={errors.email ? `${id}-email-error` : undefined}
                  onChange={(event) => setEmail(event.currentTarget.value)}
                />
              </TextField>
              {errors.email && <HelperText id={`${id}-email-error`} error>{errors.email}</HelperText>}
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-password`}>Password</Label>
              <PasswordField shown={shown} onChangeShown={setShown} error={errors.password !== undefined}>
                <PasswordFieldInput
                  id={`${id}-password`}
                  name='password'
                  autoComplete='current-password'
                  aria-invalid={errors.password !== undefined}
                  aria-describedby={errors.password ? `${id}-password-error` : undefined}
                />
                <PasswordFieldToggleIconButton aria-label='Show password'>
                  <PasswordFieldShownIcon />
                  <PasswordFieldHiddenIcon />
                </PasswordFieldToggleIconButton>
              </PasswordField>
              {errors.password && <HelperText id={`${id}-password-error`} error>{errors.password}</HelperText>}
            </div>
          </div>

          <Button size='xs' type='submit' fullWidth>Sign in</Button>
        </form>
      </Card>
      <Text size={2} style={{ textAlign: 'center', color: 'var(--label-tertiary)' }}>{`Demo password: ${DEMO_PASSWORD}`}</Text>
    </div>
  );
}

Components used