diff --git a/package-lock.json b/package-lock.json index 1a588e88..ed2a7426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "@capacitor/android": "^2.4.7", "@capacitor/core": "^2.4.7", + "@casl/ability": "^6.0.0", "@hotwax/oms-api": "^1.7.0", "@ionic/core": "6.7.5", "@ionic/vue": "6.7.5", "@ionic/vue-router": "6.7.5", "@types/file-saver": "^2.0.5", "@types/luxon": "^2.3.2", + "boon-js": "^2.0.3", "core-js": "^3.6.5", "file-saver": "^2.0.5", "luxon": "^2.4.0", @@ -1913,6 +1915,17 @@ "tslib": "^1.9.0" } }, + "node_modules/@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@cnakazawa/watch": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", @@ -4177,6 +4190,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@vue/babel-helper-vue-jsx-merge-props": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.4.0.tgz", @@ -9984,6 +10028,11 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, + "node_modules/boon-js": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/boon-js/-/boon-js-2.0.4.tgz", + "integrity": "sha512-STAfDwFteYLGyVfzGMaBwWpTjPnaGDT2K5GqwPlgnTgHAnvPIpJuRspuqMcsQznwtRywm9v991Qz3DRGGw1qrg==" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -34908,6 +34957,14 @@ "tslib": "^1.9.0" } }, + "@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "requires": { + "@ucast/mongo2js": "^1.3.0" + } + }, "@cnakazawa/watch": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", @@ -36722,6 +36779,37 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "requires": { + "@ucast/core": "^1.0.0" + } + }, + "@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "requires": { + "@ucast/core": "^1.4.1" + } + }, + "@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "requires": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "@vue/babel-helper-vue-jsx-merge-props": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.4.0.tgz", @@ -41408,6 +41496,11 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, + "boon-js": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/boon-js/-/boon-js-2.0.4.tgz", + "integrity": "sha512-STAfDwFteYLGyVfzGMaBwWpTjPnaGDT2K5GqwPlgnTgHAnvPIpJuRspuqMcsQznwtRywm9v991Qz3DRGGw1qrg==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/package.json b/package.json index a951724b..8535a305 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,14 @@ "dependencies": { "@capacitor/android": "^2.4.7", "@capacitor/core": "^2.4.7", + "@casl/ability": "^6.0.0", "@hotwax/oms-api": "^1.7.0", "@ionic/core": "6.7.5", "@ionic/vue": "6.7.5", "@ionic/vue-router": "6.7.5", "@types/file-saver": "^2.0.5", "@types/luxon": "^2.3.2", + "boon-js": "^2.0.3", "core-js": "^3.6.5", "file-saver": "^2.0.5", "luxon": "^2.4.0", diff --git a/src/authorization/Actions.ts b/src/authorization/Actions.ts new file mode 100644 index 00000000..96403ac7 --- /dev/null +++ b/src/authorization/Actions.ts @@ -0,0 +1,5 @@ +export default { + "APP_TURN_OFF_STORE": "APP_TURN_OFF_STORE", + "APP_UNPACK_ORDER": "APP_UNPACK_ORDER", + "APP_RECYCLE_ORDER": "APP_RECYCLE_ORDER", +} \ No newline at end of file diff --git a/src/authorization/Rules.ts b/src/authorization/Rules.ts new file mode 100644 index 00000000..928d66fc --- /dev/null +++ b/src/authorization/Rules.ts @@ -0,0 +1,11 @@ +export default { + "APP_OPEN_ORDERS_VIEW": "", + "APP_IN_PROGRESS_ORDERS_VIEW": "", + "APP_COMPLETED_ORDERS_VIEW": "", + "APP_EXIM_VIEW": "", + "APP_UPLOAD_IMPORT_ORDERS_VIEW": "", + "APP_DOWNLOAD_PACKED_ORDERS_VIEW": "", + "APP_TURN_OFF_STORE": "COMMON_ADMIN", + "APP_UNPACK_ORDER": "COMMON_ADMIN", + "APP_RECYCLE_ORDER": "COMMON_ADMIN", +} as any \ No newline at end of file diff --git a/src/authorization/index.ts b/src/authorization/index.ts new file mode 100644 index 00000000..387a611a --- /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.push(permissionToken.value); + } + return permissions; + }, permissions) + } + return permissions; + }, []) + 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 +} diff --git a/src/components/Menu.vue b/src/components/Menu.vue index 96769df4..e12482f4 100644 --- a/src/components/Menu.vue +++ b/src/components/Menu.vue @@ -8,7 +8,7 @@ - + (!appPage.meta || !appPage.meta.permissionId) || hasPermission(appPage.meta.permissionId)); + } + }, setup() { const store = useStore(); const router = useRouter(); @@ -73,25 +79,37 @@ export default defineComponent({ url: "/open-orders", iosIcon: mailUnreadOutline, mdIcon: mailUnreadOutline, + meta: { + permissionId: "APP_OPEN_ORDERS_VIEW" + } }, { title: "In Progress", url: "/in-progress", iosIcon: mailOpenOutline, mdIcon: mailOpenOutline, + meta: { + permissionId: "APP_IN_PROGRESS_ORDERS_VIEW" + } }, { title: "Completed", url: "/completed", iosIcon: checkmarkDoneOutline, mdIcon: checkmarkDoneOutline, + meta: { + permissionId: "APP_COMPLETED_ORDERS_VIEW" + } }, { title: "EXIM", url: "/exim", iosIcon: swapVerticalOutline, mdIcon: swapVerticalOutline, - childRoutes: ["/download-packed-orders", "/upload-import-orders", "/saved-mappings"] // defined child routes as to enable the correct menu when we are on a route that is not listed in the menu + childRoutes: ["/download-packed-orders", "/upload-import-orders"], // defined child routes as to enable the correct menu when we are on a route that is not listed in the menu + meta: { + permissionId: "APP_EXIM_VIEW" + } }, { title: "Settings", @@ -113,7 +131,8 @@ export default defineComponent({ mailOpenOutline, checkmarkDoneOutline, settingsOutline, - store + store, + hasPermission }; } }); diff --git a/src/locales/en.json b/src/locales/en.json index ee29969a..4be1a76b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -189,6 +189,7 @@ "Shipping labels": "Shipping labels", "Some of the mapping fields are missing in the CSV: ": "Some of the mapping fields are missing in the CSV: {missingFields}", "Something went wrong": "Something went wrong", + "Something went wrong while login. Please contact administrator.": "Something went wrong while login. Please contact administrator.", "Sorry, your username or password is incorrect. Please try again.": "Sorry, your username or password is incorrect. Please try again.", "Staff": "Staff", "State": "State", @@ -224,6 +225,7 @@ "You are packing an order. Select additional documents that you would like to print.": "You are packing an order. Select additional documents that you would like to print.", "You are packing orders. Select additional documents that you would like to print.": "You are packing { count } orders. { space } Select additional documents that you would like to print.", "You are shipping orders. You cannot unpack and edit orders after they have been shipped. Are you sure you are ready to ship this orders?": "You are shipping { count } order. { space } You cannot unpack and edit orders after they have been shipped. Are you sure you are ready to ship this order? | You are shipping { count } orders. { space } You cannot unpack and edit orders after they have been shipped. Are you sure you are ready to ship these orders?", + "You do not have permission to access this page": "You do not have permission to access this page", "Zip Code": "Zip Code", ", and other products are identified as unfulfillable. other orders containing these products will be unassigned from this store and sent to be rebrokered.": "{ productName }, and { products } other products are identified as unfulfillable. { space } { orders } other orders containing these products will be unassigned from this store and sent to be rebrokered." } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index b333a6b6..55b6a66a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,6 +28,10 @@ import './theme/variables.css'; import i18n from './i18n' import store from './store' +import permissionPlugin from '@/authorization'; +import permissionRules from '@/authorization/Rules'; +import permissionActions from '@/authorization/Actions'; + const app = createApp(App) .use(IonicVue, { mode: 'md' @@ -37,7 +41,11 @@ const app = createApp(App) }) .use(router) .use(i18n) - .use(store); + .use(store) + .use(permissionPlugin, { + rules: permissionRules, + actions: permissionActions + }); router.isReady().then(() => { app.mount('#app'); diff --git a/src/router/index.ts b/src/router/index.ts index be9f4c12..068cad54 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -9,11 +9,22 @@ import store from '@/store' import Exim from "@/views/Exim.vue" import UploadImportOrders from "@/views/UploadImportOrders.vue" import DownloadPackedOrders from "@/views/DownloadPackedOrders.vue" +import { hasPermission } from '@/authorization'; +import { showToast } from '@/utils' +import { translate } from '@/i18n' +import 'vue-router' + +// Defining types for the meta values +declare module 'vue-router' { + interface RouteMeta { + permissionId?: string; + } +} import SavedMappings from "@/views/SavedMappings.vue" const authGuard = (to: any, from: any, next: any) => { if (store.getters['user/isAuthenticated']) { - next() + next() } else { next("/login") } @@ -21,7 +32,7 @@ const authGuard = (to: any, from: any, next: any) => { const loginGuard = (to: any, from: any, next: any) => { if (!store.getters['user/isAuthenticated']) { - next() + next() } else { next("/") } @@ -36,43 +47,61 @@ const routes: Array = [ path: '/open-orders', name: 'OpenOrders', component: OpenOrders, - beforeEnter: authGuard + beforeEnter: authGuard, + meta: { + permissionId: "APP_OPEN_ORDERS_VIEW" + } }, { path: '/in-progress', name: 'InProgress', component: InProgress, - beforeEnter: authGuard + beforeEnter: authGuard, + meta: { + permissionId: "APP_IN_PROGRESS_ORDERS_VIEW" + } }, { path: '/completed', name: 'Completed', component: Completed, - beforeEnter: authGuard - }, - { - path: '/login', - name: 'Login', - component: Login, - beforeEnter: loginGuard + beforeEnter: authGuard, + meta: { + permissionId: "APP_COMPLETED_ORDERS_VIEW" + } }, { path: "/exim", name: "EXIM", component: Exim, - beforeEnter: authGuard + beforeEnter: authGuard, + meta: { + permissionId: "APP_EXIM_VIEW" + } }, { path: "/upload-import-orders", name: "UploadImportOrders", component: UploadImportOrders, - beforeEnter: authGuard + beforeEnter: authGuard, + meta: { + permissionId: "APP_UPLOAD_IMPORT_ORDERS_VIEW" + } }, { path: "/download-packed-orders", name: "DownloadPackedOrders", component: DownloadPackedOrders, - beforeEnter: authGuard + beforeEnter: authGuard, + meta: { + permissionId: "APP_DOWNLOAD_PACKED_ORDERS_VIEW" + } + }, + { + path: '/login', + name: 'Login', + component: Login, + beforeEnter: loginGuard }, { path: "/saved-mappings", @@ -93,4 +122,16 @@ const router = createRouter({ routes }) +router.beforeEach((to, from) => { + if (to.meta.permissionId && !hasPermission(to.meta.permissionId)) { + let redirectToPath = from.path; + // If the user has navigated from Login page or if it is page load, redirect user to settings page without showing any toast + if (redirectToPath == "/login" || redirectToPath == "/") redirectToPath = "/settings"; + else showToast(translate('You do not have permission to access this page')); + return { + path: redirectToPath, + } + } +}) + export default router diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 83c2bd1b..9e7f36fa 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -1,4 +1,4 @@ -import { api, client } from '@/adapter'; +import { api, client, hasError } from '@/adapter'; import store from '@/store'; const login = async (username: string, password: string): Promise => { @@ -12,23 +12,6 @@ const login = async (username: string, password: string): Promise => { }); } -const checkPermission = async (payload: any): Promise => { - let baseURL = store.getters['user/getInstanceUrl']; - baseURL = baseURL && baseURL.startsWith('http') ? baseURL : `https://${baseURL}.hotwax.io/api/`; - return client({ - url: "checkPermission", - method: "post", - baseURL: baseURL, - ...payload - }); -} - -const getProfile = async (): Promise => { - return api({ - url: "user-profile", - method: "get", - }); -} const getAvailableTimeZones = async (): Promise => { return api({ url: "getAvailableTimeZones", @@ -36,6 +19,7 @@ const getAvailableTimeZones = async (): Promise => { cache: true }); } + const setUserTimeZone = async (payload: any): Promise => { return api({ url: "setUserTimeZone", @@ -93,21 +77,172 @@ const getOutstandingOrdersCount = async(payload: any): Promise => { }) } -const getEComStores = async (payload: any): Promise => { - return api({ - url: "performFind", - method: "get", - params: payload - }); +const getEComStores = async (token: any, facilityId: any): Promise => { + try { + const params = { + "inputFields": { + "storeName_op": "not-empty", + facilityId + }, + "fieldList": ["productStoreId", "storeName"], + "entityName": "ProductStoreFacilityDetail", + "distinct": "Y", + "noConditionFind": "Y", + "filterByDate": 'Y', + } + const baseURL = store.getters['user/getBaseUrl']; + const resp = await client({ + url: "performFind", + method: "get", + baseURL, + params, + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }); + if (hasError(resp)) { + return Promise.reject(resp.data); + } else { + return Promise.resolve(resp.data.docs); + } + } catch(error: any) { + return Promise.reject(error) + } } -const getUserPreference = async (payload: any): Promise => { - return api({ - url: "service/getUserPreference", - //TODO Due to security reasons service model OMS 1.0 does not support sending parameters in get request that's why we use post here - method: "post", - data: payload - }); +const getPreferredStore = async (token: any): Promise => { + const baseURL = store.getters['user/getBaseUrl']; + try { + const resp = await client({ + url: "service/getUserPreference", + //TODO Due to security reasons service model of OMS 1.0 does not support sending parameters in get request that's why we use post here + method: "post", + baseURL, + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + data: { + 'userPrefTypeId': 'SELECTED_BRAND' + }, + }); + if (hasError(resp)) { + return Promise.reject(resp.data); + } else { + return Promise.resolve(resp.data.userPrefValue); + } + } catch (error: any) { + return Promise.reject(error) + } +} + +const getUserPermissions = async (payload: any, token: any): Promise => { + const baseURL = store.getters['user/getBaseUrl']; + 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 baseURL = store.getters['user/getBaseUrl']; + try { + const resp = await client({ + url: "user-profile", + method: "get", + baseURL, + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }); + if(hasError(resp)) return Promise.reject("Error getting user profile: " + JSON.stringify(resp.data)); + return Promise.resolve(resp.data) + } catch(error: any) { + return Promise.reject(error) + } } const setUserPreference = async (payload: any): Promise => { @@ -160,13 +295,13 @@ export const UserService = { getFieldMappings, getInProgressOrdersCount, getOutstandingOrdersCount, - getProfile, - getUserPreference, + getUserProfile, + getPreferredStore, recycleInProgressOrders, recycleOutstandingOrders, setUserPreference, setUserTimeZone, - checkPermission, + getUserPermissions, updateFacility, updateFieldMapping } \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 0bcb27b3..eea6dde0 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -8,6 +8,7 @@ import userModule from './modules/user'; import productModule from "./modules/product" import orderModule from "./modules/order" import utilModule from "./modules/util" +import { setPermissions } from '@/authorization' // TODO check how to register it from the components only @@ -39,6 +40,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 252bd62b..0413fffe 100644 --- a/src/store/modules/user/UserState.ts +++ b/src/store/modules/user/UserState.ts @@ -1,6 +1,7 @@ export default interface UserState { token: string; current: object | null; + permissions: any; currentFacility: object; instanceUrl: string; currentEComStore: object; diff --git a/src/store/modules/user/actions.ts b/src/store/modules/user/actions.ts index 7e29bf21..57846f5f 100644 --- a/src/store/modules/user/actions.ts +++ b/src/store/modules/user/actions.ts @@ -9,66 +9,117 @@ import { translate } from '@/i18n' import { Settings } from 'luxon' import { updateInstanceUrl, updateToken, resetConfig } from '@/adapter' import logger from '@/logger' +import { getServerPermissionsFromRules, prepareAppPermissions, resetPermissions, setPermissions } from '@/authorization' const actions: ActionTree = { /** * Login user and return token */ - async login ({ commit, dispatch }, { username, password }) { + async login ({ commit }, { username, password }) { try { - const resp = await UserService.login(username, password) - if (resp.status === 200 && resp.data) { - if (resp.data.token) { - const permissionId = process.env.VUE_APP_PERMISSION_ID; - if (permissionId) { - const checkPermissionResponse = await UserService.checkPermission({ - data: { - permissionId - }, - headers: { - Authorization: 'Bearer ' + resp.data.token, - 'Content-Type': 'application/json' - } - }); - - if (checkPermissionResponse.status === 200 && !hasError(checkPermissionResponse) && checkPermissionResponse.data && checkPermissionResponse.data.hasPermission) { - commit(types.USER_TOKEN_CHANGED, { newToken: resp.data.token }) - updateToken(resp.data.token) - await dispatch('getProfile') - if (resp.data._EVENT_MESSAGE_ && resp.data._EVENT_MESSAGE_.startsWith("Alert:")) { - // TODO Internationalise text - showToast(translate(resp.data._EVENT_MESSAGE_)); - } - return resp.data; - } else { - const permissionError = 'You do not have permission to access the app.'; - showToast(translate(permissionError)); - logger.error("error", permissionError); - return Promise.reject(new Error(permissionError)); - } - } else { - commit(types.USER_TOKEN_CHANGED, { newToken: resp.data.token }) - updateToken(resp.data.token) - await dispatch('getProfile') - return resp.data; - } - } else if (hasError(resp)) { - showToast(translate('Sorry, your username or password is incorrect. Please try again.')); - logger.error("error", resp.data._ERROR_MESSAGE_); - return Promise.reject(new Error(resp.data._ERROR_MESSAGE_)); - } - } else { - showToast(translate('Something went wrong')); + const resp = await UserService.login(username, password); + // Further we will have only response having 2xx status + // https://axios-http.com/docs/handling_errors + // We haven't customized validateStatus method and default behaviour is for all status other than 2xx + // TODO Check if we need to handle all 2xx status other than 200 + + + /* ---- Guard clauses starts here --- */ + // Know about Guard clauses here: https://learningactors.com/javascript-guard-clauses-how-you-can-refactor-conditional-logic/ + // https://medium.com/@scadge/if-statements-design-guard-clauses-might-be-all-you-need-67219a1a981a + + + // If we have any error most possible reason is incorrect credentials. + if (hasError(resp)) { + showToast(translate('Sorry, your username or password is incorrect. Please try again.')); logger.error("error", resp.data._ERROR_MESSAGE_); return Promise.reject(new Error(resp.data._ERROR_MESSAGE_)); } + + const token = resp.data.token; + + // 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 = await UserService.getUserPermissions({ + permissionIds: serverPermissionsFromRules + }, 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((appPermissionId: any) => appPermissionId === 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)); + } + } + + const userProfile = await UserService.getUserProfile(token); + + if (!userProfile.facilities.length) throw 'Unable to login. User is not assocaited with any facility' + + // Getting unique facilities + userProfile.facilities.reduce((uniqueFacilities: any, facility: any, index: number) => { + if (uniqueFacilities.includes(facility.facilityId)) userProfile.facilities.splice(index, 1); + else uniqueFacilities.push(facility.facilityId); + return uniqueFacilities + }, []); + + // TODO Use a separate API for getting facilities, this should handle user like admin accessing the app + const currentFacility = userProfile.facilities[0]; + userProfile.stores = await UserService.getEComStores(token, currentFacility.facilityId); + + // In Job Manager application, we have jobs which may not be associated with any product store + userProfile.stores.push({ + productStoreId: "", + storeName: "None" + }) + let preferredStore = userProfile.stores[0] + + const preferredStoreId = await UserService.getPreferredStore(token); + if (preferredStoreId) { + const store = userProfile.stores.find((store: any) => store.productStoreId === preferredStoreId); + store && (preferredStore = store) + } + + /* ---- Guard clauses ends here --- */ + + setPermissions(appPermissions); + if (userProfile.userTimeZone) { + Settings.defaultZone = userProfile.userTimeZone; + } + + // TODO user single mutation + commit(types.USER_CURRENT_ECOM_STORE_UPDATED, preferredStore); + commit(types.USER_CURRENT_FACILITY_UPDATED, currentFacility); + commit(types.USER_INFO_UPDATED, userProfile); + commit(types.USER_PERMISSIONS_UPDATED, appPermissions); + commit(types.USER_TOKEN_CHANGED, { newToken: token }) + updateToken(resp.data.token) + + // Handling case for warnings like password may expire in few days + if (resp.data._EVENT_MESSAGE_ && resp.data._EVENT_MESSAGE_.startsWith("Alert:")) { + // TODO Internationalise text + showToast(translate(resp.data._EVENT_MESSAGE_)); + } } catch (err: any) { - showToast(translate('Something went wrong')); - logger.error("error", err); + // If any of the API call in try block has status code other than 2xx it will be handled in common catch block. + // TODO Check if handling of specific status codes is required. + showToast(translate('Something went wrong while login. Please contact administrator.')); + logger.error("error: ", err.toString()); return Promise.reject(new Error(err)) } - // return resp }, /** @@ -79,50 +130,26 @@ const actions: ActionTree = { commit(types.USER_END_SESSION) this.dispatch('order/clearOrders') resetConfig(); + resetPermissions(); }, /** - * Get User profile + * update current facility information */ - async getProfile ( { commit, dispatch }) { - try { - const resp = await UserService.getProfile() - if (resp.data.userTimeZone) { - Settings.defaultZone = resp.data.userTimeZone; - } - - // logic to remove duplicate facilities - const facilityIds = new Set(); - const facilities = [] as Array; + async setFacility ({ commit, state }, payload) { + const userProfile = JSON.parse(JSON.stringify(state.current as any)); + userProfile.stores = await UserService.getEComStores(undefined, payload.facility.facilityId); - resp.data.facilities.map((facility: any) => { - if(!facilityIds.has(facility.facilityId)) { - facilityIds.add(facility.facilityId) - facilities.push(facility) - } - }) + let preferredStore = userProfile.stores[0]; + const preferredStoreId = await UserService.getPreferredStore(undefined); - resp.data.facilities = facilities - - const currentFacility = resp.data.facilities.length > 0 ? resp.data.facilities[0] : {}; - resp.data.stores = await dispatch('getEComStores', { facilityId: currentFacility.facilityId }) - - dispatch('getFieldMappings') - commit(types.USER_INFO_UPDATED, resp.data); - commit(types.USER_CURRENT_FACILITY_UPDATED, currentFacility); - } catch(err) { - logger.error('Failed to fetch user profile information', err) + if (preferredStoreId) { + const store = userProfile.stores.find((store: any) => store.productStoreId === preferredStoreId); + store && (preferredStore = store) } - }, - - /** - * update current facility information - */ - async setFacility ({ commit, dispatch, state }, payload) { - const user = JSON.parse(JSON.stringify(state.current as any)); + commit(types.USER_INFO_UPDATED, userProfile); commit(types.USER_CURRENT_FACILITY_UPDATED, payload.facility); - user.stores = await dispatch("getEComStores", { facilityId: payload.facility.facilityId }); - commit(types.USER_INFO_UPDATED, user); + commit(types.USER_CURRENT_ECOM_STORE_UPDATED, preferredStore); this.dispatch('order/clearOrders') }, @@ -145,41 +172,6 @@ const actions: ActionTree = { updateInstanceUrl(payload) }, - async getEComStores({ commit }, payload) { - let resp; - - try { - const param = { - "inputFields": { - "facilityId": payload.facilityId, - "storeName_op": "not-empty" - }, - "fieldList": ["productStoreId", "storeName"], - "entityName": "ProductStoreFacilityDetail", - "distinct": "Y", - "noConditionFind": "Y" - } - - resp = await UserService.getEComStores(param); - if(!hasError(resp)) { - const eComStores = resp.data.docs - - const userPref = await UserService.getUserPreference({ - 'userPrefTypeId': 'SELECTED_BRAND' - }); - const userPrefStore = eComStores.find((store: any) => store.productStoreId == userPref.data.userPrefValue) - - commit(types.USER_CURRENT_ECOM_STORE_UPDATED, userPrefStore ? userPrefStore : eComStores.length > 0 ? eComStores[0] : {}); - return eComStores - } else { - throw resp.data - } - } catch(error) { - logger.error('Failed to get ecom stores', error); - } - return [] - }, - /** * update current eComStore information */ diff --git a/src/store/modules/user/getters.ts b/src/store/modules/user/getters.ts index 7cb0c49a..58fa9f77 100644 --- a/src/store/modules/user/getters.ts +++ b/src/store/modules/user/getters.ts @@ -12,6 +12,9 @@ const getters: GetterTree = { getUserToken (state) { return state.token }, + getUserPermissions (state) { + return state.permissions; + }, getUserProfile (state) { return state.current }, @@ -22,6 +25,11 @@ const getters: GetterTree = { const baseUrl = process.env.VUE_APP_BASE_URL; return baseUrl ? baseUrl : state.instanceUrl; }, + getBaseUrl (state) { + let baseURL = process.env.VUE_APP_BASE_URL; + if (!baseURL) baseURL = state.instanceUrl; + return baseURL.startsWith('http') ? baseURL : `https://${baseURL}.hotwax.io/api/`; + }, getCurrentEComStore(state) { return state.currentEComStore }, diff --git a/src/store/modules/user/index.ts b/src/store/modules/user/index.ts index 61f1f3d9..c2e33d02 100644 --- a/src/store/modules/user/index.ts +++ b/src/store/modules/user/index.ts @@ -9,7 +9,8 @@ const userModule: Module = { namespaced: true, state: { token: '', - current: null, + permissions: [], + current: {}, currentFacility: {}, instanceUrl: '', currentEComStore: {}, diff --git a/src/store/modules/user/mutation-types.ts b/src/store/modules/user/mutation-types.ts index 5b72068c..d23b38d9 100644 --- a/src/store/modules/user/mutation-types.ts +++ b/src/store/modules/user/mutation-types.ts @@ -6,6 +6,7 @@ export const USER_CURRENT_FACILITY_UPDATED = SN_USER + '/CURRENT_FACILITY_UPDATE 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_PREFERENCE_UPDATED = SN_USER + '/PREFERENCE_UPDATED' +export const USER_PERMISSIONS_UPDATED = SN_USER + '/PERMISSIONS_UPDATED' export const USER_CURRENT_FIELD_MAPPING_UPDATED = SN_USER + '/_CURRENT_FIELD_MAPPING_UPDATED' export const USER_FIELD_MAPPINGS_UPDATED = SN_USER + '/FIELD_MAPPINGS_UPDATED' -export const USER_FIELD_MAPPING_CREATED = SN_USER + '/FIELD_MAPPING_CREATED' \ No newline at end of file +export const USER_FIELD_MAPPING_CREATED = SN_USER + '/FIELD_MAPPING_CREATED' diff --git a/src/store/modules/user/mutations.ts b/src/store/modules/user/mutations.ts index 5260af0e..130ca360 100644 --- a/src/store/modules/user/mutations.ts +++ b/src/store/modules/user/mutations.ts @@ -8,12 +8,13 @@ const mutations: MutationTree = { }, [types.USER_END_SESSION] (state) { state.token = '' - state.current = null + state.current = {}, state.currentFacility = {} state.currentEComStore = {} + state.permissions = [] }, [types.USER_INFO_UPDATED] (state, payload) { - state.current = payload + state.current = { ...state.current, ...payload} }, [types.USER_CURRENT_FACILITY_UPDATED] (state, payload) { state.currentFacility = payload; @@ -27,6 +28,9 @@ const mutations: MutationTree = { [types.USER_PREFERENCE_UPDATED] (state, payload) { state.preference = {...state.preference, ...payload}; }, + [types.USER_PERMISSIONS_UPDATED] (state, payload) { + state.permissions = payload + }, [types.USER_FIELD_MAPPINGS_UPDATED] (state, payload) { state.fieldMappings = payload; }, diff --git a/src/utils/index.ts b/src/utils/index.ts index 4c3f2e9e..9a7936d1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,7 +11,7 @@ import Papa from 'papaparse' // TODO Remove it when HC APIs are fully REST compliant const hasError = (response: any) => { - return !!response.data._ERROR_MESSAGE_ || !!response.data._ERROR_MESSAGE_LIST_; + return typeof response.data != "object" || !!response.data._ERROR_MESSAGE_ || !!response.data._ERROR_MESSAGE_LIST_ || !!response.data.error; } const showToast = async (message: string) => { diff --git a/src/views/Completed.vue b/src/views/Completed.vue index 36fdfb74..713dc53c 100644 --- a/src/views/Completed.vue +++ b/src/views/Completed.vue @@ -107,7 +107,7 @@
- {{ $t("Unpack") }} + {{ $t("Unpack") }}
@@ -170,6 +170,7 @@ import ViewSizeSelector from '@/components/ViewSizeSelector.vue' import { translate } from '@/i18n'; import { OrderService } from '@/services/OrderService'; import logger from '@/logger'; +import { Actions, hasPermission } from '@/authorization' export default defineComponent({ name: 'Home', @@ -582,12 +583,14 @@ export default defineComponent({ const router = useRouter(); return { + Actions, copyToClipboard, checkmarkDoneOutline, downloadOutline, ellipsisVerticalOutline, formatUtcDate, getFeature, + hasPermission, optionsOutline, pricetagOutline, printOutline, diff --git a/src/views/Login.vue b/src/views/Login.vue index 8fec6e10..2a786b31 100644 --- a/src/views/Login.vue +++ b/src/views/Login.vue @@ -74,12 +74,10 @@ export default defineComponent({ const instanceURL = this.instanceUrl.trim().toLowerCase(); if(!this.baseURL) this.store.dispatch("user/setUserInstanceUrl", this.alias[instanceURL] ? this.alias[instanceURL] : instanceURL); const { username, password } = this; - this.store.dispatch("user/login", { username, password }).then((data: any) => { - if (data.token) { - this.username = '' - this.password = '' - this.$router.push('/') - } + this.store.dispatch("user/login", { username: username.trim(), password }).then(() => { + this.username = '' + this.password = '' + this.$router.push('/') }) } }, diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 613a3470..0358d936 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -53,12 +53,12 @@
- {{ $t("Recycle all open orders") }} - {{ $t("Recycle all in progress orders") }} + {{ $t("Recycle all open orders") }} + {{ $t("Recycle all in progress orders") }}
- {{ $t("Turn off fulfillment") }} + {{ $t("Turn off fulfillment") }} {{ $t("Turn on fulfillment") }}
@@ -114,6 +114,7 @@ import { showToast } from '@/utils'; import { hasError } from '@/adapter'; import { translate } from '@/i18n'; import logger from '@/logger'; +import { Actions, hasPermission } from '@/authorization' export default defineComponent({ name: 'Settings', @@ -265,7 +266,8 @@ export default defineComponent({ }, async setFacility (event: any) { // not updating the facility when the current facility in vuex state and the selected facility are same - if(this.currentFacility.facilityId === event.detail.value) { + // or when an empty value is given (on logout) + if (this.currentFacility.facilityId === event.detail.value || !event.detail.value) { return; } @@ -419,7 +421,8 @@ export default defineComponent({ }, async setEComStore(event: any) { // not updating the ecomstore when the current value in vuex state and selected value are same - if(this.currentEComStore.productStoreId === event.detail.value) { + // or when an empty value is given (on logout) + if (this.currentEComStore.productStoreId === event.detail.value || !event.detail.value) { return; } @@ -443,12 +446,14 @@ export default defineComponent({ const router = useRouter(); return { + Actions, codeWorkingOutline, ellipsisVerticalOutline, globeOutline, timeOutline, router, - store + store, + hasPermission } } });