mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(editor): Show additional data on Users list page (#17339)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -104,6 +104,7 @@
|
||||
"generic.missing.permissions": "Missing permissions to perform this action",
|
||||
"generic.shortcutHint": "Or press",
|
||||
"generic.upgradeToEnterprise": "Upgrade to Enterprise",
|
||||
"generic.never": "Never",
|
||||
"about.aboutN8n": "About n8n",
|
||||
"about.close": "Close",
|
||||
"about.license": "License",
|
||||
@@ -1953,6 +1954,7 @@
|
||||
"settings.personal.security": "Security",
|
||||
"settings.signup.signUpInviterInfo": "{firstName} {lastName} has invited you to n8n",
|
||||
"settings.users": "Users",
|
||||
"settings.users.count": "{count} user | {count} users",
|
||||
"settings.users.search.placeholder": "Search by name or email",
|
||||
"settings.users.confirmDataHandlingAfterDeletion": "What should we do with their data?",
|
||||
"settings.users.confirmUserDeletion": "Are you sure you want to delete this invited user?",
|
||||
@@ -2014,7 +2016,6 @@
|
||||
"settings.users.usersInvited": "Users invited",
|
||||
"settings.users.usersInvitedError": "Could not invite users",
|
||||
"settings.users.advancedPermissions.warning": "{link} to unlock the ability to create additional admin users",
|
||||
"settings.users.advancedPermissions.warning.link": "Upgrade",
|
||||
"settings.users.userRoleUpdated": "Changes saved",
|
||||
"settings.users.userRoleUpdated.message": "{user} has been successfully updated to a {role}",
|
||||
"settings.users.userRoleUpdatedError": "Unable to updated role",
|
||||
@@ -2032,7 +2033,6 @@
|
||||
"settings.users.table.row.2fa.disabled": "@:_reusableBaseText.disabled",
|
||||
"settings.api": "API",
|
||||
"settings.api.scopes.upgrade": "{link} to unlock the ability to modify API key scopes",
|
||||
"settings.api.scopes.upgrade.link": "Upgrade",
|
||||
"settings.n8napi": "n8n API",
|
||||
"settings.log-streaming": "Log Streaming",
|
||||
"settings.log-streaming.heading": "Log Streaming",
|
||||
@@ -2352,6 +2352,10 @@
|
||||
"templates.workflows": "Workflows",
|
||||
"templates.workflowsNotFound": "Workflow could not be found",
|
||||
"textEdit.edit": "Edit",
|
||||
"userActivity.daysAgo": "{count} days ago",
|
||||
"userActivity.lastTime": "Last {time}",
|
||||
"userActivity.today": "Today",
|
||||
"userActivity.yesterday": "Yesterday",
|
||||
"timeAgo.daysAgo": "%s days ago",
|
||||
"timeAgo.hoursAgo": "%s hours ago",
|
||||
"timeAgo.inDays": "in %s days",
|
||||
@@ -3256,7 +3260,6 @@
|
||||
"insights.dashboard.table.title": "Breakdown by workflow",
|
||||
"insights.dashboard.table.estimate": "Estimate",
|
||||
"insights.dashboard.title": "Insights",
|
||||
"insights.dashboard.paywall.cta": "Upgrade",
|
||||
"insights.dashboard.paywall.title": "Upgrade to access more detailed insights",
|
||||
"insights.dashboard.paywall.description": "Gain access to more granular, per-workflow insights and visual breakdown of production executions over different time periods.",
|
||||
"insights.banner.title.timeSaved.tooltip": "Total time saved calculated from your estimated time savings per execution across all workflows",
|
||||
@@ -3281,7 +3284,6 @@
|
||||
"communityNodeInfo.contact.admin": "Please contact an administrator to install this community node:",
|
||||
"communityNodeUpdateInfo.available": "A new node package version is available",
|
||||
"insights.upgradeModal.button.dismiss": "Dismiss",
|
||||
"insights.upgradeModal.button.upgrade": "Upgrade",
|
||||
"insights.upgradeModal.content": "Viewing this time period requires an enterprise plan. Upgrade to Enterprise to unlock advanced features.",
|
||||
"insights.upgradeModal.perks.0": "View up to one year of insights history",
|
||||
"insights.upgradeModal.perks.1": "Zoom into last 24 hours with hourly granularity",
|
||||
|
||||
@@ -120,7 +120,7 @@ function goToUpgradeApiKeyScopes() {
|
||||
<I18nT keypath="settings.api.scopes.upgrade" scope="global">
|
||||
<template #link>
|
||||
<n8n-link size="small" @click="goToUpgradeApiKeyScopes">
|
||||
{{ i18n.baseText('settings.api.scopes.upgrade.link') }}
|
||||
{{ i18n.baseText('generic.upgrade') }}
|
||||
</n8n-link>
|
||||
</template>
|
||||
</I18nT>
|
||||
|
||||
@@ -308,7 +308,7 @@ function getEmail(email: string): string {
|
||||
<I18nT keypath="settings.users.advancedPermissions.warning" scope="global">
|
||||
<template #link>
|
||||
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
||||
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||
{{ i18n.baseText('generic.upgrade') }}
|
||||
</n8n-link>
|
||||
</template>
|
||||
</I18nT>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { type UsersList } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { formatTimeAgo } from '@/utils/formatters/dateFormatter';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const props = defineProps<{ data: UsersList['items'][number] }>();
|
||||
const formattedDate = computed(() =>
|
||||
props.data.lastActiveAt ? formatTimeAgo(props.data.lastActiveAt) : i18n.baseText('generic.never'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ formattedDate }}</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -13,6 +13,7 @@ import type { IUser } from '@/Interface';
|
||||
import SettingsUsersRoleCell from '@/components/SettingsUsers/SettingsUsersRoleCell.vue';
|
||||
import SettingsUsersProjectsCell from '@/components/SettingsUsers/SettingsUsersProjectsCell.vue';
|
||||
import SettingsUsersActionsCell from '@/components/SettingsUsers/SettingsUsersActionsCell.vue';
|
||||
import SettingsUsersLastActiveCell from '@/components/SettingsUsers/SettingsUsersLastActiveCell.vue';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import type { UsersInfoProps } from '@n8n/design-system/components/N8nUserInfo/UserInfo.vue';
|
||||
|
||||
@@ -54,6 +55,17 @@ const headers = ref<Array<TableHeader<Item>>>([
|
||||
title: i18n.baseText('settings.users.table.header.accountType'),
|
||||
key: 'role',
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.users.table.header.lastActive'),
|
||||
key: 'lastActiveAt',
|
||||
value(row) {
|
||||
return {
|
||||
...row,
|
||||
// TODO: Fix N8nDataTableServer so it doesn't break sorting when `value` is of mixed type (for example, string or null)
|
||||
lastActiveAt: row.lastActiveAt ?? '',
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.users.table.header.2fa'),
|
||||
key: 'mfaEnabled',
|
||||
@@ -160,6 +172,9 @@ const onRoleChange = ({ role, userId }: { role: string; userId: string }) => {
|
||||
/>
|
||||
<N8nText v-else color="text-dark">{{ roles[item.role ?? ROLE.Default].label }}</N8nText>
|
||||
</template>
|
||||
<template #[`item.lastActiveAt`]="{ item }">
|
||||
<SettingsUsersLastActiveCell :data="item" />
|
||||
</template>
|
||||
<template #[`item.projects`]="{ item }">
|
||||
<SettingsUsersProjectsCell :data="item" />
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,7 @@ const goToUpgrade = async () => {
|
||||
{{ i18n.baseText('insights.dashboard.paywall.description') }}
|
||||
</N8nText>
|
||||
<N8nButton type="primary" native-type="button" size="large" @click="goToUpgrade">
|
||||
{{ i18n.baseText('insights.dashboard.paywall.cta') }}
|
||||
{{ i18n.baseText('generic.upgrade') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -45,7 +45,7 @@ const perks = computed(() =>
|
||||
{{ i18n.baseText('insights.upgradeModal.button.dismiss') }}
|
||||
</N8nButton>
|
||||
<N8nButton type="primary" @click="goToUpgrade">
|
||||
{{ i18n.baseText('insights.upgradeModal.button.upgrade') }}
|
||||
{{ i18n.baseText('generic.upgrade') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { formatTimeAgo } from './dateFormatter';
|
||||
|
||||
describe('formatTimeAgo', () => {
|
||||
const now = new Date('2023-07-15T12:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('should return "Today" for today', () => {
|
||||
const today = new Date('2023-07-15T08:00:00Z');
|
||||
expect(formatTimeAgo(today)).toBe('Today');
|
||||
});
|
||||
|
||||
test('should return "Yesterday" for 1 day ago', () => {
|
||||
const yesterday = new Date('2023-07-14T12:00:00Z');
|
||||
expect(formatTimeAgo(yesterday)).toBe('Yesterday');
|
||||
});
|
||||
|
||||
test('should return weekday name for 2-6 days ago', () => {
|
||||
const threeDaysAgo = new Date('2023-07-12T12:00:00Z'); // Wednesday
|
||||
expect(formatTimeAgo(threeDaysAgo)).toBe('Wednesday');
|
||||
|
||||
const sixDaysAgo = new Date('2023-07-09T12:00:00Z'); // Sunday
|
||||
expect(formatTimeAgo(sixDaysAgo)).toBe('Sunday');
|
||||
});
|
||||
|
||||
test('should return "Last [Weekday]" for 7-13 days ago', () => {
|
||||
const sevenDaysAgo = new Date('2023-07-08T12:00:00Z'); // Saturday
|
||||
expect(formatTimeAgo(sevenDaysAgo)).toBe('Last Saturday');
|
||||
|
||||
const thirteenDaysAgo = new Date('2023-07-02T12:00:00Z'); // Sunday
|
||||
expect(formatTimeAgo(thirteenDaysAgo)).toBe('Last Sunday');
|
||||
});
|
||||
|
||||
test('should return "[n] days ago" for 14-30 days ago', () => {
|
||||
const fourteenDaysAgo = new Date('2023-07-01T12:00:00Z');
|
||||
expect(formatTimeAgo(fourteenDaysAgo)).toBe('14 days ago');
|
||||
|
||||
const thirtyDaysAgo = new Date('2023-06-15T12:00:00Z');
|
||||
expect(formatTimeAgo(thirtyDaysAgo)).toBe('30 days ago');
|
||||
});
|
||||
|
||||
test('should return "[Month] [Day], [Year]" for 31+ days ago', () => {
|
||||
const thirtyOneDaysAgo = new Date('2023-06-14T12:00:00Z');
|
||||
expect(formatTimeAgo(thirtyOneDaysAgo)).toBe('June 14, 2023');
|
||||
|
||||
const oneYearAgo = new Date('2022-07-15T12:00:00Z');
|
||||
expect(formatTimeAgo(oneYearAgo)).toBe('July 15, 2022');
|
||||
});
|
||||
|
||||
test('should handle string input', () => {
|
||||
const yesterday = '2023-07-14T12:00:00Z';
|
||||
expect(formatTimeAgo(yesterday)).toBe('Yesterday');
|
||||
});
|
||||
|
||||
test('should handle edge cases at boundaries', () => {
|
||||
// Exactly 7 days ago
|
||||
const exactlySevenDaysAgo = new Date('2023-07-08T12:00:00Z');
|
||||
expect(formatTimeAgo(exactlySevenDaysAgo)).toBe('Last Saturday');
|
||||
|
||||
// Exactly 14 days ago
|
||||
const exactlyFourteenDaysAgo = new Date('2023-07-01T12:00:00Z');
|
||||
expect(formatTimeAgo(exactlyFourteenDaysAgo)).toBe('14 days ago');
|
||||
|
||||
// Exactly 31 days ago
|
||||
const exactlyThirtyOneDaysAgo = new Date('2023-06-14T12:00:00Z');
|
||||
expect(formatTimeAgo(exactlyThirtyOneDaysAgo)).toBe('June 14, 2023');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import dateformat from 'dateformat';
|
||||
import { i18n } from '@n8n/i18n';
|
||||
|
||||
export const convertToDisplayDateComponents = (
|
||||
fullDate: Date | string | number,
|
||||
@@ -27,3 +28,26 @@ export const toDayMonth = (fullDate: Date | string) => dateformat(fullDate, 'd m
|
||||
|
||||
export const toTime = (fullDate: Date | string, includeMillis: boolean = false) =>
|
||||
dateformat(fullDate, includeMillis ? 'HH:MM:ss.l' : 'HH:MM:ss');
|
||||
|
||||
export const formatTimeAgo = (fullDate: Date | string): string => {
|
||||
const now = new Date();
|
||||
const date = new Date(fullDate);
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffInDays === 0) {
|
||||
return i18n.baseText('userActivity.today');
|
||||
} else if (diffInDays === 1) {
|
||||
return i18n.baseText('userActivity.yesterday');
|
||||
} else if (diffInDays >= 2 && diffInDays <= 6) {
|
||||
return dateformat(date, 'dddd');
|
||||
} else if (diffInDays >= 7 && diffInDays <= 13) {
|
||||
return i18n.baseText('userActivity.lastTime', {
|
||||
interpolate: { time: dateformat(date, 'dddd') },
|
||||
});
|
||||
} else if (diffInDays >= 14 && diffInDays <= 30) {
|
||||
return i18n.baseText('userActivity.daysAgo', { interpolate: { count: diffInDays.toString() } });
|
||||
} else {
|
||||
return dateformat(date, 'mmmm d, yyyy');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,9 @@ const usersTableState = ref<TableOptions>({
|
||||
],
|
||||
});
|
||||
const showUMSetupWarning = computed(() => hasPermission(['defaultUser']));
|
||||
const isEnforceMFAEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.EnforceMFA],
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.users'));
|
||||
@@ -350,11 +353,19 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<n8n-heading tag="h1" size="2xlarge" class="mb-xl">
|
||||
<N8nHeading tag="h1" size="2xlarge" class="mb-xl">
|
||||
{{ i18n.baseText('settings.users') }}
|
||||
</n8n-heading>
|
||||
<N8nText v-if="!showUMSetupWarning" :class="$style.userCount" color="text-light">{{
|
||||
i18n.baseText('settings.users.count', {
|
||||
interpolate: {
|
||||
count: usersStore.usersList.state.count,
|
||||
},
|
||||
adjustToNumber: usersStore.usersList.state.count,
|
||||
})
|
||||
}}</N8nText>
|
||||
</N8nHeading>
|
||||
<div v-if="!usersStore.usersLimitNotReached" :class="$style.setupInfoContainer">
|
||||
<n8n-action-box
|
||||
<N8nActionBox
|
||||
:heading="
|
||||
i18n.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
|
||||
"
|
||||
@@ -367,25 +378,30 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
@click:button="goToUpgrade"
|
||||
/>
|
||||
</div>
|
||||
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
|
||||
<N8nNotice v-if="!isAdvancedPermissionsEnabled">
|
||||
<I18nT keypath="settings.users.advancedPermissions.warning" scope="global">
|
||||
<template #link>
|
||||
<n8n-link
|
||||
<N8nLink
|
||||
data-test-id="upgrade-permissions-link"
|
||||
size="small"
|
||||
@click="goToUpgradeAdvancedPermissions"
|
||||
>
|
||||
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||
</n8n-link>
|
||||
{{ i18n.baseText('generic.upgrade') }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
</I18nT>
|
||||
</n8n-notice>
|
||||
</N8nNotice>
|
||||
<div :class="$style.settingsContainer">
|
||||
<div :class="$style.settingsContainerInfo">
|
||||
<n8n-text :bold="true">{{ i18n.baseText('settings.personal.mfa.enforce.title') }}</n8n-text>
|
||||
<n8n-text size="small" color="text-light">{{
|
||||
<N8nText :bold="true"
|
||||
>{{ i18n.baseText('settings.personal.mfa.enforce.title') }}
|
||||
<N8nBadge v-if="!isEnforceMFAEnabled" class="ml-4xs">{{
|
||||
i18n.baseText('generic.upgrade')
|
||||
}}</N8nBadge>
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light">{{
|
||||
i18n.baseText('settings.personal.mfa.enforce.message')
|
||||
}}</n8n-text>
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.settingsContainerAction">
|
||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.EnforceMFA]">
|
||||
@@ -397,12 +413,7 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
/>
|
||||
<template #fallback>
|
||||
<N8nTooltip>
|
||||
<el-switch
|
||||
:model-value="settingsStore.isMFAEnforced"
|
||||
size="large"
|
||||
:disabled="true"
|
||||
@update:model-value="onUpdateMfaEnforced"
|
||||
/>
|
||||
<el-switch :model-value="settingsStore.isMFAEnforced" size="large" :disabled="true" />
|
||||
<template #content>
|
||||
<I18nT :keypath="tooltipKey" tag="span" scope="global">
|
||||
<template #action>
|
||||
@@ -418,7 +429,7 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showUMSetupWarning" :class="$style.buttonContainer">
|
||||
<n8n-input
|
||||
<N8nInput
|
||||
:class="$style.search"
|
||||
:model-value="search"
|
||||
:placeholder="i18n.baseText('settings.users.search.placeholder')"
|
||||
@@ -427,15 +438,15 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
@update:model-value="onSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<n8n-icon icon="search" />
|
||||
<N8nIcon icon="search" />
|
||||
</template>
|
||||
</n8n-input>
|
||||
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
|
||||
</N8nInput>
|
||||
<N8nTooltip :disabled="!ssoStore.isSamlLoginEnabled">
|
||||
<template #content>
|
||||
<span> {{ i18n.baseText('settings.users.invite.tooltip') }} </span>
|
||||
</template>
|
||||
<div>
|
||||
<n8n-button
|
||||
<N8nButton
|
||||
:disabled="ssoStore.isSamlLoginEnabled || !usersStore.usersLimitNotReached"
|
||||
:label="i18n.baseText('settings.users.invite')"
|
||||
size="large"
|
||||
@@ -443,7 +454,7 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
@click="onInvite"
|
||||
/>
|
||||
</div>
|
||||
</n8n-tooltip>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
<!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
|
||||
-->
|
||||
@@ -466,6 +477,11 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.userCount {
|
||||
display: block;
|
||||
padding: var(--spacing-3xs) 0 0;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user