npm package: @lcsf/acl
Description: 权限控制模块
Author: huyouming@lcsw.cn
Latest version: 1.1.3

## 快速开始

ACL 全称叫访问控制列表（Access Control List），是一种非常简单的基于角色权限控制方式

## 使用

Install `lc-acl:

```bash
npm add lc-acl
```

Import `LcAclModule` module:

```typescript
import { LcAclModule } from 'lc-acl';

@NgModule({
  imports: [
    LcAclModule.forRoot()
  ]
})
export class AppModule { }
```

```typescript
// 子模块
import { LcAclModule } from 'lc-acl';

@NgModule({
  imports: [
    LcAclModule
  ]
})
export class XXXModule { }
```

### ACLService

| Name | Description |
|------|-------------|
| `[change]` | 监听ACL变更通知 |
| `[data]` | 获取所有ACL数据 |
| `setFull(val: boolean)` | 标识当前用户为全量，即不受限 |
| `set(value: ACLType)` | 设置当前用户角色或权限能力（会先清除所有） |
| `setRole(roles: string[])` | 设置当前用户角色（会先清除所有） |
| `add(value: ACLType)` | 为当前用户增加角色或权限能力 |
| `attachRole(roles: string[])` | 为当前用户附加角色 |
| `removeRole(roles: string[])` | 为当前用户移除角色 |
| `can(roleOrPermission: ACLCanType)` | 当前用户是否有对应角色|权限 |
| `canAuthUrl(url: string)` | 当前用户是否有打开当前页面的权限 |
| `getUrlModeId(url: string)` | 获取当前页面url所属的菜单模块id |
| `getAuthPaths(menus: AuthModelList[])` | 根据传入的菜单数据获取当前用户可访问的路由表 |

### LcACLCanType

```ts
type LcACLCanType = string | string[] | LcACLType;
```

### ACLType

| Name | Type | Summary | Default |
|------|------|---------|---------|
| `[role]` | `string[]` | 角色 | - |
| `[type]` | `string[]` | 用户类型 | - |
| `[permissionGroups]` | `string[]` | 用户的权限列表组，单个权限是由 :: 连接，例如: trade::refund::read | - |
| `[permissions]` | `string[]` | 待校验的权限列表，单个权限是由 :: 连接，例如: trade::refund::read | - |
| `[menus]` | `AuthModelList[]` | 用户的所有菜单数据 | - |
| `[authPaths]` | `Map<string, string>` | 整理菜单数据得到的授权路由表，Map接口，url为key,model_id为value | - |
| `[authPath]` | `string` | 待验证的path | - |
| `[mode]` | `allOf, oneOf` | `allOf` 表示必须满足所有角色或权限点数组算有效 `oneOf` 表示只须满足角色或权限点数组中的一项算有效 | `oneOf` |
| `[except]` | `boolean` | 是否取反，即结果为 true 时表示未授权 | `false` |


## 粒度控制

## 写在前面

很多时候需要对某个按钮进行权限控制，`lc-acl` 提供一个 `lcAcl` 指令，可以利用角色或权限点对某个按钮、表格、列表等元素进行权限控制。

## 原理

`[lcAcl]` 默认会在目标元素上增加一个 `lcAcl__hide` 样式，利用 `display: none` 来隐藏未授权元素，它是一个简单、又高效的方式。

以此相对应的 `*lcAclIf` 是一个结构型指令，它类似 `ngIf` 在未授权时会不渲染该元素。

## 示例

### 角色

按钮必须拥有 5 角色显示。

```html
<button [lcAcl]="'5'"></button> <button *lcAclIf="'5'"></button>
```

按钮必须拥有 5 或 1 角色显示。

```html
<button [lcAcl]="['5', '1']"></button> <button *lcAclIf="['5', '1']"></button>
```

按钮必须拥有 5 和 1 角色显示。

```html
<button [lcAcl]="{ role: ['5', '1'], mode: 'allOf' }"></button>
<button *lcAclIf="{ role: ['5', '1'], mode: 'allOf' }"></button>
```

按钮必须拥有 角色是 3-0 或 3-2 或 5 （ `3-0： 3为角色，0为用户类型`）

> 注意用户类型判断必须要用对象的形式描述，因为跟角色一样都是数字，没办法区分 {type: [0]}

```html
<button [lcAcl]="['3-0', '3-2','5']"></button> 
<button *lcAclIf="['3-0', '3-2', '5']"></button>
```

当拥有 5 角色显示文本框，未授权显示文本。

```html
<input nz-input *lcAclIf="'5'; else unauthorized" /> <ng-container #unauthorized>{{user}}</ng-container>
```

使用 `except` 反向控制，当未拥有 5 角色时显示。

```html
<ng-container [lcAcl]="{ role: ['5'] , except: true}" >
  <input nz-input />
