Commit 507291e0 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab-ce master

parents 01b64cb4 28f2225b
......@@ -12,6 +12,7 @@ import 'core-js/es/promise/finally';
import 'core-js/es/string/code-point-at';
import 'core-js/es/string/from-code-point';
import 'core-js/es/string/includes';
import 'core-js/es/string/repeat';
import 'core-js/es/string/starts-with';
import 'core-js/es/string/ends-with';
import 'core-js/es/symbol';
......
......@@ -3,9 +3,16 @@ import autosize from 'autosize';
import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete';
import dropzoneInput from './dropzone_input';
import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
import IndentHelper from './helpers/indent_helper';
import { keystroke } from './lib/utils/common_utils';
import * as keys from './lib/utils/keycodes';
import UndoStack from './lib/utils/undo_stack';
export default class GLForm {
constructor(form, enableGFM = {}) {
this.handleKeyShortcuts = this.handleKeyShortcuts.bind(this);
this.setState = this.setState.bind(this);
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM);
......@@ -16,6 +23,10 @@ export default class GLForm {
this.enableGFM[item] = Boolean(dataSources[item]);
}
});
this.undoStack = new UndoStack();
this.indentHelper = new IndentHelper(this.textarea[0]);
// Before we start, we should clean up any previous data for this form
this.destroy();
// Set up the form
......@@ -85,9 +96,84 @@ export default class GLForm {
clearEventListeners() {
this.textarea.off('focus');
this.textarea.off('blur');
this.textarea.off('keydown');
removeMarkdownListeners(this.form);
}
setState(state) {
const selection = [this.textarea[0].selectionStart, this.textarea[0].selectionEnd];
this.textarea.val(state);
this.textarea[0].setSelectionRange(selection[0], selection[1]);
}
/*
Handle keypresses for a custom undo/redo stack.
We need this because the toolbar buttons and indentation helpers mess with the browser's
native undo/redo capability.
*/
handleUndo(event) {
const content = this.textarea.val();
const { selectionStart, selectionEnd } = this.textarea[0];
const stack = this.undoStack;
if (stack.isEmpty()) {
// ==== Save initial state in undo history ====
stack.save(content);
}
if (keystroke(event, keys.Z_KEY_CODE, 'l')) {
// ==== Undo ====
event.preventDefault();
stack.save(content);
if (stack.canUndo()) {
this.setState(stack.undo());
}
} else if (keystroke(event, keys.Z_KEY_CODE, 'ls') || keystroke(event, keys.Y_KEY_CODE, 'l')) {
// ==== Redo ====
event.preventDefault();
if (stack.canRedo()) {
this.setState(stack.redo());
}
} else if (
keystroke(event, keys.SPACE_KEY_CODE) ||
keystroke(event, keys.ENTER_KEY_CODE) ||
selectionStart !== selectionEnd
) {
// ==== Save after finishing a word or before deleting a large selection ====
stack.save(content);
} else if (content === '') {
// ==== Save after deleting everything ====
stack.save('');
} else {
// ==== Save after 1 second of inactivity ====
stack.scheduleSave(content);
}
}
handleIndent(event) {
if (keystroke(event, keys.LEFT_BRACKET_KEY_CODE, 'l')) {
// ==== Unindent selected lines ====
event.preventDefault();
this.indentHelper.unindent();
} else if (keystroke(event, keys.RIGHT_BRACKET_KEY_CODE, 'l')) {
// ==== Indent selected lines ====
event.preventDefault();
this.indentHelper.indent();
} else if (keystroke(event, keys.ENTER_KEY_CODE)) {
// ==== Auto-indent new lines ====
event.preventDefault();
this.indentHelper.newline();
} else if (keystroke(event, keys.BACKSPACE_KEY_CODE)) {
// ==== Auto-delete indents at the beginning of the line ====
this.indentHelper.backspace(event);
}
}
handleKeyShortcuts(event) {
this.handleIndent(event);
this.handleUndo(event);
}
addEventListeners() {
this.textarea.on('focus', function focusTextArea() {
$(this)
......@@ -99,5 +185,6 @@ export default class GLForm {
.closest('.md-area')
.removeClass('is-focused');
});
this.textarea.on('keydown', e => this.handleKeyShortcuts(e.originalEvent));
}
}
const INDENT_SEQUENCE = ' ';
function countLeftSpaces(text) {
const i = text.split('').findIndex(c => c !== ' ');
return i === -1 ? text.length : i;
}
/**
* IndentHelper provides methods that allow manual and smart indentation in
* textareas. It supports line indent/unindent, selection indent/unindent,
* auto indentation on newlines, and smart deletion of indents with backspace.
*/
export default class IndentHelper {
/**
* Creates a new IndentHelper and binds it to the given `textarea`. You can provide a custom indent sequence in the second parameter, but the `newline` and `backspace` operations may work funny if the indent sequence isn't spaces only.
*/
constructor(textarea, indentSequence = INDENT_SEQUENCE) {
this.element = textarea;
this.seq = indentSequence;
}
getSelection() {
return { start: this.element.selectionStart, end: this.element.selectionEnd };
}
isRangeSelection() {
return this.element.selectionStart !== this.element.selectionEnd;
}
/**
* Re-implementation of textarea's setRangeText method, because IE/Edge don't support it.
*
* @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-textarea%2Finput-setrangetext
*/
setRangeText(replacement, start, end, selectMode) {
// Disable eslint to remain as faithful as possible to the above linked spec
/* eslint-disable no-param-reassign, no-case-declarations */
const text = this.element.value;
if (start > end) {
throw new RangeError('setRangeText: start index must be less than or equal to end index');
}
// Clamp to [0, len]
start = Math.max(0, Math.min(start, text.length));
end = Math.max(0, Math.min(end, text.length));
let selection = { start: this.element.selectionStart, end: this.element.selectionEnd };
this.element.value = text.slice(0, start) + replacement + text.slice(end);
const newLength = replacement.length;
const newEnd = start + newLength;
switch (selectMode) {
case 'select':
selection = { start, newEnd };
break;
case 'start':
selection = { start, end: start };
break;
case 'end':
selection = { start: newEnd, end: newEnd };
break;
case 'preserve':
default:
const oldLength = end - start;
const delta = newLength - oldLength;
if (selection.start > end) {
selection.start += delta;
} else if (selection.start > start) {
selection.start = start;
}
if (selection.end > end) {
selection.end += delta;
} else if (selection.end > start) {
selection.end = newEnd;
}
}
this.element.setSelectionRange(selection.start, selection.end);
/* eslint-enable no-param-reassign, no-case-declarations */
}
/**
* Returns an array of lines in the textarea, with information about their
* start/end offsets and whether they are included in the current selection.
*/
splitLines() {
const { start, end } = this.getSelection();
const lines = this.element.value.split('\n');
let textStart = 0;
const lineObjects = [];
lines.forEach(line => {
const lineObj = {
text: line,
start: textStart,
end: textStart + line.length,
};
lineObj.inSelection = lineObj.start <= end && lineObj.end >= start;
lineObjects.push(lineObj);
textStart += line.length + 1;
});
return lineObjects;
}
/**
* Indents selected lines by one level.
*/
indent() {
const { start } = this.getSelection();
const selectedLines = this.splitLines().filter(line => line.inSelection);
if (!this.isRangeSelection() && start === selectedLines[0].start) {
// Special case: if cursor is at the beginning of the line, move it one
// indent right.
const line = selectedLines[0];
this.setRangeText(this.seq, line.start, line.start, 'end');
} else {
selectedLines.reverse();
selectedLines.forEach(line => {
this.setRangeText(INDENT_SEQUENCE, line.start, line.start, 'preserve');
});
}
}
/**
* Unindents selected lines by one level.
*/
unindent() {
const lines = this.splitLines().filter(line => line.inSelection);
lines.reverse();
lines
.filter(line => line.text.startsWith(this.seq))
.forEach(line => {
this.setRangeText('', line.start, line.start + this.seq.length, 'preserve');
});
}
/**
* Emulates a newline keypress, automatically indenting the new line.
*/
newline() {
const { start, end } = this.getSelection();
if (this.isRangeSelection()) {
// Manually kill the selection before calculating the indent
this.setRangeText('', start, end, 'start');
}
// Auto-indent the next line
const currentLine = this.splitLines().find(line => line.end >= start);
const spaces = countLeftSpaces(currentLine.text);
this.setRangeText(`\n${' '.repeat(spaces)}`, start, start, 'end');
}
/**
* If the cursor is positioned at the end of a line's leading indents,
* emulates a backspace keypress by deleting a single level of indents.
* @param event The DOM KeyboardEvent that triggers this action, or null.
*/
backspace(event) {
const { start } = this.getSelection();
// If the cursor is at the end of leading indents, delete an indent.
if (!this.isRangeSelection()) {
const currentLine = this.splitLines().find(line => line.end >= start);
const cursorPosition = start - currentLine.start;
if (countLeftSpaces(currentLine.text) === cursorPosition && cursorPosition > 0) {
if (event) event.preventDefault();
let spacesToDelete = cursorPosition % this.seq.length;
if (spacesToDelete === 0) {
spacesToDelete = this.seq.length;
}
this.setRangeText('', start - spacesToDelete, start, 'start');
}
}
}
}
......@@ -203,6 +203,71 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const getPlatformLeaderKey = () => {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
if (navigator && navigator.platform && navigator.platform.startsWith('Mac')) {
return 'meta';
}
return 'ctrl';
};
export const getPlatformLeaderKeyHTML = () => {
if (getPlatformLeaderKey() === 'meta') {
return '&#8984;';
}
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return 'Ctrl';
};
export const isPlatformLeaderKey = e => {
if (getPlatformLeaderKey() === 'meta') {
return Boolean(e.metaKey);
}
return Boolean(e.ctrlKey);
};
/**
* Tests if a KeyboardEvent corresponds exactly to a keystroke.
*
* This function avoids hacking around an old version of Mousetrap, which we ship at the moment. It should be removed after we upgrade to the newest Mousetrap. See:
* - https://gitlab.com/gitlab-org/gitlab-ce/issues/63182
* - https://gitlab.com/gitlab-org/gitlab-ce/issues/64246
*
* @example
* // Matches the enter key with exactly zero modifiers
* keystroke(event, 13)
*
* @example
* // Matches Control-Shift-Z
* keystroke(event, 90, 'cs')
*
* @param e The KeyboardEvent to test.
* @param keyCode The key code of the key to test. Why keycodes? IE/Edge don't support the more convenient `key` and `code` properties.
* @param modifiers A string of modifiers keys. Each modifier key is represented by one character. The set of pressed modifier keys must match the given string exactly. Available options are 'a' for Alt/Option, 'c' for Control, 'm' for Meta/Command, 's' for Shift, and 'l' for the leader key (Meta on MacOS and Control otherwise).
* @returns {boolean} True if the KeyboardEvent corresponds to the given keystroke.
*/
export const keystroke = (e, keyCode, modifiers = '') => {
if (!e || !keyCode) {
return false;
}
const leader = getPlatformLeaderKey();
const mods = modifiers.toLowerCase().replace('l', leader.charAt(0));
// Match depressed modifier keys
if (
e.altKey !== mods.includes('a') ||
e.ctrlKey !== mods.includes('c') ||
e.metaKey !== mods.includes('m') ||
e.shiftKey !== mods.includes('s')
) {
return false;
}
// Match the depressed key
return keyCode === (e.keyCode || e.which);
};
export const contentTop = () => {
const perfBar = $('#js-peek').outerHeight() || 0;
const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0;
......
export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const BACKSPACE_KEY_CODE = 8;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
export const SPACE_KEY_CODE = 32;
export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const Y_KEY_CODE = 89;
export const Z_KEY_CODE = 90;
export const LEFT_BRACKET_KEY_CODE = 219;
export const RIGHT_BRACKET_KEY_CODE = 221;
/**
* UndoStack provides a custom implementation of an undo/redo engine. It was originally written for GitLab's Markdown editor (`gl_form.js`), whose rich text editing capabilities broke native browser undo/redo behaviour.
*
* UndoStack supports predictable undos/redos, debounced saves, maximum history length, and duplicate detection.
*
* Usage:
* - `stack = new UndoStack();`
* - Saves a state to the stack with `stack.save(state)`.
* - Get the current state with `stack.current()`.
* - Revert to the previous state with `stack.undo()`.
* - Redo a previous undo with `stack.redo()`;
* - Queue a future save with `stack.scheduleSave(state, delay)`. Useful for text editors.
* - See the full undo history in `stack.history`.
*/
export default class UndoStack {
constructor(maxLength = 1000) {
this.clear();
this.maxLength = maxLength;
// If you're storing reference-types in the undo stack, you might want to
// reassign this property to some deep-equals function.
this.comparator = (a, b) => a === b;
}
current() {
if (this.cursor === -1) {
return undefined;
}
return this.history[this.cursor];
}
isEmpty() {
return this.history.length === 0;
}
clear() {
this.clearPending();
this.history = [];
this.cursor = -1;
}
save(state) {
this.clearPending();
if (this.comparator(state, this.current())) {
// Don't save state if it's the same as the current state
return;
}
this.history.length = this.cursor + 1;
this.history.push(state);
this.cursor += 1;
if (this.history.length > this.maxLength) {
this.history.shift();
this.cursor -= 1;
}
}
scheduleSave(state, delay = 1000) {
this.clearPending();
this.pendingState = state;
this.timeout = setTimeout(this.saveNow.bind(this), delay);
}
saveNow() {
// Persists scheduled saves immediately
this.save(this.pendingState);
this.clearPending();
}
clearPending() {
// Cancels any scheduled saves
if (this.timeout) {
clearTimeout(this.timeout);
delete this.timeout;
delete this.pendingState;
}
}
canUndo() {
return this.cursor > 0;
}
undo() {
this.clearPending();
if (!this.canUndo()) {
return undefined;
}
this.cursor -= 1;
return this.history[this.cursor];
}
canRedo() {
return this.cursor >= 0 && this.cursor < this.history.length - 1;
}
redo() {
this.clearPending();
if (!this.canRedo()) {
return undefined;
}
this.cursor += 1;
return this.history[this.cursor];
}
}
<script>
import { GlLink } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
components: {
......@@ -22,8 +23,28 @@ export default {
},
},
computed: {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
toolbarHelpHtml() {
const mdLinkStart = `<a href="${this.markdownDocsPath}" target="_blank" rel="noopener noreferrer" tabindex="-1">`;
const actionsLinkStart = `<a href="${this.quickActionsDocsPath}" target="_blank" rel="noopener noreferrer" tabindex="-1">`;
const linkEnd = '</a>';
if (this.markdownDocsPath && !this.quickActionsDocsPath) {
return sprintf(
s__('Editor|%{mdLinkStart}Markdown is supported%{mdLinkEnd}'),
{ mdLinkStart, mdLinkEnd: linkEnd },
false,
);
} else if (this.markdownDocsPath && this.quickActionsDocsPath) {
return sprintf(
s__(
'Editor|%{mdLinkStart}Markdown%{mdLinkEnd} and %{actionsLinkStart}quick actions%{actionsLinkEnd} are supported',
),
{ mdLinkStart, mdLinkEnd: linkEnd, actionsLinkStart, actionsLinkEnd: linkEnd },
false,
);
}
return null;
},
},
};
......@@ -32,21 +53,7 @@ export default {
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
<gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
__('Markdown is supported')
}}</gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
<gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
__('Markdown')
}}</gl-link>
and
<gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">{{
__('quick actions')
}}</gl-link>
are supported
</template>
<span v-html="toolbarHelpHtml"></span>
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
......
......@@ -55,7 +55,7 @@ module AuthenticatesWithTwoFactor
remember_me(user) if user_params[:remember_me] == '1'
user.save!
sign_in(user, message: :two_factor_authenticated)
sign_in(user, message: :two_factor_authenticated, event: :authentication)
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
......@@ -72,7 +72,7 @@ module AuthenticatesWithTwoFactor
session.delete(:challenge)
remember_me(user) if user_params[:remember_me] == '1'
sign_in(user, message: :two_factor_authenticated)
sign_in(user, message: :two_factor_authenticated, event: :authentication)
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
......
......@@ -139,7 +139,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if user.two_factor_enabled? && !auth_user.bypass_two_factor?
prompt_for_two_factor(user)
else
sign_in_and_redirect(user)
sign_in_and_redirect(user, event: :authentication)
end
else
fail_login(user)
......
......@@ -26,6 +26,17 @@ class SessionsController < Devise::SessionsController
after_action :log_failed_login, if: -> { action_name == 'new' && failed_login? }
helper_method :captcha_enabled?
# protect_from_forgery is already prepended in ApplicationController but
# authenticate_with_two_factor which signs in the user is prepended before
# that here.
# We need to make sure CSRF token is verified before authenticating the user
# because Devise.clean_up_csrf_token_on_authentication is set to true by
# default to avoid CSRF token fixation attacks. Authenticating the user first
# would cause the CSRF token to be cleared and then
# RequestForgeryProtection#verify_authenticity_token would fail because of
# token mismatch.
protect_from_forgery with: :exception, prepend: true
CAPTCHA_HEADER = 'X-GitLab-Show-Login-Captcha'.freeze
def new
......
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
.comment-toolbar.clearfix
.toolbar-text
= link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank', tabindex: -1
- md_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" tabindex="-1">'.html_safe % { url: help_page_path('user/markdown') }
- actions_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" tabindex="-1">'.html_safe % { url: help_page_path('user/project/quick_actions') }
- link_end = '</a>'.html_safe
- if supports_quick_actions
and
= link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
are
= s_('Editor|%{mdLinkStart}Markdown%{mdLinkEnd} and %{actionsLinkStart}quick actions%{actionsLinkEnd} are supported').html_safe % { mdLinkStart: md_link_start, mdLinkEnd: link_end, actionsLinkStart: actions_link_start, actionsLinkEnd: link_end }
- else
is
supported
= s_('Editor|%{mdLinkStart}Markdown is supported%{mdLinkEnd}').html_safe % { mdLinkStart: md_link_start, mdLinkEnd: link_end }
%span.uploading-container
%span.uploading-progress-container.hide
......
---
title: Ensure Warden triggers after_authentication callback
merge_request: 31138
author:
type: fixed
---
title: Markdown editors now have indentation shortcuts and auto-indentation
merge_request: 28914
author:
type: added
......@@ -37,14 +37,17 @@ module Gitlab
def user_authenticated!
self.class.user_authenticated_counter_increment!
case @opts[:message]
when :two_factor_authenticated
self.class.user_two_factor_authenticated_counter_increment!
end
end
def user_session_override!
self.class.user_session_override_counter_increment!
case @opts[:message]
when :two_factor_authenticated
self.class.user_two_factor_authenticated_counter_increment!
when :sessionless_sign_in
self.class.user_sessionless_authentication_counter_increment!
end
......
......@@ -17575,9 +17575,6 @@ msgstr[1] ""
msgid "project avatar"
msgstr ""
msgid "quick actions"
msgstr ""
msgid "register"
msgstr ""
......
......@@ -34,6 +34,7 @@ describe 'OAuth Login', :js, :allow_forgery_protection do
before do
stub_omniauth_config(provider)
expect(ActiveSession).to receive(:cleanup).with(user).at_least(:once).and_call_original
end
context 'when two-factor authentication is disabled' do
......
......@@ -132,9 +132,15 @@ describe "User creates wiki page" do
fill_in(:wiki_content, with: ascii_content)
page.within(".wiki-form") do
click_button("Create page")
end
# This is the dumbest bug in the world:
# When the #wiki_content textarea is filled in, JS captures the `Enter` keydown event in order to do
# auto-indentation and manually inserts a newline. However, for whatever reason, when you try to click on the
# submit button in Capybara, it will not trigger the `click` event if a \n or \r character has been manually
# added to the textarea. It will, however, trigger ALL OTHER EVENTS, including `mouseover`/down/up, focus, and
# blur. Just not `click`. But only when you manually insert \n or \r - if you manually insert any other sequence
# then `click` is fired normally. And it's only Capybara. Browsers and JSDOM don't have this issue.
# So that's why the next line performs the click via JS.
page.execute_script("document.querySelector('.qa-create-page-button').click()")
page.within ".md" do
expect(page).to have_selector(".katex", count: 3).and have_content("2+2 is 4")
......
......@@ -132,7 +132,6 @@ describe 'Login' do
it 'does not show a "You are already signed in." error message' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
.and increment(:user_two_factor_authenticated_counter)
enter_code(user.current_otp)
......@@ -144,7 +143,6 @@ describe 'Login' do
it 'allows login with valid code' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
.and increment(:user_two_factor_authenticated_counter)
enter_code(user.current_otp)
......@@ -170,7 +168,6 @@ describe 'Login' do
it 'allows login with invalid code, then valid code' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
.and increment(:user_two_factor_authenticated_counter)
enter_code('foo')
......@@ -179,6 +176,15 @@ describe 'Login' do
enter_code(user.current_otp)
expect(current_path).to eq root_path
end
it 'triggers ActiveSession.cleanup for the user' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_two_factor_authenticated_counter)
expect(ActiveSession).to receive(:cleanup).with(user).once.and_call_original
enter_code(user.current_otp)
end
end
context 'using backup code' do
......@@ -195,7 +201,6 @@ describe 'Login' do
it 'allows login' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
.and increment(:user_two_factor_authenticated_counter)
enter_code(codes.sample)
......@@ -206,7 +211,6 @@ describe 'Login' do
it 'invalidates the used code' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
.and increment(:user_two_factor_authenticated_counter)
expect { enter_code(codes.sample) }
......@@ -216,7 +220,6 @@ describe 'Login' do
it 'invalidates backup codes twice in a row' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter).twice
.and increment(:user_session_override_counter).twice
.and increment(:user_two_factor_authenticated_counter).twice
.and increment(:user_session_destroyed_counter)
......@@ -230,6 +233,15 @@ describe 'Login' do
expect { enter_code(codes.sample) }
.to change { user.reload.otp_backup_codes.size }.by(-1)
end
it 'triggers ActiveSession.cleanup for the user' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_two_factor_authenticated_counter)
expect(ActiveSession).to receive(:cleanup).with(user).once.and_call_original
enter_code(codes.sample)
end
end
context 'with invalid code' do
......@@ -274,7 +286,7 @@ describe 'Login' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
expect(ActiveSession).to receive(:cleanup).with(user).once.and_call_original
sign_in_using_saml!
......@@ -287,8 +299,8 @@ describe 'Login' do
it 'shows 2FA prompt after OAuth login' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
.and increment(:user_two_factor_authenticated_counter)
expect(ActiveSession).to receive(:cleanup).with(user).once.and_call_original
sign_in_using_saml!
......@@ -329,6 +341,14 @@ describe 'Login' do
expect(page).not_to have_content(I18n.t('devise.failure.already_authenticated'))
end
it 'triggers ActiveSession.cleanup for the user' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
expect(ActiveSession).to receive(:cleanup).with(user).once.and_call_original
gitlab_sign_in(user)
end
end
context 'with invalid username and password' do
......@@ -649,7 +669,6 @@ describe 'Login' do
it 'asks the user to accept the terms' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
.and increment(:user_two_factor_authenticated_counter)
visit new_user_session_path
......@@ -708,7 +727,6 @@ describe 'Login' do
it 'asks the user to accept the terms before setting an email' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
.and increment(:user_session_override_counter)
gitlab_sign_in_via('saml', user, 'my-uid')
......
This diff is collapsed.
import * as cu from '~/lib/utils/common_utils';
const CMD_ENTITY = '&#8984;';
// Redefine `navigator.platform` because it's unsettable by default in JSDOM.
let platform;
Object.defineProperty(navigator, 'platform', {
configurable: true,
get: () => platform,
set: val => {
platform = val;
},
});
describe('common_utils', () => {
describe('platform leader key helpers', () => {
const CTRL_EVENT = { ctrlKey: true };
const META_EVENT = { metaKey: true };
const BOTH_EVENT = { ctrlKey: true, metaKey: true };
it('should return "ctrl" if navigator.platform is unset', () => {
expect(cu.getPlatformLeaderKey()).toBe('ctrl');
expect(cu.getPlatformLeaderKeyHTML()).toBe('Ctrl');
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
it('should return "meta" on MacOS', () => {
navigator.platform = 'MacIntel';
expect(cu.getPlatformLeaderKey()).toBe('meta');
expect(cu.getPlatformLeaderKeyHTML()).toBe(CMD_ENTITY);
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
it('should return "ctrl" on Linux', () => {
navigator.platform = 'Linux is great';
expect(cu.getPlatformLeaderKey()).toBe('ctrl');
expect(cu.getPlatformLeaderKeyHTML()).toBe('Ctrl');
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
it('should return "ctrl" on Windows', () => {
navigator.platform = 'Win32';
expect(cu.getPlatformLeaderKey()).toBe('ctrl');
expect(cu.getPlatformLeaderKeyHTML()).toBe('Ctrl');
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
});
describe('keystroke', () => {
const CODE_BACKSPACE = 8;
const CODE_TAB = 9;
const CODE_ENTER = 13;
const CODE_SPACE = 32;
const CODE_4 = 52;
const CODE_F = 70;
const CODE_Z = 90;
// Helper function that quickly creates KeyboardEvents
const k = (code, modifiers = '') => ({
keyCode: code,
which: code,
altKey: modifiers.includes('a'),
ctrlKey: modifiers.includes('c'),
metaKey: modifiers.includes('m'),
shiftKey: modifiers.includes('s'),
});
const EV_F = k(CODE_F);
const EV_ALT_F = k(CODE_F, 'a');
const EV_CONTROL_F = k(CODE_F, 'c');
const EV_META_F = k(CODE_F, 'm');
const EV_SHIFT_F = k(CODE_F, 's');
const EV_CONTROL_SHIFT_F = k(CODE_F, 'cs');
const EV_ALL_F = k(CODE_F, 'scma');
const EV_ENTER = k(CODE_ENTER);
const EV_TAB = k(CODE_TAB);
const EV_SPACE = k(CODE_SPACE);
const EV_BACKSPACE = k(CODE_BACKSPACE);
const EV_4 = k(CODE_4);
const EV_$ = k(CODE_4, 's');
const { keystroke } = cu;
it('short-circuits with bad arguments', () => {
expect(keystroke()).toBe(false);
expect(keystroke({})).toBe(false);
});
it('handles keystrokes using key codes', () => {
// Test a letter key with modifiers
expect(keystroke(EV_F, CODE_F)).toBe(true);
expect(keystroke(EV_F, CODE_F, '')).toBe(true);
expect(keystroke(EV_ALT_F, CODE_F, 'a')).toBe(true);
expect(keystroke(EV_CONTROL_F, CODE_F, 'c')).toBe(true);
expect(keystroke(EV_META_F, CODE_F, 'm')).toBe(true);
expect(keystroke(EV_SHIFT_F, CODE_F, 's')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'cs')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F, 'acms')).toBe(true);
// Test non-letter keys
expect(keystroke(EV_TAB, CODE_TAB)).toBe(true);
expect(keystroke(EV_ENTER, CODE_ENTER)).toBe(true);
expect(keystroke(EV_SPACE, CODE_SPACE)).toBe(true);
expect(keystroke(EV_BACKSPACE, CODE_BACKSPACE)).toBe(true);
// Test a number/symbol key
expect(keystroke(EV_4, CODE_4)).toBe(true);
expect(keystroke(EV_$, CODE_4, 's')).toBe(true);
// Test wrong input
expect(keystroke(EV_F, CODE_Z)).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F)).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F, 'c')).toBe(false);
});
it('is case-insensitive', () => {
expect(keystroke(EV_ALL_F, CODE_F, 'ACMS')).toBe(true);
});
it('handles bogus inputs', () => {
expect(keystroke(EV_F, 'not a keystroke')).toBe(false);
expect(keystroke(EV_F, null)).toBe(false);
});
it('handles exact modifier keys, in any order', () => {
// Test permutations of modifiers
expect(keystroke(EV_ALL_F, CODE_F, 'acms')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F, 'smca')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F, 'csma')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'cs')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'sc')).toBe(true);
// Test wrong modifiers
expect(keystroke(EV_ALL_F, CODE_F, 'smca')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F)).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, '')).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, 'c')).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, 'ca')).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, 'ms')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'cs')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'c')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 's')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'csa')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'm')).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F, 's')).toBe(true);
expect(keystroke(EV_SHIFT_F, CODE_F, 'c')).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F, 'csm')).toBe(false);
});
it('handles the platform-dependent leader key', () => {
navigator.platform = 'Win32';
let EV_UNDO = k(CODE_Z, 'c');
let EV_REDO = k(CODE_Z, 'cs');
expect(keystroke(EV_UNDO, CODE_Z, 'l')).toBe(true);
expect(keystroke(EV_UNDO, CODE_Z, 'c')).toBe(true);
expect(keystroke(EV_UNDO, CODE_Z, 'm')).toBe(false);
expect(keystroke(EV_REDO, CODE_Z, 'sl')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sc')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sm')).toBe(false);
navigator.platform = 'MacIntel';
EV_UNDO = k(CODE_Z, 'm');
EV_REDO = k(CODE_Z, 'ms');
expect(keystroke(EV_UNDO, CODE_Z, 'l')).toBe(true);
expect(keystroke(EV_UNDO, CODE_Z, 'c')).toBe(false);
expect(keystroke(EV_UNDO, CODE_Z, 'm')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sl')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sc')).toBe(false);
expect(keystroke(EV_REDO, CODE_Z, 'sm')).toBe(true);
});
});
});
import UndoStack from '~/lib/utils/undo_stack';
import { isEqual } from 'underscore';
describe('UndoStack', () => {
let stack;
beforeEach(() => {
stack = new UndoStack();
});
afterEach(() => {
// Make sure there's not pending saves
const history = Array.from(stack.history);
jest.runAllTimers();
expect(stack.history).toEqual(history);
});
it('is blank on construction', () => {
expect(stack.isEmpty()).toBe(true);
expect(stack.history).toEqual([]);
expect(stack.cursor).toBe(-1);
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(false);
});
it('handles simple undo/redo behaviour', () => {
stack.save(10);
stack.save(11);
stack.save(12);
expect(stack.history).toEqual([10, 11, 12]);
expect(stack.cursor).toBe(2);
expect(stack.current()).toBe(12);
expect(stack.isEmpty()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(false);
stack.undo();
expect(stack.history).toEqual([10, 11, 12]);
expect(stack.current()).toBe(11);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(true);
stack.undo();
expect(stack.current()).toBe(10);
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(true);
stack.redo();
expect(stack.current()).toBe(11);
stack.redo();
expect(stack.current()).toBe(12);
expect(stack.isEmpty()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(false);
// Saving should clear the redo stack
stack.undo();
stack.save(13);
expect(stack.history).toEqual([10, 11, 13]);
expect(stack.current()).toBe(13);
});
it('clear() should clear the undo history', () => {
stack.save(0);
stack.save(1);
stack.save(2);
stack.clear();
expect(stack.history).toEqual([]);
expect(stack.current()).toBeUndefined();
});
it('undo and redo are no-ops if unavailable', () => {
stack.save(10);
expect(stack.canRedo()).toBe(false);
expect(stack.canUndo()).toBe(false);
stack.save(11);
expect(stack.canRedo()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.redo()).toBeUndefined();
expect(stack.history).toEqual([10, 11]);
expect(stack.current()).toBe(11);
expect(stack.canRedo()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.undo()).toBe(10);
expect(stack.undo()).toBeUndefined();
expect(stack.history).toEqual([10, 11]);
expect(stack.current()).toBe(10);
expect(stack.canRedo()).toBe(true);
expect(stack.canUndo()).toBe(false);
});
it('should not save a duplicate state', () => {
stack.save(10);
stack.save(11);
stack.save(11);
stack.save(10);
stack.save(10);
expect(stack.history).toEqual([10, 11, 10]);
});
it('uses the === operator to detect duplicates', () => {
stack.save(10);
stack.save(10);
expect(stack.history).toEqual([10]);
// eslint-disable-next-line eqeqeq
expect(2 == '2' && '2' == 2).toBe(true);
stack.clear();
stack.save(2);
stack.save(2);
stack.save('2');
stack.save('2');
stack.save(2);
expect(stack.history).toEqual([2, '2', 2]);
const obj = {};
stack.clear();
stack.save(obj);
stack.save(obj);
stack.save({});
stack.save({});
expect(stack.history).toEqual([{}, {}, {}]);
});
it('should allow custom comparators', () => {
stack.comparator = isEqual;
const obj = {};
stack.clear();
stack.save(obj);
stack.save(obj);
stack.save({});
stack.save({});
expect(stack.history).toEqual([{}]);
});
it('should enforce a max number of undo states', () => {
// Try 2000 saves. Only the last 1000 should be preserved.
const sequence = Array(2000)
.fill(0)
.map((el, i) => i);
sequence.forEach(stack.save.bind(stack));
expect(stack.history.length).toBe(1000);
expect(stack.history).toEqual(sequence.slice(1000));
expect(stack.current()).toBe(1999);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(false);
// Saving drops the oldest elements from the stack
stack.save('end');
expect(stack.history.length).toBe(1000);
expect(stack.current()).toBe('end');
expect(stack.history).toEqual([...sequence.slice(1001), 'end']);
// If states were undone but the history is full, can still add.
stack.undo();
stack.undo();
expect(stack.current()).toBe(1998);
stack.save(3000);
expect(stack.history.length).toBe(999);
// should be [1001, 1002, ..., 1998, 3000]
expect(stack.history).toEqual([...sequence.slice(1001, 1999), 3000]);
// Try a different max length
stack = new UndoStack(2);
stack.save(0);
expect(stack.history).toEqual([0]);
stack.save(1);
expect(stack.history).toEqual([0, 1]);
stack.save(2);
expect(stack.history).toEqual([1, 2]);
});
describe('scheduled saves', () => {
it('should work', () => {
// Schedules 1000 ms ahead by default
stack.save(0);
stack.scheduleSave(1);
expect(stack.history).toEqual([0]);
jest.advanceTimersByTime(999);
expect(stack.history).toEqual([0]);
jest.advanceTimersByTime(1);
expect(stack.history).toEqual([0, 1]);
});
it('should have an adjustable delay', () => {
stack.scheduleSave(2, 100);
jest.advanceTimersByTime(100);
expect(stack.history).toEqual([2]);
});
it('should cancel previous scheduled saves', () => {
stack.scheduleSave(3);
jest.advanceTimersByTime(100);
stack.scheduleSave(4);
jest.runAllTimers();
expect(stack.history).toEqual([4]);
});
it('should be canceled by explicit saves', () => {
stack.scheduleSave(5);
stack.save(6);
jest.runAllTimers();
expect(stack.history).toEqual([6]);
});
it('should be canceled by undos and redos', () => {
stack.save(1);
stack.save(2);
stack.scheduleSave(3);
stack.undo();
jest.runAllTimers();
expect(stack.history).toEqual([1, 2]);
expect(stack.current()).toBe(1);
stack.scheduleSave(4);
stack.redo();
jest.runAllTimers();
expect(stack.history).toEqual([1, 2]);
expect(stack.current()).toBe(2);
});
it('should be persisted immediately with saveNow()', () => {
stack.scheduleSave(7);
stack.scheduleSave(8);
stack.saveNow();
jest.runAllTimers();
expect(stack.history).toEqual([8]);
});
});
});
......@@ -8340,7 +8340,7 @@ monaco-editor@^0.15.6:
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.15.6.tgz#d63b3b06f86f803464f003b252627c3eb4a09483"
integrity sha512-JoU9V9k6KqT9R9Tiw1RTU8ohZ+Xnf9DMg6Ktqqw5hILumwmq7xqa/KLXw513uTUsWbhtnHoSJYYR++u3pkyxJg==
mousetrap@^1.4.6:
mousetrap@1.4.6:
version "1.4.6"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.4.6.tgz#eaca72e22e56d5b769b7555873b688c3332e390a"
integrity sha1-6spy4i5W1bdpt1VYc7aIwzMuOQo=
......
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