Skip to content
  1. Overlays
  2. Verification code dialog

Verification code dialog

A step-up before a sensitive action. Step one says where the code goes, step two takes it. Verified, it closes and hands over to whatever it was guarding.

import { Button } from '@/components/button'
import { Field, Label } from '@/components/fieldset'
import { FormDialog } from '@/components/form-dialog'
import { Input } from '@/components/input'
import { VerificationCodeDialog } from '@/components/verification-code-dialog'
import { useState } from 'react'

export default function Example({ user }) {
  let [verifyOpen, setVerifyOpen] = useState(false)
  let [createOpen, setCreateOpen] = useState(false)

  return (
    <>
      <Button onClick={() => setVerifyOpen(true)}>Add endpoint</Button>
      <VerificationCodeDialog
        open={verifyOpen}
        onClose={() => setVerifyOpen(false)}
        onVerified={() => setCreateOpen(true)}
        onSend={() => sendCode(user)}
        action="create a webhook endpoint"
        email={user.email}
      />
      <FormDialog
        open={createOpen}
        onClose={() => setCreateOpen(false)}
        onSubmit={(event) => {
          createEndpoint(new FormData(event.currentTarget))
          setCreateOpen(false)
        }}
        title="Add endpoint"
        submitLabel="Add endpoint"
      >
        <Field>
          <Label>Endpoint URL</Label>
          <Input type="url" name="url" required autoFocus />
        </Field>
      </FormDialog>
    </>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Field from '@/components/field.vue'
import FormDialog from '@/components/form-dialog.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
import VerificationCodeDialog from '@/components/verification-code-dialog.vue'
import { ref } from 'vue'

const props = defineProps<{ user: User }>()
const verifyOpen = ref(false)
const createOpen = ref(false)

function submit(event: Event) {
  createEndpoint(new FormData(event.currentTarget as HTMLFormElement))
  createOpen.value = false
}
</script>

<template>
  <Button @click="verifyOpen = true">Add endpoint</Button>
  <VerificationCodeDialog
    :open="verifyOpen"
    action="create a webhook endpoint"
    :email="user.email"
    @close="verifyOpen = false"
    @verified="createOpen = true"
    @send="sendCode(user)"
  />
  <FormDialog :open="createOpen" title="Add endpoint" submit-label="Add endpoint" @close="createOpen = false" @submit="submit">
    <Field>
      <Label>Endpoint URL</Label>
      <Input type="url" name="url" required autofocus />
    </Field>
  </FormDialog>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import { EnvelopeIcon } from '@heroicons/react/16/solid'
import { useEffect, useRef, useState } from 'react'
import { Badge } from './badge'
import { CodeInput } from './code-input'
import { Field, Label } from './fieldset'
import { FormDialog } from './form-dialog'
import { Notice } from './notice'

export function VerificationCodeDialog({
  open,
  onClose,
  onVerified,
  action,
  email,
  codeLength = 6,
  onSend,
  onResend,
  title = 'Security verification',
}: {
  open: boolean
  onClose: () => void
  onVerified: () => void
  action: string
  email: string
  codeLength?: number
  onSend?: () => void
  onResend?: () => void
  title?: string
}) {
  let [step, setStep] = useState(1)
  let [code, setCode] = useState('')
  let codeField = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!open) {
      setStep(1)
      setCode('')
    }
  }, [open])

  useEffect(() => {
    if (step === 2) codeField.current?.querySelector<HTMLInputElement>('input:not([type=hidden])')?.focus()
  }, [step])

  let [local = '', domain = ''] = email.split('@')
  let masked = `${local.slice(0, 2)}•••••@${domain}`
  let complete = code.length === codeLength

  function verify() {
    if (!complete) return
    onClose()
    onVerified()
  }

  return (
    <FormDialog
      open={open}
      onClose={onClose}
      onSubmit={() => {
        if (step === 1) {
          onSend?.()
          setStep(2)
        } else {
          verify()
        }
      }}
      size="xl"
      title={title}
      badge={
        <Badge color="zinc" className="shrink-0">
          Step {step} of 2
        </Badge>
      }
      description={`To ${action}, verify your identity by email.`}
      submitLabel={step === 1 ? 'Send verification code' : 'Verify'}
      submitDisabled={step === 2 && !complete}
    >
      <Notice tone="zinc" icon={EnvelopeIcon}>
        We&apos;ll send a verification code to{' '}
        <span className="text-zinc-950 dark:text-white font-medium">{masked}</span>.
      </Notice>
      {step === 2 && (
        <>
          <div ref={codeField}>
            <Field className="mt-6">
              <Label>Verification code</Label>
              <CodeInput value={code} onChange={setCode} length={codeLength} className="mt-3" />
            </Field>
          </div>
          <p className="mt-3 text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400">
            Didn&apos;t receive a code?{' '}
            <button
              type="button"
              onClick={onResend}
              className="cursor-pointer text-zinc-950 underline decoration-zinc-950/50 hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:hover:decoration-white"
            >
              Resend
            </button>
          </p>
        </>
      )}
    </FormDialog>
  )
}
<script setup lang="ts">
import { EnvelopeIcon } from '@heroicons/vue/16/solid'
import { computed, nextTick, ref, watch } from 'vue'
import Badge from './badge.vue'
import CodeInput from './code-input.vue'
import Field from './field.vue'
import FormDialog from './form-dialog.vue'
import Label from './label.vue'
import Notice from './notice.vue'