</ng-container>
<ng-container *lcAclIf="{ role: ['5'] , except: true}" >
  <input nz-input />
</ng-container>
```

用户自定义额外字段 `extraOne`
按钮拥有角色 或 extraOne为真 显示。

```html
<button [lcAcl]="{ role: ['5'], extraOne: true }"></button>
<button *lcAclIf="{ role: ['5'], extraOne: true }"></button>
```

按钮拥有角色 并且 extraAll 为真 显示。

```html
<button [lcAcl]="{ role: ['5'], extraAll: true}"></button>
<button *lcAclIf="{ role: ['5'], extraAll: true }"></button>
```


### 权限点

按钮必须拥有 退款 权限点显示。

```html
<button [lcAcl]="refund::trade::write"></button>
```

按钮必须拥有 退款或导出 权限点显示。

```html
<button [lcAcl]="['refund::trade::write', 'trade::export::write']"></button>
```

acl 指令为了能所传递的值是角色还是权限点，所以带有 `::` 表示权限点，否则表示角色

使用 `mode: 'allOf'` 表示必须同时拥有。

- `oneOf` 表示只须满足角色或权限点数组中的一项算有效（默认）
- `allOf` 表示必须满足所有角色或权限点数组算有效

按钮必须拥有 退款和导出 权限点时显示。

```html
<button [lcAcl]="{ permissions: ['refund::trade::write', 'trade::export::write'], mode: 'allOf' }"></button>
```

同理在js层逻辑判断的时候也可以直接使用LcAclService来做判断；
```typescript
xxx.component.ts

import { LcAClService } from 'lc-acl';

···
constructor(private lcAClService: LcAClService) {

}
···
// 某个业务逻辑
// 判断某个请求必须是 manager 才能发出
if (this.lcAClService.can(['manager'])) {
  // your code
}
```

## API

### *lcAclIf

参数      | 说明            | 类型     | 默认值
----------|----------------|----------|-------
`[lcAclIf]`  | `can` 方法参数体   | `ACLCanType` | -
`[lcAclIfThen]` | 已授权时显示模板 | `TemplateRef<void> | null` | -
`[lcAclIfElse]` | 未授权时显示模板 | `TemplateRef<void> | null` | -
`[except]` | 未授权时显示 | `boolean` | `false`



## 类型说明
```typescript
import type { NzSafeAny } from 'ng-zorro-antd/core/types';

export interface LcACLType {
  /**
   * 角色
   */
  role?: string[];

  /**
   * 角色
   */
  type?: string[];

  /**
   * 权限组
   */
  permissionGroups?: string[];

  /**
   * 权限组拼接字符组
   */
  permissions?: string[];

  /**
   * 设置的可以访问的路由菜单
   */
  menus?: AuthModelList[];

  /**
   * 授权可访问的菜单列表
   */
  authPaths?: Map<string, string>;

  /**
   * 待验证的path
   */
  authPath?: string;

  /**
   * Validated against, default: `oneOf`
   * - `allOf` the value validates against all the roles or abilities
   * - `oneOf` the value validates against exactly one of the roles or abilities
   */
  mode?: 'allOf' | 'oneOf';

  /**
   * 是否取反，即结果为 `true` 时表示未授权
   */
  except?: boolean;

  [key: string]: NzSafeAny;
}

export type LcACLCanType = string | string[] | LcACLType;

export interface LcACLConfig {
  /**
   * Router URL when guard fail, default: `/auth/403`
   */
  guard_url?: string;
}

/**
 * 授权的菜单列表单个
 */
export interface AuthPathItem {
  icon: string;
  id: number;
  level: number;
  menu_name: string;
  parent_id: number;
  url: string;
  children: null | AuthPathItem[];
}

/**
 * 菜单模块列表组
 */
