mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor(editor): Move a few components to script setup (no-changelog) (#10029)
This commit is contained in:
parent
293147642f
commit
2e9ab66602
|
@ -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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NDVFloatingNodes
|
<NDVFloatingNodes
|
||||||
v-if="activeNode"
|
v-if="ndvStore.activeNode"
|
||||||
:root-node="activeNode"
|
:root-node="ndvStore.activeNode"
|
||||||
@switch-selected-node="onSwitchSelectedNode"
|
@switch-selected-node="onSwitchSelectedNode"
|
||||||
/>
|
/>
|
||||||
<div v-if="!hideInputAndOutput" :class="$style.inputPanel" :style="inputPanelStyles">
|
<div v-if="!hideInputAndOutput" :class="$style.inputPanel" :style="inputPanelStyles">
|
||||||
|
@ -41,377 +412,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style lang="scss" module>
|
||||||
.dataPanel {
|
.dataPanel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<span :class="$style.container" data-test-id="save-button">
|
<span :class="$style.container" data-test-id="save-button">
|
||||||
<span v-if="saved" :class="$style.saved">{{ $locale.baseText('saveButton.saved') }}</span>
|
<span v-if="saved" :class="$style.saved">{{ $locale.baseText('saveButton.saved') }}</span>
|
||||||
|
@ -28,59 +64,6 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</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>
|
<style lang="scss" module>
|
||||||
.container {
|
.container {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
@ -1,47 +1,50 @@
|
||||||
<template>
|
<script lang="ts" setup>
|
||||||
<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';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
const credentialsStore = useCredentialsStore();
|
||||||
name: 'ScopesNotice',
|
const i18n = useI18n();
|
||||||
props: ['activeCredentialType', 'scopes'],
|
|
||||||
computed: {
|
const props = defineProps<{
|
||||||
...mapStores(useCredentialsStore),
|
activeCredentialType: string;
|
||||||
scopesShortContent(): string {
|
scopes: string[];
|
||||||
return this.$locale.baseText('nodeSettings.scopes.notice', {
|
}>();
|
||||||
adjustToNumber: this.scopes.length,
|
|
||||||
|
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: {
|
interpolate: {
|
||||||
activeCredential: this.shortCredentialDisplayName,
|
activeCredential: shortCredentialDisplayName.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
scopesFullContent(): string {
|
|
||||||
return this.$locale.baseText('nodeSettings.scopes.expandedNoticeWithScopes', {
|
const scopesFullContent = computed((): string => {
|
||||||
adjustToNumber: this.scopes.length,
|
return i18n.baseText('nodeSettings.scopes.expandedNoticeWithScopes', {
|
||||||
|
adjustToNumber: props.scopes.length,
|
||||||
interpolate: {
|
interpolate: {
|
||||||
activeCredential: this.shortCredentialDisplayName,
|
activeCredential: shortCredentialDisplayName.value,
|
||||||
scopes: this.scopes
|
scopes: props.scopes
|
||||||
.map((s: string) => (s.includes('/') ? s.split('/').pop() : s))
|
.map((s: string) => (s.includes('/') ? s.split('/').pop() : s))
|
||||||
.join('<br>'),
|
.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>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-notice :content="scopesShortContent" :full-content="scopesFullContent" />
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,75 +1,74 @@
|
||||||
<template>
|
<script lang="ts" setup>
|
||||||
<span v-show="false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import type { ITelemetrySettings } from 'n8n-workflow';
|
import type { ITelemetrySettings } from 'n8n-workflow';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
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({
|
const isTelemetryInitialized = ref(false);
|
||||||
name: 'Telemetry',
|
|
||||||
data() {
|
const rootStore = useRootStore();
|
||||||
return {
|
const settingsStore = useSettingsStore();
|
||||||
isTelemetryInitialized: false,
|
const usersStore = useUsersStore();
|
||||||
};
|
const projectsStore = useProjectsStore();
|
||||||
},
|
const telemetryPlugin = useTelemetry();
|
||||||
computed: {
|
const route = useRoute();
|
||||||
...mapStores(useRootStore, useSettingsStore, useUsersStore, useProjectsStore),
|
|
||||||
currentUserId(): string {
|
const currentUserId = computed((): string => {
|
||||||
return this.usersStore.currentUserId ?? '';
|
return usersStore.currentUserId ?? '';
|
||||||
},
|
});
|
||||||
isTelemetryEnabledOnRoute(): boolean {
|
|
||||||
const routeMeta = this.$route.meta as { telemetry?: { disabled?: boolean } } | undefined;
|
const isTelemetryEnabledOnRoute = computed((): boolean => {
|
||||||
|
const routeMeta = route.meta as { telemetry?: { disabled?: boolean } } | undefined;
|
||||||
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
|
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
|
||||||
},
|
});
|
||||||
telemetry(): ITelemetrySettings {
|
|
||||||
return this.settingsStore.telemetry;
|
const telemetry = computed((): ITelemetrySettings => {
|
||||||
},
|
return settingsStore.telemetry;
|
||||||
isTelemetryEnabled(): boolean {
|
});
|
||||||
return !!this.telemetry?.enabled;
|
|
||||||
},
|
const isTelemetryEnabled = computed((): boolean => {
|
||||||
},
|
return !!telemetry.value?.enabled;
|
||||||
watch: {
|
});
|
||||||
telemetry() {
|
|
||||||
this.init();
|
watch(telemetry, () => {
|
||||||
},
|
init();
|
||||||
currentUserId(userId) {
|
});
|
||||||
if (this.isTelemetryEnabled) {
|
|
||||||
this.$telemetry.identify(this.rootStore.instanceId, userId);
|
watch(currentUserId, (userId) => {
|
||||||
|
if (isTelemetryEnabled.value) {
|
||||||
|
telemetryPlugin.identify(rootStore.instanceId, userId);
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
isTelemetryEnabledOnRoute(enabled) {
|
|
||||||
|
watch(isTelemetryEnabledOnRoute, (enabled) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
this.init();
|
init();
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
},
|
|
||||||
mounted() {
|
onMounted(() => {
|
||||||
this.init();
|
init();
|
||||||
},
|
});
|
||||||
methods: {
|
|
||||||
init() {
|
function init() {
|
||||||
if (
|
if (isTelemetryInitialized.value || !isTelemetryEnabledOnRoute.value || !isTelemetryEnabled.value)
|
||||||
this.isTelemetryInitialized ||
|
|
||||||
!this.isTelemetryEnabledOnRoute ||
|
|
||||||
!this.isTelemetryEnabled
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.$telemetry.init(this.telemetry, {
|
telemetryPlugin.init(telemetry.value, {
|
||||||
instanceId: this.rootStore.instanceId,
|
instanceId: rootStore.instanceId,
|
||||||
userId: this.currentUserId,
|
userId: currentUserId.value,
|
||||||
projectId: this.projectsStore.personalProject?.id,
|
projectId: projectsStore.personalProject?.id,
|
||||||
versionCli: this.rootStore.versionCli,
|
versionCli: rootStore.versionCli,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isTelemetryInitialized = true;
|
isTelemetryInitialized.value = true;
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span v-show="false" />
|
||||||
|
</template>
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<div class="workflow-activator">
|
<div class="workflow-activator">
|
||||||
<div :class="$style.activeStatusText" data-test-id="workflow-activator-status">
|
<div :class="$style.activeStatusText" data-test-id="workflow-activator-status">
|
||||||
|
@ -7,27 +88,27 @@
|
||||||
size="small"
|
size="small"
|
||||||
bold
|
bold
|
||||||
>
|
>
|
||||||
{{ $locale.baseText('workflowActivator.active') }}
|
{{ i18n.baseText('workflowActivator.active') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
<n8n-text v-else color="text-base" size="small" bold>
|
<n8n-text v-else color="text-base" size="small" bold>
|
||||||
{{ $locale.baseText('workflowActivator.inactive') }}
|
{{ i18n.baseText('workflowActivator.inactive') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
<n8n-tooltip :disabled="!disabled" placement="bottom">
|
<n8n-tooltip :disabled="!disabled" placement="bottom">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<div>
|
||||||
{{ $locale.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes') }}
|
{{ i18n.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-switch
|
<el-switch
|
||||||
v-loading="updatingWorkflowActivation"
|
v-loading="workflowActivate.updatingWorkflowActivation.value"
|
||||||
:model-value="workflowActive"
|
:model-value="workflowActive"
|
||||||
:title="
|
:title="
|
||||||
workflowActive
|
workflowActive
|
||||||
? $locale.baseText('workflowActivator.deactivateWorkflow')
|
? i18n.baseText('workflowActivator.deactivateWorkflow')
|
||||||
: $locale.baseText('workflowActivator.activateWorkflow')
|
: i18n.baseText('workflowActivator.activateWorkflow')
|
||||||
"
|
"
|
||||||
:disabled="disabled || updatingWorkflowActivation"
|
:disabled="disabled || workflowActivate.updatingWorkflowActivation.value"
|
||||||
:active-color="getActiveColor"
|
:active-color="getActiveColor"
|
||||||
inactive-color="#8899AA"
|
inactive-color="#8899AA"
|
||||||
data-test-id="workflow-activate-switch"
|
data-test-id="workflow-activate-switch"
|
||||||
|
@ -41,7 +122,7 @@
|
||||||
<template #content>
|
<template #content>
|
||||||
<div
|
<div
|
||||||
@click="displayActivationError"
|
@click="displayActivationError"
|
||||||
v-html="$locale.baseText('workflowActivator.theWorkflowIsSetToBeActiveBut')"
|
v-html="i18n.baseText('workflowActivator.theWorkflowIsSetToBeActiveBut')"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
<font-awesome-icon icon="exclamation-triangle" @click="displayActivationError" />
|
<font-awesome-icon icon="exclamation-triangle" @click="displayActivationError" />
|
||||||
|
@ -50,95 +131,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style lang="scss" module>
|
||||||
.activeStatusText {
|
.activeStatusText {
|
||||||
width: 64px; // Required to avoid jumping when changing active state
|
width: 64px; // Required to avoid jumping when changing active state
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
{{ time }}
|
{{ time }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</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>
|
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<div :class="$style.container" data-test-id="personal-settings-container">
|
<div :class="$style.container" data-test-id="personal-settings-container">
|
||||||
<div :class="$style.header">
|
<div :class="$style.header">
|
||||||
|
@ -45,15 +216,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isMfaFeatureEnabled" data-test-id="mfa-section">
|
<div v-if="isMfaFeatureEnabled" data-test-id="mfa-section">
|
||||||
<div class="mb-xs">
|
<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">
|
<n8n-text :bold="false" :class="$style.infoText">
|
||||||
{{
|
{{
|
||||||
mfaDisabled
|
mfaDisabled
|
||||||
? $locale.baseText('settings.personal.mfa.button.disabled.infobox')
|
? i18n.baseText('settings.personal.mfa.button.disabled.infobox')
|
||||||
: $locale.baseText('settings.personal.mfa.button.enabled.infobox')
|
: i18n.baseText('settings.personal.mfa.button.enabled.infobox')
|
||||||
}}
|
}}
|
||||||
<n8n-link :to="mfaDocsUrl" size="small" :bold="true">
|
<n8n-link :to="MFA_DOCS_URL" size="small" :bold="true">
|
||||||
{{ $locale.baseText('generic.learnMore') }}
|
{{ i18n.baseText('generic.learnMore') }}
|
||||||
</n8n-link>
|
</n8n-link>
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
|
@ -61,7 +232,7 @@
|
||||||
v-if="mfaDisabled"
|
v-if="mfaDisabled"
|
||||||
:class="$style.button"
|
:class="$style.button"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
:label="$locale.baseText('settings.personal.mfa.button.enabled')"
|
:label="i18n.baseText('settings.personal.mfa.button.enabled')"
|
||||||
data-test-id="enable-mfa-button"
|
data-test-id="enable-mfa-button"
|
||||||
@click="onMfaEnableClick"
|
@click="onMfaEnableClick"
|
||||||
/>
|
/>
|
||||||
|
@ -69,7 +240,7 @@
|
||||||
v-else
|
v-else
|
||||||
:class="$style.disableMfaButton"
|
:class="$style.disableMfaButton"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
:label="$locale.baseText('settings.personal.mfa.button.disabled')"
|
:label="i18n.baseText('settings.personal.mfa.button.disabled')"
|
||||||
data-test-id="disable-mfa-button"
|
data-test-id="disable-mfa-button"
|
||||||
@click="onMfaDisableClick"
|
@click="onMfaDisableClick"
|
||||||
/>
|
/>
|
||||||
|
@ -114,190 +285,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style lang="scss" module>
|
||||||
.container {
|
.container {
|
||||||
> * {
|
> * {
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<div :class="$style.container">
|
<div :class="$style.container">
|
||||||
<div>
|
<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">
|
<div v-if="!showUMSetupWarning" :class="$style.buttonContainer">
|
||||||
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
|
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
|
||||||
<template #content>
|
<template #content>
|
||||||
<span> {{ $locale.baseText('settings.users.invite.tooltip') }} </span>
|
<span> {{ i18n.baseText('settings.users.invite.tooltip') }} </span>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
:disabled="ssoStore.isSamlLoginEnabled || !settingsStore.isBelowUserQuota"
|
:disabled="ssoStore.isSamlLoginEnabled || !settingsStore.isBelowUserQuota"
|
||||||
:label="$locale.baseText('settings.users.invite')"
|
:label="i18n.baseText('settings.users.invite')"
|
||||||
size="large"
|
size="large"
|
||||||
data-test-id="settings-users-invite-button"
|
data-test-id="settings-users-invite-button"
|
||||||
@click="onInvite"
|
@click="onInvite"
|
||||||
|
@ -22,15 +239,13 @@
|
||||||
<div v-if="!settingsStore.isBelowUserQuota" :class="$style.setupInfoContainer">
|
<div v-if="!settingsStore.isBelowUserQuota" :class="$style.setupInfoContainer">
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
:heading="
|
:heading="
|
||||||
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
|
i18n.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
|
||||||
"
|
"
|
||||||
:description="
|
:description="
|
||||||
$locale.baseText(
|
i18n.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.description)
|
||||||
uiStore.contextBasedTranslationKeys.users.settings.unavailable.description,
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
:button-text="
|
:button-text="
|
||||||
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
|
i18n.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
|
||||||
"
|
"
|
||||||
@click:button="goToUpgrade"
|
@click:button="goToUpgrade"
|
||||||
/>
|
/>
|
||||||
|
@ -39,7 +254,7 @@
|
||||||
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
||||||
<template #link>
|
<template #link>
|
||||||
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
||||||
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
|
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||||
</n8n-link>
|
</n8n-link>
|
||||||
</template>
|
</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
|
@ -79,237 +294,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style lang="scss" module>
|
||||||
.container {
|
.container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
Loading…
Reference in a new issue