diff --git a/kolibri/core/assets/src/state/modules/core/actions.js b/kolibri/core/assets/src/state/modules/core/actions.js index 24f000ddae9..191b8791045 100644 --- a/kolibri/core/assets/src/state/modules/core/actions.js +++ b/kolibri/core/assets/src/state/modules/core/actions.js @@ -67,94 +67,6 @@ export function handleApiError(store, { error, reloadOnReconnect = false } = {}) throw error; } -export function setSession(store, { session, clientNow }) { - const serverTime = session.server_time; - if (clientNow) { - setServerTime(serverTime, clientNow); - } - session = pick(session, Object.keys(baseSessionState)); - store.commit('CORE_SET_SESSION', session); -} - -/** - * Sets a password that is currently not specified - * due to an account that was created while passwords - * were not required. - * - * @param {object} store The store. - * @param {object} sessionPayload The session payload. - */ -export function kolibriSetUnspecifiedPassword(store, { username, password, facility }) { - const data = { - username, - password, - facility, - }; - return client({ - url: urls['kolibri:core:setnonspecifiedpassword'](), - data, - method: 'post', - }); -} - -/** - * Signs in user. - * - * @param {object} store The store. - * @param {object} sessionPayload The session payload. - */ -export function kolibriLogin(store, sessionPayload) { - Lockr.set(UPDATE_MODAL_DISMISSED, false); - return client({ - data: { - ...sessionPayload, - active: true, - browser, - os, - }, - url: urls['kolibri:core:session_list'](), - method: 'post', - }) - .then(() => { - // check redirect is disabled: - if (!sessionPayload.disableRedirect) - if (sessionPayload.next) { - // OIDC redirect - redirectBrowser(sessionPayload.next); - } - // Normal redirect on login - else { - redirectBrowser(); - } - }) - .catch(error => { - const errorsCaught = CatchErrors(error, [ - ERROR_CONSTANTS.INVALID_CREDENTIALS, - ERROR_CONSTANTS.MISSING_PASSWORD, - ERROR_CONSTANTS.PASSWORD_NOT_SPECIFIED, - ERROR_CONSTANTS.NOT_FOUND, - ]); - if (errorsCaught) { - if (errorsCaught.includes(ERROR_CONSTANTS.INVALID_CREDENTIALS)) { - return LoginErrors.INVALID_CREDENTIALS; - } else if (errorsCaught.includes(ERROR_CONSTANTS.MISSING_PASSWORD)) { - return LoginErrors.PASSWORD_MISSING; - } else if (errorsCaught.includes(ERROR_CONSTANTS.PASSWORD_NOT_SPECIFIED)) { - return LoginErrors.PASSWORD_NOT_SPECIFIED; - } else if (errorsCaught.includes(ERROR_CONSTANTS.NOT_FOUND)) { - return LoginErrors.USER_NOT_FOUND; - } - } else { - store.dispatch('handleApiError', { error }); - } - }); -} - -export function kolibriLogout() { - // Use the logout backend URL to initiate logout - redirectBrowser(urls['kolibri:core:logout']()); -} - const _setPageVisibility = debounce((store, visibility) => { store.commit('CORE_SET_PAGE_VISIBILITY', visibility); }, 500); diff --git a/packages/kolibri/__tests__/heartbeat.spec.js b/packages/kolibri/__tests__/heartbeat.spec.js index 1e2a286d569..59b4214e8df 100644 --- a/packages/kolibri/__tests__/heartbeat.spec.js +++ b/packages/kolibri/__tests__/heartbeat.spec.js @@ -1,27 +1,29 @@ import mock from 'xhr-mock'; -import coreStore from 'kolibri/store'; import redirectBrowser from 'kolibri/utils/redirectBrowser'; import * as serverClock from 'kolibri/utils/serverClock'; import { get, set } from '@vueuse/core'; import useSnackbar, { useSnackbarMock } from 'kolibri/composables/useSnackbar'; // eslint-disable-line import { ref } from '@vue/composition-api'; import { DisconnectionErrorCodes } from 'kolibri/constants'; +import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line import { HeartBeat } from '../heartbeat.js'; import { trs } from '../internal/disconnection'; -import coreModule from '../../../kolibri/core/assets/src/state/modules/core'; import { stubWindowLocation } from 'testUtils'; // eslint-disable-line jest.mock('kolibri/utils/redirectBrowser'); jest.mock('kolibri/urls'); jest.mock('lockr'); jest.mock('kolibri/composables/useSnackbar'); - -coreStore.registerModule('core', coreModule); +jest.mock('kolibri/composables/useUser'); describe('HeartBeat', function () { stubWindowLocation(beforeAll, afterAll); // replace the real XHR object with the mock XHR object before each test - beforeEach(() => mock.setup()); + + beforeEach(() => { + mock.setup(); + useUser.mockImplementation(() => useUserMock()); + }); // put the real XHR object back and clear the mocks after each test afterEach(() => mock.teardown()); @@ -206,7 +208,8 @@ describe('HeartBeat', function () { jest.spyOn(heartBeat, '_sessionUrl').mockReturnValue('url'); }); it('should sign out if an auto logout is detected', function () { - coreStore.commit('CORE_SET_SESSION', { user_id: 'test', id: 'current' }); + const { setSession } = useUser(); + setSession({ session: { user_id: 'test', id: 'current' } }); mock.put(/.*/, { status: 200, body: JSON.stringify({ user_id: null, id: 'current' }), @@ -218,7 +221,8 @@ describe('HeartBeat', function () { }); }); it('should redirect if a change in user is detected', function () { - coreStore.commit('CORE_SET_SESSION', { user_id: 'test', id: 'current' }); + const { setSession } = useUser(); + setSession({ session: { user_id: 'test', id: 'current' } }); redirectBrowser.mockReset(); mock.put(/.*/, { status: 200, @@ -230,7 +234,8 @@ describe('HeartBeat', function () { }); }); it('should not sign out if user_id changes but session is being set for first time', function () { - coreStore.commit('CORE_SET_SESSION', { user_id: undefined, id: undefined }); + const { setSession } = useUser(); + setSession({ session: { user_id: undefined, id: undefined } }); mock.put(/.*/, { status: 200, body: JSON.stringify({ user_id: null, id: 'current' }), @@ -242,7 +247,8 @@ describe('HeartBeat', function () { }); }); it('should call setServerTime with a clientNow value that is between the start and finish of the poll', function () { - coreStore.commit('CORE_SET_SESSION', { user_id: 'test', id: 'current' }); + const { setSession } = useUser(); + setSession({ session: { user_id: 'test', id: 'current' } }); const serverTime = new Date().toJSON(); mock.put(/.*/, { status: 200, diff --git a/packages/kolibri/composables/__mocks__/useUser.js b/packages/kolibri/composables/__mocks__/useUser.js index c179ee9273c..5f024c81746 100644 --- a/packages/kolibri/composables/__mocks__/useUser.js +++ b/packages/kolibri/composables/__mocks__/useUser.js @@ -30,10 +30,11 @@ * useUser.mockImplementation(() => useUserMock()) * ``` */ -import { computed } from '@vue/composition-api'; +import { ref, computed } from '@vue/composition-api'; import { UserKinds } from 'kolibri/constants'; +import { jest } from '@jest/globals'; // Ensure jest is imported for mocking functions -const session = { +const MOCK_DEFAULT_SESSION = { app_context: false, can_manage_content: false, facility_id: undefined, @@ -63,9 +64,9 @@ const MOCK_DEFAULTS = { userFacilityId: undefined, getUserKind: UserKinds.ANONYMOUS, userHasPermissions: false, - session, - //state - ...session, + session: { ...MOCK_DEFAULT_SESSION }, + // Mock state + ...MOCK_DEFAULT_SESSION, }; export function useUserMock(overrides = {}) { @@ -77,7 +78,29 @@ export function useUserMock(overrides = {}) { for (const key in mocks) { computedMocks[key] = computed(() => mocks[key]); } - return computedMocks; + + // Module-level state reference for actions + const session = ref({ ...mocks.session }); + + // Mock implementation of `useUser` methods + return { + ...computedMocks, + session, // Make session mutable for test scenarios + + // Actions + setSession: jest.fn(({ session: newSession, clientNow }) => { + session.value = { + ...MOCK_DEFAULT_SESSION, + ...newSession, + }; + }), + + kolibriLogin: jest.fn(async () => Promise.resolve()), + + kolibriLogout: jest.fn(() => {}), + + kolibrisetUnspecifiedPassword: jest.fn(async () => Promise.resolve()), + }; } export default jest.fn(() => useUserMock()); diff --git a/packages/kolibri/composables/useUser.js b/packages/kolibri/composables/useUser.js index 88f59f4816c..bb398f5df66 100644 --- a/packages/kolibri/composables/useUser.js +++ b/packages/kolibri/composables/useUser.js @@ -1,39 +1,132 @@ -import { computed } from '@vue/composition-api'; -import store from 'kolibri/store'; +import { ref, computed } from '@vue/composition-api'; +import client from 'kolibri/client'; +import urls from 'kolibri/urls'; +import redirectBrowser from 'kolibri/utils/redirectBrowser'; +import CatchErrors from 'kolibri/utils/CatchErrors'; +import Lockr from 'lockr'; +import some from 'lodash/some'; +import pick from 'lodash/pick'; +import { setServerTime } from 'kolibri/utils/serverClock'; +import { UserKinds, ERROR_CONSTANTS, LoginErrors, UPDATE_MODAL_DISMISSED } from 'kolibri/constants'; +import { browser, os } from 'kolibri/utils/browserInfo'; + +// Module level state +const session = ref({ + app_context: false, + can_manage_content: false, + facility_id: undefined, + full_name: '', + id: undefined, + kind: [UserKinds.ANONYMOUS], + user_id: undefined, + username: '', + full_facility_import: true, +}); export default function useUser() { - //getters - const isUserLoggedIn = computed(() => store.getters.isUserLoggedIn); - const currentUserId = computed(() => store.getters.currentUserId); - const isLearnerOnlyImport = computed(() => store.getters.isLearnerOnlyImport); - const isCoach = computed(() => store.getters.isCoach); - const isAdmin = computed(() => store.getters.isAdmin); - const isSuperuser = computed(() => store.getters.isSuperuser); - const canManageContent = computed(() => store.getters.canManageContent); - const isAppContext = computed(() => store.getters.isAppContext); - const isClassCoach = computed(() => store.getters.isClassCoach); - const isFacilityCoach = computed(() => store.getters.isFacilityCoach); - const isLearner = computed(() => store.getters.isLearner); - const isFacilityAdmin = computed(() => store.getters.isFacilityAdmin); - const userIsMultiFacilityAdmin = computed(() => store.getters.userIsMultiFacilityAdmin); - const getUserPermissions = computed(() => store.getters.getUserPermissions); - const userFacilityId = computed(() => store.getters.userFacilityId); - const getUserKind = computed(() => store.getters.getUserKind); - const userHasPermissions = computed(() => store.getters.userHasPermissions); - const session = computed(() => store.getters.session); + // Computed properties (former Vuex getters) + const isUserLoggedIn = computed(() => session.value.id !== undefined); + const currentUserId = computed(() => session.value.user_id); + const isLearnerOnlyImport = computed(() => !session.value.full_facility_import); + const isCoach = computed(() => + session.value.kind.some(kind => [UserKinds.COACH, UserKinds.ASSIGNABLE_COACH].includes(kind)), + ); + const isAdmin = computed(() => + session.value.kind.some(kind => [UserKinds.ADMIN, UserKinds.SUPERUSER].includes(kind)), + ); + const isSuperuser = computed(() => session.value.kind.includes(UserKinds.SUPERUSER)); + const canManageContent = computed(() => + session.value.kind.includes(UserKinds.CAN_MANAGE_CONTENT), + ); + const isAppContext = computed(() => session.value.app_context); + const isClassCoach = computed(() => session.value.kind.includes(UserKinds.ASSIGNABLE_COACH)); + const isFacilityCoach = computed(() => session.value.kind.includes(UserKinds.COACH)); + const isFacilityAdmin = computed(() => session.value.kind.includes(UserKinds.ADMIN)); + const userIsMultiFacilityAdmin = computed( + rootState => isSuperuser.value && rootState.core.facilities.length > 1, + ); + const getUserPermissions = computed(() => { + const permissions = {}; + permissions.can_manage_content = canManageContent.value; + return permissions; + }); + const isLearner = computed(() => session.value.kind.includes(UserKinds.LEARNER)); + const userFacilityId = computed(() => session.value.facility_id); + const getUserKind = computed(() => session.value.kind[0]); + const userHasPermissions = computed(() => some(getUserPermissions.value)); + + // Actions + async function kolibriLogin(sessionPayload) { + Lockr.set(UPDATE_MODAL_DISMISSED, false); + + try { + await client({ + data: { + ...sessionPayload, + active: true, + browser, + os, + }, + url: urls['kolibri:core:session-list'](), + method: 'post', + }); + + if (!sessionPayload.disableRedirect) { + if (sessionPayload.next) { + redirectBrowser(sessionPayload.next); + } else { + redirectBrowser(); + } + } + } catch (error) { + const errorsCaught = CatchErrors(error, [ + ERROR_CONSTANTS.INVALID_CREDENTIALS, + ERROR_CONSTANTS.MISSING_PASSWORD, + ERROR_CONSTANTS.PASSWORD_NOT_SPECIFIED, + ERROR_CONSTANTS.NOT_FOUND, + ]); - //state - const app_context = computed(() => store.getters.session.app_context); - const can_manage_content = computed(() => store.getters.session.can_manage_content); - const facility_id = computed(() => store.getters.session.facility_id); - const full_name = computed(() => store.getters.session.full_name); - const id = computed(() => store.getters.session.id); - const kind = computed(() => store.getters.session.kind); - const user_id = computed(() => store.getters.session.user_id); - const full_facility_import = computed(() => store.getters.session.full_facility_import); - const username = computed(() => store.getters.session.username); + if (errorsCaught) { + if (errorsCaught.includes(ERROR_CONSTANTS.INVALID_CREDENTIALS)) { + return LoginErrors.INVALID_CREDENTIALS; + } else if (errorsCaught.includes(ERROR_CONSTANTS.MISSING_PASSWORD)) { + return LoginErrors.PASSWORD_MISSING; + } else if (errorsCaught.includes(ERROR_CONSTANTS.PASSWORD_NOT_SPECIFIED)) { + return LoginErrors.PASSWORD_NOT_SPECIFIED; + } else if (errorsCaught.includes(ERROR_CONSTANTS.NOT_FOUND)) { + return LoginErrors.USER_NOT_FOUND; + } + } + throw error; + } + } + + function kolibriLogout() { + redirectBrowser(urls['kolibri:core:logout']()); + } + + function setSession({ session: newSession, clientNow }) { + const serverTime = newSession.server_time; + if (clientNow) { + setServerTime(serverTime, clientNow); + } + const filteredSession = pick(newSession, Object.keys(session)); + session.value = { + ...session.value, + ...filteredSession, + }; + } + + async function kolibrisetUnspecifiedPassword({ username, password, facility }) { + return client({ + url: urls['kolibri:core:setnonspecifiedpassword'](), + data: { username, password, facility }, + method: 'post', + }); + } return { + // Computed isLearnerOnlyImport, isUserLoggedIn, currentUserId, @@ -52,15 +145,11 @@ export default function useUser() { getUserKind, userHasPermissions, session, - //state - app_context, - can_manage_content, - facility_id, - full_name, - id, - kind, - user_id, - username, - full_facility_import, + + // Actions + kolibriLogin, + kolibriLogout, + setSession, + kolibrisetUnspecifiedPassword, }; } diff --git a/packages/kolibri/heartbeat.js b/packages/kolibri/heartbeat.js index 5806a32a3fe..d8d8415945d 100644 --- a/packages/kolibri/heartbeat.js +++ b/packages/kolibri/heartbeat.js @@ -161,7 +161,7 @@ export class HeartBeat { * @return {Promise} promise that resolves when the endpoint check is complete. */ _checkSession() { - const { id, currentUserId } = useUser(); + const { id, currentUserId, setSession } = useUser(); // Record the current user id to check if a different one is returned by the server. if (!get(this._connection.connected)) { // If not currently connected to the server, flag that we are currently trying to reconnect. @@ -196,17 +196,8 @@ export class HeartBeat { redirectBrowser(); } } - store.dispatch('setSession', { - session, - // Calculate an approximation of the client 'now' that was simultaneous to the server - // 'now' that was sent back with the request. We calculate this as the mean of the - // start of the request and the end of the request, which assumes that the calculation - // of the local_now on the server side happens at the midpoint of the request response - // cycle. Evidently this is not completely accurate, but it is the best that we can do. - // Further, this fails to account for relativity, as simultaneity depends on your specific - // frame of reference. If the client is moving relative to the server at speeds - // approaching the speed of light, this may produce some odd results, - // but I think that was always true. + setSession({ + session: session, clientNow: new Date((pollEnd + pollStart) / 2), }); })