Trigger ArkoseLabs username check on submit

This tweaks the ArkoseLabs sign-in flow to trigger a username check when
the form is submitted. This ensures that the challenge cannot be skipped
by submitting the form without the username field ever losing the focus.
If the check determines that the challenge still isn't needed, the
form's submission proceeds normally.
parent d853a87a
......@@ -51,6 +51,7 @@ export default {
username: '',
isLoading: false,
arkoseInitialized: false,
submitOnSuppress: false,
arkoseToken: '',
arkoseContainerClass: uniqueId(ARKOSE_CONTAINER_CLASS),
arkoseChallengePassed: false,
......@@ -65,15 +66,12 @@ export default {
},
},
watch: {
username() {
this.checkIfNeedsChallenge();
},
isLoading(val) {
this.updateSubmitButtonLoading(val);
},
},
mounted() {
this.username = this.getUsernameValue();
this.checkIfNeedsChallenge();
},
methods: {
onArkoseLabsIframeShown() {
......@@ -86,29 +84,47 @@ export default {
getUsernameValue() {
return document.querySelector(this.usernameSelector)?.value || '';
},
onUsernameBlur() {
this.username = this.getUsernameValue();
},
onSubmit(e) {
if (!this.arkoseInitialized || this.arkoseChallengePassed) {
if (this.arkoseChallengePassed) {
// If the challenge was solved already, proceed with the form's submission.
return;
}
e.preventDefault();
this.showArkoseNeededError = true;
if (!this.arkoseInitialized) {
// If the challenge hasn't been initialized yet, we trigger a check now to make sure it
// wasn't skipped by submitting the form without the username field ever losing the focus.
this.checkAndSubmit(e.target);
} else {
// Otherwise, we show an error message as the form has been submitted without completing
// the challenge.
this.showArkoseNeededError = true;
}
},
async checkAndSubmit(form) {
this.submitOnSuppress = true;
await this.checkIfNeedsChallenge();
if (!this.arkoseInitialized) {
// If the challenge still hasn't been initialized, the user definitely doesn't need one and
// we can proceed with the form's submission.
form.submit();
}
},
async checkIfNeedsChallenge() {
if (!this.username || this.arkoseInitialized) {
const username = this.getUsernameValue();
if (!username || username === this.username || this.arkoseInitialized) {
return;
}
this.username = username;
this.isLoading = true;
try {
const {
data: { result },
} = await needsArkoseLabsChallenge(this.username);
if (result) {
this.arkoseInitialized = true;
await this.initArkoseLabs();
}
} catch (e) {
......@@ -127,8 +143,6 @@ export default {
}
},
async initArkoseLabs() {
this.arkoseInitialized = true;
const enforcement = await initArkoseLabsScript({ publicKey: this.publicKey });
enforcement.setConfig({
......@@ -136,6 +150,7 @@ export default {
selector: `.${this.arkoseContainerClass}`,
onShown: this.onArkoseLabsIframeShown,
onCompleted: this.passArkoseLabsChallenge,
onSuppress: this.onArkoseLabsSuppress,
onError: this.handleArkoseLabsFailure,
});
},
......@@ -144,6 +159,13 @@ export default {
this.arkoseToken = response.token;
this.hideErrors();
},
onArkoseLabsSuppress() {
if (this.submitOnSuppress) {
// If the challenge was suppressed following the form's submission, we need to proceed with
// the submission.
document.querySelector(this.formSelector).submit();
}
},
handleArkoseLabsFailure(e) {
logError('ArkoseLabs initialization error', e);
this.showArkoseFailure = true;
......@@ -179,7 +201,7 @@ export default {
type="hidden"
:value="arkoseToken"
/>
<dom-element-listener :selector="usernameSelector" @blur="onUsernameBlur" />
<dom-element-listener :selector="usernameSelector" @blur="checkIfNeedsChallenge" />
<dom-element-listener :selector="formSelector" @submit="onSubmit" />
<div
class="gl-display-flex gl-justify-content-center gl-mt-3 gl-mb-n3"
......
......@@ -12,11 +12,18 @@ jest.mock('~/lib/logger');
jest.mock('ee/arkose_labs/init_arkose_labs_script');
let onShown;
let onCompleted;
let onSuppress;
let onError;
initArkoseLabsScript.mockImplementation(() => ({
setConfig: ({ onShown: shownHandler, onCompleted: completedHandler, onError: errorHandler }) => {
setConfig: ({
onShown: shownHandler,
onCompleted: completedHandler,
onSuppress: suppressHandler,
onError: errorHandler,
}) => {
onShown = shownHandler;
onCompleted = completedHandler;
onSuppress = suppressHandler;
onError = errorHandler;
},
}));
......@@ -91,6 +98,7 @@ describe('SignInArkoseApp', () => {
afterEach(() => {
axiosMock.restore();
wrapper?.destroy();
document.getElementsByTagName('html')[0].innerHTML = '';
});
describe('when the username field is pre-filled', () => {
......@@ -134,7 +142,7 @@ describe('SignInArkoseApp', () => {
it('does not show ArkoseLabs error when submitting the form', async () => {
submitForm();
await nextTick();
await waitForPromises();
expect(findArkoseLabsErrorMessage().exists()).toBe(false);
});
......@@ -150,6 +158,49 @@ describe('SignInArkoseApp', () => {
});
});
describe('when the form is submitted without the username field losing the focus', () => {
beforeEach(() => {
initArkoseLabs();
jest.spyOn(findSignInForm(), 'submit');
axiosMock.onGet().reply(200, { result: false });
findUsernameInput().value = `noblur-${MOCK_USERNAME}`;
});
it('triggers a username check', async () => {
expect(axiosMock.history.get).toHaveLength(0);
submitForm();
await waitForPromises();
expect(axiosMock.history.get).toHaveLength(1);
});
it("proceeds with the form's submission if the challenge still isn't needed", async () => {
submitForm();
await waitForPromises();
expect(findSignInForm().submit).toHaveBeenCalled();
});
describe('when the challenge becomes needed', () => {
beforeEach(() => {
axiosMock.onGet().reply(200, { result: true });
submitForm();
return waitForPromises();
});
it("blocks the form's submission if the challenge becomes needed", async () => {
expect(findSignInForm().submit).not.toHaveBeenCalled();
});
it("proceeds with the form's submission if the challenge is being suppressed", async () => {
onSuppress();
expect(findSignInForm().submit).toHaveBeenCalled();
});
});
});
describe('if the challenge is needed', () => {
beforeEach(async () => {
axiosMock.onGet().reply(200, { result: true });
......@@ -189,6 +240,13 @@ describe('SignInArkoseApp', () => {
expectArkoseLabsInitError();
});
it('does not submit the form when the challenge is being suppressed', () => {
jest.spyOn(findSignInForm(), 'submit');
onSuppress();
expect(findSignInForm().submit).not.toHaveBeenCalled();
});
describe('when ArkoseLabs calls `onCompleted` handler that has been configured', () => {
const response = { token: 'verification-token' };
......
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