diff --git a/src/client/components/captcha.vue b/src/client/components/captcha.vue new file mode 100644 index 000000000..6b1ee6f0b --- /dev/null +++ b/src/client/components/captcha.vue @@ -0,0 +1,119 @@ +<template> +<div> + <span v-if="!available">{{ $t('waiting') }}<mk-ellipsis/></span> + <div ref="captcha"></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +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' | 'grecaptcha'; + +type CaptchaContainer = { + readonly [_ in CaptchaProvider]?: Captcha; +}; + +declare global { + interface Window extends CaptchaContainer { + } +} + +export default Vue.extend({ + i18n, + props: { + provider: { + type: String, + required: true, + }, + sitekey: { + type: String, + required: true, + }, + value: { + type: String, + }, + }, + + data() { + return { + available: false, + }; + }, + + computed: { + loaded() { + return !!window[this.provider as CaptchaProvider]; + }, + src() { + const endpoint = ({ + hcaptcha: 'https://hcaptcha.com/1', + grecaptcha: 'https://www.google.com/recaptcha', + } as Record<PropertyKey, unknown>)[this.provider]; + + return `${typeof endpoint == 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; + }, + captcha() { + return window[this.provider as CaptchaProvider] || {} as unknown as Captcha; + }, + }, + + created() { + if (this.loaded) { + this.available = true; + } else { + (document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { + async: true, + id: this.provider, + src: this.src, + }))) + .addEventListener('load', () => this.available = true); + } + }, + + mounted() { + if (this.available) { + this.requestRender(); + } else { + this.$watch('available', this.requestRender); + } + }, + + beforeDestroy() { + this.reset(); + }, + + methods: { + reset() { + this.captcha?.reset(); + }, + requestRender() { + if (this.captcha.render && this.$refs.captcha instanceof Element) { + this.captcha.render(this.$refs.captcha, { + sitekey: this.sitekey, + theme: this.$store.state.device.darkMode ? 'dark' : 'light', + callback: this.callback, + 'expired-callback': this.callback, + 'error-callback': this.callback, + }); + } else { + setTimeout(this.requestRender.bind(this), 1); + } + }, + callback(response?: string) { + this.$emit('input', typeof response == 'string' ? response : null); + }, + }, +}); +</script> diff --git a/src/client/components/hcaptcha.vue b/src/client/components/hcaptcha.vue deleted file mode 100644 index 4bc03342f..000000000 --- a/src/client/components/hcaptcha.vue +++ /dev/null @@ -1,77 +0,0 @@ -<template> - <div ref="hCaptcha"></div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -declare global { - interface Window { - hcaptcha?: { - 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; - }; - } -} - -export default Vue.extend({ - props: { - sitekey: { - type: String, - required: true, - }, - value: { - type: String, - }, - }, - - data() { - return { - available: false, - }; - }, - - created() { - if (window.hcaptcha) { // loaded - this.available = true; - } else { - (document.getElementById('hcaptcha') || document.head.appendChild(Object.assign(document.createElement('script'), { - async: true, - id: 'hcaptcha', - src: 'https://hcaptcha.com/1/api.js?render=explicit', - }))) - .addEventListener('load', () => this.available = true); - } - }, - - mounted() { - if (this.available) { - this.render(); - } else { - this.$watch('available', this.render); - } - }, - - methods: { - render() { - if (this.$refs.hCaptcha instanceof Element) { - window.hcaptcha!.render(this.$refs.hCaptcha, { - sitekey: this.sitekey, - theme: this.$store.state.device.darkMode ? 'dark' : 'light', - callback: this.callback, - 'expired-callback': this.callback, - 'error-callback': this.callback, - }); - } - }, - callback(response?: string) { - this.$emit('input', typeof response == 'string' ? response : null); - }, - }, -}); -</script> diff --git a/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue index 10cdf3a56..4db79af51 100644 --- a/src/client/components/signup-dialog.vue +++ b/src/client/components/signup-dialog.vue @@ -1,5 +1,5 @@ <template> -<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }"> +<x-window ref="window" :width="366" :height="506" @closed="() => { $emit('closed'); destroyDom(); }"> <template #header>{{ $t('signup') }}</template> <x-signup :auto-set="autoSet" @signup="onSignup"/> </x-window> diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue index 5d16a82ba..7e8bac776 100644 --- a/src/client/components/signup.vue +++ b/src/client/components/signup.vue @@ -41,8 +41,8 @@ <a :href="meta.tosUrl" class="_link" target="_blank">{{ $t('tos') }}</a> </i18n> </mk-switch> - <div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div> - <h-captcha v-if="meta.enableHcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> + <captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> + <captcha v-if="meta.enableRecaptcha" class="captcha" provider="grecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> <mk-button type="submit" :disabled="shouldDisableSubmitting" primary>{{ $t('start') }}</mk-button> </template> </form> @@ -66,7 +66,7 @@ export default Vue.extend({ MkButton, MkInput, MkSwitch, - hCaptcha: () => import('./hcaptcha.vue').then(x => x.default), + captcha: () => import('./captcha.vue').then(x => x.default), }, data() { @@ -83,6 +83,7 @@ export default Vue.extend({ submitting: false, ToSAgreement: false, hCaptchaResponse: null, + reCaptchaResponse: null, faLock, faExclamationTriangle, faSpinner, faCheck, faKey } }, @@ -124,14 +125,6 @@ export default Vue.extend({ } }, - mounted() { - if (this.meta.enableRecaptcha) { - const script = document.createElement('script'); - script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); - document.head.appendChild(script); - } - }, - methods: { onChangeUsername() { if (this.username == '') { @@ -189,7 +182,7 @@ export default Vue.extend({ password: this.password, invitationCode: this.invitationCode, 'hcaptcha-response': this.hCaptchaResponse, - 'g-recaptcha-response': this.meta.enableRecaptcha ? (window as any).grecaptcha.getResponse() : null + 'g-recaptcha-response': this.meta.reCaptchaResponse, }).then(() => { this.$root.api('signin', { username: this.username, @@ -199,17 +192,25 @@ export default Vue.extend({ }); }).catch(() => { this.submitting = false; + this.$refs.hcaptcha?.reset?.(); + this.$refs.recaptcha?.reset?.(); this.$root.dialog({ type: 'error', text: this.$t('error') }); - - if (this.meta.enableRecaptcha) { - (window as any).grecaptcha.reset(); - } }); } } }); </script> + +<style lang="scss" scoped> +.mk-signup { + padding: 32px 0 0; + + .captcha { + margin: 16px 0; + } +} +</style> diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue index 008d6ef1a..f0fa84699 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/instance/settings.vue @@ -41,15 +41,15 @@ <section class="_card"> <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div> <div class="_content"> - <mk-switch v-model="enableHcaptcha">{{ $t('enableHcaptcha') }}</mk-switch> + <mk-switch v-model="enableHcaptcha" @input="guide('enableHcaptcha')">{{ $t('enableHcaptcha') }}</mk-switch> <template v-if="enableHcaptcha"> <mk-input v-model="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</mk-input> <mk-input v-model="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</mk-input> </template> </div> - <div class="_content" v-if="enableHcaptcha && hcaptchaSiteKey"> + <div class="_content" v-if="enableHcaptcha"> <header>{{ $t('preview') }}</header> - <h-captcha v-if="enableHcaptcha" :sitekey="hcaptchaSiteKey"/> + <captcha v-if="enableHcaptcha" provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> </div> <div class="_footer"> <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> @@ -59,7 +59,7 @@ <section class="_card"> <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div> <div class="_content"> - <mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch> + <mk-switch v-model="enableRecaptcha" @input="guide('enableRecaptcha')">{{ $t('enableRecaptcha') }}</mk-switch> <template v-if="enableRecaptcha"> <mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input> <mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input> @@ -67,7 +67,7 @@ </div> <div class="_content" v-if="enableRecaptcha && recaptchaSiteKey"> <header>{{ $t('preview') }}</header> - <div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div> + <captcha v-if="enableRecaptcha" provider="grecaptcha" :sitekey="recaptchaSiteKey"/> </div> <div class="_footer"> <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> @@ -213,12 +213,6 @@ import { url } from '../../config'; import i18n from '../../i18n'; import getAcct from '../../../misc/acct/render'; -declare global { - interface Window { - onRecaptchaLoad?: Function; - } -} - export default Vue.extend({ i18n, @@ -234,12 +228,11 @@ export default Vue.extend({ MkTextarea, MkSwitch, MkInfo, - hCaptcha: () => import('../../components/hcaptcha.vue').then(x => x.default), + Captcha: () => import('../../components/captcha.vue').then(x => x.default), }, data() { return { - loaded: false, url, proxyAccount: null, proxyAccountId: null, @@ -299,41 +292,6 @@ export default Vue.extend({ }, }, - watch: { - enableHcaptcha(enabled) { - if (enabled && this.loaded && this.enableRecaptcha) { - this.$root.dialog({ - type: 'question', // warning だと間違って cancel するかもしれない - showCancelButton: true, - title: this.$t('settingGuide'), - text: this.$t('avoidMultiCaptchaConfirm'), - }).then(({ canceled }) => { - if (canceled) { - return; - } - - this.enableRecaptcha = false; - }); - } - }, - enableRecaptcha(enabled) { - if (enabled && this.loaded && this.enableHcaptcha) { - this.$root.dialog({ - type: 'question', // warning だと間違って cancel するかもしれない - showCancelButton: true, - title: this.$t('settingGuide'), - text: this.$t('avoidMultiCaptchaConfirm'), - }).then(({ canceled }) => { - if (canceled) { - return; - } - - this.enableHcaptcha = false; - }); - } - } - }, - created() { this.name = this.meta.name; this.description = this.meta.description; @@ -388,43 +346,46 @@ export default Vue.extend({ this.proxyAccount = proxyAccount; }); } - - this.loaded = true; - }, - - mounted() { - const renderRecaptchaPreview = () => { - if (!(window as any).grecaptcha) return; - if (!this.$refs.recaptcha) return; - if (!this.enableRecaptcha) return; - if (!this.recaptchaSiteKey) return; - (window as any).grecaptcha.render(this.$refs.recaptcha, { - sitekey: this.recaptchaSiteKey - }); - }; - let recaptchaLoaded: boolean = false; - const requestRenderRecaptchaPreview = () => { - if (window.onRecaptchaLoad) { // loading - return; - } - - if (recaptchaLoaded) { // loaded - renderRecaptchaPreview(); - } else { // init - window.onRecaptchaLoad = () => { - recaptchaLoaded = delete window.onRecaptchaLoad; - renderRecaptchaPreview(); - }; - const script = document.createElement('script'); - script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad'); - document.head.appendChild(script); - } - }; - this.$watch('enableRecaptcha', requestRenderRecaptchaPreview); - this.$watch('recaptchaSiteKey', requestRenderRecaptchaPreview); }, methods: { + guide(key: 'enableHcaptcha' | 'enableRecaptcha') { + ({ + enableHcaptcha() { + if (this.enableHcaptcha && this.enableRecaptcha) { + this.$root.dialog({ + type: 'question', // warning だと間違って cancel するかもしれない + showCancelButton: true, + title: this.$t('settingGuide'), + text: this.$t('avoidMultiCaptchaConfirm'), + }).then(({ canceled }) => { + if (canceled) { + return; + } + + this.enableRecaptcha = false; + }); + } + }, + enableRecaptcha() { + if (this.enableRecaptcha && this.enableHcaptcha) { + this.$root.dialog({ + type: 'question', // warning だと間違って cancel するかもしれない + showCancelButton: true, + title: this.$t('settingGuide'), + text: this.$t('avoidMultiCaptchaConfirm'), + }).then(({ canceled }) => { + if (canceled) { + return; + } + + this.enableHcaptcha = false; + }); + } + }, + })[key](); + }, + invite() { this.$root.api('admin/invite').then(x => { this.$root.dialog({