feat(scheduling): pluggable captcha (Turnstile) on public booking
This commit is contained in:
@@ -18,6 +18,10 @@ export default defineNuxtConfig({
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL
|
||||
|| (process.env.NODE_ENV === 'production' ? 'https://booking.dezky.eu' : 'http://localhost:3000'),
|
||||
// Cloudflare Turnstile site-key. When set, the booking form renders a
|
||||
// captcha widget and sends its token with the booking POST. Server-side
|
||||
// enforcement is gated independently by TURNSTILE_SECRET on platform-api.
|
||||
turnstileSiteKey: process.env.NUXT_PUBLIC_TURNSTILE_SITE_KEY || '',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -30,6 +30,71 @@ const submitting = ref(false)
|
||||
const submitError = ref<string | null>(null)
|
||||
const booking = ref<PublicBooking | null>(null)
|
||||
|
||||
// Cloudflare Turnstile (optional). Only when a public site-key is configured do
|
||||
// we load the script + render a widget on the details step; otherwise the form
|
||||
// behaves exactly as before. We never solve the captcha — the widget produces a
|
||||
// token that we forward to platform-api, which verifies it server-side.
|
||||
const turnstileSiteKey = useRuntimeConfig().public.turnstileSiteKey as string
|
||||
const captchaToken = ref<string | null>(null)
|
||||
const captchaEl = ref<HTMLElement | null>(null)
|
||||
let captchaWidgetId: string | null = null
|
||||
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
|
||||
function loadTurnstileScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).turnstile) return resolve()
|
||||
const existing = document.querySelector(`script[src^="${TURNSTILE_SRC}"]`) as HTMLScriptElement | null
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve())
|
||||
existing.addEventListener('error', () => reject(new Error('turnstile load failed')))
|
||||
return
|
||||
}
|
||||
const s = document.createElement('script')
|
||||
s.src = TURNSTILE_SRC
|
||||
s.async = true
|
||||
s.defer = true
|
||||
s.addEventListener('load', () => resolve())
|
||||
s.addEventListener('error', () => reject(new Error('turnstile load failed')))
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
}
|
||||
|
||||
async function renderCaptcha() {
|
||||
if (!turnstileSiteKey || !import.meta.client || !captchaEl.value || captchaWidgetId) return
|
||||
try {
|
||||
await loadTurnstileScript()
|
||||
const turnstile = (window as any).turnstile
|
||||
if (!turnstile || !captchaEl.value) return
|
||||
captchaWidgetId = turnstile.render(captchaEl.value, {
|
||||
sitekey: turnstileSiteKey,
|
||||
callback: (token: string) => {
|
||||
captchaToken.value = token
|
||||
},
|
||||
'expired-callback': () => {
|
||||
captchaToken.value = null
|
||||
},
|
||||
'error-callback': () => {
|
||||
captchaToken.value = null
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// If the widget can't load we leave captchaToken null; the server decides
|
||||
// whether that's fatal (it is only when TURNSTILE_SECRET is set).
|
||||
}
|
||||
}
|
||||
|
||||
function resetCaptcha() {
|
||||
captchaToken.value = null
|
||||
if (import.meta.client && captchaWidgetId && (window as any).turnstile) {
|
||||
;(window as any).turnstile.reset(captchaWidgetId)
|
||||
}
|
||||
}
|
||||
|
||||
// Render the widget whenever the details step becomes visible.
|
||||
watch(step, (s) => {
|
||||
if (s === 'details' && turnstileSiteKey) void nextTick(renderCaptcha)
|
||||
})
|
||||
|
||||
// Whitelabel theming — drive the accent from the tenant's brandColor.
|
||||
watchEffect(() => {
|
||||
if (!import.meta.client || !info.value) return
|
||||
@@ -90,6 +155,7 @@ async function confirm() {
|
||||
attendeeEmail: form.email,
|
||||
attendeeTimezone: visitorTz.value,
|
||||
attendeeNotes: form.notes || undefined,
|
||||
captchaToken: captchaToken.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -101,6 +167,9 @@ async function confirm() {
|
||||
await loadSlots()
|
||||
} else if (e?.statusCode === 503) {
|
||||
submitError.value = 'The calendar is temporarily unavailable. Please try again in a moment.'
|
||||
} else if (e?.statusCode === 403) {
|
||||
submitError.value = 'Captcha verification failed. Please complete the challenge and try again.'
|
||||
resetCaptcha()
|
||||
} else {
|
||||
submitError.value = e?.statusMessage || 'Something went wrong. Please try again.'
|
||||
}
|
||||
@@ -222,8 +291,13 @@ useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.va
|
||||
<label class="bk-label" for="notes">Notes (optional)</label>
|
||||
<textarea id="notes" v-model="form.notes" class="bk-input" maxlength="2000" />
|
||||
</div>
|
||||
<div v-if="turnstileSiteKey" ref="captchaEl" class="captcha" />
|
||||
<p v-if="submitError" class="err">{{ submitError }}</p>
|
||||
<button type="submit" class="bk-btn bk-btn--primary" :disabled="submitting">
|
||||
<button
|
||||
type="submit"
|
||||
class="bk-btn bk-btn--primary"
|
||||
:disabled="submitting || (!!turnstileSiteKey && !captchaToken)"
|
||||
>
|
||||
{{ submitting ? 'Confirming…' : 'Confirm booking' }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -271,6 +345,7 @@ useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.va
|
||||
.slot { height: 44px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); font-size: 15px; font-weight: 600; cursor: pointer; }
|
||||
.slot:hover { border-color: var(--accent); }
|
||||
.form { display: flex; flex-direction: column; gap: 16px; margin-top: 14px; }
|
||||
.captcha { min-height: 65px; }
|
||||
.chosen { font-weight: 600; margin: 0 0 6px; }
|
||||
.back { background: none; border: none; color: var(--text-mute); cursor: pointer; padding: 0 0 12px; font-size: 14px; }
|
||||
.done { text-align: center; }
|
||||
|
||||
Reference in New Issue
Block a user