mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 12:08:01 +00:00
feat: Prevent saving preferences and status when impersonating (#11164)
This PR will prevent saving user preferences and online status when impersonating. Previously, these settings could be updated during impersonation, causing the user to see a different view or UI settings. Fixes https://linear.app/chatwoot/issue/CW-4163/impersonation-improvements
This commit is contained in:
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
const statusList = computed(() => {
|
||||
return [
|
||||
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
|
||||
});
|
||||
|
||||
function changeAvailabilityStatus(availability) {
|
||||
if (isImpersonating.value) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
store.dispatch('updateAvailability', {
|
||||
availability,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
||||
@@ -20,6 +21,10 @@ export default {
|
||||
AvailabilityStatusBadge,
|
||||
NextButton,
|
||||
},
|
||||
setup() {
|
||||
const { isImpersonating } = useImpersonation();
|
||||
return { isImpersonating };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isStatusMenuOpened: false,
|
||||
@@ -73,6 +78,13 @@ export default {
|
||||
});
|
||||
},
|
||||
changeAvailabilityStatus(availability) {
|
||||
if (this.isImpersonating) {
|
||||
useAlert(
|
||||
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useImpersonation } from '../useImpersonation';
|
||||
|
||||
vi.mock('shared/helpers/sessionStorage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
|
||||
describe('useImpersonation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return true if impersonation flag is set in session storage', () => {
|
||||
SessionStorage.get.mockReturnValue(true);
|
||||
const { isImpersonating } = useImpersonation();
|
||||
expect(isImpersonating.value).toBe(true);
|
||||
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false if impersonation flag is not set in session storage', () => {
|
||||
SessionStorage.get.mockReturnValue(false);
|
||||
const { isImpersonating } = useImpersonation();
|
||||
expect(isImpersonating.value).toBe(false);
|
||||
expect(SessionStorage.get).toHaveBeenCalledWith(
|
||||
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||
);
|
||||
});
|
||||
});
|
||||
10
app/javascript/dashboard/composables/useImpersonation.js
Normal file
10
app/javascript/dashboard/composables/useImpersonation.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
|
||||
export function useImpersonation() {
|
||||
const isImpersonating = computed(() => {
|
||||
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||
});
|
||||
return { isImpersonating };
|
||||
}
|
||||
3
app/javascript/dashboard/constants/sessionStorage.js
Normal file
3
app/javascript/dashboard/constants/sessionStorage.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export const SESSION_STORAGE_KEYS = {
|
||||
IMPERSONATION_USER: 'impersonationUser',
|
||||
};
|
||||
@@ -185,7 +185,8 @@
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
|
||||
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
|
||||
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
|
||||
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Your email address",
|
||||
|
||||
@@ -2,6 +2,8 @@ import types from '../mutation-types';
|
||||
import authAPI from '../../api/auth';
|
||||
|
||||
import { setUser, clearCookiesOnLogout } from '../utils/api';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
|
||||
const initialState = {
|
||||
currentUser: {
|
||||
@@ -145,8 +147,15 @@ export const actions = {
|
||||
updateUISettings: async ({ commit }, params) => {
|
||||
try {
|
||||
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
|
||||
const response = await authAPI.updateUISettings(params);
|
||||
commit(types.SET_CURRENT_USER, response.data);
|
||||
|
||||
const isImpersonating = SessionStorage.get(
|
||||
SESSION_STORAGE_KEYS.IMPERSONATION_USER
|
||||
);
|
||||
|
||||
if (!isImpersonating) {
|
||||
const response = await authAPI.updateUISettings(params);
|
||||
commit(types.SET_CURRENT_USER, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import Cookies from 'js-cookie';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import {
|
||||
ANALYTICS_IDENTITY,
|
||||
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
|
||||
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
|
||||
};
|
||||
|
||||
export const clearSessionStorageOnLogout = () => {
|
||||
SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||
};
|
||||
|
||||
export const deleteIndexedDBOnLogout = async () => {
|
||||
let dbs = [];
|
||||
try {
|
||||
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
|
||||
emitter.emit(ANALYTICS_RESET);
|
||||
clearBrowserSessionCookies();
|
||||
clearLocalStorageOnLogout();
|
||||
clearSessionStorageOnLogout();
|
||||
const globalConfig = window.globalConfig || {};
|
||||
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
|
||||
window.location = logoutRedirectLink;
|
||||
|
||||
26
app/javascript/shared/helpers/sessionStorage.js
Normal file
26
app/javascript/shared/helpers/sessionStorage.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export default {
|
||||
clearAll() {
|
||||
window.sessionStorage.clear();
|
||||
},
|
||||
|
||||
get(key) {
|
||||
try {
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch (error) {
|
||||
return window.sessionStorage.getItem(key);
|
||||
}
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
137
app/javascript/shared/helpers/specs/sessionStorage.spec.js
Normal file
137
app/javascript/shared/helpers/specs/sessionStorage.spec.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import SessionStorage from '../sessionStorage';
|
||||
|
||||
// Mocking sessionStorage
|
||||
const sessionStorageMock = (() => {
|
||||
let store = {};
|
||||
return {
|
||||
getItem: key => store[key] || null,
|
||||
setItem: (key, value) => {
|
||||
store[key] = String(value);
|
||||
},
|
||||
removeItem: key => delete store[key],
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
describe('SessionStorage utility', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe('clearAll method', () => {
|
||||
it('should clear all items from sessionStorage', () => {
|
||||
sessionStorage.setItem('testKey1', 'testValue1');
|
||||
sessionStorage.setItem('testKey2', 'testValue2');
|
||||
|
||||
SessionStorage.clearAll();
|
||||
|
||||
expect(sessionStorage.getItem('testKey1')).toBeNull();
|
||||
expect(sessionStorage.getItem('testKey2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get method', () => {
|
||||
it('should retrieve and parse JSON values correctly', () => {
|
||||
const testObject = { a: 1, b: 'test' };
|
||||
sessionStorage.setItem('testKey', JSON.stringify(testObject));
|
||||
|
||||
expect(SessionStorage.get('testKey')).toEqual(testObject);
|
||||
});
|
||||
|
||||
it('should return null for non-existent keys', () => {
|
||||
expect(SessionStorage.get('nonExistentKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle non-JSON values by returning the raw value', () => {
|
||||
sessionStorage.setItem('testKey', 'plain string value');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBe('plain string value');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', () => {
|
||||
sessionStorage.setItem('testKey', '{malformed:json}');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set method', () => {
|
||||
it('should store object values as JSON strings', () => {
|
||||
const testObject = { a: 1, b: 'test' };
|
||||
SessionStorage.set('testKey', testObject);
|
||||
|
||||
expect(sessionStorage.getItem('testKey')).toBe(
|
||||
JSON.stringify(testObject)
|
||||
);
|
||||
});
|
||||
|
||||
it('should store primitive values directly', () => {
|
||||
SessionStorage.set('stringKey', 'test string');
|
||||
expect(sessionStorage.getItem('stringKey')).toBe('test string');
|
||||
|
||||
SessionStorage.set('numberKey', 42);
|
||||
expect(sessionStorage.getItem('numberKey')).toBe('42');
|
||||
|
||||
SessionStorage.set('booleanKey', true);
|
||||
expect(sessionStorage.getItem('booleanKey')).toBe('true');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
SessionStorage.set('nullKey', null);
|
||||
|
||||
expect(sessionStorage.getItem('nullKey')).toBe('null');
|
||||
expect(SessionStorage.get('nullKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
SessionStorage.set('undefinedKey', undefined);
|
||||
|
||||
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove method', () => {
|
||||
it('should remove an item from sessionStorage', () => {
|
||||
SessionStorage.set('testKey', 'testValue');
|
||||
expect(SessionStorage.get('testKey')).toBe('testValue');
|
||||
|
||||
SessionStorage.remove('testKey');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should do nothing when removing a non-existent key', () => {
|
||||
expect(() => {
|
||||
SessionStorage.remove('nonExistentKey');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration of methods', () => {
|
||||
it('should set, get, and remove values correctly', () => {
|
||||
SessionStorage.set('testKey', { value: 'test' });
|
||||
|
||||
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
|
||||
|
||||
SessionStorage.remove('testKey');
|
||||
expect(SessionStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should correctly handle impersonation flag (common use case)', () => {
|
||||
SessionStorage.set('impersonationUser', true);
|
||||
|
||||
expect(SessionStorage.get('impersonationUser')).toBe(true);
|
||||
|
||||
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
|
||||
|
||||
SessionStorage.remove('impersonationUser');
|
||||
expect(SessionStorage.get('impersonationUser')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { parseBoolean } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
// mixins
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
@@ -21,6 +22,8 @@ const ERROR_MESSAGES = {
|
||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
@@ -112,6 +115,14 @@ export default {
|
||||
this.loginApi.message = message;
|
||||
useAlert(this.loginApi.message);
|
||||
},
|
||||
handleImpersonation() {
|
||||
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
|
||||
if (impersonation) {
|
||||
SessionStorage.set(SESSION_STORAGE_KEYS.IMPERSONATION_USER, true);
|
||||
}
|
||||
},
|
||||
submitLogin() {
|
||||
this.loginApi.hasErrored = false;
|
||||
this.loginApi.showLoading = true;
|
||||
@@ -128,6 +139,7 @@ export default {
|
||||
|
||||
login(credentials)
|
||||
.then(() => {
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
|
||||
@@ -20,6 +20,10 @@ module SsoAuthenticatable
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
|
||||
end
|
||||
|
||||
def generate_sso_link_with_impersonation
|
||||
"#{generate_sso_link}&impersonation=true"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sso_token_key(token)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<hr/>
|
||||
<div class="form-actions">
|
||||
<p class="text-color-red">Caution: Any actions executed after impersonate will appear as if performed by the impersonated user - [<%= page.resource.name %> ]</p>
|
||||
<a class='button' target='_blank' href='<%= page.resource.generate_sso_link %>'>Impersonate user </a>
|
||||
<a class='button' target='_blank' href='<%= page.resource.generate_sso_link_with_impersonation %>'>Impersonate user </a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user