Commit 33b095e4 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Paul Gascou-Vaillancourt

Refactor with Vue

Apply @pslaughter's patch to refactor the challenge logic with a Vue
component.
parent 4e6e375a
import Vue from 'vue';
import { GlAlert } from '@gitlab/ui';
import { needsArkoseLabsChallenge } from 'ee/rest_api';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { __ } from '~/locale';
const VERIFICATION_TOKEN_INPUT_NAME = 'arkose_labs_token';
const LOADING_ICON = loadingIconForLegacyJS({ classes: ['gl-mr-2'] });
const CHALLENGE_ERRORS_CONTAINER_CLASS = 'js-arkose-labs-error-message';
export class ArkoseLabs {
constructor() {
this.signInForm = document.querySelector('.js-sign-in-form');
if (!this.signInForm) {
return;
}
this.usernameField = this.signInForm.querySelector('.js-username-field');
this.arkoseLabsChallengeContainer = this.signInForm.querySelector('.js-arkose-labs-challenge');
this.signInButton = this.signInForm.querySelector('.js-sign-in-button');
this.onUsernameFieldBlur = this.onUsernameFieldBlur.bind(this);
this.onSignInFormSubmitted = this.onSignInFormSubmitted.bind(this);
this.setConfig = this.setConfig.bind(this);
this.passArkoseLabsChallenge = this.passArkoseLabsChallenge.bind(this);
this.handleArkoseLabsFailure = this.handleArkoseLabsFailure.bind(this);
this.publicKey = this.arkoseLabsChallengeContainer.dataset.apiKey;
this.username = this.usernameField.value || '';
this.arkoseLabsInitialized = false;
this.arkoseLabsChallengePassed = false;
window.setupArkoseLabsEnforcement = this.setConfig;
this.attachEventListeners();
if (this.username.length) {
this.checkIfNeedsChallenge();
}
}
attachEventListeners() {
this.usernameField.addEventListener('blur', this.onUsernameFieldBlur);
this.signInForm.addEventListener('submit', this.onSignInFormSubmitted);
}
detachEventListeners() {
this.usernameField.removeEventListener('blur', this.onUsernameFieldBlur);
this.signInForm.removeEventListener('submit', this.onSignInFormSubmitted);
}
onUsernameFieldBlur() {
const { value } = this.usernameField;
if (this.username !== this.usernameField.value) {
this.username = value;
this.checkIfNeedsChallenge();
}
}
onSignInFormSubmitted(e) {
if (!this.arkoseLabsInitialized || this.arkoseLabsChallengePassed) {
return;
}
e.preventDefault();
this.showArkoseLabsErrorMessage();
}
async checkIfNeedsChallenge() {
if (this.arkoseLabsInitialized) {
return;
}
this.setButtonLoadingState();
try {
const {
data: { result },
} = await needsArkoseLabsChallenge(this.username);
if (result) {
this.initArkoseLabsChallenge();
}
} catch {
// API call failed, do not initialize Arkose challenge.
// Button will be reset in `finally` block.
} finally {
this.resetButton();
}
}
setButtonLoadingState() {
const label = __('Loading');
this.signInButton.innerHTML = `
${LOADING_ICON.outerHTML}
${label}
`;
this.signInButton.setAttribute('disabled', true);
}
resetButton() {
this.signInButton.innerText = __('Sign in');
this.signInButton.removeAttribute('disabled');
}
initArkoseLabsChallenge() {
this.arkoseLabsInitialized = true;
const tag = document.createElement('script');
[
['type', 'text/javascript'],
['src', `https://client-api.arkoselabs.com/v2/${this.publicKey}/api.js`],
['nonce', true],
['async', true],
['defer', true],
['data-callback', 'setupArkoseLabsEnforcement'],
].forEach(([attr, value]) => {
tag.setAttribute(attr, value);
});
document.head.appendChild(tag);
const tokenInput = document.createElement('input');
tokenInput.name = VERIFICATION_TOKEN_INPUT_NAME;
tokenInput.setAttribute('type', 'hidden');
this.tokenInput = tokenInput;
this.signInForm.appendChild(tokenInput);
}
setConfig(enforcement) {
enforcement.setConfig({
mode: 'inline',
selector: '.js-arkose-labs-challenge',
onShown: () => {
this.arkoseLabsChallengeContainer.classList.remove('gl-display-none!');
},
onCompleted: this.passArkoseLabsChallenge,
onSuppress: this.passArkoseLabsChallenge,
onError: this.handleArkoseLabsFailure,
});
}
createArkoseLabsErrorMessageContainer() {
if (!this.arkoseLabsErrorMessageContainer) {
const arkoseLabsErrorMessageContainer = document.createElement('div');
arkoseLabsErrorMessageContainer.className = `gl-mb-3 ${CHALLENGE_ERRORS_CONTAINER_CLASS}`;
arkoseLabsErrorMessageContainer.setAttribute('data-testid', 'arkose-labs-error-message');
this.arkoseLabsChallengeContainer.parentNode.insertBefore(
arkoseLabsErrorMessageContainer,
this.arkoseLabsChallengeContainer.nextSibling,
);
this.arkoseLabsErrorMessageContainer = arkoseLabsErrorMessageContainer;
}
this.arkoseLabsErrorMessageContainer.classList.remove('gl-display-none');
}
showArkoseLabsErrorMessage() {
this.createArkoseLabsErrorMessageContainer();
this.arkoseLabsErrorMessageContainer.innerHTML = `
<span class="gl-text-red-500">
${__('Complete verification to sign in.')}
</span>`;
}
hideArkoseLabsErrorMessage() {
this.arkoseLabsErrorMessageContainer?.classList.add('gl-display-none');
}
passArkoseLabsChallenge(response) {
this.arkoseLabsChallengePassed = true;
this.tokenInput.value = response.token;
this.hideArkoseLabsErrorMessage();
}
handleArkoseLabsFailure() {
this.createArkoseLabsErrorMessageContainer();
return new Vue({
el: `.${CHALLENGE_ERRORS_CONTAINER_CLASS}`,
components: { GlAlert },
render(h) {
return h(
GlAlert,
{
props: {
title: __('Unable to verify the user'),
dismissible: false,
variant: 'danger',
},
attrs: {
'data-testid': 'arkose-labs-failure-alert',
},
},
__(
'An error occurred when loading the user verification challenge. Refresh to try again.',
),
);
},
});
}
}
<script>
import { uniqueId } from 'lodash';
import { GlAlert } from '@gitlab/ui';
import { needsArkoseLabsChallenge } from 'ee/rest_api';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { __ } from '~/locale';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
import { initArkoseLabsScript } from '../init_arkose_labs_script';
const LOADING_ICON = loadingIconForLegacyJS({ classes: ['gl-mr-2'] });
const MSG_ARKOSE_NEEDED = __('Complete verification to sign in.');
const MSG_ARKOSE_FAILURE_TITLE = __('Unable to verify the user');
const MSG_ARKOSE_FAILURE_BODY = __(
'An error occurred when loading the user verification challenge. Refresh to try again.',
);
const ARKOSE_CONTAINER_CLASS = 'js-arkose-labs-container-';
const VERIFICATION_TOKEN_INPUT_NAME = 'arkose_labs_token';
export default {
components: {
DomElementListener,
GlAlert,
},
props: {
publicKey: {
type: String,
required: true,
},
formSelector: {
type: String,
required: true,
},
usernameSelector: {
type: String,
required: true,
},
submitSelector: {
type: String,
required: true,
},
},
data() {
return {
isVisible: false,
showArkoseNeededError: false,
showArkoseFailure: false,
username: '',
isLoading: false,
arkoseInitialized: false,
arkoseToken: '',
arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS),
arkoseChallengePassed: false,
};
},
computed: {
showErrorContainer() {
return this.showArkoseNeededError || this.showArkoseFailure;
},
},
watch: {
username() {
this.checkIfNeedsChallenge();
},
isLoading(val) {
this.updateSubmitButtonLoading(val);
},
},
mounted() {
this.username = this.getUsernameValue();
},
methods: {
show() {
this.isVisible = true;
},
hideErrors() {
this.showArkoseNeededError = false;
this.showArkoseFailure = false;
},
getUsernameValue() {
return document.querySelector(this.usernameSelector)?.value || '';
},
onUsernameBlur() {
this.username = this.getUsernameValue();
},
onSubmit(e) {
if (!this.arkoseInitialized || this.arkoseChallengePassed) {
return;
}
e.preventDefault();
this.showArkoseNeededError = true;
},
async checkIfNeedsChallenge() {
if (!this.username || this.arkoseInitialized) {
return;
}
this.isLoading = true;
try {
const {
data: { result },
} = await needsArkoseLabsChallenge(this.username);
if (result) {
this.arkoseInitialized = true;
await this.initArkoseLabs();
}
} catch {
// API call failed, do not initialize Arkose challenge.
// Button will be reset in `finally` block.
// TODO - what if initArkoseLabs failed?
// TODO - Do we get any error objects we should console log?
} finally {
this.isLoading = false;
}
},
async initArkoseLabs() {
const enforcement = await initArkoseLabsScript({ publicKey: this.publicKey });
enforcement.setConfig({
mode: 'inline',
selector: `.${this.arkoseContainerClass}`,
onShown: this.show,
onCompleted: this.passArkoseLabsChallenge,
onError: this.handleArkoseLabsFailure,
});
},
passArkoseLabsChallenge(response) {
this.arkoseChallengePassed = true;
this.arkoseToken = response.token;
this.hideErrors();
},
handleArkoseLabsFailure() {
// TODO - do we get an error object here we can console log?
this.showArkoseFailure = true;
},
updateSubmitButtonLoading(val) {
const button = document.querySelector(this.submitSelector);
if (val) {
const label = __('Loading');
button.innerHTML = `
${LOADING_ICON.outerHTML}
${label}
`;
button.setAttribute('disabled', true);
} else {
button.innerText = __('Sign in');
button.removeAttribute('disabled');
}
},
},
MSG_ARKOSE_NEEDED,
MSG_ARKOSE_FAILURE_TITLE,
MSG_ARKOSE_FAILURE_BODY,
VERIFICATION_TOKEN_INPUT_NAME,
};
</script>
<template>
<div v-show="isVisible">
<input
v-if="arkoseInitialized"
:name="$options.VERIFICATION_TOKEN_INPUT_NAME"
type="hidden"
:value="arkoseToken"
/>
<dom-element-listener :selector="usernameSelector" @blur="onUsernameBlur" />
<dom-element-listener :selector="formSelector" @submit="onSubmit" />
<div
class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3"
:class="arkoseContainerClass"
data-testid="arkose-labs-challenge"
></div>
<div v-if="showErrorContainer" class="gl-mb-3" data-testid="arkose-labs-error-message">
<gl-alert
v-if="showArkoseFailure"
:title="$options.MSG_ARKOSE_FAILURE_TITLE"
variant="danger"
:dismissible="false"
>
{{ $options.MSG_ARKOSE_FAILURE_BODY }}
</gl-alert>
<span v-else-if="showArkoseNeededError" class="gl-text-red-500">
{{ $options.MSG_ARKOSE_NEEDED }}
</span>
</div>
</div>
</template>
import Vue from 'vue';
import SignInArkoseApp from './components/sign_in_arkose_app.vue';
const FORM_SELECTOR = '.js-sign-in-form';
const USERNAME_SELECTOR = `${FORM_SELECTOR} .js-username-field`;
const SUBMIT_SELECTOR = `${FORM_SELECTOR} .js-sign-in-button`;
export const setupArkoseLabs = () => {
const signInForm = document.querySelector(FORM_SELECTOR);
const el = signInForm?.querySelector('.js-arkose-labs-challenge');
if (!el) {
return null;
}
const publicKey = el.dataset.apiKey;
return new Vue({
el,
render(h) {
return h(SignInArkoseApp, {
props: {
publicKey,
formSelector: FORM_SELECTOR,
usernameSelector: USERNAME_SELECTOR,
submitSelector: SUBMIT_SELECTOR,
},
})
}
});
};
import { uniqueId } from 'lodash';
const CALLBACK_NAME = '_initArkoseLabsScript_callback_';
const getCallbackName = () => uniqueId(CALLBACK_NAME);
export const initArkoseLabsScript = ({ publicKey }) => {
const callbackFunctionName = getCallbackName();
return new Promise((resolve) => {
window[callbackFunctionName] = (enforcement) => {
delete window[callbackFunctionName];
resolve(enforcement);
};
const tag = document.createElement('script');
[
['type', 'text/javascript'],
['src', `https://client-api.arkoselabs.com/v2/${publicKey}/api.js`],
['data-callback', callbackFunctionName],
].forEach(([attr, value]) => {
tag.setAttribute(attr, value);
});
document.head.appendChild(tag);
});
};
import '~/pages/sessions/new/index';
if (gon.features.arkoseLabsLoginChallenge) {
import('ee/arkose_labs/arkose_labs')
.then(({ ArkoseLabs }) => {
// eslint-disable-next-line no-new
new ArkoseLabs();
import('ee/arkose_labs')
.then(({ setupArkoseLabs }) => {
setupArkoseLabs();
})
.catch(() => {});
.catch((e) => {
throw e;
});
}
.js-arkose-labs-challenge.gl-display-flex.gl-justify-content-center.gl-mt-3.gl-mb-n3{ class: "gl-display-none!", data: { api_key: @arkose_labs_public_key, testid: 'arkose-labs-challenge' } }
.js-arkose-labs-challenge{ data: { api_key: @arkose_labs_public_key } }
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment