diff --git a/.env.example b/.env.example index f47483d..a0555b9 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ VUE_APP_I18N_LOCALE=en VUE_APP_I18N_FALLBACK_LOCALE=en VUE_APP_CACHE_MAX_AGE=3600 VUE_APP_VIEW_SIZE=10 -VUE_APP_PERMISSION_ID= +VUE_APP_PERMISSION_ID="ORDER_ROUTING_APP_VIEW" VUE_APP_DEFAULT_LOG_LEVEL="error" VUE_APP_RULE_ENUMS={"QUEUE":{"id":"OIP_QUEUE","code":"facilityId"},"SHIPPING_METHOD":{"id":"OIP_SHIP_METH_TYPE","code":"shipmentMethodTypeId"},"PRIORITY":{"id":"OIP_PRIORITY","code":"priority"},"PROMISE_DATE":{"id":"OIP_PROMISE_DATE","code":"promiseDaysCutoff"},"SALES_CHANNEL":{"id":"OIP_SALES_CHANNEL","code":"salesChannelEnumId"},"ORIGIN_FACILITY_GROUP":{"id":"OIP_ORIGIN_FAC_GRP","code":"originFacilityGroupId"},"SHIP_BY":{"id":"OSP_SHIP_BY","code":"shipBeforeDate"},"SHIP_AFTER":{"id":"OSP_SHIP_AFTER","code":"shipAfterDate"},"ORDER_DATE":{"id":"OSP_ORDER_DATE","code":"orderDate"},"SHIPPING_METHOD_SORT":{"id":"OSP_SHIP_METH","code":"deliveryDays"},"SORT_PRIORITY":{"id":"OSP_PRIORITY","code":"priority"}} VUE_APP_RULE_FILTER_ENUMS={"FACILITY_GROUP":{"id":"IIP_FACILITY_GROUP","code":"facilityGroupId"},"PROXIMITY":{"id":"IIP_PROXIMITY","code":"distance"},"BRK_SAFETY_STOCK":{"id":"IIP_BRK_SFTY_STOCK","code":"brokeringSafetyStock"},"MEASUREMENT_SYSTEM":{"id":"IIP_MSMNT_SYSTEM","code":"measurementSystem"}, "SPLIT_ITEM_GROUP":{"id":"IIP_SPLIT_ITEM_GROUP","code":"splitOrderItemGroup"}, "FACILITY_ORDER_LIMIT": {"id":"IFP_IGNORE_ORD_FAC_LIMIT", "code":"ignoreFacilityOrderLimit"}} diff --git a/package.json b/package.json index 9c12b6b..9fa9f45 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@capacitor/android": "^2.4.7", "@capacitor/core": "^2.4.7", + "@casl/ability": "^6.0.0", "@hotwax/app-version-info": "^1.0.0", "@hotwax/apps-theme": "^1.2.6", "@hotwax/dxp-components": "^1.14.0", @@ -23,6 +24,7 @@ "@ionic/vue-router": "^7.6.0", "axios": "^0.21.1", "axios-cache-adapter": "^2.7.3", + "boon-js": "^2.0.3", "core-js": "^3.6.5", "cron-parser": "^4.9.0", "cronstrue": "^2.50.0", diff --git a/src/api/index.ts b/src/api/index.ts index 0d2f4fa..b7c8d81 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,8 +3,7 @@ import { setupCache } from "axios-cache-adapter" import OfflineHelper from "@/offline-helper" import emitter from "@/event-bus" import store from "@/store"; -import { StatusCodes } from "http-status-codes"; -import router from "@/router" + axios.interceptors.request.use((config: any) => { // TODO: pass csrf token @@ -44,12 +43,12 @@ axios.interceptors.response.use(function (response) { if (error.response) { // TODO Handle case for failed queue request const { status } = error.response; - if (status === StatusCodes.UNAUTHORIZED) { - store.dispatch("user/logout"); - const redirectUrl = window.location.origin + '/login'; - // Explicitly passing isLoggedOut as in case of maarg apps we need to call the logout api in launchpad - window.location.href = `${process.env.VUE_APP_LOGIN_URL}?redirectUrl=${redirectUrl}&isLoggedOut=true`; - } + // if (status === StatusCodes.UNAUTHORIZED) { + // store.dispatch("user/logout"); + // const redirectUrl = window.location.origin + '/login'; + // Explicitly passing isLoggedOut as in case of maarg apps we need to call the logout api in launchpad + // window.location.href = `${process.env.VUE_APP_LOGIN_URL}?redirectUrl=${redirectUrl}&isLoggedOut=true`; + // } } // Any status codes that falls outside the range of 2xx cause this function to trigger // Do something with response error @@ -88,7 +87,11 @@ const api = async (customConfig: any) => { } const baseURL = store.getters["user/getInstanceUrl"]; - if (baseURL) config.baseURL = baseURL.startsWith('http') ? baseURL.includes('/rest/s1/order-routing') ? baseURL : `${baseURL}/rest/s1/order-routing/` : `https://${baseURL}.hotwax.io/rest/s1/order-routing/`; + + if (baseURL) { + config.baseURL = baseURL.startsWith('http') ? baseURL.includes('/rest/s1/order-routing') ? baseURL : `${baseURL}/rest/s1/order-routing/` : `https://${baseURL}.hotwax.io/rest/s1/order-routing/`; + } + if(customConfig.cache) config.adapter = axiosCache.adapter; const networkStatus = await OfflineHelper.getNetworkStatus(); if (customConfig.queue && !networkStatus.connected) { @@ -109,7 +112,7 @@ const api = async (customConfig: any) => { * @return {Promise} Response from API as returned by Axios */ const client = (config: any) => { - return axios.request(config); + return axios.create().request(config) } export { api as default, client, axios }; \ No newline at end of file diff --git a/src/authorization/Actions.ts b/src/authorization/Actions.ts new file mode 100644 index 0000000..7fd62c4 --- /dev/null +++ b/src/authorization/Actions.ts @@ -0,0 +1,3 @@ +export default { + "ORDER_ROUTING_APP_VIEW": "ORDER_ROUTING_APP_VIEW" +} \ No newline at end of file diff --git a/src/authorization/Rules.ts b/src/authorization/Rules.ts new file mode 100644 index 0000000..7fd62c4 --- /dev/null +++ b/src/authorization/Rules.ts @@ -0,0 +1,3 @@ +export default { + "ORDER_ROUTING_APP_VIEW": "ORDER_ROUTING_APP_VIEW" +} \ No newline at end of file diff --git a/src/authorization/index.ts b/src/authorization/index.ts new file mode 100644 index 0000000..4ac0f6a --- /dev/null +++ b/src/authorization/index.ts @@ -0,0 +1,124 @@ +import { AbilityBuilder, PureAbility } from '@casl/ability'; +import { getEvaluator, parse } from 'boon-js'; +import { Tokens } from 'boon-js/lib/types' + +// TODO Improve this +// We will move this code to an external plugin and use below Actions and Rules accordlingly +let Actions = {} as any; +let Rules = {} as any; + +// We are using CASL library to define permissions. +// Instead of using Action-Subject based authorisation we are going with Claim based Authorization. +// We would be defining the permissions for each action and case, map with server permissiosn based upon certain rules. +// https://casl.js.org/v5/en/cookbook/claim-authorization +// Following the comment of Sergii Stotskyi, author of CASL +// https://github.com/stalniy/casl/issues/525 +// We are defining a PureAbility and creating an instance with AbilityBuilder. +type ClaimBasedAbility = PureAbility; +const { build } = new AbilityBuilder(PureAbility); +const ability = build(); + +/** + * The method returns list of permissions required for the rules. We are having set of rules, + * through which app permissions are defined based upon the server permissions. + * When getting server permissions, as all the permissions are not be required. + * Specific permissions used defining the rules are extracted and sent to server. + * @returns permissions + */ +const getServerPermissionsFromRules = () => { + // Iterate for each rule + const permissions = Object.keys(Rules).reduce((permissions: any, rule: any) => { + const permissionRule = Rules[rule]; + // some rules may be empty, no permission is required from server + if (permissionRule) { + // Each rule may have multiple permissions along with operators + // Boon js parse rules into tokens, each token may be operator or server permission + // permissionId will have token name as identifier. + const permissionTokens = parse(permissionRule); + permissions = permissionTokens.reduce((permissions: any, permissionToken: any) => { + // Token object with name as identifier has permissionId + if (Tokens.IDENTIFIER === permissionToken.name) { + permissions.add(permissionToken.value); + } + return permissions; + }, permissions) + } + return permissions; + }, new Set()) + return [...permissions]; +} + +/** + * The method is used to prepare app permissions from the server permissions. + * Rules could be defined such that each app permission could be defined based upon certain one or more server permissions. + * @param serverPermissions + * @returns appPermissions + */ +const prepareAppPermissions = (serverPermissions: any) => { + const serverPermissionsInput = serverPermissions.reduce((serverPermissionsInput: any, permission: any) => { + serverPermissionsInput[permission] = true; + return serverPermissionsInput; + }, {}) + // Boonjs evaluator needs server permissions as object with permissionId and boolean value + // Each rule is passed to evaluator along with the server permissions + // if the server permissions and rule matches, app permission is added to list + const permissions = Object.keys(Rules).reduce((permissions: any, rule: any) => { + const permissionRule = Rules[rule]; + // If for any app permission, we have empty rule we user is assigned the permission + // If rule is not defined, the app permisions is still evaluated or provided to all the users. + if (!permissionRule || (permissionRule && getEvaluator(permissionRule)(serverPermissionsInput))) { + permissions.push(rule); + } + return permissions; + }, []) + const { can, rules } = new AbilityBuilder(PureAbility); + permissions.map((permission: any) => { + can(permission); + }) + return rules; +} + +/** + * + * Sets the current app permissions. This should be used after perparing the app permissions from the server permissions + * @param permissions + * @returns + */ +const setPermissions = (permissions: any) => { + // If the user has passed undefined or null, it should not break the code + if (!permissions) permissions = []; + ability.update(permissions) + return true; +}; + +/** + * Resets the permissions list. Used for cases like logout + */ +const resetPermissions = () => setPermissions([]); + +/** + * + * @param permission + * @returns + */ +const hasPermission = (permission: string) => ability.can(permission); + +export { Actions, getServerPermissionsFromRules, hasPermission, prepareAppPermissions, resetPermissions, setPermissions}; + +// TODO Move this code to an external plugin, to be used across the apps +export default { + install(app: any, options: any) { + + // Rules and Actions could be app and OMS package specific + Rules = options.rules; + Actions = options.actions; + + // TODO Check why global properties is not working and apply across. + app.config.globalProperties.$permission = this; + }, + getServerPermissionsFromRules, + hasPermission, + prepareAppPermissions, + resetPermissions, + setPermissions +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 63ba612..ceaf53c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,9 @@ import i18n from "./i18n" import store from "./store" import { DateTime } from "luxon"; import logger from './logger'; +import permissionPlugin from '@/authorization'; +import permissionRules from '@/authorization/Rules'; +import permissionActions from '@/authorization/Actions'; const app = createApp(App) .use(IonicVue, { @@ -44,6 +47,10 @@ const app = createApp(App) .use(router) .use(i18n) .use(store) + .use(permissionPlugin, { + rules: permissionRules, + actions: permissionActions + }) .use(dxpComponents, { defaultImgUrl: require("@/assets/images/defaultImage.png"), login, diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 4500e11..40172a5 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -31,6 +31,96 @@ const login = async (token: string): Promise => { return Promise.resolve(api_key) } +const getUserPermissions = async (payload: any, url: string, token: any): Promise => { + // Currently, making this request in ofbiz + const baseURL = url.startsWith('http') ? url.includes('/api') ? url : `${url}/api/` : `https://${url}.hotwax.io/api/`; + let serverPermissions = [] as any; + + // If the server specific permission list doesn't exist, getting server permissions will be of no use + // It means there are no rules yet depending upon the server permissions. + if (payload.permissionIds && payload.permissionIds.length == 0) return serverPermissions; + // TODO pass specific permissionIds + let resp; + // TODO Make it configurable from the environment variables. + // Though this might not be an server specific configuration, + // we will be adding it to environment variable for easy configuration at app level + const viewSize = 200; + + try { + const params = { + "viewIndex": 0, + viewSize, + permissionIds: payload.permissionIds + } + resp = await client({ + url: "getPermissions", + method: "post", + baseURL, + data: params, + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }) + if(resp.status === 200 && resp.data.docs?.length && !hasError(resp)) { + serverPermissions = resp.data.docs.map((permission: any) => permission.permissionId); + const total = resp.data.count; + const remainingPermissions = total - serverPermissions.length; + if (remainingPermissions > 0) { + // We need to get all the remaining permissions + const apiCallsNeeded = Math.floor(remainingPermissions / viewSize) + ( remainingPermissions % viewSize != 0 ? 1 : 0); + const responses = await Promise.all([...Array(apiCallsNeeded).keys()].map(async (index: any) => { + const response = await client({ + url: "getPermissions", + method: "post", + baseURL, + data: { + "viewIndex": index + 1, + viewSize, + permissionIds: payload.permissionIds + }, + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }) + if(!hasError(response)){ + return Promise.resolve(response); + } else { + return Promise.reject(response); + } + })) + const permissionResponses = { + success: [], + failed: [] + } + responses.reduce((permissionResponses: any, permissionResponse: any) => { + if (permissionResponse.status !== 200 || hasError(permissionResponse) || !permissionResponse.data?.docs) { + permissionResponses.failed.push(permissionResponse); + } else { + permissionResponses.success.push(permissionResponse); + } + return permissionResponses; + }, permissionResponses) + + serverPermissions = permissionResponses.success.reduce((serverPermissions: any, response: any) => { + serverPermissions.push(...response.data.docs.map((permission: any) => permission.permissionId)); + return serverPermissions; + }, serverPermissions) + + // If partial permissions are received and we still allow user to login, some of the functionality might not work related to the permissions missed. + // Show toast to user intimiting about the failure + // Allow user to login + // TODO Implement Retry or improve experience with show in progress icon and allowing login only if all the data related to user profile is fetched. + if (permissionResponses.failed.length > 0) Promise.reject("Something went wrong while getting complete user permissions."); + } + } + return serverPermissions; + } catch(error: any) { + return Promise.reject(error); + } +} + const getUserProfile = async (token: any): Promise => { const url = store.getters["user/getBaseUrl"] const baseURL = url.startsWith('http') ? url.includes('/rest/s1/order-routing') ? url : `${url}/rest/s1/order-routing/` : `https://${url}.hotwax.io/rest/s1/order-routing/`; @@ -106,6 +196,7 @@ export const UserService = { getAvailableTimeZones, getEComStores, getUserProfile, + getUserPermissions, login, setUserTimeZone, } \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index a563108..83e5c43 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -7,6 +7,7 @@ import createPersistedState from "vuex-persistedstate"; import userModule from "./modules/user"; import utilModule from "./modules/util" import orderRoutingModule from "./modules/orderRouting" +import { setPermissions } from "@/authorization" // TODO check how to register it from the components only // Handle same module registering multiple time on page refresh @@ -33,6 +34,8 @@ const store = createStore({ }, }) +setPermissions(store.getters['user/getUserPermissions']); + export default store export function useStore(): typeof store { return useVuexStore() diff --git a/src/store/modules/user/UserState.ts b/src/store/modules/user/UserState.ts index 1708471..5d195d2 100644 --- a/src/store/modules/user/UserState.ts +++ b/src/store/modules/user/UserState.ts @@ -7,4 +7,5 @@ export default interface UserState { url: string; token: string; } + permissions: any; } \ No newline at end of file diff --git a/src/store/modules/user/actions.ts b/src/store/modules/user/actions.ts index 45c7c21..8e14d9f 100644 --- a/src/store/modules/user/actions.ts +++ b/src/store/modules/user/actions.ts @@ -10,6 +10,7 @@ import emitter from "@/event-bus" import { Settings } from "luxon" import { useAuthStore } from '@hotwax/dxp-components' import { resetConfig } from '@/adapter' +import { getServerPermissionsFromRules, prepareAppPermissions, resetPermissions, setPermissions } from "@/authorization" const actions: ActionTree = { @@ -23,23 +24,50 @@ const actions: ActionTree = { // TODO: oms here is of ofbiz we need to check how to get the maarg url from here as we need to hit all apis on maarg const { token, oms, omsRedirectionUrl } = payload; dispatch("setUserInstanceUrl", oms); - + + // Getting the permissions list from server + const permissionId = process.env.VUE_APP_PERMISSION_ID; + // Prepare permissions list + const serverPermissionsFromRules = getServerPermissionsFromRules(); + if (permissionId) serverPermissionsFromRules.push(permissionId); + + const serverPermissions: Array = await UserService.getUserPermissions({ + permissionIds: [...new Set(serverPermissionsFromRules)] + }, omsRedirectionUrl, token); + const appPermissions = prepareAppPermissions(serverPermissions); + // Checking if the user has permission to access the app + // If there is no configuration, the permission check is not enabled + if (permissionId) { + // As the token is not yet set in the state passing token headers explicitly + // TODO Abstract this out, how token is handled should be part of the method not the callee + const hasPermission = appPermissions.some((appPermission: any) => appPermission.action === permissionId ); + // If there are any errors or permission check fails do not allow user to login + if (!hasPermission) { + const permissionError = 'You do not have permission to access the app.'; + showToast(translate(permissionError)); + logger.error("error", permissionError); + return Promise.reject(new Error(permissionError)); + } + } + emitter.emit("presentLoader", { message: "Logging in...", backdropDismiss: false }) const api_key = await UserService.login(token) const userProfile = await UserService.getUserProfile(api_key); - + // TODO: fetch only associated product stores for user, currently api does not support this userProfile.stores = await UserService.getEComStores(api_key); - + if (userProfile.timeZone) { Settings.defaultZone = userProfile.timeZone; } - + + setPermissions(appPermissions); if(omsRedirectionUrl && token) { dispatch("setOmsRedirectionInfo", { url: omsRedirectionUrl, token }) } commit(types.USER_TOKEN_CHANGED, { newToken: api_key }) commit(types.USER_INFO_UPDATED, userProfile); + commit(types.USER_PERMISSIONS_UPDATED, appPermissions); commit(types.USER_CURRENT_ECOM_STORE_UPDATED, userProfile.stores.length ? userProfile.stores[0] : {}); emitter.emit("dismissLoader") } catch (err: any) { @@ -64,7 +92,7 @@ const actions: ActionTree = { this.dispatch("util/clearUtilState") dispatch("setOmsRedirectionInfo", { url: "", token: "" }) resetConfig(); - + resetPermissions(); // reset plugin state on logout authStore.$reset() diff --git a/src/store/modules/user/getters.ts b/src/store/modules/user/getters.ts index b86d352..c32feb1 100644 --- a/src/store/modules/user/getters.ts +++ b/src/store/modules/user/getters.ts @@ -27,6 +27,9 @@ const getters: GetterTree = { }, getOmsRedirectionInfo(state) { return state.omsRedirectionInfo; - } + }, + getUserPermissions (state) { + return state.permissions; + }, } export default getters; \ No newline at end of file diff --git a/src/store/modules/user/index.ts b/src/store/modules/user/index.ts index 8fdc69f..7c65369 100644 --- a/src/store/modules/user/index.ts +++ b/src/store/modules/user/index.ts @@ -15,7 +15,8 @@ const userModule: Module = { omsRedirectionInfo: { url: "", token: "" - } + }, + permissions: [] }, getters, actions, diff --git a/src/store/modules/user/mutation-types.ts b/src/store/modules/user/mutation-types.ts index 5bb9103..21780e8 100644 --- a/src/store/modules/user/mutation-types.ts +++ b/src/store/modules/user/mutation-types.ts @@ -4,4 +4,5 @@ export const USER_END_SESSION = SN_USER + "/END_SESSION" export const USER_INFO_UPDATED = SN_USER + "/INFO_UPDATED" export const USER_INSTANCE_URL_UPDATED = SN_USER + "/INSTANCE_URL_UPDATED" export const USER_CURRENT_ECOM_STORE_UPDATED = SN_USER + '/CURRENT_ECOM_STORE_UPDATED' -export const USER_OMS_REDIRECTION_INFO_UPDATED = SN_USER + '/OMS_REDIRECTION_INFO_UPDATED' \ No newline at end of file +export const USER_OMS_REDIRECTION_INFO_UPDATED = SN_USER + '/OMS_REDIRECTION_INFO_UPDATED' +export const USER_PERMISSIONS_UPDATED = SN_USER + '/PERMISSIONS_UPDATED' \ No newline at end of file diff --git a/src/store/modules/user/mutations.ts b/src/store/modules/user/mutations.ts index 330e090..14cd29b 100644 --- a/src/store/modules/user/mutations.ts +++ b/src/store/modules/user/mutations.ts @@ -10,6 +10,7 @@ const mutations: MutationTree = { state.token = "" state.current = null state.currentEComStore = {} + state.permissions = [] }, [types.USER_INFO_UPDATED] (state, payload) { state.current = payload @@ -22,6 +23,9 @@ const mutations: MutationTree = { }, [types.USER_OMS_REDIRECTION_INFO_UPDATED](state, payload) { state.omsRedirectionInfo = payload; + }, + [types.USER_PERMISSIONS_UPDATED] (state, payload) { + state.permissions = payload } } export default mutations; \ No newline at end of file