summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/api.js35
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue30
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue14
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue55
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue16
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue13
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue22
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue98
-rw-r--r--app/assets/javascripts/logs/components/log_control_buttons.vue57
-rw-r--r--app/assets/javascripts/logs/stores/actions.js86
-rw-r--r--app/assets/javascripts/logs/stores/getters.js10
-rw-r--r--app/assets/javascripts/logs/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/logs/stores/mutations.js52
-rw-r--r--app/assets/javascripts/logs/stores/state.js13
-rw-r--r--app/assets/javascripts/logs/utils.js5
-rw-r--r--app/assets/javascripts/pages/admin/sessions/index.js1
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue49
-rw-r--r--app/assets/javascripts/releases/mount_index.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue14
-rw-r--r--app/assets/stylesheets/framework/mixins.scss1
-rw-r--r--app/assets/stylesheets/pages/builds.scss20
-rw-r--r--app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb84
-rw-r--r--app/controllers/admin/sessions_controller.rb18
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb17
-rw-r--r--app/controllers/projects/import/jira_controller.rb60
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_group.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_issuable.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_project.rb2
-rw-r--r--app/helpers/releases_helper.rb4
-rw-r--r--app/models/concerns/bulk_insert_safe.rb8
-rw-r--r--app/models/jira_import_data.rb19
-rw-r--r--app/views/admin/sessions/_new_base.html.haml4
-rw-r--r--app/views/admin/sessions/_tabs_normal.html.haml2
-rw-r--r--app/views/admin/sessions/_two_factor_otp.html.haml9
-rw-r--r--app/views/admin/sessions/_two_factor_u2f.html.haml17
-rw-r--r--app/views/admin/sessions/new.html.haml6
-rw-r--r--app/views/admin/sessions/two_factor.html.haml15
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml11
-rw-r--r--app/views/projects/import/jira/show.html.haml24
-rw-r--r--app/views/projects/issues/import_csv/_button.html.haml2
-rw-r--r--app/views/projects/tags/new.html.haml12
-rw-r--r--app/views/u2f/_authenticate.html.haml1
-rw-r--r--app/workers/all_queues.yml2
-rw-r--r--app/workers/authorized_projects_worker.rb4
45 files changed, 615 insertions, 312 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index c85e5b68f5f..dc6ea148047 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -492,41 +492,6 @@ const Api = {
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
-
- /**
- * Returns pods logs for an environment with an optional pod and container
- *
- * @param {Object} params
- * @param {Object} param.environment - Environment object
- * @param {string=} params.podName - Pod name, if not set the backend assumes a default one
- * @param {string=} params.containerName - Container name, if not set the backend assumes a default one
- * @param {string=} params.start - Starting date to query the logs in ISO format
- * @param {string=} params.end - Ending date to query the logs in ISO format
- * @returns {Promise} Axios promise for the result of a GET request of logs
- */
- getPodLogs({ environment, podName, containerName, search, start, end }) {
- const url = this.buildUrl(environment.logs_api_path);
-
- const params = {};
-
- if (podName) {
- params.pod_name = podName;
- }
- if (containerName) {
- params.container_name = containerName;
- }
- if (search) {
- params.search = search;
- }
- if (start) {
- params.start = start;
- }
- if (end) {
- params.end = end;
- }
-
- return axios.get(url, { params });
- },
};
export default Api;
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index 3398cd091ba..e618fb3daae 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -24,25 +24,19 @@ export default {
discardModalTitle() {
return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path });
},
- actionButtonText() {
- return this.activeFile.staged ? __('Unstage') : __('Stage');
- },
isStaged() {
return !this.activeFile.changed && this.activeFile.staged;
},
},
methods: {
...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']),
- actionButtonClicked() {
- if (this.activeFile.staged) {
- this.unstageChange(this.activeFile.path);
- } else {
- this.stageChange(this.activeFile.path);
- }
- },
showDiscardModal() {
this.$refs.discardModal.show();
},
+ discardChanges(path) {
+ this.unstageChange(path);
+ this.discardFileChanges(path);
+ },
},
};
</script>
@@ -65,19 +59,7 @@ export default {
class="btn btn-remove btn-inverted append-right-8"
@click="showDiscardModal"
>
- {{ __('Discard') }}
- </button>
- <button
- ref="actionButton"
- :class="{
- 'btn-success': !isStaged,
- 'btn-warning': isStaged,
- }"
- type="button"
- class="btn btn-inverted"
- @click="actionButtonClicked"
- >
- {{ actionButtonText }}
+ {{ __('Discard changes') }}
</button>
</div>
<gl-modal
@@ -87,7 +69,7 @@ export default {
:ok-title="__('Discard changes')"
:modal-id="discardModalId"
:title="discardModalTitle"
- @ok="discardFileChanges(activeFile.path)"
+ @ok="discardChanges(activeFile.path)"
>
{{ __("You will lose all changes you've made to this file. This action cannot be undone.") }}
</gl-modal>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 5ec3fc4041b..f6ca728defc 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import { sprintf, __ } from '~/locale';
+import { n__, __ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
@@ -26,15 +26,7 @@ export default {
...mapGetters(['hasChanges']),
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
overviewText() {
- return sprintf(
- __(
- '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes',
- ),
- {
- stagedFilesLength: this.stagedFiles.length,
- changedFilesLength: this.changedFiles.length,
- },
- );
+ return n__('%d changed file', '%d changed files', this.stagedFiles.length);
},
commitButtonText() {
return this.stagedFiles.length ? __('Commit') : __('Stage & Commit');
@@ -125,7 +117,7 @@ export default {
>
{{ __('Commitā€¦') }}
</button>
- <p class="text-center" v-html="overviewText"></p>
+ <p class="text-center bold">{{ overviewText }}</p>
</div>
<form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commitChanges">
<transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index d9a385a9d31..2e273d45506 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -17,10 +17,6 @@ export default {
tooltip,
},
props: {
- title: {
- type: String,
- required: true,
- },
fileList: {
type: Array,
required: true,
@@ -29,18 +25,6 @@ export default {
type: String,
required: true,
},
- action: {
- type: String,
- required: true,
- },
- actionBtnText: {
- type: String,
- required: true,
- },
- actionBtnIcon: {
- type: String,
- required: true,
- },
stagedList: {
type: Boolean,
required: false,
@@ -63,9 +47,9 @@ export default {
},
computed: {
titleText() {
- return sprintf(__('%{title} changes'), {
- title: this.title,
- });
+ if (!this.title) return __('Changes');
+
+ return sprintf(__('%{title} changes'), { title: this.title });
},
filesLength() {
return this.fileList.length;
@@ -73,17 +57,16 @@ export default {
},
methods: {
...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']),
- actionBtnClicked() {
- this[this.action]();
-
- $(this.$refs.actionBtn).tooltip('hide');
- },
openDiscardModal() {
$('#discard-all-changes').modal('show');
},
+ unstageAndDiscardAllChanges() {
+ this.unstageAllChanges();
+ this.discardAllChanges();
+ },
},
discardModalText: __(
- "You will lose all the unstaged changes you've made in this project. This action cannot be undone.",
+ "You will lose all uncommitted changes you've made in this project. This action cannot be undone.",
),
};
</script>
@@ -96,24 +79,6 @@ export default {
<strong> {{ titleText }} </strong>
<div class="d-flex ml-auto">
<button
- ref="actionBtn"
- v-tooltip
- :title="actionBtnText"
- :aria-label="actionBtnText"
- :disabled="!filesLength"
- :class="{
- 'disabled-content': !filesLength,
- }"
- type="button"
- class="d-flex ide-staged-action-btn p-0 border-0 align-items-center"
- data-placement="bottom"
- data-container="body"
- data-boundary="viewport"
- @click="actionBtnClicked"
- >
- <icon :name="actionBtnIcon" :size="16" class="ml-auto mr-auto" />
- </button>
- <button
v-if="!stagedList"
v-tooltip
:title="__('Discard all changes')"
@@ -151,9 +116,9 @@ export default {
v-if="!stagedList"
id="discard-all-changes"
:footer-primary-button-text="__('Discard all changes')"
- :header-title-text="__('Discard all unstaged changes?')"
+ :header-title-text="__('Discard all changes?')"
footer-primary-button-variant="danger"
- @submit="discardAllChanges"
+ @submit="unstageAndDiscardAllChanges"
>
{{ $options.discardModalText }}
</gl-modal>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 726e2b7e1fc..e49d96efe50 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -57,13 +57,7 @@ export default {
},
},
methods: {
- ...mapActions([
- 'discardFileChanges',
- 'updateViewer',
- 'openPendingTab',
- 'unstageChange',
- 'stageChange',
- ]),
+ ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']),
openFileInEditor() {
if (this.file.type === 'tree') return null;
@@ -76,13 +70,6 @@ export default {
}
});
},
- fileAction() {
- if (this.file.staged) {
- this.unstageChange(this.file.path);
- } else {
- this.stageChange(this.file.path);
- }
- },
},
};
</script>
@@ -97,7 +84,6 @@ export default {
}"
class="multi-file-commit-list-path w-100 border-0 ml-0 mr-0"
role="button"
- @dblclick="fileAction"
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 3ef7d863bd5..32822a75772 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
-import { n__, __, sprintf } from '~/locale';
+import { n__ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
@@ -49,16 +49,7 @@ export default {
folderChangesTooltip() {
if (this.changesCount === 0) return undefined;
- if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) {
- return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount);
- } else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) {
- return n__('%d staged change', '%d staged changes', this.folderStagedCount);
- }
-
- return sprintf(__('%{staged} staged and %{unstaged} unstaged changes'), {
- unstaged: this.folderUnstagedCount,
- staged: this.folderStagedCount,
- });
+ return n__('%d changed file', '%d changed files', this.changesCount);
},
showTreeChangesCount() {
return this.isTree && this.changesCount > 0 && !this.file.opened;
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 62fb0b03975..b8dca2709c8 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -86,28 +86,12 @@ export default {
</deprecated-modal>
<template v-if="showStageUnstageArea">
<commit-files-list
- :title="__('Unstaged')"
- :key-prefix="$options.stageKeys.unstaged"
- :file-list="changedFiles"
- :action-btn-text="__('Stage all changes')"
- :active-file-key="activeFileKey"
- :empty-state-text="__('There are no unstaged changes')"
- action="stageAllChanges"
- action-btn-icon="stage-all"
- class="is-first"
- icon-name="unstaged"
- />
- <commit-files-list
- :title="__('Staged')"
:key-prefix="$options.stageKeys.staged"
:file-list="stagedFiles"
- :action-btn-text="__('Unstage all changes')"
- :staged-list="true"
:active-file-key="activeFileKey"
- :empty-state-text="__('There are no staged changes')"
- action="unstageAllChanges"
- action-btn-icon="unstage-all"
- icon-name="staged"
+ :empty-state-text="__('There are no changes')"
+ class="is-first"
+ icon-name="unstaged"
/>
</template>
<empty-state v-if="unusedSeal" />
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index b94cd2bcec4..b0acd69bae0 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -1,23 +1,37 @@
<script>
+import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { GlDropdown, GlDropdownItem, GlFormGroup, GlSearchBoxByClick, GlAlert } from '@gitlab/ui';
+import {
+ GlSprintf,
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormGroup,
+ GlSearchBoxByClick,
+ GlInfiniteScroll,
+} from '@gitlab/ui';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
-import { scrollDown } from '~/lib/utils/scroll_utils';
import LogControlButtons from './log_control_buttons.vue';
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
import { timeRangeFromUrl } from '~/monitoring/utils';
+import { formatDate } from '../utils';
export default {
components: {
+ GlSprintf,
GlAlert,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlSearchBoxByClick,
+ GlInfiniteScroll,
DateTimePicker,
LogControlButtons,
},
+ filters: {
+ formatDate,
+ },
props: {
environmentName: {
type: String,
@@ -39,11 +53,13 @@ export default {
required: true,
},
},
+ traceHeight: 600,
data() {
return {
searchQuery: '',
timeRanges,
isElasticStackCalloutDismissed: false,
+ scrollDownButtonDisabled: true,
};
},
computed: {
@@ -52,7 +68,7 @@ export default {
timeRangeModel: {
get() {
- return this.timeRange.current;
+ return this.timeRange.selected;
},
set(val) {
this.setTimeRange(val);
@@ -60,7 +76,7 @@ export default {
},
showLoader() {
- return this.logs.isLoading || !this.logs.isComplete;
+ return this.logs.isLoading;
},
advancedFeaturesEnabled() {
const environment = this.environments.options.find(
@@ -75,16 +91,6 @@ export default {
return !this.isElasticStackCalloutDismissed && this.disableAdvancedControls;
},
},
- watch: {
- trace(val) {
- this.$nextTick(() => {
- if (val) {
- scrollDown();
- }
- this.$refs.scrollButtons.update();
- });
- },
- },
mounted() {
this.setInitData({
timeRange: timeRangeFromUrl() || defaultTimeRange,
@@ -102,12 +108,26 @@ export default {
'showPodLogs',
'showEnvironment',
'fetchEnvironments',
+ 'fetchMoreLogsPrepend',
]),
+
+ topReached() {
+ if (!this.logs.isLoading) {
+ this.fetchMoreLogsPrepend();
+ }
+ },
+ scrollDown() {
+ this.$refs.infiniteScroll.scrollDown();
+ },
+ scroll: throttle(function scrollThrottled({ target = {} }) {
+ const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
+ this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
+ }, 200),
},
};
</script>
<template>
- <div class="build-page-pod-logs mt-3">
+ <div class="environment-logs-viewer mt-3">
<gl-alert
v-if="shouldShowElasticStackCallout"
class="mb-3 js-elasticsearch-alert"
@@ -209,14 +229,50 @@ export default {
<log-control-buttons
ref="scrollButtons"
class="controllers align-self-end mb-1"
+ :scroll-down-button-disabled="scrollDownButtonDisabled"
@refresh="showPodLogs(pods.current)"
+ @scrollDown="scrollDown"
/>
</div>
- <pre class="build-trace js-log-trace"><code class="bash js-build-output">{{trace}}
- <div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
- <div class="dot"></div>
- <div class="dot"></div>
- <div class="dot"></div>
- </div></code></pre>
+
+ <gl-infinite-scroll
+ ref="infiniteScroll"
+ class="log-lines"
+ :style="{ height: `${$options.traceHeight}px` }"
+ :max-list-height="$options.traceHeight"
+ :fetched-items="logs.lines.length"
+ @topReached="topReached"
+ @scroll="scroll"
+ >
+ <template #items>
+ <pre
+ class="build-trace js-log-trace"
+ ><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
+ <div class="dot"></div>
+ <div class="dot"></div>
+ <div class="dot"></div>
+ </div>{{trace}}
+ </code></pre>
+ </template>
+ <template #default
+ ><div></div
+ ></template>
+ </gl-infinite-scroll>
+
+ <div ref="logFooter" class="log-footer py-2 px-3">
+ <gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
+ <template #start>{{ timeRange.current.start | formatDate }}</template>
+ <template #end>{{ timeRange.current.end | formatDate }}</template>
+ </gl-sprintf>
+ <gl-sprintf
+ v-if="!logs.isComplete"
+ :message="s__('Environments|Currently showing %{fetched} results.')"
+ >
+ <template #fetched>{{ logs.lines.length }}</template>
+ </gl-sprintf>
+ <template v-else>
+ {{ s__('Environments|Currently showing all results.') }}</template
+ >
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue
index d55c2f7cd4c..170d0474447 100644
--- a/app/assets/javascripts/logs/components/log_control_buttons.vue
+++ b/app/assets/javascripts/logs/components/log_control_buttons.vue
@@ -1,12 +1,5 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import {
- canScroll,
- isScrolledToTop,
- isScrolledToBottom,
- scrollDown,
- scrollUp,
-} from '~/lib/utils/scroll_utils';
import Icon from '~/vue_shared/components/icon.vue';
export default {
@@ -17,32 +10,34 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ props: {
+ scrollUpButtonDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scrollDownButtonDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
data() {
return {
- scrollToTopEnabled: false,
- scrollToBottomEnabled: false,
+ scrollUpAvailable: Boolean(this.$listeners.scrollUp),
+ scrollDownAvailable: Boolean(this.$listeners.scrollDown),
};
},
- created() {
- window.addEventListener('scroll', this.update);
- },
- destroyed() {
- window.removeEventListener('scroll', this.update);
- },
methods: {
- /**
- * Checks if page can be scrolled and updates
- * enabled/disabled state of buttons accordingly
- */
- update() {
- this.scrollToTopEnabled = canScroll() && !isScrolledToTop();
- this.scrollToBottomEnabled = canScroll() && !isScrolledToBottom();
- },
handleRefreshClick() {
this.$emit('refresh');
},
- scrollUp,
- scrollDown,
+ handleScrollUp() {
+ this.$emit('scrollUp');
+ },
+ handleScrollDown() {
+ this.$emit('scrollDown');
+ },
},
};
</script>
@@ -50,6 +45,7 @@ export default {
<template>
<div>
<div
+ v-if="scrollUpAvailable"
v-gl-tooltip
class="controllers-buttons"
:title="__('Scroll to top')"
@@ -59,13 +55,15 @@ export default {
id="scroll-to-top"
class="btn-blank js-scroll-to-top"
:aria-label="__('Scroll to top')"
- :disabled="!scrollToTopEnabled"
- @click="scrollUp()"
+ :disabled="scrollUpButtonDisabled"
+ @click="handleScrollUp()"
><icon name="scroll_up"
/></gl-button>
</div>
<div
+ v-if="scrollDownAvailable"
v-gl-tooltip
+ :disabled="scrollUpButtonDisabled"
class="controllers-buttons"
:title="__('Scroll to bottom')"
aria-labelledby="scroll-to-bottom"
@@ -74,8 +72,9 @@ export default {
id="scroll-to-bottom"
class="btn-blank js-scroll-to-bottom"
:aria-label="__('Scroll to bottom')"
- :disabled="!scrollToBottomEnabled"
- @click="scrollDown()"
+ :v-if="scrollDownAvailable"
+ :disabled="scrollDownButtonDisabled"
+ @click="handleScrollDown()"
><icon name="scroll_down"
/></gl-button>
</div>
diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js
index 89a896b9dec..4544ebdfec1 100644
--- a/app/assets/javascripts/logs/stores/actions.js
+++ b/app/assets/javascripts/logs/stores/actions.js
@@ -1,4 +1,3 @@
-import Api from '~/api';
import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
@@ -16,9 +15,10 @@ const flashLogsError = () => {
flash(s__('Metrics|There was an error fetching the logs, please try again'));
};
-const requestLogsUntilData = params =>
+const requestUntilData = (url, params) =>
backOff((next, stop) => {
- Api.getPodLogs(params)
+ axios
+ .get(url, { params })
.then(res => {
if (res.status === httpStatusCodes.ACCEPTED) {
next();
@@ -31,10 +31,36 @@ const requestLogsUntilData = params =>
});
});
-export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
- if (timeRange) {
- commit(types.SET_TIME_RANGE, timeRange);
+const requestLogsUntilData = state => {
+ const params = {};
+ const { logs_api_path } = state.environments.options.find(
+ ({ name }) => name === state.environments.current,
+ );
+
+ if (state.pods.current) {
+ params.pod_name = state.pods.current;
+ }
+ if (state.search) {
+ params.search = state.search;
+ }
+ if (state.timeRange.current) {
+ try {
+ const { start, end } = convertToFixedRange(state.timeRange.current);
+ params.start = start;
+ params.end = end;
+ } catch {
+ flashTimeRangeWarning();
+ }
+ }
+ if (state.logs.cursor) {
+ params.cursor = state.logs.cursor;
}
+
+ return requestUntilData(logs_api_path, params);
+};
+
+export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
+ commit(types.SET_TIME_RANGE, timeRange);
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, podName);
};
@@ -60,10 +86,15 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => {
dispatch('fetchLogs');
};
+/**
+ * Fetch environments data and initial logs
+ * @param {Object} store
+ * @param {String} environmentsPath
+ */
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
commit(types.REQUEST_ENVIRONMENTS_DATA);
- axios
+ return axios
.get(environmentsPath)
.then(({ data }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
@@ -76,32 +107,16 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
};
export const fetchLogs = ({ commit, state }) => {
- const params = {
- environment: state.environments.options.find(({ name }) => name === state.environments.current),
- podName: state.pods.current,
- search: state.search,
- };
-
- if (state.timeRange.current) {
- try {
- const { start, end } = convertToFixedRange(state.timeRange.current);
- params.start = start;
- params.end = end;
- } catch {
- flashTimeRangeWarning();
- }
- }
-
commit(types.REQUEST_PODS_DATA);
commit(types.REQUEST_LOGS_DATA);
- return requestLogsUntilData(params)
+ return requestLogsUntilData(state)
.then(({ data }) => {
- const { pod_name, pods, logs } = data;
+ const { pod_name, pods, logs, cursor } = data;
commit(types.SET_CURRENT_POD_NAME, pod_name);
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
- commit(types.RECEIVE_LOGS_DATA_SUCCESS, logs);
+ commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
})
.catch(() => {
commit(types.RECEIVE_PODS_DATA_ERROR);
@@ -110,5 +125,24 @@ export const fetchLogs = ({ commit, state }) => {
});
};
+export const fetchMoreLogsPrepend = ({ commit, state }) => {
+ if (state.logs.isComplete) {
+ // return when all logs are loaded
+ return Promise.resolve();
+ }
+
+ commit(types.REQUEST_LOGS_DATA_PREPEND);
+
+ return requestLogsUntilData(state)
+ .then(({ data }) => {
+ const { logs, cursor } = data;
+ commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
+ })
+ .catch(() => {
+ commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
+ flashLogsError();
+ });
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/logs/stores/getters.js b/app/assets/javascripts/logs/stores/getters.js
index c7dbb72ce3d..58f2dbf4835 100644
--- a/app/assets/javascripts/logs/stores/getters.js
+++ b/app/assets/javascripts/logs/stores/getters.js
@@ -1,9 +1,9 @@
-import dateFormat from 'dateformat';
+import { formatDate } from '../utils';
-export const trace = state =>
- state.logs.lines
- .map(item => [dateFormat(item.timestamp, 'UTC:mmm dd HH:MM:ss.l"Z"'), item.message].join(' | '))
- .join('\n');
+const mapTrace = ({ timestamp = null, message = '' }) =>
+ [timestamp ? formatDate(timestamp) : '', message].join(' | ');
+
+export const trace = state => state.logs.lines.map(mapTrace).join('\n');
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js
index b8e70f95d92..5ff49135e41 100644
--- a/app/assets/javascripts/logs/stores/mutation_types.js
+++ b/app/assets/javascripts/logs/stores/mutation_types.js
@@ -10,6 +10,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'
export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
+export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND';
+export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS';
+export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR';
export const REQUEST_PODS_DATA = 'REQUEST_PODS_DATA';
export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js
index ca31dd3bc20..d94d71cd25a 100644
--- a/app/assets/javascripts/logs/stores/mutations.js
+++ b/app/assets/javascripts/logs/stores/mutations.js
@@ -1,17 +1,24 @@
import * as types from './mutation_types';
+import { convertToFixedRange } from '~/lib/utils/datetime_range';
+
+const mapLine = ({ timestamp, message }) => ({
+ timestamp,
+ message,
+});
export default {
- /** Search data */
+ // Search Data
[types.SET_SEARCH](state, searchQuery) {
state.search = searchQuery;
},
- /** Time Range data */
+ // Time Range Data
[types.SET_TIME_RANGE](state, timeRange) {
- state.timeRange.current = timeRange;
+ state.timeRange.selected = timeRange;
+ state.timeRange.current = convertToFixedRange(timeRange);
},
- /** Environments data */
+ // Environments Data
[types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
state.environments.current = environmentName;
},
@@ -28,24 +35,49 @@ export default {
state.environments.isLoading = false;
},
- /** Logs data */
+ // Logs data
[types.REQUEST_LOGS_DATA](state) {
+ state.timeRange.current = convertToFixedRange(state.timeRange.selected);
+
state.logs.lines = [];
state.logs.isLoading = true;
+
+ // start pagination from the beginning
+ state.logs.cursor = null;
state.logs.isComplete = false;
},
- [types.RECEIVE_LOGS_DATA_SUCCESS](state, lines) {
- state.logs.lines = lines;
+ [types.RECEIVE_LOGS_DATA_SUCCESS](state, { logs = [], cursor }) {
+ state.logs.lines = logs.map(mapLine);
state.logs.isLoading = false;
- state.logs.isComplete = true;
+ state.logs.cursor = cursor;
+
+ if (!cursor) {
+ state.logs.isComplete = true;
+ }
},
[types.RECEIVE_LOGS_DATA_ERROR](state) {
state.logs.lines = [];
state.logs.isLoading = false;
- state.logs.isComplete = true;
},
- /** Pods data */
+ [types.REQUEST_LOGS_DATA_PREPEND](state) {
+ state.logs.isLoading = true;
+ },
+ [types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { logs = [], cursor }) {
+ const lines = logs.map(mapLine);
+ state.logs.lines = lines.concat(state.logs.lines);
+ state.logs.isLoading = false;
+ state.logs.cursor = cursor;
+
+ if (!cursor) {
+ state.logs.isComplete = true;
+ }
+ },
+ [types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state) {
+ state.logs.isLoading = false;
+ },
+
+ // Pods data
[types.SET_CURRENT_POD_NAME](state, podName) {
state.pods.current = podName;
},
diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js
index eaf1b1bdd93..e058f15b6b4 100644
--- a/app/assets/javascripts/logs/stores/state.js
+++ b/app/assets/javascripts/logs/stores/state.js
@@ -1,4 +1,5 @@
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
+import { convertToFixedRange } from '~/lib/utils/datetime_range';
export default () => ({
/**
@@ -11,7 +12,10 @@ export default () => ({
*/
timeRange: {
options: timeRanges,
- current: defaultTimeRange,
+ // Selected time range, can be fixed or relative
+ selected: defaultTimeRange,
+ // Current time range, must be fixed
+ current: convertToFixedRange(defaultTimeRange),
},
/**
@@ -29,7 +33,12 @@ export default () => ({
logs: {
lines: [],
isLoading: false,
- isComplete: true,
+ /**
+ * Logs `cursor` represents the current pagination position,
+ * Should be sent in next batch (page) of logs to be fetched
+ */
+ cursor: null,
+ isComplete: false,
},
/**
diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js
index 668efee74e8..30213dbc130 100644
--- a/app/assets/javascripts/logs/utils.js
+++ b/app/assets/javascripts/logs/utils.js
@@ -1,4 +1,7 @@
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
+import dateFormat from 'dateformat';
+
+const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
/**
* Returns a time range (`start`, `end`) where `start` is the
@@ -20,4 +23,6 @@ export const getTimeRange = (seconds = 0) => {
};
};
+export const formatDate = timestamp => dateFormat(timestamp, dateFormatMask);
+
export default {};
diff --git a/app/assets/javascripts/pages/admin/sessions/index.js b/app/assets/javascripts/pages/admin/sessions/index.js
new file mode 100644
index 00000000000..680ebd19a9f
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/sessions/index.js
@@ -0,0 +1 @@
+import '~/pages/sessions/index';
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index b9e80899e25..511b3cda9c8 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,11 +1,12 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui';
+import { GlSkeletonLoading, GlEmptyState, GlLink } from '@gitlab/ui';
import {
getParameterByName,
historyPushState,
buildUrlWithCurrentLocation,
} from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import ReleaseBlock from './release_block.vue';
@@ -16,13 +17,14 @@ export default {
GlEmptyState,
ReleaseBlock,
TablePagination,
+ GlLink,
},
props: {
projectId: {
type: String,
required: true,
},
- documentationLink: {
+ documentationPath: {
type: String,
required: true,
},
@@ -30,6 +32,11 @@ export default {
type: String,
required: true,
},
+ newReleasePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapState('list', ['isLoading', 'releases', 'hasError', 'pageInfo']),
@@ -39,6 +46,11 @@ export default {
shouldRenderSuccessState() {
return this.releases.length && !this.isLoading && !this.hasError;
},
+ emptyStateText() {
+ return __(
+ "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.",
+ );
+ },
},
created() {
this.fetchReleases({
@@ -56,7 +68,16 @@ export default {
};
</script>
<template>
- <div class="prepend-top-default">
+ <div class="flex flex-column mt-2">
+ <gl-link
+ v-if="newReleasePath"
+ :href="newReleasePath"
+ :aria-describedby="shouldRenderEmptyState && 'releases-description'"
+ class="btn btn-success align-self-end mb-2 js-new-release-btn"
+ >
+ {{ __('New release') }}
+ </gl-link>
+
<gl-skeleton-loading v-if="isLoading" class="js-loading" />
<gl-empty-state
@@ -64,14 +85,20 @@ export default {
class="js-empty-state"
:title="__('Getting started with releases')"
:svg-path="illustrationPath"
- :description="
- __(
- 'Releases are based on Git tags and mark specific points in a project\'s development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.',
- )
- "
- :primary-button-link="documentationLink"
- :primary-button-text="__('Open Documentation')"
- />
+ >
+ <template #description>
+ <span id="releases-description">
+ {{ emptyStateText }}
+ <gl-link
+ :href="documentationPath"
+ :aria-label="__('Releases documentation')"
+ target="_blank"
+ >
+ {{ __('More information') }}
+ </gl-link>
+ </span>
+ </template>
+ </gl-empty-state>
<div v-else-if="shouldRenderSuccessState" class="js-success-state">
<release-block
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index ad82d9a65d6..5f0bf3b6459 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -15,11 +15,7 @@ export default () => {
}),
render: h =>
h(ReleaseListApp, {
- props: {
- projectId: el.dataset.projectId,
- documentationLink: el.dataset.documentationPath,
- illustrationPath: el.dataset.illustrationPath,
- },
+ props: el.dataset,
}),
});
};
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index 9ec99ac93d7..7acbe949151 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import { getCommitIconMap } from '~/ide/utils';
export default {
@@ -51,17 +51,7 @@ export default {
tooltipTitle() {
if (!this.showTooltip || !this.file.changed) return undefined;
- const type = this.file.tempFile ? 'addition' : 'modification';
-
- if (this.file.staged) {
- return sprintf(__('Staged %{type}'), {
- type,
- });
- }
-
- return sprintf(__('Unstaged %{type}'), {
- type,
- });
+ return this.file.tempFile ? __('Added') : __('Modified');
},
showIcon() {
return (
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index d54648cc34b..fd448ee24ed 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -257,7 +257,6 @@
width: 15px;
height: 15px;
display: $svg-display;
- fill: $gl-text-color;
top: $svg-top;
}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 59266af96b4..c829695621c 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -358,17 +358,30 @@
}
}
-.build-page-pod-logs {
+.environment-logs-viewer {
.build-trace-container {
position: relative;
}
+ .log-lines,
+ .gl-infinite-scroll-container {
+ // makes scrollbar visible by creating contrast
+ background: $black;
+ }
+
+ .gl-infinite-scroll-legend {
+ margin: 0;
+ }
+
.build-trace {
@include build-trace();
+ margin: 0;
}
.top-bar {
@include build-trace-top-bar($gl-line-height * 5);
+ position: relative;
+ top: 0;
.dropdown-menu-toggle {
width: 200px;
@@ -395,4 +408,9 @@
.build-loader-animation {
@include build-loader-animation;
}
+
+ .log-footer {
+ color: $white-normal;
+ background-color: $gray-900;
+ }
}
diff --git a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb b/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb
new file mode 100644
index 00000000000..c6fd1d55e51
--- /dev/null
+++ b/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Authenticates2FAForAdminMode
+ extend ActiveSupport::Concern
+
+ included do
+ include AuthenticatesWithTwoFactor
+ end
+
+ def admin_mode_prompt_for_two_factor(user)
+ return handle_locked_user(user) unless user.can?(:log_in)
+
+ session[:otp_user_id] = user.id
+ setup_u2f_authentication(user)
+
+ render 'admin/sessions/two_factor', layout: 'application'
+ end
+
+ def admin_mode_authenticate_with_two_factor
+ user = current_user
+
+ return handle_locked_user(user) unless user.can?(:log_in)
+
+ if user_params[:otp_attempt].present? && session[:otp_user_id]
+ admin_mode_authenticate_with_two_factor_via_otp(user)
+ elsif user_params[:device_response].present? && session[:otp_user_id]
+ admin_mode_authenticate_with_two_factor_via_u2f(user)
+ elsif user && user.valid_password?(user_params[:password])
+ admin_mode_prompt_for_two_factor(user)
+ else
+ invalid_login_redirect
+ end
+ end
+
+ def admin_mode_authenticate_with_two_factor_via_otp(user)
+ if valid_otp_attempt?(user)
+ # Remove any lingering user data from login
+ session.delete(:otp_user_id)
+
+ user.save!
+
+ # The admin user has successfully passed 2fa, enable admin mode ignoring password
+ enable_admin_mode
+ else
+ user.increment_failed_attempts!
+ Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
+ flash.now[:alert] = _('Invalid two-factor code.')
+
+ admin_mode_prompt_for_two_factor(user)
+ end
+ end
+
+ def admin_mode_authenticate_with_two_factor_via_u2f(user)
+ if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
+ # Remove any lingering user data from login
+ session.delete(:otp_user_id)
+ session.delete(:challenge)
+
+ # The admin user has successfully passed 2fa, enable admin mode ignoring password
+ enable_admin_mode
+ else
+ user.increment_failed_attempts!
+ Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
+ flash.now[:alert] = _('Authentication via U2F device failed.')
+
+ admin_mode_prompt_for_two_factor(user)
+ end
+ end
+
+ private
+
+ def enable_admin_mode
+ if current_user_mode.enable_admin_mode!(skip_password_validation: true)
+ redirect_to redirect_path, notice: _('Admin mode enabled')
+ else
+ invalid_login_redirect
+ end
+ end
+
+ def invalid_login_redirect
+ flash.now[:alert] = _('Invalid login or password')
+ render :new
+ end
+end
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index f9587655a8d..841ad46b47e 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Admin::SessionsController < ApplicationController
+ include Authenticates2FAForAdminMode
include InternalRedirect
before_action :user_is_admin!
@@ -15,7 +16,9 @@ class Admin::SessionsController < ApplicationController
end
def create
- if current_user_mode.enable_admin_mode!(password: params[:password])
+ if two_factor_enabled_for_user?
+ admin_mode_authenticate_with_two_factor
+ elsif current_user_mode.enable_admin_mode!(password: user_params[:password])
redirect_to redirect_path, notice: _('Admin mode enabled')
else
flash.now[:alert] = _('Invalid login or password')
@@ -37,6 +40,10 @@ class Admin::SessionsController < ApplicationController
render_404 unless current_user&.admin?
end
+ def two_factor_enabled_for_user?
+ current_user&.two_factor_enabled?
+ end
+
def redirect_path
redirect_to_path = safe_redirect_path(stored_location_for(:redirect)) || safe_redirect_path_for_url(request.referer)
@@ -51,4 +58,13 @@ class Admin::SessionsController < ApplicationController
def excluded_redirect_paths
[new_admin_session_path, admin_session_path]
end
+
+ def user_params
+ params.fetch(:user, {}).permit(:password, :otp_attempt, :device_response)
+ end
+
+ def valid_otp_attempt?(user)
+ user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
+ user.invalidate_otp_backup_code!(user_params[:otp_attempt])
+ end
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 6f0c7abac16..b885e55f902 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -3,8 +3,6 @@
# == AuthenticatesWithTwoFactor
#
# Controller concern to handle two-factor authentication
-#
-# Upon inclusion, skips `require_no_authentication` on `:create`.
module AuthenticatesWithTwoFactor
extend ActiveSupport::Concern
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index bc3308fd6c6..d82a46e57ea 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -2,6 +2,7 @@
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
+ include Authenticates2FAForAdminMode
include Devise::Controllers::Rememberable
include AuthHelper
include InitializesCurrentUserMode
@@ -97,7 +98,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
log_audit_event(current_user, with: oauth['provider'])
if Feature.enabled?(:user_mode_in_session)
- return admin_mode_flow if current_user_mode.admin_mode_requested?
+ return admin_mode_flow(auth_module::User) if current_user_mode.admin_mode_requested?
end
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
@@ -245,13 +246,19 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
- def admin_mode_flow
- if omniauth_identity_matches_current_user?
+ def admin_mode_flow(auth_user_class)
+ auth_user = build_auth_user(auth_user_class)
+
+ return fail_admin_mode_invalid_credentials unless omniauth_identity_matches_current_user?
+
+ if current_user.two_factor_enabled? && !auth_user.bypass_two_factor?
+ admin_mode_prompt_for_two_factor(current_user)
+ else
+ # Can only reach here if the omniauth identity matches current user
+ # and current_user is an admin that requested admin mode
current_user_mode.enable_admin_mode!(skip_password_validation: true)
redirect_to stored_location_for(:redirect) || admin_root_path, notice: _('Admin mode enabled')
- else
- fail_admin_mode_invalid_credentials
end
end
diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb
new file mode 100644
index 00000000000..c74c180fa20
--- /dev/null
+++ b/app/controllers/projects/import/jira_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Projects
+ module Import
+ class JiraController < Projects::ApplicationController
+ before_action :jira_import_enabled?
+ before_action :jira_integration_configured?
+
+ def show
+ unless @project.import_state&.in_progress?
+ jira_client = @project.jira_service.client
+ @jira_projects = jira_client.Project.all.map { |p| ["#{p.name} (#{p.key})", p.key] }
+ end
+
+ flash[:notice] = _("Import %{status}") % { status: @project.import_state.status } if @project.import_state.present? && !@project.import_state.none?
+ end
+
+ def import
+ import_state = @project.import_state || @project.create_import_state
+
+ schedule_import(jira_import_params) unless import_state.in_progress?
+
+ redirect_to project_import_jira_path(@project)
+ end
+
+ private
+
+ def jira_import_enabled?
+ return if Feature.enabled?(:jira_issue_import, @project)
+
+ redirect_to project_issues_path(@project)
+ end
+
+ def jira_integration_configured?
+ return if @project.jira_service
+
+ flash[:notice] = _("Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page." %
+ { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe })
+ redirect_to project_issues_path(@project)
+ end
+
+ def schedule_import(params)
+ import_data = @project.create_or_update_import_data(data: {}).becomes(JiraImportData)
+
+ import_data << JiraImportData::JiraProjectDetails.new(
+ params[:jira_project_key],
+ Time.now.strftime('%Y-%m-%d %H:%M:%S'),
+ { user_id: current_user.id, name: current_user.name }
+ )
+
+ @project.import_type = 'jira'
+ @project.import_state.schedule if @project.save
+ end
+
+ def jira_import_params
+ params.permit(:jira_project_key)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/concerns/mutations/resolves_group.rb b/app/graphql/mutations/concerns/mutations/resolves_group.rb
index d5a040c84e9..11d7b34217d 100644
--- a/app/graphql/mutations/concerns/mutations/resolves_group.rb
+++ b/app/graphql/mutations/concerns/mutations/resolves_group.rb
@@ -9,7 +9,7 @@ module Mutations
end
def group_resolver
- Resolvers::GroupResolver.new(object: nil, context: context)
+ Resolvers::GroupResolver.new(object: nil, context: context, field: nil)
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
index 4146bf8fdc8..3a4db5ae18d 100644
--- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
+++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
@@ -14,7 +14,7 @@ module Mutations
def issuable_resolver(type, parent, context)
resolver_class = "Resolvers::#{type.to_s.classify.pluralize}Resolver".constantize
- resolver_class.single.new(object: parent, context: context)
+ resolver_class.single.new(object: parent, context: context, field: nil)
end
def resolve_issuable_parent(parent_path)
diff --git a/app/graphql/mutations/concerns/mutations/resolves_project.rb b/app/graphql/mutations/concerns/mutations/resolves_project.rb
index 0e91a25b803..e223e3edd94 100644
--- a/app/graphql/mutations/concerns/mutations/resolves_project.rb
+++ b/app/graphql/mutations/concerns/mutations/resolves_project.rb
@@ -9,7 +9,7 @@ module Mutations
end
def project_resolver
- Resolvers::ProjectResolver.new(object: nil, context: context)
+ Resolvers::ProjectResolver.new(object: nil, context: context, field: nil)
end
end
end
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index c4fe40a0875..6fbef800faa 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -17,7 +17,9 @@ module ReleasesHelper
project_id: @project.id,
illustration_path: illustration,
documentation_path: help_page
- }
+ }.tap do |data|
+ data[:new_release_path] = new_project_tag_path(@project) if can?(current_user, :create_release, @project)
+ end
end
def data_for_edit_release_page
diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb
index d8587ea78ec..a4814fc0d48 100644
--- a/app/models/concerns/bulk_insert_safe.rb
+++ b/app/models/concerns/bulk_insert_safe.rb
@@ -100,7 +100,13 @@ module BulkInsertSafe
def _bulk_insert_item_attributes(items, validate_items)
items.map do |item|
item.validate! if validate_items
- attributes = item.attributes
+
+ attributes = {}
+ column_names.each do |name|
+ value = item.read_attribute(name)
+ value = item.type_for_attribute(name).serialize(value) # rubocop:disable Cop/ActiveRecordSerialize
+ attributes[name] = value
+ end
_bulk_insert_reject_primary_key!(attributes, item.class.primary_key)
diff --git a/app/models/jira_import_data.rb b/app/models/jira_import_data.rb
new file mode 100644
index 00000000000..3f882deb24d
--- /dev/null
+++ b/app/models/jira_import_data.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class JiraImportData < ProjectImportData
+ JiraProjectDetails = Struct.new(:key, :scheduled_at, :scheduled_by)
+
+ def projects
+ return [] unless data
+
+ projects = data.dig('jira', 'projects').map do |p|
+ JiraProjectDetails.new(p['key'], p['scheduled_at'], p['scheduled_by'])
+ end
+ projects.sort_by { |jp| jp.scheduled_at }
+ end
+
+ def <<(project)
+ self.data ||= { jira: { projects: [] } }
+ self.data['jira']['projects'] << project.to_h.deep_stringify_keys!
+ end
+end
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index 50fa48855c0..a8d678d2b61 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -1,7 +1,7 @@
= form_tag(admin_session_path, method: :post, html: { class: 'new_user gl-show-field-errors', 'aria-live': 'assertive'}) do
.form-group
- = label_tag :password, _('Password'), class: 'label-bold'
- = password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
+ = label_tag :user_password, _('Password'), class: 'label-bold'
+ = password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
.submit-container.move-submit-down
= submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml
index 20830051d31..2e279013720 100644
--- a/app/views/admin/sessions/_tabs_normal.html.haml
+++ b/app/views/admin/sessions/_tabs_normal.html.haml
@@ -1,3 +1,3 @@
%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' }
%li.nav-item{ role: 'presentation' }
- %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter Admin Mode')
+ %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title
diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml
new file mode 100644
index 00000000000..9d4acbf1b99
--- /dev/null
+++ b/app/views/admin/sessions/_two_factor_otp.html.haml
@@ -0,0 +1,9 @@
+= form_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_u2f_enabled?}" }) do
+ .form-group
+ = label_tag :user_otp_attempt, _('Two-Factor Authentication code')
+ = text_field_tag 'user[otp_attempt]', nil, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.')
+ %p.form-text.text-muted.hint
+ = _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
+
+ .submit-container.move-submit-down
+ = submit_tag 'Verify code', class: 'btn btn-success'
diff --git a/app/views/admin/sessions/_two_factor_u2f.html.haml b/app/views/admin/sessions/_two_factor_u2f.html.haml
new file mode 100644
index 00000000000..09b91d76295
--- /dev/null
+++ b/app/views/admin/sessions/_two_factor_u2f.html.haml
@@ -0,0 +1,17 @@
+#js-authenticate-u2f
+%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
+
+%script#js-authenticate-u2f-in-progress{ type: "text/template" }
+ %p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
+
+-# haml-lint:disable NoPlainNodes
+%script#js-authenticate-u2f-error{ type: "text/template" }
+ %div
+ %p <%= error_message %> (#{_("error code:")} <%= error_code %>)
+ %a.btn.btn-block.btn-warning#js-u2f-try-again= _("Try again?")
+
+%script#js-authenticate-u2f-authenticated{ type: "text/template" }
+ %div
+ %p= _("We heard back from your U2F device. You have been authenticated.")
+ = form_tag(admin_session_path, method: :post, id: 'js-login-u2f-form') do |f|
+ = hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index a1d440f2cfd..0a7f20b861e 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -2,10 +2,10 @@
- page_title _('Enter Admin Mode')
.row.justify-content-center
- .col-6.new-session-forms-container
+ .col-md-5.new-session-forms-container
.login-page
#signin-container
- = render 'admin/sessions/tabs_normal'
+ = render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
.tab-content
- if !current_user.require_password_creation_for_web?
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
@@ -14,7 +14,7 @@
- if omniauth_enabled? && button_based_providers_enabled?
.clearfix
- = render 'devise/shared/omniauth_box'
+ = render 'devise/shared/omniauth_box', hide_remember_me: true
-# Show a message if none of the mechanisms above are enabled
- if current_user.require_password_creation_for_web? && !omniauth_enabled?
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
new file mode 100644
index 00000000000..3a0cbe3facb
--- /dev/null
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -0,0 +1,15 @@
+- @hide_breadcrumbs = true
+- page_title _('Enter 2FA for Admin Mode')
+
+.row.justify-content-center
+ .col-md-5.new-session-forms-container
+ .login-page
+ #signin-container
+ = render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
+ .tab-content
+ .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
+ .login-body
+ - if current_user.two_factor_otp_enabled?
+ = render 'admin/sessions/two_factor_otp'
+ - if current_user.two_factor_u2f_enabled?
+ = render 'admin/sessions/two_factor_u2f'
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 1b583ea85d6..cca0f756e76 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -10,8 +10,9 @@
= provider_image_tag(provider)
%span
= label_for_provider(provider)
- %fieldset.remember-me
- %label
- = check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
- %span
- Remember me
+ - unless defined?(hide_remember_me) && hide_remember_me
+ %fieldset.remember-me
+ %label
+ = check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
+ %span
+ Remember me
diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml
new file mode 100644
index 00000000000..f295a241113
--- /dev/null
+++ b/app/views/projects/import/jira/show.html.haml
@@ -0,0 +1,24 @@
+- title = _('Jira Issue Import')
+- page_title title
+- breadcrumb_title title
+- header_title _("Projects"), root_path
+
+= render 'import/shared/errors'
+
+- if @project.import_state&.in_progress?
+ %h3.page-title.d-flex.align-items-center
+ = sprite_icon('issues', size: 16, css_class: 'mr-1')
+ = _('Import in progress')
+- else
+ %h3.page-title.d-flex.align-items-center
+ = sprite_icon('issues', size: 16, css_class: 'mr-1')
+ = _('Import issues from Jira')
+
+ = form_tag import_project_import_jira_path(@project), method: :post do
+ .form-group.row
+ = label_tag :jira_project_key, _('From project'), class: 'col-form-label col-md-2'
+ .col-md-4
+ = select_tag :jira_project_key, options_for_select(@jira_projects, ''), { class: 'select2' }
+ .form-actions
+ = submit_tag _('Import issues'), class: 'btn btn-success'
+ = link_to _('Cancel'), project_issues_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml
index fe89d2fb748..78c561e81ef 100644
--- a/app/views/projects/issues/import_csv/_button.html.haml
+++ b/app/views/projects/issues/import_csv/_button.html.haml
@@ -7,3 +7,5 @@
- else
= _('Import CSV')
+- if Feature.enabled?(:jira_issue_import, @project)
+ = link_to _("Import Jira issues"), project_import_jira_path(@project), class: "btn btn-default"
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index a7f739ab13d..1b3b0972744 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -36,11 +36,19 @@
.form-group.row
= label_tag :release_description, s_('TagsPage|Release notes'), class: 'col-form-label col-sm-2'
.col-sm-10
+ .form-text.mb-3
+ - link_start = '<a href="%{url}" rel="noopener noreferrer" target="_blank">'.html_safe
+ - releases_page_path = project_releases_path(@project)
+ - releases_page_link_start = link_start % { url: releases_page_path }
+ - docs_url = help_page_path('user/project/releases/index.md', anchor: 'creating-a-release')
+ - docs_link_start = link_start % { url: docs_url }
+ - link_end = '</a>'.html_safe
+ - replacements = { releases_page_link_start: releases_page_link_start, docs_link_start: docs_link_start, link_end: link_end }
+ = s_('TagsPage|Optionally, create a public Release of your project, based on this tag. Release notes are displayed on the %{releases_page_link_start}Releases%{link_end} page. %{docs_link_start}More information%{link_end}').html_safe % replacements
+
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files hereā€¦'), current_text: @release_description
= render 'shared/notes/hints'
- .form-text.text-muted
- = s_('TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.')
.form-actions
= button_tag s_('TagsPage|Create tag'), class: 'btn btn-success'
= link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml
index 979f6862de3..51018428b1b 100644
--- a/app/views/u2f/_authenticate.html.haml
+++ b/app/views/u2f/_authenticate.html.haml
@@ -1,7 +1,6 @@
#js-authenticate-u2f
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
--# haml-lint:disable InlineJavaScript
%script#js-authenticate-u2f-in-progress{ type: "text/template" }
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 545e8886d61..49dcc441780 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -856,7 +856,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent: true
- :name: background_migration
:feature_category: :not_owned
:has_external_dependencies:
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index 17537cdaa26..a35e0320553 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AuthorizedProjectsWorker # rubocop:disable Scalability/IdempotentWorker
+class AuthorizedProjectsWorker
include ApplicationWorker
prepend WaitableWorker
@@ -8,6 +8,8 @@ class AuthorizedProjectsWorker # rubocop:disable Scalability/IdempotentWorker
urgency :high
weight 2
+ idempotent!
+
# This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the
# visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231
# for more details.