diff --git a/cypress/e2e/po/components/list-row.po.ts b/cypress/e2e/po/components/list-row.po.ts index ccd84c9a7d2..365cc794e87 100644 --- a/cypress/e2e/po/components/list-row.po.ts +++ b/cypress/e2e/po/components/list-row.po.ts @@ -2,6 +2,6 @@ import ComponentPo from '@/cypress/e2e/po/components/component.po'; export default class ListRowPo extends ComponentPo { column(index: number) { - return this.self().find('td').eq(index); + return this.self().find('td').eq(index).scrollIntoView(); } } diff --git a/cypress/e2e/po/lists/management.cattle.io.user.po.ts b/cypress/e2e/po/lists/management.cattle.io.user.po.ts index 05878f15d72..78222821206 100644 --- a/cypress/e2e/po/lists/management.cattle.io.user.po.ts +++ b/cypress/e2e/po/lists/management.cattle.io.user.po.ts @@ -46,7 +46,10 @@ export default class MgmtUsersListPo extends BaseResourceList { } clickRowActionMenuItem(name: string, itemLabel:string) { - return this.resourceTable().sortableTable().rowActionMenuOpen(name, 7).getMenuItem(itemLabel) + return this.resourceTable() + .sortableTable() + .rowActionMenuOpen(name, 10) + .getMenuItem(itemLabel) .click(); } } diff --git a/cypress/e2e/tests/pages/users-and-auth/users.spec.ts b/cypress/e2e/tests/pages/users-and-auth/users.spec.ts index e910d401a9f..5d8642ac4d5 100644 --- a/cypress/e2e/tests/pages/users-and-auth/users.spec.ts +++ b/cypress/e2e/tests/pages/users-and-auth/users.spec.ts @@ -66,7 +66,7 @@ describe('Users', { tags: '@adminUser' }, () => { // usersPo.goTo(); usersPo.waitForPage(); - usersPo.list().elementWithName(userBaseUsername).should('be.visible'); + usersPo.list().elementWithName(userBaseUsername).should('exist'); }); it('can create Standard User and view their details', () => { @@ -84,7 +84,7 @@ describe('Users', { tags: '@adminUser' }, () => { userId = res.response?.body.userId; usersPo.waitForPage(); - usersPo.list().elementWithName(standardUsername).should('be.visible'); + usersPo.list().elementWithName(standardUsername).should('exist'); // view user's details usersPo.list().details(standardUsername, 2).find('a').click(); @@ -109,11 +109,11 @@ describe('Users', { tags: '@adminUser' }, () => { // Deactivate user and check state is Inactive usersPo.goTo(); usersPo.list().clickRowActionMenuItem(standardUsername, 'Deactivate'); - usersPo.list().details(standardUsername, 1).should('include.text', 'Inactive'); + usersPo.list().details(standardUsername, 1).should('include.text', 'Disabled'); // Activate user and check state is Active usersPo.list().clickRowActionMenuItem(standardUsername, 'Activate'); - usersPo.list().details(standardUsername, 1).should('include.text', 'Active'); + usersPo.list().details(standardUsername, 1).should('include.text', 'Enabled'); }); it('can Refresh Group Memberships', () => { @@ -192,14 +192,14 @@ describe('Users', { tags: '@adminUser' }, () => { usersPo.list().selectAll().set(); usersPo.list().deactivate().click(); cy.wait('@updateUsers'); - cy.contains('Inactive'); - usersPo.list().details('admin', 1).should('include.text', 'Active'); - usersPo.list().details(userBaseUsername, 1).should('include.text', 'Inactive'); + cy.contains('Disabled'); + usersPo.list().details('admin', 1).should('include.text', 'Enabled'); + usersPo.list().details(userBaseUsername, 1).should('include.text', 'Disabled'); // Activate user and check state is Active usersPo.list().activate().click(); cy.wait('@updateUsers'); - usersPo.list().details(userBaseUsername, 1).should('include.text', 'Active'); + usersPo.list().details(userBaseUsername, 1).should('include.text', 'Enabled'); }); it('can Download YAML', () => { diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index d4f350e39a0..e467f9bd3a5 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -240,6 +240,7 @@ suffix: ib: iB mib: MiB gb: GB + ago: ago revisions: |- {count, plural, =1 { Revision } @@ -5541,6 +5542,9 @@ tableHeaders: users: Users userDisplayName: Display Name userId: ID + userDeletedIn: Delete After + userDisabledIn: Disable After + userLastLogin: Last Login userStatus: Status username: Local Username value: Value @@ -5561,6 +5565,10 @@ target: placeholder: Select a version user: + state: + active: 'Enabled' + inactive: 'Disabled' + unknown: 'Unknown' detail: username: Username globalPermissions: diff --git a/shell/components/formatter/LiveDate.vue b/shell/components/formatter/LiveDate.vue index 9a5666701f6..a7e28b1cd29 100644 --- a/shell/components/formatter/LiveDate.vue +++ b/shell/components/formatter/LiveDate.vue @@ -34,6 +34,16 @@ export default { showTooltip: { type: Boolean, default: true + }, + + /** + * Determines if the live date should behave like a countdown by comparing + * the provided value and the current date. When the countdown reaches 0, a + * "-" is rendered. + */ + isCountdown: { + type: Boolean, + default: false, } }, @@ -104,6 +114,12 @@ export default { return 300; } + if (this.isCountdown && now.valueOf() > this.dayValue?.valueOf()) { + this.label = '-'; + + return 300; + } + const diff = diffFrom(this.dayValue, now); const prefix = (diff.diff < 0 || !this.addPrefix ? '' : '-'); diff --git a/shell/config/product/explorer.js b/shell/config/product/explorer.js index d141ff2515a..750775b3407 100644 --- a/shell/config/product/explorer.js +++ b/shell/config/product/explorer.js @@ -14,7 +14,7 @@ import { STATE, NAME as NAME_COL, NAMESPACE as NAMESPACE_COL, AGE, KEYS, INGRESS_DEFAULT_BACKEND, INGRESS_TARGET, INGRESS_CLASS, SPEC_TYPE, TARGET_PORT, SELECTOR, NODE as NODE_COL, TYPE, WORKLOAD_IMAGES, POD_IMAGES, - USER_ID, USERNAME, USER_DISPLAY_NAME, USER_PROVIDER, WORKLOAD_ENDPOINTS, STORAGE_CLASS_DEFAULT, + USER_ID, USERNAME, USER_DISPLAY_NAME, USER_PROVIDER, USER_LAST_LOGIN, USER_DISABLED_IN, USER_DELETED_IN, WORKLOAD_ENDPOINTS, STORAGE_CLASS_DEFAULT, STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE, HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA, ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE, POD_RESTARTS, @@ -259,6 +259,9 @@ export function init(store) { USER_DISPLAY_NAME, USER_PROVIDER, USERNAME, + USER_LAST_LOGIN, + USER_DISABLED_IN, + USER_DELETED_IN, AGE ]); diff --git a/shell/config/table-headers.js b/shell/config/table-headers.js index 50927e57317..8faaf4a6fa0 100644 --- a/shell/config/table-headers.js +++ b/shell/config/table-headers.js @@ -398,6 +398,33 @@ export const USER_PROVIDER = { sort: 'providerDisplay', }; +export const USER_LAST_LOGIN = { + name: 'user-last-login', + labelKey: 'tableHeaders.userLastLogin', + value: 'userLastLogin', + formatter: 'LiveDate', + formatterOpts: { addSuffix: true }, + sort: 'userLastLogin', +}; + +export const USER_DISABLED_IN = { + name: 'user-disabled-in', + labelKey: 'tableHeaders.userDisabledIn', + value: 'userDisabledInDisplay', + formatter: 'LiveDate', + formatterOpts: { isCountdown: true }, + sort: 'userDisabledIn', +}; + +export const USER_DELETED_IN = { + name: 'user-deleted-in', + labelKey: 'tableHeaders.userDeletedIn', + value: 'userDeletedIn', + formatter: 'LiveDate', + formatterOpts: { isCountdown: true }, + sort: 'userDeletedIn', +}; + export const USER_ID = { name: 'user-id', labelKey: 'tableHeaders.userId', diff --git a/shell/list/management.cattle.io.feature.vue b/shell/list/management.cattle.io.feature.vue index a371ea065c0..6a6cf663778 100644 --- a/shell/list/management.cattle.io.feature.vue +++ b/shell/list/management.cattle.io.feature.vue @@ -82,7 +82,7 @@ export default { }, enableRowActions() { - const schema = this.$store.getters[`management/schemaFor`](MANAGEMENT.SETTING); + const schema = this.$store.getters[`management/schemaFor`](MANAGEMENT.FEATURE); return schema?.resourceMethods?.includes('PUT'); }, diff --git a/shell/models/management.cattle.io.user.js b/shell/models/management.cattle.io.user.js index 10d67ea9e3e..505e4656d90 100644 --- a/shell/models/management.cattle.io.user.js +++ b/shell/models/management.cattle.io.user.js @@ -1,5 +1,6 @@ import { NORMAN } from '@shell/config/types'; import HybridModel, { cleanHybridResources } from '@shell/plugins/steve/hybrid-class'; +import day from 'dayjs'; export default class User extends HybridModel { // Preserve description @@ -99,6 +100,38 @@ export default class User extends HybridModel { return this.$rootGetters['i18n/withFallback'](`model.authConfig.provider."${ this.provider }"`, null, this.provider); } + /** + * Gets the last-login label in milliseconds + * @returns {number} + */ + get userLastLogin() { + return this.metadata?.labels?.['cattle.io/last-login'] * 1000; + } + + /** + * Gets the disabled-after label in milliseconds + * @returns {number} + */ + get userDisabledIn() { + return this.metadata?.labels?.['cattle.io/disable-after'] * 1000; + } + + /** + * Provides a display value for the userDisabledIn date based on the user + * state. + */ + get userDisabledInDisplay() { + return this.state === 'inactive' ? null : this.userDisabledIn; + } + + /** + * Gets the delete-after label in milliseconds + * @returns {number} + */ + get userDeletedIn() { + return this.metadata?.labels?.['cattle.io/delete-after'] * 1000; + } + get state() { if ( this.enabled === false ) { return 'inactive'; @@ -107,6 +140,19 @@ export default class User extends HybridModel { return this.metadata?.state?.name || 'unknown'; } + get stateDisplay() { + switch (this.state) { + case 'inactive': + return this.t('user.state.inactive'); + case 'active': + return this.t('user.state.active'); + case 'unknown': + return this.t('user.state.unknown'); + default: + return this.state; + } + } + get description() { return this._description; } @@ -211,6 +257,25 @@ export default class User extends HybridModel { formatter: 'CopyToClipboard', content: this.username }, + { separator: true }, + { + label: this.t('tableHeaders.userLastLogin'), + formatter: 'LiveDate', + formatterOpts: { addSuffix: true, suffix: `${ this.t('suffix.ago') } (${ day(this.userLastLogin) })` }, + content: this.userLastLogin, + }, + { + label: this.t('tableHeaders.userDisabledIn'), + formatter: 'LiveDate', + formatterOpts: { isCountdown: true }, + content: this.userDisabledInDisplay, + }, + { + label: this.t('tableHeaders.userDeletedIn'), + formatter: 'LiveDate', + formatterOpts: { isCountdown: true }, + content: this.userDeletedIn, + }, ...this._details ]; }