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'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'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>
)
}Also copy Badge, Code input, Fieldset, Form dialog, Notice.
<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>Also copy Badge, Code input, Fieldset, Form dialog, Notice.
Component API
| Prop | Default | Description |
|---|---|---|
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. |
codeLength | 6 | Digits 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. |
| Prop | Default | Description |
|---|---|---|
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. |
codeLength | 6 | Digits 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"
/>