summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkCaptcha.vue
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components/MkCaptcha.vue')
-rw-r--r--packages/frontend/src/components/MkCaptcha.vue118
1 files changed, 118 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
new file mode 100644
index 0000000000..6d218389fc
--- /dev/null
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -0,0 +1,118 @@
+<template>
+<div>
+ <span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span>
+ <div ref="captchaEl"></div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+type Captcha = {
+ render(container: string | Node, options: {
+ readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
+ }): string;
+ remove(id: string): void;
+ execute(id: string): void;
+ reset(id?: string): void;
+ getResponse(id: string): string;
+};
+
+type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile';
+
+type CaptchaContainer = {
+ readonly [_ in CaptchaProvider]?: Captcha;
+};
+
+declare global {
+ interface Window extends CaptchaContainer { }
+}
+
+const props = defineProps<{
+ provider: CaptchaProvider;
+ sitekey: string;
+ modelValue?: string | null;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update:modelValue', v: string | null): void;
+}>();
+
+const available = ref(false);
+
+const captchaEl = ref<HTMLDivElement | undefined>();
+
+const variable = computed(() => {
+ switch (props.provider) {
+ case 'hcaptcha': return 'hcaptcha';
+ case 'recaptcha': return 'grecaptcha';
+ case 'turnstile': return 'turnstile';
+ }
+});
+
+const loaded = !!window[variable.value];
+
+const src = computed(() => {
+ switch (props.provider) {
+ case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off';
+ case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
+ case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
+ }
+});
+
+const scriptId = computed(() => `script-${props.provider}`)
+
+const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
+
+if (loaded) {
+ available.value = true;
+} else {
+ (document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), {
+ async: true,
+ id: scriptId.value,
+ src: src.value,
+ })))
+ .addEventListener('load', () => available.value = true);
+}
+
+function reset() {
+ if (captcha.value.reset) captcha.value.reset();
+}
+
+function requestRender() {
+ if (captcha.value.render && captchaEl.value instanceof Element) {
+ captcha.value.render(captchaEl.value, {
+ sitekey: props.sitekey,
+ theme: defaultStore.state.darkMode ? 'dark' : 'light',
+ callback: callback,
+ 'expired-callback': callback,
+ 'error-callback': callback,
+ });
+ } else {
+ window.setTimeout(requestRender, 1);
+ }
+}
+
+function callback(response?: string) {
+ emit('update:modelValue', typeof response === 'string' ? response : null);
+}
+
+onMounted(() => {
+ if (available.value) {
+ requestRender();
+ } else {
+ watch(available, requestRender);
+ }
+});
+
+onBeforeUnmount(() => {
+ reset();
+});
+
+defineExpose({
+ reset,
+});
+
+</script>