diff --git a/package.json b/package.json index 7e4e7c74..b665250a 100644 --- a/package.json +++ b/package.json @@ -33,15 +33,16 @@ "@formkit/vue": "1.0.0-beta.9", "@halo-dev/admin-api": "^1.1.0", "@halo-dev/admin-shared": "workspace:*", - "@halo-dev/api-client": "^0.0.0", + "@halo-dev/api-client": "^0.0.2", "@halo-dev/components": "workspace:*", - "@vueuse/components": "^8.9.2", - "@vueuse/core": "^8.9.2", + "@vueuse/components": "^8.9.3", + "@vueuse/core": "^8.9.3", "axios": "^0.27.2", "filepond": "^4.30.4", "filepond-plugin-image-preview": "^4.6.11", "floating-vue": "2.0.0-beta.16", "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0", "pinia": "^2.0.16", "qs": "^6.11.0", "uuid": "^8.3.2", @@ -51,7 +52,7 @@ "vue-router": "^4.1.2" }, "devDependencies": { - "@changesets/cli": "^2.23.1", + "@changesets/cli": "^2.23.2", "@rushstack/eslint-patch": "^1.1.4", "@tailwindcss/aspect-ratio": "^0.4.0", "@types/jsdom": "^16.2.14", diff --git a/packages/components/package.json b/packages/components/package.json index 6442a716..fb672d32 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -49,7 +49,7 @@ "@rollup/plugin-typescript": "^8.3.3", "histoire": "^0.7.9", "unplugin-icons": "^0.14.7", - "vite-plugin-dts": "^1.2.1" + "vite-plugin-dts": "^1.3.0" }, "peerDependencies": { "vue": "^3.2.37", diff --git a/packages/shared/package.json b/packages/shared/package.json index 33121cbb..68c92f4b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,12 +36,12 @@ "homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme", "license": "MIT", "dependencies": { - "@halo-dev/api-client": "^0.0.0", + "@halo-dev/api-client": "^0.0.2", "@halo-dev/components": "workspace:*", "axios": "^0.27.2" }, "devDependencies": { - "vite-plugin-dts": "^1.2.1" + "vite-plugin-dts": "^1.3.0" }, "peerDependencies": { "vue": "^3.2.37", diff --git a/packages/shared/src/states/pages.ts b/packages/shared/src/states/pages.ts index fcefa7e3..dc217a2b 100644 --- a/packages/shared/src/states/pages.ts +++ b/packages/shared/src/states/pages.ts @@ -6,4 +6,5 @@ export interface FunctionalPagesState { name: string; path: string; url?: string; + permissions?: Array; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47be1f95..61ce70e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,7 +4,7 @@ importers: .: specifiers: - '@changesets/cli': ^2.23.1 + '@changesets/cli': ^2.23.2 '@formkit/addons': 1.0.0-beta.9 '@formkit/core': 1.0.0-beta.9 '@formkit/i18n': 1.0.0-beta.9 @@ -13,7 +13,7 @@ importers: '@formkit/vue': 1.0.0-beta.9 '@halo-dev/admin-api': ^1.1.0 '@halo-dev/admin-shared': workspace:* - '@halo-dev/api-client': ^0.0.0 + '@halo-dev/api-client': ^0.0.2 '@halo-dev/components': workspace:* '@rushstack/eslint-patch': ^1.1.4 '@tailwindcss/aspect-ratio': ^0.4.0 @@ -30,8 +30,8 @@ importers: '@vue/eslint-config-typescript': ^11.0.0 '@vue/test-utils': ^2.0.2 '@vue/tsconfig': ^0.1.3 - '@vueuse/components': ^8.9.2 - '@vueuse/core': ^8.9.2 + '@vueuse/components': ^8.9.3 + '@vueuse/core': ^8.9.3 autoprefixer: ^10.4.7 axios: ^0.27.2 c8: ^7.11.3 @@ -45,6 +45,7 @@ importers: husky: ^8.0.1 jsdom: ^19.0.0 lodash.clonedeep: ^4.5.0 + lodash.isequal: ^4.5.0 pinia: ^2.0.16 postcss: ^8.4.14 prettier: ^2.7.1 @@ -78,15 +79,16 @@ importers: '@formkit/vue': 1.0.0-beta.9_jly5jqkcc2zgnt3crhnp3znzv4 '@halo-dev/admin-api': 1.1.0 '@halo-dev/admin-shared': link:packages/shared - '@halo-dev/api-client': 0.0.0 + '@halo-dev/api-client': 0.0.2 '@halo-dev/components': link:packages/components - '@vueuse/components': 8.9.2_vue@3.2.37 - '@vueuse/core': 8.9.2_vue@3.2.37 + '@vueuse/components': 8.9.3_vue@3.2.37 + '@vueuse/core': 8.9.3_vue@3.2.37 axios: 0.27.2 filepond: 4.30.4 filepond-plugin-image-preview: 4.6.11_filepond@4.30.4 floating-vue: 2.0.0-beta.16_vue@3.2.37 lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 pinia: 2.0.16_j6bzmzd4ujpabbp5objtwxyjp4 qs: 6.11.0 uuid: 8.3.2 @@ -95,7 +97,7 @@ importers: vue-grid-layout: 3.0.0-beta1 vue-router: 4.1.2_vue@3.2.37 devDependencies: - '@changesets/cli': 2.23.1 + '@changesets/cli': 2.23.2 '@rushstack/eslint-patch': 1.1.4 '@tailwindcss/aspect-ratio': 0.4.0_tailwindcss@3.1.6 '@types/jsdom': 16.2.14 @@ -143,26 +145,26 @@ importers: '@rollup/plugin-typescript': ^8.3.3 histoire: ^0.7.9 unplugin-icons: ^0.14.7 - vite-plugin-dts: ^1.2.1 + vite-plugin-dts: ^1.3.0 devDependencies: '@iconify-json/ri': 1.1.3 '@rollup/plugin-typescript': 8.3.3 histoire: 0.7.9 unplugin-icons: 0.14.7 - vite-plugin-dts: 1.2.1 + vite-plugin-dts: 1.3.0 packages/shared: specifiers: - '@halo-dev/api-client': ^0.0.0 + '@halo-dev/api-client': ^0.0.2 '@halo-dev/components': workspace:* axios: ^0.27.2 - vite-plugin-dts: ^1.2.1 + vite-plugin-dts: ^1.3.0 dependencies: - '@halo-dev/api-client': 0.0.0 + '@halo-dev/api-client': 0.0.2 '@halo-dev/components': link:../components axios: 0.27.2 devDependencies: - vite-plugin-dts: 1.2.1 + vite-plugin-dts: 1.3.0 packages: @@ -1454,8 +1456,8 @@ packages: '@changesets/types': 5.0.0 dev: true - /@changesets/cli/2.23.1: - resolution: {integrity: sha512-yXQ29Iw/26yq1oJpOCa7BJDeUvuurZmREmCX9p9m5RxlKNDnROJBylQDCVfqQdZbeV2jfvd3XRKvci80SvGssg==} + /@changesets/cli/2.23.2: + resolution: {integrity: sha512-o7CWC+mcwOmA3yK5axqHOSYPYEjX/x+nq/s9aX78AyzH1SQZa6L5HX4P9uUXibyjcKynklkmusxv8vN8+hJggA==} hasBin: true dependencies: '@babel/runtime': 7.17.9 @@ -1787,8 +1789,8 @@ packages: - debug dev: false - /@halo-dev/api-client/0.0.0: - resolution: {integrity: sha512-DC+0MsnX3e5IkqFFya3gz8JK893hGH+9ohKzLp1b7QqYj1Hvxxc1Nbr77IxvVbktZ14LUZTeHOdWHdqFAFQdvw==} + /@halo-dev/api-client/0.0.2: + resolution: {integrity: sha512-7amtdteNPCZ9ObM969HfknIqd+eXK8gjJ0cKWc7F7TET6puerlPxmmPC0SSdUEpnTf8zK1XUJg+Y06joxZboHA==} dev: false /@halo-dev/logger/1.1.0: @@ -2876,11 +2878,11 @@ packages: '@types/node': 17.0.45 dev: true - /@vueuse/components/8.9.2_vue@3.2.37: - resolution: {integrity: sha512-7K39dgpSdMJgotCGCaa3W7q/9AEZ2jitG+mBWH0TK+HSqLeYd5syHQKKK61raWP/qImu/mrwcsqeKtbFunU1FA==} + /@vueuse/components/8.9.3_vue@3.2.37: + resolution: {integrity: sha512-7A97cUdJxwAESo1dJvIzxGW7Z8n5LGrLPOrQ9qgNGUKZlwVgBHJNiQ5KMddDDoqSwTVrLGspc1p8q8/+tYpHKA==} dependencies: - '@vueuse/core': 8.9.2_vue@3.2.37 - '@vueuse/shared': 8.9.2_vue@3.2.37 + '@vueuse/core': 8.9.3_vue@3.2.37 + '@vueuse/shared': 8.9.3_vue@3.2.37 vue-demi: 0.12.1_vue@3.2.37 transitivePeerDependencies: - '@vue/composition-api' @@ -2903,9 +2905,33 @@ packages: '@vueuse/shared': 8.9.2_vue@3.2.37 vue: 3.2.37 vue-demi: 0.12.1_vue@3.2.37 + dev: true + + /@vueuse/core/8.9.3_vue@3.2.37: + resolution: {integrity: sha512-q2pr3N7FPG7IBBhEXTYOJU+38VwKMLP5IfD33byzBV4Th7f1JHT4qPKvJrvr17knAefPRzNqgt9et+xFqaRlPQ==} + peerDependencies: + '@vue/composition-api': ^1.1.0 + vue: ^2.6.0 || ^3.2.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue: + optional: true + dependencies: + '@types/web-bluetooth': 0.0.14 + '@vueuse/metadata': 8.9.3 + '@vueuse/shared': 8.9.3_vue@3.2.37 + vue: 3.2.37 + vue-demi: 0.12.1_vue@3.2.37 + dev: false /@vueuse/metadata/8.9.2: resolution: {integrity: sha512-g2s2BeyeEtJElmMFfFPnM+BTvnt0omniyvz8U18/zXDpQIMGozlNQgHoFeratyMfgVBhH/u2VKzmchChtDsgPQ==} + dev: true + + /@vueuse/metadata/8.9.3: + resolution: {integrity: sha512-57gZZKtWAmcJaUBmciCohvmumVLz4+FnoVnWj7U5BWs5PC2/7gU9Z0/i1i9leDNeboAauFzAq7z1GjS8eYnT+w==} + dev: false /@vueuse/shared/8.9.2_vue@3.2.37: resolution: {integrity: sha512-s4Nk82oheL5z1GywyGnqjob0MzbAt88olMZa0vgt/p3gcMsT8Ff7+SqmNgEFC6AAs6xiuhOAZpnew9Zs3d90yQ==} @@ -2920,6 +2946,22 @@ packages: dependencies: vue: 3.2.37 vue-demi: 0.12.1_vue@3.2.37 + dev: true + + /@vueuse/shared/8.9.3_vue@3.2.37: + resolution: {integrity: sha512-foorYQAU3CGknAO1w9No/rpGBJmb7L74MPltnZAYxeBRfhsajjJYYgja+D5IT2vT+/a0NciISaVp3fDwMN1ocA==} + peerDependencies: + '@vue/composition-api': ^1.1.0 + vue: ^2.6.0 || ^3.2.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue: + optional: true + dependencies: + vue: 3.2.37 + vue-demi: 0.12.1_vue@3.2.37 + dev: false /abab/2.0.5: resolution: {integrity: sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==} @@ -3731,7 +3773,7 @@ packages: cli-table3: 0.6.1 commander: 5.1.0 common-tags: 1.8.2 - dayjs: 1.10.8 + dayjs: 1.11.3 debug: 4.3.4_supports-color@8.1.1 enquirer: 2.3.6 eventemitter2: 6.4.5 @@ -3775,8 +3817,8 @@ packages: whatwg-url: 10.0.0 dev: true - /dayjs/1.10.8: - resolution: {integrity: sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow==} + /dayjs/1.11.3: + resolution: {integrity: sha512-xxwlswWOlGhzgQ4TKzASQkUhqERI3egRNqgV4ScR8wlANA/A9tZ7miXa44vTTKEq5l7vWoL5G57bG3zA+Kow0A==} dev: true /debug/2.6.9: @@ -5777,7 +5819,6 @@ packages: /lodash.isequal/4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} - dev: true /lodash.merge/4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -6067,7 +6108,7 @@ packages: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: hosted-git-info: 2.8.9 - resolve: 1.22.0 + resolve: 1.22.1 semver: 5.7.1 validate-npm-package-license: 3.0.4 dev: true @@ -7755,8 +7796,8 @@ packages: - stylus dev: true - /vite-plugin-dts/1.2.1: - resolution: {integrity: sha512-V59rsKQnPI6FTGybh/ED4+dyK3UeSkvC1CJzpuDNoXb7mKNUcWmg66EM0N5Ijoc8xDAfZIXYxQjg675YHIDvFw==} + /vite-plugin-dts/1.3.0: + resolution: {integrity: sha512-YxDNqOE2wp713SyZ6AMmSu/sNfmiiy7GtlFXCMvlpD4nMaIbpqltidbve7fNlc3+gxlV+e156As/TwBtBp3g4Q==} engines: {node: '>=12.0.0'} peerDependencies: vite: '>=2.4.4' diff --git a/src/main.ts b/src/main.ts index 376f0ccc..1a055074 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import type { DirectiveBinding } from "vue"; import { createApp } from "vue"; import { createPinia } from "pinia"; import App from "./App.vue"; @@ -18,6 +19,8 @@ import { coreModules } from "./modules"; import { useScriptTag } from "@vueuse/core"; import { usePluginStore } from "@/stores/plugin"; import type { User } from "@halo-dev/api-client"; +import { hasPermission } from "@/utils/permission"; +import { useRoleStore } from "@/stores/role"; const app = createApp(App); @@ -141,10 +144,29 @@ async function loadCurrentUser() { const { data: user } = await apiClient.user.getCurrentUserDetail(); app.provide("currentUser", user); - const { data: permissions } = await apiClient.user.getPermissions( + const { data: currentPermissions } = await apiClient.user.getPermissions( user.metadata.name ); - app.provide("permissions", permissions); + const roleStore = useRoleStore(); + roleStore.$patch({ + permissions: currentPermissions, + }); + app.directive( + "permission", + (el: HTMLElement, binding: DirectiveBinding) => { + const uiPermissions = Array.from( + currentPermissions.uiPermissions + ); + const { value } = binding; + const { any, enable } = binding.modifiers; + + if (hasPermission(uiPermissions, value, any)) { + return; + } + + enable ? (el.style.backgroundColor = "red") : el.remove(); + } + ); } (async function () { diff --git a/src/modules/contents/pages/PageList.vue b/src/modules/contents/pages/PageList.vue index fb88772b..08eb8a43 100644 --- a/src/modules/contents/pages/PageList.vue +++ b/src/modules/contents/pages/PageList.vue @@ -99,6 +99,7 @@ onMounted(() => {
  • { + const roleStore = useRoleStore(); + const { uiPermissions } = roleStore.permissions; + const { meta } = to; + if (meta && meta.permissions) { + const flag = hasPermission( + Array.from(uiPermissions), + meta.permissions as string[], + false + ); + if (!flag) { + next({ name: "Forbidden" }); + } + next(); + } + next(); + }); +} diff --git a/src/router/index.ts b/src/router/index.ts index 7622695c..1d794658 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHashHistory } from "vue-router"; import routesConfig from "@/router/routes.config"; +import { setupPermissionGuard } from "./guards/permission"; const router = createRouter({ history: createWebHashHistory(import.meta.env.BASE_URL), @@ -7,4 +8,6 @@ const router = createRouter({ scrollBehavior: () => ({ left: 0, top: 0 }), }); +setupPermissionGuard(router); + export default router; diff --git a/src/router/routes.config.ts b/src/router/routes.config.ts index b0178250..323507d4 100644 --- a/src/router/routes.config.ts +++ b/src/router/routes.config.ts @@ -1,5 +1,6 @@ import type { RouteRecordRaw } from "vue-router"; import NotFound from "@/views/exceptions/NotFound.vue"; +import Forbidden from "@/views/exceptions/Forbidden.vue"; import { BasicLayout } from "@halo-dev/admin-shared"; export const routes: Array = [ @@ -8,6 +9,17 @@ export const routes: Array = [ component: BasicLayout, children: [{ path: "", name: "NotFound", component: NotFound }], }, + { + path: "/403", + component: BasicLayout, + children: [ + { + path: "", + name: "Forbidden", + component: Forbidden, + }, + ], + }, ]; export default routes; diff --git a/src/stores/role.ts b/src/stores/role.ts new file mode 100644 index 00000000..85e108af --- /dev/null +++ b/src/stores/role.ts @@ -0,0 +1,18 @@ +import { defineStore } from "pinia"; +import type { Role, UserPermission } from "@halo-dev/api-client"; + +interface RoleStoreState { + roles: Role[]; // all roles + permissions: UserPermission; // current user's permissions +} + +export const useRoleStore = defineStore({ + id: "role", + state: (): RoleStoreState => ({ + roles: [], + permissions: { + roles: new Set([]), + uiPermissions: new Set([]), + }, + }), +}); diff --git a/src/utils/__tests__/permission.spec.ts b/src/utils/__tests__/permission.spec.ts new file mode 100644 index 00000000..6ba3e517 --- /dev/null +++ b/src/utils/__tests__/permission.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { hasPermission } from "../permission"; + +describe("hasPermission", () => { + it("should return true if user has permission", () => { + const uiPermissions = ["system:post:manage", "system:post:view"]; + expect(hasPermission(uiPermissions, ["*"], false)).toBe(true); + expect(hasPermission(uiPermissions, ["*"], true)).toBe(true); + expect(hasPermission(uiPermissions, ["system:post:manage"], false)).toBe( + false + ); + expect(hasPermission(uiPermissions, ["system:post:view"], false)).toBe( + false + ); + expect(hasPermission(uiPermissions, ["system:post:view"], true)).toBe(true); + expect( + hasPermission( + uiPermissions, + ["system:post:manage", "system:post:view"], + true + ) + ).toBe(true); + expect( + hasPermission( + uiPermissions, + ["system:post:manage", "system:post:view"], + false + ) + ).toBe(true); + }); +}); diff --git a/src/utils/permission.ts b/src/utils/permission.ts new file mode 100644 index 00000000..a12ed43d --- /dev/null +++ b/src/utils/permission.ts @@ -0,0 +1,26 @@ +import isEqual from "lodash.isEqual"; + +export function hasPermission( + uiPermissions: Array, + targetPermissions: Array, + any: boolean +): boolean { + if (!targetPermissions || !targetPermissions.length) { + return true; + } + + // super admin has all permissions + if (targetPermissions.includes("*")) { + return true; + } + + const intersection = uiPermissions.filter((p) => + targetPermissions.includes(p) + ); + + if (any && intersection.length) { + return true; + } + + return !!(!any && isEqual(uiPermissions, targetPermissions)); +} diff --git a/src/views/exceptions/Forbidden.vue b/src/views/exceptions/Forbidden.vue new file mode 100644 index 00000000..42dea41c --- /dev/null +++ b/src/views/exceptions/Forbidden.vue @@ -0,0 +1 @@ +