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
+ })
+ })
+})