export interface AuthModelList {
  menu_list: AuthPathItem[];
  model_name: string;
  model_id: string;
  url: string;
}

```


## 工具方法

### 1. 解决点击最上层面包屑的时候出现白屏或403的情况，提供一个方法，该方法会查找当前模块可以访问的第一个页面路由，针对大菜单级别路由，也提供一个查找当前大菜单可以访问的第一个模块

```JavaScript
// 一段路由配置
{
  path: 'merchantInfo',
  data: { breadcrumb: '商户信息' },
  children: [
    {
      path: 'base',
      data: { breadcrumb: '基本信息' },
      component: BaseInfoComponent,
    },
    {
      path: 'statement',
      data: { breadcrumb: '结算信息' },
      component: StatementInfoComponent,
    },
    {
      path: 'rate',
      data: { breadcrumb: '费率信息' },
      component: RateInfoComponent,
    },
    {
      path: 'auth',
      data: { breadcrumb: '认证信息' },
      component: AuthInfoComponent,
    },
    {
      path: '',
      redirectTo: 'base', 
      pathMatch: 'full',
    },
  ],
}
```

当前 merchantInfo 模块默认的路由是 /merchantInfo/base, 但是当前用户如果没有/merchantInfo/base 路由权限的话就会跳转到403页面，如果不配置就会出现白屏的情况

解决方案：

```javascript
import { getMenuFirstAuthModel, getModelFirstAuthPath } from '@app/package/acl';

[{
  path: 'queryPay',
  data: { breadcrumb: '交易查询' },
  children: [
    {
      path: 'statistics',
      data: { breadcrumb: '交易统计' },
      children: [
        { path: '', component: PayStatisticsComponent },
        {
          path: 'detail/:outTradeNo/:orderType',
          component: PayStatisticsDetailComponent,
          data: { breadcrumb: '详情' },
        },
      ],
    },
    {
      path: 'preAuthorization',
      data: { breadcrumb: '预授权交易查询' },
      children: [
        { path: '', component: PreAuthorizationComponent },
        {
          path: 'detail/:tradeNo',
          component: PreAuthorizationDetailComponent,
          data: { breadcrumb: '详情' },
        },
      ],
    },
    {
      path: '',
      redirectTo: getModelFirstAuthPath("/pay/queryPay"),
      pathMatch: 'full',
    },
  ],
},
{
  path: '',
  redirectTo: getMenuFirstAuthModel(),
  pathMatch: 'full',
}]
```

```javascript
// lc-acl.utils.ts
/**
 * 根据当前模块路径获取当前模块可以访问的第一个路由
 * @param model_path
 * @returns
 */
export function getModelFirstAuthPath(model_path: string, menu_storage_key = 'LCmenus') {
  const LCmenus = JSON.parse(localStorage.getItem(menu_storage_key) || '[]');
  let result_path = '/auth/403';
  if (LCmenus.length > 0) {
    LCmenus.forEach(model_item => {
      if (model_item.url === model_path) {
        if (!model_item.children || !model_item.children.length) {
          result_path = model_item.url;
        } else {
          result_path = model_item.children[0].url;
        }
      }
    });
  }
  return result_path;
}

/**
 * 获取当前menu第一个授权的模块path
 * @param menu_storage_key
 */
export function getMenuFirstAuthModel(menu_storage_key = 'LCmenus') {
  const LCmenus = JSON.parse(localStorage.getItem(menu_storage_key) || '[]');
  let result_path = '';
  if (LCmenus.length) {
    result_path = LCmenus[0].url;
  }
  console.log(result_path)
  return result_path;
}

```


### 2. 在当前菜单树种查找第一个最深层次的菜单url

场景：用户A在pageA页面退出之后，用户B重新登录，进入PageA,但是用户B没有PageA页面的权限，需要查找到用户B可以访问的第一个页面路由

```typescript
// 源码说明
interface MixMenu {
  children?: MixMenu[];
  menu_list?: MixMenu[];
  url: string;
}

/**
 * 提供一个方法方便查找菜单树中第一个最深层次的菜单url
 * @param menus
 * @returns
 */
export function findFirstUrl(menus: MixMenu[]) {
  let firstMenu = menus[0];
  let list = firstMenu.menu_list || firstMenu.children;
  if (!list || !list.length) {
    return firstMenu.url;
  }
  return findFirstUrl(list);
}

```