const props = withDefaults(
  defineProps<{
    open: boolean
    action: string
    email: string
    codeLength?: number
    title?: string
  }>(),
  { codeLength: 6, title: 'Security verification' }
)

const emit = defineEmits<{ close: []; send: []; resend: []; verified: [] }>()

const step = ref(1)
const code = ref('')
const codeField = ref<HTMLElement>()

watch(
  () => props.open,
  (open) => {
    if (open) return
    step.value = 1
    code.value = ''
  }
)

watch(step, async (value) => {
  if (value !== 2) return
  await nextTick()
  codeField.value?.querySelector<HTMLInputElement>('input:not([type=hidden])')?.focus()
})

const masked = computed(() => {
  const [local = '', domain = ''] = props.email.split('@')
  return `${local.slice(0, 2)}•••••@${domain}`
})

const complete = computed(() => code.value.length === props.codeLength)

function verify() {
  if (!complete.value) return
  emit('close')
  emit('verified')
}

function submit() {
  if (step.value === 1) {
    emit('send')
    step.value = 2
  } else {
    verify()
  }
}
</script>

<template>
  <FormDialog
    :open="open"
    size="xl"
    :title="title"
    :description="`To ${action}, verify your identity by email.`"
    :submit-label="step === 1 ? 'Send verification code' : 'Verify'"
    :submit-disabled="step === 2 && !complete"
    @close="emit('close')"
    @submit="submit"
  >
    <template #badge>
      <Badge color="zinc" class="shrink-0">Step {{ step }} of 2</Badge>
    </template>
    <Notice tone="zinc" :icon="EnvelopeIcon">
      We'll send a verification code to
      <span class="text-zinc-950 dark:text-white font-medium">{{ masked }}</span>.
    </Notice>
    <template v-if="step === 2">
      <div ref="codeField">
        <Field class="mt-6">
          <Label>Verification code</Label>
          <CodeInput v-model="code" :length="codeLength" class="mt-3" />
        </Field>
      </div>
      <p class="mt-3 text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400">
        Didn't receive a code?
        <button
          type="button"
          class="cursor-pointer text-zinc-950 underline decoration-zinc-950/50 hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:hover:decoration-white"
          @click="emit('resend')"
        >
          Resend
        </button>
      </p>
    </template>
  </FormDialog>
</template>

Component API

