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:
Csaba Tuncsik
2025-07-17 17:26:39 +02:00
committed by GitHub
parent ccac8f7d71
commit f3f4461ac5
12 changed files with 184 additions and 34 deletions

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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');
}
};

View File

@@ -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;