Files
portal/app/resources/js/components/auth/SmartCaptchaWidget.vue
T
Дмитрий df79557c08
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(auth): фронт-виджет Yandex SmartCaptcha на регистрации с fallback на стаб без ключа (M-2)
2026-06-21 09:34:31 +03:00

119 lines
3.6 KiB
Vue

<script setup lang="ts">
/**
* Виджет Yandex SmartCaptcha (M-2). v-model = строка-токен.
*
* sitekey задан (VITE_YANDEX_SMARTCAPTCHA_SITEKEY) → реальный виджет:
* подгружает captcha.js, рендерит через window.smartCaptcha.render, токен
* приходит в callback → emit. reset() — сброс после ошибки регистрации
* (одноразовый токен Yandex живёт 5 мин и используется однократно).
*
* sitekey пуст (dev/local/CI) → fallback на чекбокс «не робот» со стаб-токеном,
* чтобы без ключа окружение и существующие сценарии не падали.
*
* Интеграция сверена по докам Yandex SmartCaptcha (advanced method, render=onload).
*/
import { onMounted, onUnmounted, ref } from 'vue';
defineProps<{
modelValue: string;
errorMessages?: string[];
}>();
const emit = defineEmits<{
'update:modelValue': [token: string];
}>();
const sitekey = import.meta.env.VITE_YANDEX_SMARTCAPTCHA_SITEKEY ?? '';
const useReal = sitekey !== '';
const container = ref<HTMLElement | null>(null);
const widgetId = ref<number | null>(null);
const fallbackChecked = ref(false);
const SCRIPT_ID = 'yandex-smartcaptcha-js';
const SCRIPT_SRC = 'https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=__lidSmartCaptchaOnload';
function renderWidget(): void {
if (!window.smartCaptcha || container.value === null || widgetId.value !== null) {
return;
}
widgetId.value = window.smartCaptcha.render(container.value, {
sitekey,
hl: 'ru',
callback: (token: string) => emit('update:modelValue', token),
});
}
function loadAndRender(): void {
if (window.smartCaptcha) {
renderWidget();
return;
}
window.__lidSmartCaptchaOnload = renderWidget;
if (document.getElementById(SCRIPT_ID) === null) {
const script = document.createElement('script');
script.id = SCRIPT_ID;
script.src = SCRIPT_SRC;
script.defer = true;
document.head.appendChild(script);
}
}
function onFallbackChange(value: boolean | null): void {
fallbackChecked.value = value === true;
emit('update:modelValue', fallbackChecked.value ? 'dev-captcha-stub' : '');
}
/** Сброс капчи после ошибки регистрации — нужен свежий токен. */
function reset(): void {
if (useReal) {
if (window.smartCaptcha && widgetId.value !== null) {
window.smartCaptcha.reset(widgetId.value);
}
} else {
fallbackChecked.value = false;
}
emit('update:modelValue', '');
}
defineExpose({ reset });
onMounted(() => {
if (useReal) {
loadAndRender();
}
});
onUnmounted(() => {
if (useReal && window.smartCaptcha?.destroy && widgetId.value !== null) {
window.smartCaptcha.destroy(widgetId.value);
}
});
</script>
<template>
<div class="smart-captcha">
<div v-if="useReal" ref="container" class="smart-captcha__widget" />
<v-checkbox
v-else
:model-value="fallbackChecked"
density="compact"
hide-details
color="primary"
:error-messages="errorMessages"
@update:model-value="onFallbackChange"
>
<template #label>
<span class="text-body-2">Подтвердите, что вы не робот</span>
</template>
</v-checkbox>
</div>
</template>
<style scoped>
.smart-captcha__widget {
min-height: 100px;
}
</style>