Human verification
A verify-you-are-human widget with the whole flow: idle, verifying, verified, and an error path.
<script setup lang="ts">
import SaHumanVerification from '@shared/components/common/SaHumanVerification.vue'
import { ref } from 'vue'
const human = ref(false)
const token = ref('')
</script>
<template>
<div class="max-w-md space-y-3">
<SaHumanVerification v-model="human" @verified="token = $event" />
<p class="text-sm text-muted-foreground">
{{ human ? `Token: ${token}` : 'Not verified yet' }}
</p>
</div>
</template>Usage
v-model is the pass or fail boolean. verified carries the token a provider would hand back.
The widget makes no network request. The check is a timer, so the whole flow runs with no backend and nothing fails in the console.
Wiring it to a real provider such as Cloudflare Turnstile changes one function inside the component, runChallenge: load the provider's script once, render the widget into the element, and settle with the token from its callback. Nothing in the form around it changes, because v-model and verified already carry what a provider gives you.
The site key belongs in an environment variable rather than in SmartAdminConfig. The config holds the on-and-off switch, not a per-deployment value.
Feature flag
features.humanVerification is the one flag that ships off, so the demo's sign-in is two clicks. It is read at the call site, on the sign-in and sign-up pages, which keeps this a plain reusable widget. See Global config.
Examples
In a form
The usual shape: the submit button stays disabled until the widget passes.
<script setup lang="ts">
import SaHumanVerification from '@shared/components/common/SaHumanVerification.vue'
import { Button } from '@shared/components/ui/button'
import { Input } from '@shared/components/ui/input'
import { Label } from '@shared/components/ui/label'
import { ref } from 'vue'
const email = ref('')
const human = ref(false)
const sent = ref(false)
function onSubmit(): void {
sent.value = true
}
</script>
<template>
<form class="max-w-md space-y-4" @submit.prevent="onSubmit">
<div class="space-y-2">
<Label for="verify-email">Email</Label>
<Input id="verify-email" v-model="email" type="email" placeholder="you@example.com" />
</div>
<SaHumanVerification v-model="human" />
<Button type="submit" :disabled="!human || !email">
Create account
</Button>
<p v-if="sent" class="text-sm text-muted-foreground">
Submitted for {{ email }}.
</p>
</form>
</template>Resetting and failing
reset() and fail() are exposed on the component instance. Call reset() after a failed submit so the reader can verify again, and fail() when your server refuses the token.
<script setup lang="ts">
import SaHumanVerification from '@shared/components/common/SaHumanVerification.vue'
import { Button } from '@shared/components/ui/button'
import { ref, useTemplateRef } from 'vue'
const human = ref(false)
const widget = useTemplateRef<InstanceType<typeof SaHumanVerification>>('widget')
</script>
<template>
<div class="max-w-md space-y-3">
<SaHumanVerification ref="widget" v-model="human" :duration="800" />
<div class="flex gap-2">
<Button size="sm" variant="outline" @click="widget?.reset()">
Reset
</Button>
<Button size="sm" variant="outline" @click="widget?.fail()">
Reject the token
</Button>
</div>
</div>
</template>Clearing the bound value from the parent also sends a verified widget back to idle, so it can never read "Success!" while the form believes otherwise.
Disabled
<script setup lang="ts">
import SaHumanVerification from '@shared/components/common/SaHumanVerification.vue'
import { ref } from 'vue'
const human = ref(false)
</script>
<template>
<div class="max-w-md">
<SaHumanVerification v-model="human" disabled label="Fill the form first" />
</div>
</template>API
Source: src/shared/components/common/SaHumanVerification.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | boolean | false | true once the challenge has passed. |
duration | number | 1400 | How long the simulated challenge takes, in milliseconds. |
auto | boolean | false | Starts the challenge on mount instead of waiting for a click. |
disabled | boolean | false | Dims the control and blocks it. |
label | string | — | The idle text and the control's accessible name. Defaults to "Verify you are human". |
verifyingLabel | string | — | Defaults to "Verifying…". |
verifiedLabel | string | — | Defaults to "Success!". |
errorLabel | string | — | Defaults to "Verification failed. Click to retry." |
brand | string | 'Smart Admin' | The vendor mark in the corner. Decorative. |
Emits
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: boolean | true on success, false on reset() and fail(). |
verified | token: string | The token. In the shipped widget it is a stand-in string. |
error | — | Fires from fail(). |
Exposed
| Member | Type | Description |
|---|---|---|
reset() | () => void | Back to idle, and emits update:modelValue with false. |
fail() | () => void | Into the error state, and emits error. |
state | 'idle' | 'verifying' | 'verified' | 'error' | The current state. Also on the root as data-state. |
Slots
None.
Accessibility
- The control is a real
role="checkbox"witharia-checked, named bylabel. - A visually hidden
role="status"witharia-live="polite"announces every transition. The spinner is given an empty label on purpose, so the two do not announce "Verifying…" twice. - A passed challenge locks the control: re-running it is not something the reader should be able to do.
- The error state is carried by an icon and by text, not by color alone.
- The root is positioned, because the visually hidden status line is absolute and would otherwise anchor to the page and add to its height.