PropDefaultDescription
VerificationCodeDialogbuilt on the <FormDialog> component
open-Whether the dialog is open. It goes back to step one each time it closes.
onClose-Called by Cancel, Escape and the backdrop.
onVerified-Called once the code is complete and Verify is pressed. The dialog closes first.
action-What the code unlocks, in the infinitive: `create a webhook endpoint`.
email-Address the code goes to. Shown with the local part masked.
codeLength6Digits in the code.
onSend-Called when the user asks for the code: send it.
onResend-Called by the Resend button.
title'Security verification'Title of the dialog.
PropDefaultDescription
VerificationCodeDialogbuilt on the <FormDialog> component
open-Whether the dialog is open. It goes back to step one each time it closes.
@close-Emitted by Cancel, Escape and the backdrop.
@verified-Emitted once the code is complete and Verify is pressed. The dialog closes first.
action-What the code unlocks, in the infinitive: `create a webhook endpoint`.
email-Address the code goes to. Shown with the local part masked.
codeLength6Digits in the code.
@send-Emitted when the user asks for the code: send it.
@resend-Emitted by the Resend button.
title'Security verification'Title of the dialog.

Examples

Basic example

Open it in place of the action. onVerified then opens the real dialog, or runs the request. The code field takes the focus, accepts a paste and submits on Enter:

import { Button } from '@/components/button'
import { Field, Label } from '@/components/fieldset'
import { FormDialog } from '@/components/form-dialog'
import { Input } from '@/components/input'
import { VerificationCodeDialog } from '@/components/verification-code-dialog'
import { useState } from 'react'

export default function Example({ user }) {
  let [verifyOpen, setVerifyOpen] = useState(false)
  let [createOpen, setCreateOpen] = useState(false)

  return (
    <>
      <Button onClick={() => setVerifyOpen(true)}>Add endpoint</Button>
      <VerificationCodeDialog
        open={verifyOpen}
        onClose={() => setVerifyOpen(false)}
        onVerified={() => setCreateOpen(true)}
        onSend={() => sendCode(user)}
        action="create a webhook endpoint"
        email={user.email}
      />
      <FormDialog
        open={createOpen}
        onClose={() => setCreateOpen(false)}
        onSubmit={(event) => {
          createEndpoint(new FormData(event.currentTarget))
          setCreateOpen(false)
        }}
        title="Add endpoint"
        submitLabel="Add endpoint"
      >
        <Field>
          <Label>Endpoint URL</Label>
          <Input type="url" name="url" required autoFocus />
        </Field>
      </FormDialog>
    </>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Field from '@/components/field.vue'
import FormDialog from '@/components/form-dialog.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
import VerificationCodeDialog from '@/components/verification-code-dialog.vue'
import { ref } from 'vue'

const props = defineProps<{ user: User }>()
const verifyOpen = ref(false)
const createOpen = ref(false)

function submit(event: Event) {
  createEndpoint(new FormData(event.currentTarget as HTMLFormElement))
  createOpen.value = false
}
</script>

<template>
  <Button @click="verifyOpen = true">Add endpoint</Button>
  <VerificationCodeDialog
    :open="verifyOpen"
    action="create a webhook endpoint"
    :email="user.email"
    @close="verifyOpen = false"
    @verified="createOpen = true"
    @send="sendCode(user)"
  />
  <FormDialog :open="createOpen" title="Add endpoint" submit-label="Add endpoint" @close="createOpen = false" @submit="submit">
    <Field>
      <Label>Endpoint URL</Label>
      <Input type="url" name="url" required autofocus />
    </Field>
  </FormDialog>
</template>

Code length

Match codeLength to what your backend sends. Verify stays disabled until every box is filled:

<VerificationCodeDialog
  open={open}
  onClose={() => setOpen(false)}
  onVerified={resetKey}
  action="reset this key"
  email={user.email}
  codeLength={5}
/>
<VerificationCodeDialog
  :open="open"
  action="reset this key"
  :email="user.email"
  :code-length="5"
  @close="open = false"
  @verified="resetKey"
/>