This is an automated email from the ASF dual-hosted git repository.

harikrishna-patnala pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new c8abe2904b0 show a session expired warning when the session runs out 
(#14369)
c8abe2904b0 is described below

commit c8abe2904b0d206c04541fe778961f2390819daa
Author: Daman Arora <[email protected]>
AuthorDate: Tue Oct 27 04:45:46 2026 -0400

    show a session expired warning when the session runs out (#14369)
    
    * show a session expired warning when the session runs out
    
    * keep the 2FA wrong code error on screen after logout
---
 ui/public/locales/en.json                          |  2 +
 ui/src/utils/request.js                            |  7 +-
 ui/src/views/dashboard/SetupTwoFaAtLogin.vue       | 11 +--
 ui/src/views/dashboard/VerifyTwoFa.vue             | 11 +--
 ui/tests/unit/utils/request.spec.js                | 84 ++++++++++++++++++++++
 ui/tests/unit/views/dashboard/TwoFaFailure.spec.js | 72 +++++++++++++++++++
 6 files changed, 173 insertions(+), 14 deletions(-)

diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 7a29e700958..0acafc3b801 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -2482,6 +2482,7 @@
 "label.servicelist": "Services",
 "label.serviceofferingid": "Compute Offering",
 "label.serviceofferingname": "Compute Offering",
+"label.session.expired": "Session Expired",
 "label.sessions": "Active client sessions",
 "label.set.default.nic": "Set default NIC",
 "label.set.reservation": "Set reservation",
@@ -4109,6 +4110,7 @@
 "message.select.zone.hint": "This is the type of Zone deployment that you want 
to use. Basic zone: provides a single Network where each Instance is assigned 
an IP directly from the Network. Guest isolation can be provided through 
layer-3 means such as security groups (IP address source filtering). Advanced 
zone: For more sophisticated Network topologies. This Network model provides 
the most flexibility in defining guest Networks and providing custom Network 
offerings such as firewall, VP [...]
 "message.server": "Server : ",
 "message.server.description": "NFS, iSCSI, or PreSetup: IP address or DNS name 
of the storage device. VMWare PreSetup: IP address or DNS name of the vCenter 
server. Linstor: http(s) url of the linstor-controller.",
+"message.session.expired": "Your session has expired. Please sign in again to 
continue.",
 "message.set.default.nic": "Please confirm that you would like to make this 
NIC the default for this Instance.",
 "message.set.default.nic.manual": "Please manually update the default NIC on 
the Instance now.",
 "message.setting.updated": "Setting Updated:",
diff --git a/ui/src/utils/request.js b/ui/src/utils/request.js
index 94e5edc94aa..557e9fed062 100644
--- a/ui/src/utils/request.js
+++ b/ui/src/utils/request.js
@@ -94,12 +94,11 @@ const err = (error) => {
       } else if (originalPath !== '/user/login') {
         countNotify++
         store.commit('SET_COUNT_NOTIFY', countNotify)
-        notification.error({
+        notification.warning({
           top: '65px',
-          message: i18n.global.t('label.unauthorized'),
-          description: i18n.global.t('message.authorization.failed'),
+          message: i18n.global.t('label.session.expired'),
+          description: i18n.global.t('message.session.expired'),
           key: 'http-401',
-          duration: 0,
           onClose: () => {
             let countNotify = store.getters.countNotify
             countNotify > 0 ? countNotify-- : countNotify = 0
diff --git a/ui/src/views/dashboard/SetupTwoFaAtLogin.vue 
b/ui/src/views/dashboard/SetupTwoFaAtLogin.vue
index cf3cee760e7..b625b5f3216 100644
--- a/ui/src/views/dashboard/SetupTwoFaAtLogin.vue
+++ b/ui/src/views/dashboard/SetupTwoFaAtLogin.vue
@@ -292,12 +292,13 @@ export default {
           this.twoFAverified = true
           this.$emit('refresh-data')
         }).catch(() => {
-          this.$notification.error({
-            message: this.$t('message.request.failed'),
-            description: this.$t('message.error.setup.2fa')
-          })
+          // Logout clears all notifications, so show the error once it is done
           this.$store.dispatch('Logout').then(() => {
-            this.$router.replace({ path: '/user/login' })
+            this.$router.replace({ path: '/user/login' }).catch(() => {})
+            this.$notification.error({
+              message: this.$t('message.request.failed'),
+              description: this.$t('message.error.setup.2fa')
+            })
           })
         })
       })
diff --git a/ui/src/views/dashboard/VerifyTwoFa.vue 
b/ui/src/views/dashboard/VerifyTwoFa.vue
index b2d31c03080..c03e286821f 100644
--- a/ui/src/views/dashboard/VerifyTwoFa.vue
+++ b/ui/src/views/dashboard/VerifyTwoFa.vue
@@ -111,12 +111,13 @@ export default {
             this.$emit('refresh-data')
           }
         }).catch(() => {
+          // Logout clears all notifications, so show the error once it is done
           this.$store.dispatch('Logout').then(() => {
-            this.$router.replace({ path: '/user/login' })
-          })
-          this.$notification.error({
-            message: this.$t('message.request.failed'),
-            description: this.$t('message.two.factor.authorization.failed')
+            this.$router.replace({ path: '/user/login' }).catch(() => {})
+            this.$notification.error({
+              message: this.$t('message.request.failed'),
+              description: this.$t('message.two.factor.authorization.failed')
+            })
           })
         })
       })
diff --git a/ui/tests/unit/utils/request.spec.js 
b/ui/tests/unit/utils/request.spec.js
new file mode 100644
index 00000000000..7654b5ab0ea
--- /dev/null
+++ b/ui/tests/unit/utils/request.spec.js
@@ -0,0 +1,84 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import notification from 'ant-design-vue/es/notification'
+import router from '@/router'
+import store from '@/store'
+import { axios } from '@/utils/request'
+
+jest.mock('ant-design-vue/es/notification', () => ({
+  error: jest.fn(),
+  warning: jest.fn(),
+  warn: jest.fn()
+}))
+jest.mock('@/router', () => ({
+  currentRoute: { value: { path: '/dashboard' } },
+  push: jest.fn()
+}))
+jest.mock('@/store', () => ({
+  getters: { countNotify: 0 },
+  commit: jest.fn(),
+  dispatch: jest.fn(() => Promise.resolve())
+}))
+jest.mock('@/locales', () => ({
+  i18n: { global: { t: (key) => key } }
+}))
+jest.mock('@/vue-app', () => ({
+  vueProps: { $localStorage: { get: () => null } }
+}))
+
+const unauthorizedAdapter = (config) => Promise.reject(Object.assign(new 
Error('Request failed with status code 401'), {
+  config,
+  isAxiosError: true,
+  response: {
+    status: 401,
+    config,
+    data: { listvirtualmachinesresponse: { errorcode: 401, errortext: 'unable 
to verify user credentials and/or request signature' } }
+  }
+}))
+
+const callApi = () => axios.get('/client/api', {
+  params: { command: 'listVirtualMachines' },
+  adapter: unauthorizedAdapter
+}).catch(() => {})
+
+describe('utils > request.js', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+  })
+
+  it('shows a session expired warning when the session times out', async () => 
{
+    router.currentRoute.value.path = '/dashboard'
+    await callApi()
+
+    expect(notification.error).not.toHaveBeenCalled()
+    expect(notification.warning).toHaveBeenCalledTimes(1)
+    const shown = notification.warning.mock.calls[0][0]
+    expect(shown.message).toBe('label.session.expired')
+    expect(shown.description).toBe('message.session.expired')
+    expect(shown.duration).toBeUndefined()
+    expect(store.dispatch).toHaveBeenCalledWith('Logout')
+  })
+
+  it('shows nothing when already on the login page', async () => {
+    router.currentRoute.value.path = '/user/login'
+    await callApi()
+
+    expect(notification.error).not.toHaveBeenCalled()
+    expect(notification.warning).not.toHaveBeenCalled()
+  })
+})
diff --git a/ui/tests/unit/views/dashboard/TwoFaFailure.spec.js 
b/ui/tests/unit/views/dashboard/TwoFaFailure.spec.js
new file mode 100644
index 00000000000..f8f1d4a7f0f
--- /dev/null
+++ b/ui/tests/unit/views/dashboard/TwoFaFailure.spec.js
@@ -0,0 +1,72 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { postAPI } from '@/api'
+import VerifyTwoFa from '@/views/dashboard/VerifyTwoFa'
+import SetupTwoFaAtLogin from '@/views/dashboard/SetupTwoFaAtLogin'
+
+jest.mock('@/api', () => ({
+  getAPI: jest.fn(),
+  postAPI: jest.fn()
+}))
+jest.mock('@/store', () => ({}))
+jest.mock('@/config/eventBus', () => ({}))
+jest.mock('vue-qrious', () => ({}))
+
+const flush = () => new Promise(resolve => setTimeout(resolve, 0))
+
+function wrongCode (component, method) {
+  let finishLogout
+  const ctx = {
+    form: { code: '000000' },
+    formRef: { value: { validate: () => Promise.resolve() } },
+    $t: (key) => key,
+    $store: {
+      dispatch: jest.fn(() => new Promise(resolve => { finishLogout = resolve 
})),
+      commit: jest.fn()
+    },
+    $router: { replace: jest.fn(() => Promise.resolve()), push: jest.fn() },
+    $notification: { error: jest.fn(), destroy: jest.fn() },
+    $message: { success: jest.fn() },
+    $emit: jest.fn()
+  }
+  postAPI.mockReturnValue(Promise.reject(new Error('wrong code')))
+  component.methods[method].call(ctx)
+  return { ctx, finishLogout: () => finishLogout() }
+}
+
+describe('2FA wrong code', () => {
+  it.each([
+    [VerifyTwoFa, 'handleSubmit', 'message.two.factor.authorization.failed'],
+    [SetupTwoFaAtLogin, 'submitPin', 'message.error.setup.2fa']
+  ])('shows the error only after logout has cleared old notifications (%#)', 
async (component, method, description) => {
+    const { ctx, finishLogout } = wrongCode(component, method)
+    await flush()
+
+    expect(ctx.$store.dispatch).toHaveBeenCalledWith('Logout')
+    expect(ctx.$notification.error).not.toHaveBeenCalled()
+
+    finishLogout()
+    await flush()
+
+    expect(ctx.$router.replace).toHaveBeenCalledWith({ path: '/user/login' })
+    expect(ctx.$notification.error).toHaveBeenCalledWith({
+      message: 'message.request.failed',
+      description
+    })
+  })
+})

Reply via email to