1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-10-07 01:58:50 +03:00

refactor(editor): Move a few components to script setup (no-changelog) (#10029)

This commit is contained in:
Mutasem Aldmour 2024-07-12 15:03:23 +02:00 committed by GitHub
parent 293147642f
commit 2e9ab66602
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1051 additions and 1104 deletions

View File

@ -1,8 +1,379 @@
<script lang="ts" setup>
import { useStorage } from '@/composables/useStorage';
import type { INodeTypeDescription } from 'n8n-workflow';
import PanelDragButton from './PanelDragButton.vue';
import { LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH, MAIN_NODE_PANEL_WIDTH } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { ndvEventBus } from '@/event-bus';
import NDVFloatingNodes from '@/components/NDVFloatingNodes.vue';
import { useDebounce } from '@/composables/useDebounce';
import type { XYPosition } from '@/Interface';
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue';
const SIDE_MARGIN = 24;
const SIDE_PANELS_MARGIN = 80;
const MIN_PANEL_WIDTH = 280;
const PANEL_WIDTH = 320;
const PANEL_WIDTH_LARGE = 420;
const MIN_WINDOW_WIDTH = 2 * (SIDE_MARGIN + SIDE_PANELS_MARGIN) + MIN_PANEL_WIDTH;
const initialMainPanelWidth: { [key: string]: number } = {
regular: MAIN_NODE_PANEL_WIDTH,
dragless: MAIN_NODE_PANEL_WIDTH,
unknown: MAIN_NODE_PANEL_WIDTH,
inputless: MAIN_NODE_PANEL_WIDTH,
wide: MAIN_NODE_PANEL_WIDTH * 2,
};
interface Props {
isDraggable: boolean;
hideInputAndOutput: boolean;
nodeType: INodeTypeDescription | null;
}
const { callDebounced } = useDebounce();
const ndvStore = useNDVStore();
const props = defineProps<Props>();
const windowWidth = ref<number>(1);
const isDragging = ref<boolean>(false);
const initialized = ref<boolean>(false);
const emit = defineEmits<{
init: [{ position: number }];
dragstart: [{ position: number }];
dragend: [{ position: number; windowWidth: number }];
switchSelectedNode: [string];
close: [];
}>();
const slots = defineSlots<{
input: unknown;
output: unknown;
main: unknown;
}>();
onMounted(() => {
setTotalWidth();
/*
Only set(or restore) initial position if `mainPanelDimensions`
is at the default state({relativeLeft:1, relativeRight: 1, relativeWidth: 1}) to make sure we use store values if they are set
*/
if (
mainPanelDimensions.value.relativeLeft === 1 &&
mainPanelDimensions.value.relativeRight === 1
) {
setMainPanelWidth();
setPositions(getInitialLeftPosition(mainPanelDimensions.value.relativeWidth));
restorePositionData();
}
window.addEventListener('resize', setTotalWidth);
emit('init', { position: mainPanelDimensions.value.relativeLeft });
setTimeout(() => {
initialized.value = true;
}, 0);
ndvEventBus.on('setPositionByName', setPositionByName);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', setTotalWidth);
ndvEventBus.off('setPositionByName', setPositionByName);
});
watch(windowWidth, (width) => {
const minRelativeWidth = pxToRelativeWidth(MIN_PANEL_WIDTH);
const isBelowMinWidthMainPanel = mainPanelDimensions.value.relativeWidth < minRelativeWidth;
// Prevent the panel resizing below MIN_PANEL_WIDTH whhile maintaing position
if (isBelowMinWidthMainPanel) {
setMainPanelWidth(minRelativeWidth);
}
const isBelowMinLeft = minimumLeftPosition.value > mainPanelDimensions.value.relativeLeft;
const isMaxRight = maximumRightPosition.value > mainPanelDimensions.value.relativeRight;
// When user is resizing from non-supported view(sub ~488px) we need to refit the panels
if (width > MIN_WINDOW_WIDTH && isBelowMinLeft && isMaxRight) {
setMainPanelWidth(minRelativeWidth);
setPositions(getInitialLeftPosition(mainPanelDimensions.value.relativeWidth));
}
setPositions(mainPanelDimensions.value.relativeLeft);
});
const currentNodePaneType = computed((): string => {
if (!hasInputSlot.value) return 'inputless';
if (!props.isDraggable) return 'dragless';
if (props.nodeType === null) return 'unknown';
return props.nodeType.parameterPane ?? 'regular';
});
const mainPanelDimensions = computed(
(): {
relativeWidth: number;
relativeLeft: number;
relativeRight: number;
} => {
return ndvStore.getMainPanelDimensions(currentNodePaneType.value);
},
);
const calculatedPositions = computed(
(): { inputPanelRelativeRight: number; outputPanelRelativeLeft: number } => {
const hasInput = slots.input !== undefined;
const outputPanelRelativeLeft =
mainPanelDimensions.value.relativeLeft + mainPanelDimensions.value.relativeWidth;
const inputPanelRelativeRight = hasInput
? 1 - outputPanelRelativeLeft + mainPanelDimensions.value.relativeWidth
: 1 - pxToRelativeWidth(SIDE_MARGIN);
return {
inputPanelRelativeRight,
outputPanelRelativeLeft,
};
},
);
const outputPanelRelativeTranslate = computed((): number => {
const panelMinLeft = 1 - pxToRelativeWidth(MIN_PANEL_WIDTH + SIDE_MARGIN);
const currentRelativeLeftDelta = calculatedPositions.value.outputPanelRelativeLeft - panelMinLeft;
return currentRelativeLeftDelta > 0 ? currentRelativeLeftDelta : 0;
});
const supportedResizeDirections = computed((): string[] => {
const supportedDirections = ['right'];
if (props.isDraggable) supportedDirections.push('left');
return supportedDirections;
});
const hasInputSlot = computed((): boolean => {
return slots.input !== undefined;
});
const inputPanelMargin = computed(() => pxToRelativeWidth(SIDE_PANELS_MARGIN));
const minimumLeftPosition = computed((): number => {
if (windowWidth.value < MIN_WINDOW_WIDTH) return pxToRelativeWidth(1);
if (!hasInputSlot.value) return pxToRelativeWidth(SIDE_MARGIN);
return pxToRelativeWidth(SIDE_MARGIN + 20) + inputPanelMargin.value;
});
const maximumRightPosition = computed((): number => {
if (windowWidth.value < MIN_WINDOW_WIDTH) return pxToRelativeWidth(1);
return pxToRelativeWidth(SIDE_MARGIN + 20) + inputPanelMargin.value;
});
const canMoveLeft = computed((): boolean => {
return mainPanelDimensions.value.relativeLeft > minimumLeftPosition.value;
});
const canMoveRight = computed((): boolean => {
return mainPanelDimensions.value.relativeRight > maximumRightPosition.value;
});
const mainPanelStyles = computed((): { left: string; right: string } => {
return {
left: `${relativeWidthToPx(mainPanelDimensions.value.relativeLeft)}px`,
right: `${relativeWidthToPx(mainPanelDimensions.value.relativeRight)}px`,
};
});
const inputPanelStyles = computed((): { right: string } => {
return {
right: `${relativeWidthToPx(calculatedPositions.value.inputPanelRelativeRight)}px`,
};
});
const outputPanelStyles = computed((): { left: string; transform: string } => {
return {
left: `${relativeWidthToPx(calculatedPositions.value.outputPanelRelativeLeft)}px`,
transform: `translateX(-${relativeWidthToPx(outputPanelRelativeTranslate.value)}px)`,
};
});
const hasDoubleWidth = computed((): boolean => {
return props.nodeType?.parameterPane === 'wide';
});
const fixedPanelWidth = computed((): number => {
const multiplier = hasDoubleWidth.value ? 2 : 1;
if (windowWidth.value > 1700) {
return PANEL_WIDTH_LARGE * multiplier;
}
return PANEL_WIDTH * multiplier;
});
const onSwitchSelectedNode = (node: string) => emit('switchSelectedNode', node);
function getInitialLeftPosition(width: number): number {
if (currentNodePaneType.value === 'dragless')
return pxToRelativeWidth(SIDE_MARGIN + 1 + fixedPanelWidth.value);
return hasInputSlot.value ? 0.5 - width / 2 : minimumLeftPosition.value;
}
function setMainPanelWidth(relativeWidth?: number): void {
const mainPanelRelativeWidth =
relativeWidth || pxToRelativeWidth(initialMainPanelWidth[currentNodePaneType.value]);
ndvStore.setMainPanelDimensions({
panelType: currentNodePaneType.value,
dimensions: {
relativeWidth: mainPanelRelativeWidth,
},
});
}
function setPositions(relativeLeft: number): void {
const mainPanelRelativeLeft =
relativeLeft || 1 - calculatedPositions.value.inputPanelRelativeRight;
const mainPanelRelativeRight =
1 - mainPanelRelativeLeft - mainPanelDimensions.value.relativeWidth;
const isMaxRight = maximumRightPosition.value > mainPanelRelativeRight;
const isMinLeft = minimumLeftPosition.value > mainPanelRelativeLeft;
const isInputless = currentNodePaneType.value === 'inputless';
if (isMinLeft) {
ndvStore.setMainPanelDimensions({
panelType: currentNodePaneType.value,
dimensions: {
relativeLeft: minimumLeftPosition.value,
relativeRight: 1 - mainPanelDimensions.value.relativeWidth - minimumLeftPosition.value,
},
});
return;
}
if (isMaxRight) {
ndvStore.setMainPanelDimensions({
panelType: currentNodePaneType.value,
dimensions: {
relativeLeft: 1 - mainPanelDimensions.value.relativeWidth - maximumRightPosition.value,
relativeRight: maximumRightPosition.value,
},
});
return;
}
ndvStore.setMainPanelDimensions({
panelType: currentNodePaneType.value,
dimensions: {
relativeLeft: isInputless ? minimumLeftPosition.value : mainPanelRelativeLeft,
relativeRight: mainPanelRelativeRight,
},
});
}
function setPositionByName(position: 'minLeft' | 'maxRight' | 'initial') {
const positionByName: Record<string, number> = {
minLeft: minimumLeftPosition.value,
maxRight: maximumRightPosition.value,
initial: getInitialLeftPosition(mainPanelDimensions.value.relativeWidth),
};
setPositions(positionByName[position]);
}
function pxToRelativeWidth(px: number): number {
return px / windowWidth.value;
}
function relativeWidthToPx(relativeWidth: number) {
return relativeWidth * windowWidth.value;
}
function onResizeStart() {
setTotalWidth();
}
function onResizeEnd() {
storePositionData();
}
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
if (initialized.value) {
void callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
}
}
function onResize({ direction, x, width }: { direction: string; x: number; width: number }) {
const relativeDistance = pxToRelativeWidth(x);
const relativeWidth = pxToRelativeWidth(width);
if (direction === 'left' && relativeDistance <= minimumLeftPosition.value) return;
if (direction === 'right' && 1 - relativeDistance <= maximumRightPosition.value) return;
if (width <= MIN_PANEL_WIDTH) return;
setMainPanelWidth(relativeWidth);
setPositions(direction === 'left' ? relativeDistance : mainPanelDimensions.value.relativeLeft);
}
function restorePositionData() {
const storedPanelWidthData = useStorage(
`${LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH}_${currentNodePaneType.value}`,
).value;
if (storedPanelWidthData) {
const parsedWidth = parseFloat(storedPanelWidthData);
setMainPanelWidth(parsedWidth);
const initialPosition = getInitialLeftPosition(parsedWidth);
setPositions(initialPosition);
return true;
}
return false;
}
function storePositionData() {
useStorage(`${LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH}_${currentNodePaneType.value}`).value =
mainPanelDimensions.value.relativeWidth.toString();
}
function onDragStart() {
isDragging.value = true;
emit('dragstart', { position: mainPanelDimensions.value.relativeLeft });
}
function onDrag(position: XYPosition) {
const relativeLeft = pxToRelativeWidth(position[0]) - mainPanelDimensions.value.relativeWidth / 2;
setPositions(relativeLeft);
}
function onDragEnd() {
setTimeout(() => {
isDragging.value = false;
emit('dragend', {
windowWidth: windowWidth.value,
position: mainPanelDimensions.value.relativeLeft,
});
}, 0);
storePositionData();
}
function setTotalWidth() {
windowWidth.value = window.innerWidth;
}
</script>
<template>
<div>
<NDVFloatingNodes
v-if="activeNode"
:root-node="activeNode"
v-if="ndvStore.activeNode"
:root-node="ndvStore.activeNode"
@switch-selected-node="onSwitchSelectedNode"
/>
<div v-if="!hideInputAndOutput" :class="$style.inputPanel" :style="inputPanelStyles">
@ -41,377 +412,6 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import { get } from 'lodash-es';
import { useStorage } from '@/composables/useStorage';
import type { INodeTypeDescription } from 'n8n-workflow';
import PanelDragButton from './PanelDragButton.vue';
import { LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH, MAIN_NODE_PANEL_WIDTH } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { ndvEventBus } from '@/event-bus';
import NDVFloatingNodes from '@/components/NDVFloatingNodes.vue';
import { useDebounce } from '@/composables/useDebounce';
import type { XYPosition } from '@/Interface';
const SIDE_MARGIN = 24;
const SIDE_PANELS_MARGIN = 80;
const MIN_PANEL_WIDTH = 280;
const PANEL_WIDTH = 320;
const PANEL_WIDTH_LARGE = 420;
const initialMainPanelWidth: { [key: string]: number } = {
regular: MAIN_NODE_PANEL_WIDTH,
dragless: MAIN_NODE_PANEL_WIDTH,
unknown: MAIN_NODE_PANEL_WIDTH,
inputless: MAIN_NODE_PANEL_WIDTH,
wide: MAIN_NODE_PANEL_WIDTH * 2,
};
export default defineComponent({
name: 'NDVDraggablePanels',
components: {
PanelDragButton,
NDVFloatingNodes,
},
props: {
isDraggable: {
type: Boolean,
},
hideInputAndOutput: {
type: Boolean,
},
position: {
type: Number,
},
nodeType: {
type: Object as PropType<INodeTypeDescription | null>,
default: () => ({}),
},
},
setup() {
const { callDebounced } = useDebounce();
return { callDebounced };
},
data(): {
windowWidth: number;
isDragging: boolean;
MIN_PANEL_WIDTH: number;
initialized: boolean;
} {
return {
windowWidth: 1,
isDragging: false,
MIN_PANEL_WIDTH,
initialized: false,
};
},
mounted() {
this.setTotalWidth();
/*
Only set(or restore) initial position if `mainPanelDimensions`
is at the default state({relativeLeft:1, relativeRight: 1, relativeWidth: 1}) to make sure we use store values if they are set
*/
if (
this.mainPanelDimensions.relativeLeft === 1 &&
this.mainPanelDimensions.relativeRight === 1
) {
this.setMainPanelWidth();
this.setPositions(this.getInitialLeftPosition(this.mainPanelDimensions.relativeWidth));
this.restorePositionData();
}
window.addEventListener('resize', this.setTotalWidth);
this.$emit('init', { position: this.mainPanelDimensions.relativeLeft });
setTimeout(() => {
this.initialized = true;
}, 0);
ndvEventBus.on('setPositionByName', this.setPositionByName);
},
beforeUnmount() {
window.removeEventListener('resize', this.setTotalWidth);
ndvEventBus.off('setPositionByName', this.setPositionByName);
},
computed: {
...mapStores(useNDVStore),
mainPanelDimensions(): {
relativeWidth: number;
relativeLeft: number;
relativeRight: number;
} {
return this.ndvStore.getMainPanelDimensions(this.currentNodePaneType);
},
activeNode() {
return this.ndvStore.activeNode;
},
supportedResizeDirections(): string[] {
const supportedDirections = ['right'];
if (this.isDraggable) supportedDirections.push('left');
return supportedDirections;
},
currentNodePaneType(): string {
if (!this.hasInputSlot) return 'inputless';
if (!this.isDraggable) return 'dragless';
if (this.nodeType === null) return 'unknown';
return get(this, 'nodeType.parameterPane') || 'regular';
},
hasInputSlot(): boolean {
return this.$slots.input !== undefined;
},
inputPanelMargin(): number {
return this.pxToRelativeWidth(SIDE_PANELS_MARGIN);
},
minWindowWidth(): number {
return 2 * (SIDE_MARGIN + SIDE_PANELS_MARGIN) + MIN_PANEL_WIDTH;
},
minimumLeftPosition(): number {
if (this.windowWidth < this.minWindowWidth) return this.pxToRelativeWidth(1);
if (!this.hasInputSlot) return this.pxToRelativeWidth(SIDE_MARGIN);
return this.pxToRelativeWidth(SIDE_MARGIN + 20) + this.inputPanelMargin;
},
maximumRightPosition(): number {
if (this.windowWidth < this.minWindowWidth) return this.pxToRelativeWidth(1);
return this.pxToRelativeWidth(SIDE_MARGIN + 20) + this.inputPanelMargin;
},
canMoveLeft(): boolean {
return this.mainPanelDimensions.relativeLeft > this.minimumLeftPosition;
},
canMoveRight(): boolean {
return this.mainPanelDimensions.relativeRight > this.maximumRightPosition;
},
mainPanelStyles(): { left: string; right: string } {
return {
left: `${this.relativeWidthToPx(this.mainPanelDimensions.relativeLeft)}px`,
right: `${this.relativeWidthToPx(this.mainPanelDimensions.relativeRight)}px`,
};
},
inputPanelStyles(): { right: string } {
return {
right: `${this.relativeWidthToPx(this.calculatedPositions.inputPanelRelativeRight)}px`,
};
},
outputPanelStyles(): { left: string; transform: string } {
return {
left: `${this.relativeWidthToPx(this.calculatedPositions.outputPanelRelativeLeft)}px`,
transform: `translateX(-${this.relativeWidthToPx(this.outputPanelRelativeTranslate)}px)`,
};
},
calculatedPositions(): { inputPanelRelativeRight: number; outputPanelRelativeLeft: number } {
const hasInput = this.$slots.input !== undefined;
const outputPanelRelativeLeft =
this.mainPanelDimensions.relativeLeft + this.mainPanelDimensions.relativeWidth;
const inputPanelRelativeRight = hasInput
? 1 - outputPanelRelativeLeft + this.mainPanelDimensions.relativeWidth
: 1 - this.pxToRelativeWidth(SIDE_MARGIN);
return {
inputPanelRelativeRight,
outputPanelRelativeLeft,
};
},
outputPanelRelativeTranslate(): number {
const panelMinLeft = 1 - this.pxToRelativeWidth(MIN_PANEL_WIDTH + SIDE_MARGIN);
const currentRelativeLeftDelta =
this.calculatedPositions.outputPanelRelativeLeft - panelMinLeft;
return currentRelativeLeftDelta > 0 ? currentRelativeLeftDelta : 0;
},
hasDoubleWidth(): boolean {
return get(this, 'nodeType.parameterPane') === 'wide';
},
fixedPanelWidth(): number {
const multiplier = this.hasDoubleWidth ? 2 : 1;
if (this.windowWidth > 1700) {
return PANEL_WIDTH_LARGE * multiplier;
}
return PANEL_WIDTH * multiplier;
},
isBelowMinWidthMainPanel(): boolean {
const minRelativeWidth = this.pxToRelativeWidth(MIN_PANEL_WIDTH);
return this.mainPanelDimensions.relativeWidth < minRelativeWidth;
},
},
watch: {
windowWidth(windowWidth) {
const minRelativeWidth = this.pxToRelativeWidth(MIN_PANEL_WIDTH);
// Prevent the panel resizing below MIN_PANEL_WIDTH whhile maintaing position
if (this.isBelowMinWidthMainPanel) {
this.setMainPanelWidth(minRelativeWidth);
}
const isBelowMinLeft = this.minimumLeftPosition > this.mainPanelDimensions.relativeLeft;
const isMaxRight = this.maximumRightPosition > this.mainPanelDimensions.relativeRight;
// When user is resizing from non-supported view(sub ~488px) we need to refit the panels
if (windowWidth > this.minWindowWidth && isBelowMinLeft && isMaxRight) {
this.setMainPanelWidth(minRelativeWidth);
this.setPositions(this.getInitialLeftPosition(this.mainPanelDimensions.relativeWidth));
}
this.setPositions(this.mainPanelDimensions.relativeLeft);
},
},
methods: {
onSwitchSelectedNode(node: string) {
this.$emit('switchSelectedNode', node);
},
getInitialLeftPosition(width: number) {
if (this.currentNodePaneType === 'dragless')
return this.pxToRelativeWidth(SIDE_MARGIN + 1 + this.fixedPanelWidth);
return this.hasInputSlot ? 0.5 - width / 2 : this.minimumLeftPosition;
},
setMainPanelWidth(relativeWidth?: number) {
const mainPanelRelativeWidth =
relativeWidth || this.pxToRelativeWidth(initialMainPanelWidth[this.currentNodePaneType]);
this.ndvStore.setMainPanelDimensions({
panelType: this.currentNodePaneType,
dimensions: {
relativeWidth: mainPanelRelativeWidth,
},
});
},
setPositions(relativeLeft: number) {
const mainPanelRelativeLeft =
relativeLeft || 1 - this.calculatedPositions.inputPanelRelativeRight;
const mainPanelRelativeRight =
1 - mainPanelRelativeLeft - this.mainPanelDimensions.relativeWidth;
const isMaxRight = this.maximumRightPosition > mainPanelRelativeRight;
const isMinLeft = this.minimumLeftPosition > mainPanelRelativeLeft;
const isInputless = this.currentNodePaneType === 'inputless';
if (isMinLeft) {
this.ndvStore.setMainPanelDimensions({
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: this.minimumLeftPosition,
relativeRight: 1 - this.mainPanelDimensions.relativeWidth - this.minimumLeftPosition,
},
});
return;
}
if (isMaxRight) {
this.ndvStore.setMainPanelDimensions({
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: 1 - this.mainPanelDimensions.relativeWidth - this.maximumRightPosition,
relativeRight: this.maximumRightPosition,
},
});
return;
}
this.ndvStore.setMainPanelDimensions({
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: isInputless ? this.minimumLeftPosition : mainPanelRelativeLeft,
relativeRight: mainPanelRelativeRight,
},
});
},
setPositionByName(position: 'minLeft' | 'maxRight' | 'initial') {
const positionByName: Record<string, number> = {
minLeft: this.minimumLeftPosition,
maxRight: this.maximumRightPosition,
initial: this.getInitialLeftPosition(this.mainPanelDimensions.relativeWidth),
};
this.setPositions(positionByName[position]);
},
pxToRelativeWidth(px: number) {
return px / this.windowWidth;
},
relativeWidthToPx(relativeWidth: number) {
return relativeWidth * this.windowWidth;
},
onResizeStart() {
this.setTotalWidth();
},
onResizeEnd() {
this.storePositionData();
},
onResizeDebounced(data: { direction: string; x: number; width: number }) {
if (this.initialized) {
void this.callDebounced(this.onResize, { debounceTime: 10, trailing: true }, data);
}
},
onResize({ direction, x, width }: { direction: string; x: number; width: number }) {
const relativeDistance = this.pxToRelativeWidth(x);
const relativeWidth = this.pxToRelativeWidth(width);
if (direction === 'left' && relativeDistance <= this.minimumLeftPosition) return;
if (direction === 'right' && 1 - relativeDistance <= this.maximumRightPosition) return;
if (width <= MIN_PANEL_WIDTH) return;
this.setMainPanelWidth(relativeWidth);
this.setPositions(
direction === 'left' ? relativeDistance : this.mainPanelDimensions.relativeLeft,
);
},
restorePositionData() {
const storedPanelWidthData = useStorage(
`${LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH}_${this.currentNodePaneType}`,
).value;
if (storedPanelWidthData) {
const parsedWidth = parseFloat(storedPanelWidthData);
this.setMainPanelWidth(parsedWidth);
const initialPosition = this.getInitialLeftPosition(parsedWidth);
this.setPositions(initialPosition);
return true;
}
return false;
},
storePositionData() {
useStorage(`${LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH}_${this.currentNodePaneType}`).value =
this.mainPanelDimensions.relativeWidth.toString();
},
onDragStart() {
this.isDragging = true;
this.$emit('dragstart', { position: this.mainPanelDimensions.relativeLeft });
},
onDrag(position: XYPosition) {
const relativeLeft =
this.pxToRelativeWidth(position[0]) - this.mainPanelDimensions.relativeWidth / 2;
this.setPositions(relativeLeft);
},
onDragEnd() {
setTimeout(() => {
this.isDragging = false;
this.$emit('dragend', {
windowWidth: this.windowWidth,
position: this.mainPanelDimensions.relativeLeft,
});
}, 0);
this.storePositionData();
},
setTotalWidth() {
this.windowWidth = window.innerWidth;
},
close() {
this.$emit('close');
},
},
});
</script>
<style lang="scss" module>
.dataPanel {
position: absolute;

View File

@ -1,3 +1,39 @@
<script lang="ts" setup>
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
saved: boolean;
isSaving: boolean;
disabled: boolean;
type: string;
withShortcut: boolean;
shortcutTooltip?: string;
savingLabel?: string;
}>(),
{
isSaving: false,
type: 'primary',
withShortcut: false,
disabled: false,
},
);
const i18n = useI18n();
const saveButtonLabel = computed(() => {
return props.isSaving
? props.savingLabel ?? i18n.baseText('saveButton.saving')
: i18n.baseText('saveButton.save');
});
const shortcutTooltipLabel = computed(() => {
return props.shortcutTooltip ?? i18n.baseText('saveButton.save');
});
</script>
<template>
<span :class="$style.container" data-test-id="save-button">
<span v-if="saved" :class="$style.saved">{{ $locale.baseText('saveButton.saved') }}</span>
@ -28,59 +64,6 @@
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
export default defineComponent({
name: 'SaveButton',
components: {
KeyboardShortcutTooltip,
},
props: {
saved: {
type: Boolean,
},
isSaving: {
type: Boolean,
},
disabled: {
type: Boolean,
},
saveLabel: {
type: String,
},
savingLabel: {
type: String,
},
savedLabel: {
type: String,
},
type: {
type: String,
default: 'primary',
},
withShortcut: {
type: Boolean,
default: false,
},
shortcutTooltip: {
type: String,
},
},
computed: {
saveButtonLabel() {
return this.isSaving
? this.$locale.baseText('saveButton.saving')
: this.$locale.baseText('saveButton.save');
},
shortcutTooltipLabel() {
return this.shortcutTooltip ?? this.$locale.baseText('saveButton.save');
},
},
});
</script>
<style lang="scss" module>
.container {
display: inline-flex;

View File

@ -1,47 +1,50 @@
<script lang="ts" setup>
import { useCredentialsStore } from '@/stores/credentials.store';
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
const credentialsStore = useCredentialsStore();
const i18n = useI18n();
const props = defineProps<{
activeCredentialType: string;
scopes: string[];
}>();
const shortCredentialDisplayName = computed((): string => {
const oauth1Api = i18n.baseText('generic.oauth1Api');
const oauth2Api = i18n.baseText('generic.oauth2Api');
return (
credentialsStore
.getCredentialTypeByName(props.activeCredentialType)
?.displayName.replace(new RegExp(`${oauth1Api}|${oauth2Api}`), '')
.trim() || ''
);
});
const scopesShortContent = computed((): string => {
return i18n.baseText('nodeSettings.scopes.notice', {
adjustToNumber: props.scopes.length,
interpolate: {
activeCredential: shortCredentialDisplayName.value,
},
});
});
const scopesFullContent = computed((): string => {
return i18n.baseText('nodeSettings.scopes.expandedNoticeWithScopes', {
adjustToNumber: props.scopes.length,
interpolate: {
activeCredential: shortCredentialDisplayName.value,
scopes: props.scopes
.map((s: string) => (s.includes('/') ? s.split('/').pop() : s))
.join('<br>'),
},
});
});
</script>
<template>
<n8n-notice :content="scopesShortContent" :full-content="scopesFullContent" />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { useCredentialsStore } from '@/stores/credentials.store';
export default defineComponent({
name: 'ScopesNotice',
props: ['activeCredentialType', 'scopes'],
computed: {
...mapStores(useCredentialsStore),
scopesShortContent(): string {
return this.$locale.baseText('nodeSettings.scopes.notice', {
adjustToNumber: this.scopes.length,
interpolate: {
activeCredential: this.shortCredentialDisplayName,
},
});
},
scopesFullContent(): string {
return this.$locale.baseText('nodeSettings.scopes.expandedNoticeWithScopes', {
adjustToNumber: this.scopes.length,
interpolate: {
activeCredential: this.shortCredentialDisplayName,
scopes: this.scopes
.map((s: string) => (s.includes('/') ? s.split('/').pop() : s))
.join('<br>'),
},
});
},
shortCredentialDisplayName(): string {
const oauth1Api = this.$locale.baseText('generic.oauth1Api');
const oauth2Api = this.$locale.baseText('generic.oauth2Api');
return (
this.credentialsStore
.getCredentialTypeByName(this.activeCredentialType)
?.displayName.replace(new RegExp(`${oauth1Api}|${oauth2Api}`), '')
.trim() || ''
);
},
},
});
</script>

View File

@ -1,75 +1,74 @@
<template>
<span v-show="false" />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
<script lang="ts" setup>
import { useRootStore } from '@/stores/root.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import type { ITelemetrySettings } from 'n8n-workflow';
import { useProjectsStore } from '@/stores/projects.store';
import { computed, onMounted, watch, ref } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useRoute } from 'vue-router';
export default defineComponent({
name: 'Telemetry',
data() {
return {
isTelemetryInitialized: false,
};
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUsersStore, useProjectsStore),
currentUserId(): string {
return this.usersStore.currentUserId ?? '';
},
isTelemetryEnabledOnRoute(): boolean {
const routeMeta = this.$route.meta as { telemetry?: { disabled?: boolean } } | undefined;
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
},
telemetry(): ITelemetrySettings {
return this.settingsStore.telemetry;
},
isTelemetryEnabled(): boolean {
return !!this.telemetry?.enabled;
},
},
watch: {
telemetry() {
this.init();
},
currentUserId(userId) {
if (this.isTelemetryEnabled) {
this.$telemetry.identify(this.rootStore.instanceId, userId);
}
},
isTelemetryEnabledOnRoute(enabled) {
if (enabled) {
this.init();
}
},
},
mounted() {
this.init();
},
methods: {
init() {
if (
this.isTelemetryInitialized ||
!this.isTelemetryEnabledOnRoute ||
!this.isTelemetryEnabled
)
return;
const isTelemetryInitialized = ref(false);
this.$telemetry.init(this.telemetry, {
instanceId: this.rootStore.instanceId,
userId: this.currentUserId,
projectId: this.projectsStore.personalProject?.id,
versionCli: this.rootStore.versionCli,
});
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetryPlugin = useTelemetry();
const route = useRoute();
this.isTelemetryInitialized = true;
},
},
const currentUserId = computed((): string => {
return usersStore.currentUserId ?? '';
});
const isTelemetryEnabledOnRoute = computed((): boolean => {
const routeMeta = route.meta as { telemetry?: { disabled?: boolean } } | undefined;
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
});
const telemetry = computed((): ITelemetrySettings => {
return settingsStore.telemetry;
});
const isTelemetryEnabled = computed((): boolean => {
return !!telemetry.value?.enabled;
});
watch(telemetry, () => {
init();
});
watch(currentUserId, (userId) => {
if (isTelemetryEnabled.value) {
telemetryPlugin.identify(rootStore.instanceId, userId);
}
});
watch(isTelemetryEnabledOnRoute, (enabled) => {
if (enabled) {
init();
}
});
onMounted(() => {
init();
});
function init() {
if (isTelemetryInitialized.value || !isTelemetryEnabledOnRoute.value || !isTelemetryEnabled.value)
return;
telemetryPlugin.init(telemetry.value, {
instanceId: rootStore.instanceId,
userId: currentUserId.value,
projectId: projectsStore.personalProject?.id,
versionCli: rootStore.versionCli,
});
isTelemetryInitialized.value = true;
}
</script>
<template>
<span v-show="false" />
</template>

View File

@ -1,3 +1,84 @@
<script lang="ts" setup>
import { useToast } from '@/composables/useToast';
import { useWorkflowActivate } from '@/composables/useWorkflowActivate';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { getActivatableTriggerNodes } from '@/utils/nodeTypesUtils';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{ workflowActive: boolean; workflowId: string }>();
const { showMessage } = useToast();
const workflowActivate = useWorkflowActivate();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const isWorkflowActive = computed((): boolean => {
const activeWorkflows = workflowsStore.activeWorkflows;
return activeWorkflows.includes(props.workflowId);
});
const couldNotBeStarted = computed((): boolean => {
return props.workflowActive && isWorkflowActive.value !== props.workflowActive;
});
const getActiveColor = computed((): string => {
if (couldNotBeStarted.value) {
return '#ff4949';
}
return '#13ce66';
});
const isCurrentWorkflow = computed((): boolean => {
return workflowsStore.workflowId === props.workflowId;
});
const containsTrigger = computed((): boolean => {
const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes);
return foundTriggers.length > 0;
});
const disabled = computed((): boolean => {
const isNewWorkflow = !props.workflowId;
if (isNewWorkflow || isCurrentWorkflow.value) {
return !props.workflowActive && !containsTrigger.value;
}
return false;
});
async function activeChanged(newActiveState: boolean) {
return await workflowActivate.updateWorkflowActivation(props.workflowId, newActiveState);
}
async function displayActivationError() {
let errorMessage: string;
try {
const errorData = await workflowsStore.getActivationError(props.workflowId);
if (errorData === undefined) {
errorMessage = i18n.baseText(
'workflowActivator.showMessage.displayActivationError.message.errorDataUndefined',
);
} else {
errorMessage = i18n.baseText(
'workflowActivator.showMessage.displayActivationError.message.errorDataNotUndefined',
{ interpolate: { message: errorData } },
);
}
} catch (error) {
errorMessage = i18n.baseText(
'workflowActivator.showMessage.displayActivationError.message.catchBlock',
);
}
showMessage({
title: i18n.baseText('workflowActivator.showMessage.displayActivationError.title'),
message: errorMessage,
type: 'warning',
duration: 0,
dangerouslyUseHTMLString: true,
});
}
</script>
<template>
<div class="workflow-activator">
<div :class="$style.activeStatusText" data-test-id="workflow-activator-status">
@ -7,27 +88,27 @@
size="small"
bold
>
{{ $locale.baseText('workflowActivator.active') }}
{{ i18n.baseText('workflowActivator.active') }}
</n8n-text>
<n8n-text v-else color="text-base" size="small" bold>
{{ $locale.baseText('workflowActivator.inactive') }}
{{ i18n.baseText('workflowActivator.inactive') }}
</n8n-text>
</div>
<n8n-tooltip :disabled="!disabled" placement="bottom">
<template #content>
<div>
{{ $locale.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes') }}
{{ i18n.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes') }}
</div>
</template>
<el-switch
v-loading="updatingWorkflowActivation"
v-loading="workflowActivate.updatingWorkflowActivation.value"
:model-value="workflowActive"
:title="
workflowActive
? $locale.baseText('workflowActivator.deactivateWorkflow')
: $locale.baseText('workflowActivator.activateWorkflow')
? i18n.baseText('workflowActivator.deactivateWorkflow')
: i18n.baseText('workflowActivator.activateWorkflow')
"
:disabled="disabled || updatingWorkflowActivation"
:disabled="disabled || workflowActivate.updatingWorkflowActivation.value"
:active-color="getActiveColor"
inactive-color="#8899AA"
data-test-id="workflow-activate-switch"
@ -41,7 +122,7 @@
<template #content>
<div
@click="displayActivationError"
v-html="$locale.baseText('workflowActivator.theWorkflowIsSetToBeActiveBut')"
v-html="i18n.baseText('workflowActivator.theWorkflowIsSetToBeActiveBut')"
></div>
</template>
<font-awesome-icon icon="exclamation-triangle" @click="displayActivationError" />
@ -50,95 +131,6 @@
</div>
</template>
<script lang="ts">
import { useToast } from '@/composables/useToast';
import { useWorkflowActivate } from '@/composables/useWorkflowActivate';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import { getActivatableTriggerNodes } from '@/utils/nodeTypesUtils';
export default defineComponent({
name: 'WorkflowActivator',
props: ['workflowActive', 'workflowId'],
setup() {
return {
...useToast(),
...useWorkflowActivate(),
};
},
computed: {
...mapStores(useUIStore, useWorkflowsStore),
nodesIssuesExist(): boolean {
return this.workflowsStore.nodesIssuesExist;
},
isWorkflowActive(): boolean {
const activeWorkflows = this.workflowsStore.activeWorkflows;
return activeWorkflows.includes(this.workflowId);
},
couldNotBeStarted(): boolean {
return this.workflowActive === true && this.isWorkflowActive !== this.workflowActive;
},
getActiveColor(): string {
if (this.couldNotBeStarted) {
return '#ff4949';
}
return '#13ce66';
},
isCurrentWorkflow(): boolean {
return this.workflowsStore.workflowId === this.workflowId;
},
disabled(): boolean {
const isNewWorkflow = !this.workflowId;
if (isNewWorkflow || this.isCurrentWorkflow) {
return !this.workflowActive && !this.containsTrigger;
}
return false;
},
containsTrigger(): boolean {
const foundTriggers = getActivatableTriggerNodes(this.workflowsStore.workflowTriggerNodes);
return foundTriggers.length > 0;
},
},
methods: {
async activeChanged(newActiveState: boolean) {
return await this.updateWorkflowActivation(this.workflowId, newActiveState);
},
async displayActivationError() {
let errorMessage: string;
try {
const errorData = await this.workflowsStore.getActivationError(this.workflowId);
if (errorData === undefined) {
errorMessage = this.$locale.baseText(
'workflowActivator.showMessage.displayActivationError.message.errorDataUndefined',
);
} else {
errorMessage = this.$locale.baseText(
'workflowActivator.showMessage.displayActivationError.message.errorDataNotUndefined',
{ interpolate: { message: errorData } },
);
}
} catch (error) {
errorMessage = this.$locale.baseText(
'workflowActivator.showMessage.displayActivationError.message.catchBlock',
);
}
this.showMessage({
title: this.$locale.baseText('workflowActivator.showMessage.displayActivationError.title'),
message: errorMessage,
type: 'warning',
duration: 0,
dangerouslyUseHTMLString: true,
});
},
},
});
</script>
<style lang="scss" module>
.activeStatusText {
width: 64px; // Required to avoid jumping when changing active state

View File

@ -1,46 +1,45 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, defineProps } from 'vue';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
startTime: Date;
}>();
const i18n = useI18n();
const nowTime = ref(-1);
const intervalTimer = ref<NodeJS.Timeout | null>(null);
const time = computed(() => {
if (!props.startTime) {
return '...';
}
const msPassed = nowTime.value - new Date(props.startTime).getTime();
return i18n.displayTimer(msPassed); // Note: Adjust for $locale usage in setup
});
onMounted(() => {
setNow();
intervalTimer.value = setInterval(() => {
setNow();
}, 1000);
});
onBeforeUnmount(() => {
// Make sure that the timer gets destroyed once no longer needed
if (intervalTimer.value !== null) {
clearInterval(intervalTimer.value);
}
});
function setNow() {
nowTime.value = new Date().getTime();
}
</script>
<template>
<span>
{{ time }}
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'ExecutionsTime',
props: ['startTime'],
data() {
return {
nowTime: -1,
intervalTimer: null as null | NodeJS.Timeout,
};
},
computed: {
time(): string {
if (!this.startTime) {
return '...';
}
const msPassed = this.nowTime - new Date(this.startTime).getTime();
return this.$locale.displayTimer(msPassed);
},
},
mounted() {
this.setNow();
this.intervalTimer = setInterval(() => {
this.setNow();
}, 1000);
},
beforeUnmount() {
// Make sure that the timer gets destroyed once no longer needed
if (this.intervalTimer !== null) {
clearInterval(this.intervalTimer);
}
},
methods: {
setNow() {
this.nowTime = new Date().getTime();
},
},
});
</script>

View File

@ -1,3 +1,174 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import type { IFormInputs, IUser, ThemeOption } from '@/Interface';
import { CHANGE_PASSWORD_MODAL_KEY, MFA_DOCS_URL, MFA_SETUP_MODAL_KEY } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { createEventBus } from 'n8n-design-system/utils';
import { ref } from 'vue';
import { computed } from 'vue';
import { onMounted } from 'vue';
const i18n = useI18n();
const { showToast, showError } = useToast();
const hasAnyBasicInfoChanges = ref<boolean>(false);
const formInputs = ref<null | IFormInputs>(null);
const formBus = ref(createEventBus());
const readyToSubmit = ref(false);
const currentSelectedTheme = ref(useUIStore().theme);
const themeOptions = ref<Array<{ name: ThemeOption; label: string }>>([
{
name: 'system',
label: 'settings.personal.theme.systemDefault',
},
{
name: 'light',
label: 'settings.personal.theme.light',
},
{
name: 'dark',
label: 'settings.personal.theme.dark',
},
]);
const uiStore = useUIStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const currentUser = computed((): IUser | null => {
return usersStore.currentUser;
});
const isExternalAuthEnabled = computed((): boolean => {
const isLdapEnabled =
settingsStore.settings.enterprise.ldap && currentUser.value?.signInType === 'ldap';
const isSamlEnabled =
settingsStore.isSamlLoginEnabled && settingsStore.isDefaultAuthenticationSaml;
return isLdapEnabled || isSamlEnabled;
});
const isPersonalSecurityEnabled = computed((): boolean => {
return usersStore.isInstanceOwner || !isExternalAuthEnabled.value;
});
const mfaDisabled = computed((): boolean => {
return !usersStore.mfaEnabled;
});
const isMfaFeatureEnabled = computed((): boolean => {
return settingsStore.isMfaFeatureEnabled;
});
const hasAnyPersonalisationChanges = computed((): boolean => {
return currentSelectedTheme.value !== uiStore.theme;
});
const hasAnyChanges = computed(() => {
return hasAnyBasicInfoChanges.value || hasAnyPersonalisationChanges.value;
});
onMounted(() => {
formInputs.value = [
{
name: 'firstName',
initialValue: currentUser.value?.firstName,
properties: {
label: i18n.baseText('auth.firstName'),
maxlength: 32,
required: true,
autocomplete: 'given-name',
capitalize: true,
disabled: isExternalAuthEnabled.value,
},
},
{
name: 'lastName',
initialValue: currentUser.value?.lastName,
properties: {
label: i18n.baseText('auth.lastName'),
maxlength: 32,
required: true,
autocomplete: 'family-name',
capitalize: true,
disabled: isExternalAuthEnabled.value,
},
},
{
name: 'email',
initialValue: currentUser.value?.email,
properties: {
label: i18n.baseText('auth.email'),
type: 'email',
required: true,
validationRules: [{ name: 'VALID_EMAIL' }],
autocomplete: 'email',
capitalize: true,
disabled: !isPersonalSecurityEnabled.value,
},
},
];
});
function onInput() {
hasAnyBasicInfoChanges.value = true;
}
function onReadyToSubmit(ready: boolean) {
readyToSubmit.value = ready;
}
async function onSubmit(form: { firstName: string; lastName: string; email: string }) {
try {
await Promise.all([updateUserBasicInfo(form), updatePersonalisationSettings()]);
showToast({
title: i18n.baseText('settings.personal.personalSettingsUpdated'),
message: '',
type: 'success',
});
} catch (e) {
showError(e, i18n.baseText('settings.personal.personalSettingsUpdatedError'));
}
}
async function updateUserBasicInfo(form: { firstName: string; lastName: string; email: string }) {
if (!hasAnyBasicInfoChanges.value || !usersStore.currentUserId) {
return;
}
await usersStore.updateUser({
id: usersStore.currentUserId,
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
});
hasAnyBasicInfoChanges.value = false;
}
async function updatePersonalisationSettings() {
if (!hasAnyPersonalisationChanges.value) {
return;
}
uiStore.setTheme(currentSelectedTheme.value);
}
function onSaveClick() {
formBus.value.emit('submit');
}
function openPasswordModal() {
uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
}
function onMfaEnableClick() {
uiStore.openModal(MFA_SETUP_MODAL_KEY);
}
async function onMfaDisableClick() {
try {
await usersStore.disabledMfa();
showToast({
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),
message: i18n.baseText('settings.personal.mfa.toast.disabledMfa.message'),
type: 'success',
duration: 0,
});
} catch (e) {
showError(e, i18n.baseText('settings.personal.mfa.toast.disabledMfa.error.message'));
}
}
</script>
<template>
<div :class="$style.container" data-test-id="personal-settings-container">
<div :class="$style.header">
@ -45,15 +216,15 @@
</div>
<div v-if="isMfaFeatureEnabled" data-test-id="mfa-section">
<div class="mb-xs">
<n8n-input-label :label="$locale.baseText('settings.personal.mfa.section.title')" />
<n8n-input-label :label="i18n.baseText('settings.personal.mfa.section.title')" />
<n8n-text :bold="false" :class="$style.infoText">
{{
mfaDisabled
? $locale.baseText('settings.personal.mfa.button.disabled.infobox')
: $locale.baseText('settings.personal.mfa.button.enabled.infobox')
? i18n.baseText('settings.personal.mfa.button.disabled.infobox')
: i18n.baseText('settings.personal.mfa.button.enabled.infobox')
}}
<n8n-link :to="mfaDocsUrl" size="small" :bold="true">
{{ $locale.baseText('generic.learnMore') }}
<n8n-link :to="MFA_DOCS_URL" size="small" :bold="true">
{{ i18n.baseText('generic.learnMore') }}
</n8n-link>
</n8n-text>
</div>
@ -61,7 +232,7 @@
v-if="mfaDisabled"
:class="$style.button"
type="tertiary"
:label="$locale.baseText('settings.personal.mfa.button.enabled')"
:label="i18n.baseText('settings.personal.mfa.button.enabled')"
data-test-id="enable-mfa-button"
@click="onMfaEnableClick"
/>
@ -69,7 +240,7 @@
v-else
:class="$style.disableMfaButton"
type="tertiary"
:label="$locale.baseText('settings.personal.mfa.button.disabled')"
:label="i18n.baseText('settings.personal.mfa.button.disabled')"
data-test-id="disable-mfa-button"
@click="onMfaDisableClick"
/>
@ -114,190 +285,6 @@
</div>
</template>
<script lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import type { IFormInputs, IUser, ThemeOption } from '@/Interface';
import { CHANGE_PASSWORD_MODAL_KEY, MFA_DOCS_URL, MFA_SETUP_MODAL_KEY } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import { createEventBus } from 'n8n-design-system/utils';
export default defineComponent({
name: 'SettingsPersonalView',
setup() {
const i18n = useI18n();
return {
i18n,
...useToast(),
};
},
data() {
return {
hasAnyBasicInfoChanges: false,
formInputs: null as null | IFormInputs,
formBus: createEventBus(),
readyToSubmit: false,
mfaDocsUrl: MFA_DOCS_URL,
currentSelectedTheme: useUIStore().theme,
themeOptions: [
{
name: 'system',
label: 'settings.personal.theme.systemDefault',
},
{
name: 'light',
label: 'settings.personal.theme.light',
},
{
name: 'dark',
label: 'settings.personal.theme.dark',
},
] as Array<{ name: ThemeOption; label: string }>,
};
},
computed: {
...mapStores(useUIStore, useUsersStore, useSettingsStore),
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
isExternalAuthEnabled(): boolean {
const isLdapEnabled =
this.settingsStore.settings.enterprise.ldap && this.currentUser?.signInType === 'ldap';
const isSamlEnabled =
this.settingsStore.isSamlLoginEnabled && this.settingsStore.isDefaultAuthenticationSaml;
return isLdapEnabled || isSamlEnabled;
},
isPersonalSecurityEnabled(): boolean {
return this.usersStore.isInstanceOwner || !this.isExternalAuthEnabled;
},
mfaDisabled(): boolean {
return !this.usersStore.mfaEnabled;
},
isMfaFeatureEnabled(): boolean {
return this.settingsStore.isMfaFeatureEnabled;
},
hasAnyPersonalisationChanges(): boolean {
return this.currentSelectedTheme !== this.uiStore.theme;
},
hasAnyChanges() {
return this.hasAnyBasicInfoChanges || this.hasAnyPersonalisationChanges;
},
},
mounted() {
this.formInputs = [
{
name: 'firstName',
initialValue: this.currentUser?.firstName,
properties: {
label: this.i18n.baseText('auth.firstName'),
maxlength: 32,
required: true,
autocomplete: 'given-name',
capitalize: true,
disabled: this.isExternalAuthEnabled,
},
},
{
name: 'lastName',
initialValue: this.currentUser?.lastName,
properties: {
label: this.i18n.baseText('auth.lastName'),
maxlength: 32,
required: true,
autocomplete: 'family-name',
capitalize: true,
disabled: this.isExternalAuthEnabled,
},
},
{
name: 'email',
initialValue: this.currentUser?.email,
properties: {
label: this.i18n.baseText('auth.email'),
type: 'email',
required: true,
validationRules: [{ name: 'VALID_EMAIL' }],
autocomplete: 'email',
capitalize: true,
disabled: !this.isPersonalSecurityEnabled,
},
},
];
},
methods: {
onInput() {
this.hasAnyBasicInfoChanges = true;
},
onReadyToSubmit(ready: boolean) {
this.readyToSubmit = ready;
},
async onSubmit(form: { firstName: string; lastName: string; email: string }) {
try {
await Promise.all([this.updateUserBasicInfo(form), this.updatePersonalisationSettings()]);
this.showToast({
title: this.i18n.baseText('settings.personal.personalSettingsUpdated'),
message: '',
type: 'success',
});
} catch (e) {
this.showError(e, this.i18n.baseText('settings.personal.personalSettingsUpdatedError'));
}
},
async updateUserBasicInfo(form: { firstName: string; lastName: string; email: string }) {
if (!this.hasAnyBasicInfoChanges || !this.usersStore.currentUserId) {
return;
}
await this.usersStore.updateUser({
id: this.usersStore.currentUserId,
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
});
this.hasAnyBasicInfoChanges = false;
},
async updatePersonalisationSettings() {
if (!this.hasAnyPersonalisationChanges) {
return;
}
this.uiStore.setTheme(this.currentSelectedTheme);
},
onSaveClick() {
this.formBus.emit('submit');
},
openPasswordModal() {
this.uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
},
onMfaEnableClick() {
this.uiStore.openModal(MFA_SETUP_MODAL_KEY);
},
async onMfaDisableClick() {
try {
await this.usersStore.disabledMfa();
this.showToast({
title: this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.title'),
message: this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.message'),
type: 'success',
duration: 0,
});
} catch (e) {
this.showError(
e,
this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.error.message'),
);
}
},
},
});
</script>
<style lang="scss" module>
.container {
> * {

View File

@ -1,16 +1,233 @@
<script lang="ts" setup>
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, ROLE } from '@/constants';
import type { IRole, IUser, IUserListAction, InvitableRoleName } from '@/Interface';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useSSOStore } from '@/stores/sso.store';
import { hasPermission } from '@/utils/rbac/permissions';
import { useClipboard } from '@/composables/useClipboard';
import type { UpdateGlobalRolePayload } from '@/api/users';
import { computed, onMounted } from 'vue';
import { useI18n } from '@/composables/useI18n';
const clipboard = useClipboard();
const { showToast, showError } = useToast();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const ssoStore = useSSOStore();
const i18n = useI18n();
const showUMSetupWarning = computed(() => {
return hasPermission(['defaultUser']);
});
onMounted(async () => {
if (!showUMSetupWarning.value) {
await usersStore.fetchUsers();
}
});
const usersListActions = computed((): IUserListAction[] => {
return [
{
label: i18n.baseText('settings.users.actions.copyInviteLink'),
value: 'copyInviteLink',
guard: (user) => settingsStore.isBelowUserQuota && !user.firstName && !!user.inviteAcceptUrl,
},
{
label: i18n.baseText('settings.users.actions.reinvite'),
value: 'reinvite',
guard: (user) =>
settingsStore.isBelowUserQuota && !user.firstName && settingsStore.isSmtpSetup,
},
{
label: i18n.baseText('settings.users.actions.delete'),
value: 'delete',
guard: (user) =>
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
user.id !== usersStore.currentUserId,
},
{
label: i18n.baseText('settings.users.actions.copyPasswordResetLink'),
value: 'copyPasswordResetLink',
guard: (user) =>
hasPermission(['rbac'], { rbac: { scope: 'user:resetPassword' } }) &&
settingsStore.isBelowUserQuota &&
!user.isPendingUser &&
user.id !== usersStore.currentUserId,
},
{
label: i18n.baseText('settings.users.actions.allowSSOManualLogin'),
value: 'allowSSOManualLogin',
guard: (user) => settingsStore.isSamlLoginEnabled && !user.settings?.allowSSOManualLogin,
},
{
label: i18n.baseText('settings.users.actions.disallowSSOManualLogin'),
value: 'disallowSSOManualLogin',
guard: (user) =>
settingsStore.isSamlLoginEnabled && user.settings?.allowSSOManualLogin === true,
},
];
});
const isAdvancedPermissionsEnabled = computed((): boolean => {
return settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.AdvancedPermissions);
});
const userRoles = computed((): Array<{ value: IRole; label: string; disabled?: boolean }> => {
return [
{
value: ROLE.Member,
label: i18n.baseText('auth.roles.member'),
},
{
value: ROLE.Admin,
label: i18n.baseText('auth.roles.admin'),
disabled: !isAdvancedPermissionsEnabled.value,
},
];
});
const canUpdateRole = computed((): boolean => {
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
});
async function onUsersListAction({ action, userId }: { action: string; userId: string }) {
switch (action) {
case 'delete':
await onDelete(userId);
break;
case 'reinvite':
await onReinvite(userId);
break;
case 'copyInviteLink':
await onCopyInviteLink(userId);
break;
case 'copyPasswordResetLink':
await onCopyPasswordResetLink(userId);
break;
case 'allowSSOManualLogin':
await onAllowSSOManualLogin(userId);
break;
case 'disallowSSOManualLogin':
await onDisallowSSOManualLogin(userId);
break;
}
}
function onInvite() {
uiStore.openModal(INVITE_USER_MODAL_KEY);
}
async function onDelete(userId: string) {
const user = usersStore.usersById[userId];
if (user) {
uiStore.openDeleteUserModal(userId);
}
}
async function onReinvite(userId: string) {
const user = usersStore.usersById[userId];
if (user?.email && user?.role) {
if (!['global:admin', 'global:member'].includes(user.role)) {
throw new Error('Invalid role name on reinvite');
}
try {
await usersStore.reinviteUser({
email: user.email,
role: user.role as InvitableRoleName,
});
showToast({
type: 'success',
title: i18n.baseText('settings.users.inviteResent'),
message: i18n.baseText('settings.users.emailSentTo', {
interpolate: { email: user.email ?? '' },
}),
});
} catch (e) {
showError(e, i18n.baseText('settings.users.userReinviteError'));
}
}
}
async function onCopyInviteLink(userId: string) {
const user = usersStore.usersById[userId];
if (user?.inviteAcceptUrl) {
void clipboard.copy(user.inviteAcceptUrl);
showToast({
type: 'success',
title: i18n.baseText('settings.users.inviteUrlCreated'),
message: i18n.baseText('settings.users.inviteUrlCreated.message'),
});
}
}
async function onCopyPasswordResetLink(userId: string) {
const user = usersStore.usersById[userId];
if (user) {
const url = await usersStore.getUserPasswordResetLink(user);
void clipboard.copy(url.link);
showToast({
type: 'success',
title: i18n.baseText('settings.users.passwordResetUrlCreated'),
message: i18n.baseText('settings.users.passwordResetUrlCreated.message'),
});
}
}
async function onAllowSSOManualLogin(userId: string) {
const user = usersStore.usersById[userId];
if (user) {
if (!user.settings) {
user.settings = {};
}
user.settings.allowSSOManualLogin = true;
await usersStore.updateOtherUserSettings(userId, user.settings);
showToast({
type: 'success',
title: i18n.baseText('settings.users.allowSSOManualLogin'),
message: i18n.baseText('settings.users.allowSSOManualLogin.message'),
});
}
}
async function onDisallowSSOManualLogin(userId: string) {
const user = usersStore.usersById[userId];
if (user?.settings) {
user.settings.allowSSOManualLogin = false;
await usersStore.updateOtherUserSettings(userId, user.settings);
showToast({
type: 'success',
title: i18n.baseText('settings.users.disallowSSOManualLogin'),
message: i18n.baseText('settings.users.disallowSSOManualLogin.message'),
});
}
}
function goToUpgrade() {
void uiStore.goToUpgrade('settings-users', 'upgrade-users');
}
function goToUpgradeAdvancedPermissions() {
void uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
}
async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['newRoleName']) {
await usersStore.updateGlobalRole({ id: user.id, newRoleName });
}
</script>
<template>
<div :class="$style.container">
<div>
<n8n-heading size="2xlarge">{{ $locale.baseText('settings.users') }}</n8n-heading>
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.users') }}</n8n-heading>
<div v-if="!showUMSetupWarning" :class="$style.buttonContainer">
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
<template #content>
<span> {{ $locale.baseText('settings.users.invite.tooltip') }} </span>
<span> {{ i18n.baseText('settings.users.invite.tooltip') }} </span>
</template>
<div>
<n8n-button
:disabled="ssoStore.isSamlLoginEnabled || !settingsStore.isBelowUserQuota"
:label="$locale.baseText('settings.users.invite')"
:label="i18n.baseText('settings.users.invite')"
size="large"
data-test-id="settings-users-invite-button"
@click="onInvite"
@ -22,15 +239,13 @@
<div v-if="!settingsStore.isBelowUserQuota" :class="$style.setupInfoContainer">
<n8n-action-box
:heading="
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
i18n.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
"
:description="
$locale.baseText(
uiStore.contextBasedTranslationKeys.users.settings.unavailable.description,
)
i18n.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.description)
"
:button-text="
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
i18n.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
"
@click:button="goToUpgrade"
/>
@ -39,7 +254,7 @@
<i18n-t keypath="settings.users.advancedPermissions.warning">
<template #link>
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
</n8n-link>
</template>
</i18n-t>
@ -79,237 +294,6 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS, ROLE } from '@/constants';
import type { IRole, IUser, IUserListAction, InvitableRoleName } from '@/Interface';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useUsageStore } from '@/stores/usage.store';
import { useSSOStore } from '@/stores/sso.store';
import { hasPermission } from '@/utils/rbac/permissions';
import { useClipboard } from '@/composables/useClipboard';
import type { UpdateGlobalRolePayload } from '@/api/users';
export default defineComponent({
name: 'SettingsUsersView',
setup() {
const clipboard = useClipboard();
return {
clipboard,
...useToast(),
};
},
computed: {
...mapStores(useSettingsStore, useUIStore, useUsersStore, useUsageStore, useSSOStore),
isSharingEnabled() {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
showUMSetupWarning() {
return hasPermission(['defaultUser']);
},
usersListActions(): IUserListAction[] {
return [
{
label: this.$locale.baseText('settings.users.actions.copyInviteLink'),
value: 'copyInviteLink',
guard: (user) =>
this.settingsStore.isBelowUserQuota && !user.firstName && !!user.inviteAcceptUrl,
},
{
label: this.$locale.baseText('settings.users.actions.reinvite'),
value: 'reinvite',
guard: (user) =>
this.settingsStore.isBelowUserQuota &&
!user.firstName &&
this.settingsStore.isSmtpSetup,
},
{
label: this.$locale.baseText('settings.users.actions.delete'),
value: 'delete',
guard: (user) =>
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
user.id !== this.usersStore.currentUserId,
},
{
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
value: 'copyPasswordResetLink',
guard: (user) =>
hasPermission(['rbac'], { rbac: { scope: 'user:resetPassword' } }) &&
this.settingsStore.isBelowUserQuota &&
!user.isPendingUser &&
user.id !== this.usersStore.currentUserId,
},
{
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),
value: 'allowSSOManualLogin',
guard: (user) =>
this.settingsStore.isSamlLoginEnabled && !user.settings?.allowSSOManualLogin,
},
{
label: this.$locale.baseText('settings.users.actions.disallowSSOManualLogin'),
value: 'disallowSSOManualLogin',
guard: (user) =>
this.settingsStore.isSamlLoginEnabled && user.settings?.allowSSOManualLogin === true,
},
];
},
isAdvancedPermissionsEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.AdvancedPermissions,
);
},
userRoles(): Array<{ value: IRole; label: string; disabled?: boolean }> {
return [
{
value: ROLE.Member,
label: this.$locale.baseText('auth.roles.member'),
},
{
value: ROLE.Admin,
label: this.$locale.baseText('auth.roles.admin'),
disabled: !this.isAdvancedPermissionsEnabled,
},
];
},
canUpdateRole(): boolean {
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
},
},
async mounted() {
if (!this.showUMSetupWarning) {
await this.usersStore.fetchUsers();
}
},
methods: {
async onUsersListAction({ action, userId }: { action: string; userId: string }) {
switch (action) {
case 'delete':
await this.onDelete(userId);
break;
case 'reinvite':
await this.onReinvite(userId);
break;
case 'copyInviteLink':
await this.onCopyInviteLink(userId);
break;
case 'copyPasswordResetLink':
await this.onCopyPasswordResetLink(userId);
break;
case 'allowSSOManualLogin':
await this.onAllowSSOManualLogin(userId);
break;
case 'disallowSSOManualLogin':
await this.onDisallowSSOManualLogin(userId);
break;
}
},
redirectToSetup() {
void this.$router.push({ name: VIEWS.SETUP });
},
onInvite() {
this.uiStore.openModal(INVITE_USER_MODAL_KEY);
},
async onDelete(userId: string) {
const user = this.usersStore.usersById[userId];
if (user) {
this.uiStore.openDeleteUserModal(userId);
}
},
async onReinvite(userId: string) {
const user = this.usersStore.usersById[userId];
if (user?.email && user?.role) {
if (!['global:admin', 'global:member'].includes(user.role)) {
throw new Error('Invalid role name on reinvite');
}
try {
await this.usersStore.reinviteUser({
email: user.email,
role: user.role as InvitableRoleName,
});
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.inviteResent'),
message: this.$locale.baseText('settings.users.emailSentTo', {
interpolate: { email: user.email ?? '' },
}),
});
} catch (e) {
this.showError(e, this.$locale.baseText('settings.users.userReinviteError'));
}
}
},
async onCopyInviteLink(userId: string) {
const user = this.usersStore.usersById[userId];
if (user?.inviteAcceptUrl) {
void this.clipboard.copy(user.inviteAcceptUrl);
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.inviteUrlCreated'),
message: this.$locale.baseText('settings.users.inviteUrlCreated.message'),
});
}
},
async onCopyPasswordResetLink(userId: string) {
const user = this.usersStore.usersById[userId];
if (user) {
const url = await this.usersStore.getUserPasswordResetLink(user);
void this.clipboard.copy(url.link);
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.passwordResetUrlCreated'),
message: this.$locale.baseText('settings.users.passwordResetUrlCreated.message'),
});
}
},
async onAllowSSOManualLogin(userId: string) {
const user = this.usersStore.usersById[userId];
if (user) {
if (!user.settings) {
user.settings = {};
}
user.settings.allowSSOManualLogin = true;
await this.usersStore.updateOtherUserSettings(userId, user.settings);
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.allowSSOManualLogin'),
message: this.$locale.baseText('settings.users.allowSSOManualLogin.message'),
});
}
},
async onDisallowSSOManualLogin(userId: string) {
const user = this.usersStore.usersById[userId];
if (user?.settings) {
user.settings.allowSSOManualLogin = false;
await this.usersStore.updateOtherUserSettings(userId, user.settings);
this.showToast({
type: 'success',
title: this.$locale.baseText('settings.users.disallowSSOManualLogin'),
message: this.$locale.baseText('settings.users.disallowSSOManualLogin.message'),
});
}
},
goToUpgrade() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-users');
},
goToUpgradeAdvancedPermissions() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
},
async onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['newRoleName']) {
await this.usersStore.updateGlobalRole({ id: user.id, newRoleName });
},
},
});
</script>
<style lang="scss" module>
.container {
height: 100%;