Просмотр исходного кода

Merge pull request #3 from IamTaoChen/oidc-for-web

OIDC for web
1 год назад
Родитель
Сommit
a40324a4a9

+ 24 - 0
src/api/login.js

@@ -0,0 +1,24 @@
1
+import request from '@/utils/request';
2
+
3
+export function loginOptions() {
4
+  return request({
5
+    url: '/login-options',
6
+    method: 'get',
7
+  })
8
+}
9
+
10
+export function oidcAuth (data) {
11
+  return request({
12
+    url: '/oidc/auth',
13
+    method: 'post',
14
+    data,
15
+  })
16
+}
17
+
18
+export function oidcQuery(params){
19
+  return request({
20
+    url: '/oidc/auth-query',
21
+    method: 'get',
22
+    params,
23
+  })
24
+}

BIN
src/assets/github.png


BIN
src/assets/google.png


BIN
src/assets/oidc.png


BIN
src/assets/webauth.png


+ 55 - 13
src/store/user.js

@@ -1,8 +1,9 @@
1
 import { defineStore, acceptHMRUpdate } from 'pinia'
1
 import { defineStore, acceptHMRUpdate } from 'pinia'
2
 import { current, login } from '@/api/user'
2
 import { current, login } from '@/api/user'
3
-import { setToken, removeToken } from '@/utils/auth'
3
+import { setToken, removeToken, setCode, removeCode } from '@/utils/auth'
4
 import { useRouteStore } from '@/store/router'
4
 import { useRouteStore } from '@/store/router'
5
 import { useAppStore } from '@/store/app'
5
 import { useAppStore } from '@/store/app'
6
+import { oidcAuth, oidcQuery } from '@/api/login';
6
 
7
 
7
 export const useUserStore = defineStore({
8
 export const useUserStore = defineStore({
8
   id: 'user',
9
   id: 'user',
@@ -16,34 +17,40 @@ export const useUserStore = defineStore({
16
   }),
17
   }),
17
 
18
 
18
   actions: {
19
   actions: {
19
-    logout () {
20
+    logout() {
20
       removeToken()
21
       removeToken()
22
+      removeCode()
21
       this.$patch({
23
       this.$patch({
22
         name: '',
24
         name: '',
23
         role: {},
25
         role: {},
24
       })
26
       })
25
     },
27
     },
26
 
28
 
27
-    async login (form) {
29
+    saveUserData(userData) {
30
+      // useAppStore().getAppConfig()
31
+      setToken(userData.token)
32
+      //
33
+      localStorage.setItem('user_info', JSON.stringify({ name: userData.username }))
34
+      this.$patch({
35
+        ...userData,
36
+      })
37
+      if (userData.route_names && userData.route_names.length) {
38
+        useRouteStore().addRoutes(userData.route_names)
39
+      }
40
+    },
41
+
42
+    async login(form) {
28
       const res = await login(form).catch(_ => false)
43
       const res = await login(form).catch(_ => false)
29
       if (res) {
44
       if (res) {
30
         useAppStore().getAppConfig()
45
         useAppStore().getAppConfig()
31
         const userData = res.data
46
         const userData = res.data
32
-        setToken(userData.token)
33
-        //
34
-        localStorage.setItem('user_info', JSON.stringify({ name: userData.username }))
35
-        this.$patch({
36
-          ...userData,
37
-        })
38
-        if (userData.route_names && userData.route_names.length) {
39
-          useRouteStore().addRoutes(userData.route_names)
40
-        }
47
+        this.saveUserData(userData)
41
         return userData
48
         return userData
42
       } else {
49
       } else {
43
         return false
50
         return false
44
       }
51
       }
45
     },
52
     },
46
-    async info () {
53
+    async info() {
47
       const res = await current().catch(_ => false)
54
       const res = await current().catch(_ => false)
48
       if (res) {
55
       if (res) {
49
         useAppStore().getAppConfig()
56
         useAppStore().getAppConfig()
@@ -57,6 +64,41 @@ export const useUserStore = defineStore({
57
       }
64
       }
58
       return false
65
       return false
59
     },
66
     },
67
+    async oidc(provider, platform, browser) {
68
+      // oidc data need to be implement
69
+      const data = {
70
+        deviceInfo: {
71
+          name: navigator.userAgent, // 使用浏览器的 User-Agent 作为设备名
72
+          os: platform, // 获取操作系统信息
73
+          type: 'webadmin', // any vaule
74
+        },
75
+        id: `${platform}-${browser}`,
76
+        op: provider, // 传入的 provider
77
+        uuid: crypto.randomUUID(), // 自动生成 UUID
78
+      };
79
+      const res = await oidcAuth(data).catch(_ => false)
80
+      if (res) {
81
+        const { code, url } = res.data
82
+        setCode(code)
83
+        if (provider == 'webauth') {
84
+          window.open(url)
85
+        } else {
86
+          window.location.href = url
87
+        }
88
+      }
89
+    },
90
+    async query(code) {
91
+      const params = { "code": code, "uuid": crypto.randomUUID(), "Id": "999" }
92
+      const res = await oidcQuery(params).catch(_ => false)
93
+      if (res) {
94
+        removeCode()
95
+        useAppStore().getAppConfig()
96
+        const userData = res.data
97
+        this.saveUserData(userData)
98
+        return userData
99
+      }
100
+      return false
101
+    }
60
   },
102
   },
61
 })
103
 })
62
 
104
 

+ 30 - 0
src/utils/auth.js

@@ -1,4 +1,6 @@
1
 const TokenKey = 'access_token'
1
 const TokenKey = 'access_token'
2
+const OidcCode = 'oidc_code'
3
+const OidcCodeExpiry = 'oidc_code_expiry';
2
 
4
 
3
 export function getToken () {
5
 export function getToken () {
4
   return localStorage.getItem(TokenKey)
6
   return localStorage.getItem(TokenKey)
@@ -11,3 +13,31 @@ export function setToken (token) {
11
 export function removeToken () {
13
 export function removeToken () {
12
   return localStorage.removeItem(TokenKey)
14
   return localStorage.removeItem(TokenKey)
13
 }
15
 }
16
+
17
+// 设置 code,并存储当前时间戳(单位:毫秒)
18
+export function setCode(code) {
19
+  const now = Date.now(); // 当前时间戳(毫秒)
20
+  const expiry = now + 60 * 1000; // 60 秒后过期
21
+
22
+  localStorage.setItem(OidcCode, code); // 存储 code
23
+  localStorage.setItem(OidcCodeExpiry, expiry); // 存储过期时间戳
24
+}
25
+
26
+// 获取 code,如果已过期则删除并返回 null
27
+export function getCode() {
28
+  const expiry = localStorage.getItem(OidcCodeExpiry); // 获取过期时间戳
29
+  const now = Date.now(); // 当前时间戳
30
+
31
+  if (expiry && now > parseInt(expiry)) {
32
+    // 如果已过期,删除 code 和过期时间
33
+    removeCode();
34
+    return null;
35
+  }
36
+  return localStorage.getItem(OidcCode); // 返回 code(如果未过期)
37
+}
38
+
39
+// 删除 code 和过期时间
40
+export function removeCode() {
41
+  localStorage.removeItem(OidcCode);
42
+  localStorage.removeItem(OidcCodeExpiry);
43
+}

+ 4 - 0
src/utils/i18n/zh_CN.json

@@ -427,5 +427,9 @@
427
   },
427
   },
428
   "LastOnlineIp": {
428
   "LastOnlineIp": {
429
     "One": "最后在线IP"
429
     "One": "最后在线IP"
430
+  },
431
+  "or login in with" :
432
+  {
433
+    "One": "或使用以下登陆"
430
   }
434
   }
431
 }
435
 }

+ 6 - 0
src/utils/request.js

@@ -55,6 +55,12 @@ service.interceptors.response.use(
55
   response => {
55
   response => {
56
     const res = response.data
56
     const res = response.data
57
 
57
 
58
+    // for the endpoint /login-options
59
+    // I'm not sure if this is a good idea
60
+    if (Array.isArray(res)) {
61
+      return res;
62
+    }
63
+
58
     // if the custom code is not 20000, it is judged as an error.
64
     // if the custom code is not 20000, it is judged as an error.
59
     if (res.code !== 0) {
65
     if (res.code !== 0) {
60
       ElMessage({
66
       ElMessage({

+ 225 - 65
src/views/login/login.vue

@@ -1,98 +1,258 @@
1
 <template>
1
 <template>
2
-  <div class="login">
3
-    <el-card class="login-card">
4
-      <h1>{{ T('Login') }}</h1>
5
-      <el-form label-width="100px">
6
-        <el-form-item :label=" T('Username') ">
7
-          <el-input v-model="form.username"></el-input>
2
+  <div class="login-container">
3
+    <div class="login-card">
4
+      <img src="@/assets/logo.png" alt="logo" class="login-logo" />
5
+
6
+      <el-form label-position="top" class="login-form">
7
+        <el-form-item :label="T('Username')">
8
+          <el-input v-model="form.username" class="login-input"></el-input>
8
         </el-form-item>
9
         </el-form-item>
9
-        <el-form-item :label=" T('Password') ">
10
-          <el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password></el-input>
10
+
11
+        <el-form-item :label="T('Password')">
12
+          <el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password
13
+            class="login-input"></el-input>
11
         </el-form-item>
14
         </el-form-item>
15
+
12
         <el-form-item>
16
         <el-form-item>
13
-          <el-button @click="login" type="primary">{{ T('Login') }}</el-button>
17
+          <el-button @click="login" type="primary" class="login-button">{{ T('Login') }}</el-button>
14
         </el-form-item>
18
         </el-form-item>
15
       </el-form>
19
       </el-form>
16
-    </el-card>
20
+
21
+      <div class="divider" v-if="options.length > 0">
22
+        <span>{{ T('or login in with') }}</span>
23
+      </div>
24
+
25
+      <div class="oidc-options">
26
+        <div v-for="(option, index) in options" :key="index" class="oidc-option">
27
+          <el-button @click="handleOIDCLogin(option.name)" class="oidc-btn">
28
+            <img :src="getProviderImage(option.name)" alt="provider" class="oidc-icon" />
29
+            {{ T(option.name) }}
30
+          </el-button>
31
+        </div>
32
+      </div>
33
+    </div>
17
   </div>
34
   </div>
18
 </template>
35
 </template>
19
 
36
 
20
 <script setup>
37
 <script setup>
21
-  import { defineComponent, reactive } from 'vue'
22
-  import { useUserStore } from '@/store/user'
23
-  import { ElMessage } from 'element-plus'
24
-  import { useRoute, useRouter } from 'vue-router'
25
-  import { T } from '@/utils/i18n'
26
-
27
-  const userStore = useUserStore()
28
-  const route = useRoute()
29
-  const router = useRouter()
30
-
31
-  let platform = window.navigator.platform
32
-  if (navigator.platform.indexOf('Mac') === 0) {
33
-    platform = 'mac'
34
-  } else if (navigator.platform.indexOf('Win') === 0) {
35
-    platform = 'windows'
36
-  } else if (navigator.platform.indexOf('Linux armv') === 0) {
37
-    platform = 'android'
38
-  } else if (navigator.platform.indexOf('Linux') === 0) {
39
-    platform = 'linux'
38
+import { reactive, onMounted, ref } from 'vue';
39
+import { useUserStore } from '@/store/user'
40
+import { ElMessage } from 'element-plus';
41
+import { T } from '@/utils/i18n';
42
+import { useRoute, useRouter } from 'vue-router';
43
+import { loginOptions, oidcAuth, oidcQuery } from '@/api/login';
44
+import { getCode, removeCode } from '@/utils/auth'
45
+
46
+const oauthInfo = ref({})
47
+const userStore = useUserStore()
48
+const route = useRoute()
49
+const router = useRouter()
50
+const options = reactive([]); // 存储 OIDC 登录选项
51
+
52
+let platform = window.navigator.platform
53
+if (navigator.platform.indexOf('Mac') === 0) {
54
+  platform = 'mac'
55
+} else if (navigator.platform.indexOf('Win') === 0) {
56
+  platform = 'windows'
57
+} else if (navigator.platform.indexOf('Linux armv') === 0) {
58
+  platform = 'android'
59
+} else if (navigator.platform.indexOf('Linux') === 0) {
60
+  platform = 'linux'
61
+}
62
+const userAgent = navigator.userAgent;
63
+let browser = 'Unknown Browser';
64
+if (/chrome|crios/i.test(userAgent)) browser = 'Chrome';
65
+else if (/firefox|fxios/i.test(userAgent)) browser = 'Firefox';
66
+else if (/safari/i.test(userAgent) && !/chrome/i.test(userAgent)) browser = 'Safari';
67
+else if (/edg/i.test(userAgent)) browser = 'Edge';
68
+
69
+const form = reactive({
70
+  username: '',
71
+  password: '',
72
+  platform: platform,
73
+})
74
+
75
+const redirect = route.query?.redirect
76
+const login = async () => {
77
+  const res = await userStore.login(form)
78
+  if (res) {
79
+    ElMessage.success(T('LoginSuccess'))
80
+    router.push({ path: redirect || '/', replace: true })
81
+  }
82
+}
83
+
84
+const handleOIDCLogin = (provider) => {
85
+  userStore.oidc(provider, platform, browser)
86
+};
87
+
88
+import googleImage from '@/assets/google.png';
89
+import githubImage from '@/assets/github.png';
90
+import oidcImage from '@/assets/oidc.png';
91
+import webauthImage from '@/assets/webauth.png';
92
+import defaultImage from '@/assets/oidc.png';
93
+
94
+const providerImageMap = {
95
+  google: googleImage,
96
+  github: githubImage,
97
+  oidc: oidcImage,
98
+  webauth: webauthImage,
99
+  default: defaultImage,
100
+};
101
+
102
+const getProviderImage = (provider) => {
103
+  return providerImageMap[provider] || providerImageMap.default;
104
+};
105
+
106
+const loadLoginOptions = async () => {
107
+  try {
108
+    const res = await loginOptions().catch(() => []);
109
+    if (!Array.isArray(res) || !res.length) return console.warn('No valid response received');
110
+
111
+    const jsonPart = res[0].split('/')[1];
112
+    if (!jsonPart) return console.error('Invalid input string:', res[0]);
113
+
114
+    // const ops = JSON.parse(jsonPart).map(option => ({ name: option.name }));
115
+    // 不确定怎么处理webauth,不显示
116
+    // 解析 JSON,并过滤掉 "webauth" 类型的选项
117
+    const ops = JSON.parse(jsonPart)
118
+      .filter(option => option.name !== "webauth") // 排除 "webauth" 类型的选项
119
+      .map(option => ({ name: option.name })); // 创建新的对象数组
120
+    if (!ops.length) return;
121
+
122
+    options.push(...ops);
123
+  } catch (error) {
124
+    console.error('Error loading login options:', error.message);
40
   }
125
   }
126
+};
41
 
127
 
42
-  const form = reactive({
43
-    username: '',
44
-    password: '',
45
-    platform: platform,
46
-  })
47
-  const redirect = route.query?.redirect
48
-  const login = async () => {
49
-    const res = await userStore.login(form)
128
+onMounted(async () => {
129
+  const code = getCode();
130
+  if (code) {
131
+    // 如果code存在,进行query获取user info
132
+    const res = await userStore.query(code)
50
     if (res) {
133
     if (res) {
134
+      // 删除code,确保跳转之前对code进行清楚
135
+      removeCode()
51
       ElMessage.success(T('LoginSuccess'))
136
       ElMessage.success(T('LoginSuccess'))
52
       router.push({ path: redirect || '/', replace: true })
137
       router.push({ path: redirect || '/', replace: true })
53
     }
138
     }
139
+  } else {
140
+    // 如果code不存在, 现实登陆页面
141
+    loadLoginOptions(); // 组件挂载后调用登录选项加载函数
54
   }
142
   }
143
+});
55
 </script>
144
 </script>
56
 
145
 
57
 <style scoped lang="scss">
146
 <style scoped lang="scss">
58
-.login {
59
-  width: 100vw;
147
+.login-container {
148
+  display: flex;
149
+  justify-content: center;
150
+  align-items: center;
60
   height: 100vh;
151
   height: 100vh;
61
   background-color: #2d3a4b;
152
   background-color: #2d3a4b;
62
-  padding-top: 25vh;
63
-  box-sizing: border-box;
153
+  padding: 20px;
154
+}
64
 
155
 
65
-  .tips {
66
-    font-size: 12px;
67
-    color: #fff;
68
-    margin-left: 60px;
156
+.login-card {
157
+  width: 360px;
158
+  background-color: #283342;
159
+  padding: 40px;
160
+  border-radius: 8px;
161
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
162
+  text-align: center;
163
+}
164
+
165
+h1 {
166
+  margin-bottom: 20px;
167
+  font-size: 24px;
168
+  font-weight: bold;
169
+}
170
+
171
+.login-form {
172
+  margin-bottom: 20px;
173
+}
174
+
175
+.login-input {
176
+  width: 100%;
177
+}
178
+
179
+.login-button {
180
+  width: 100%;
181
+  height: 40px;
182
+  margin-bottom: 20px;
183
+}
184
+
185
+.divider {
186
+  display: flex;
187
+  align-items: center;
188
+  margin: 20px 0;
189
+  font-size: 14px;
190
+  color: #888;
191
+
192
+  &::before,
193
+  &::after {
194
+    content: '';
195
+    flex: 1;
196
+    height: 1px;
197
+    background-color: #ddd;
69
   }
198
   }
70
 
199
 
71
-  .login-card {
72
-    max-width: 500px;
73
-    background-color: #283342;
74
-    color: #fff;
75
-    border: none;
76
-    margin: 0 auto;
200
+  &::before {
201
+    margin-right: 10px;
202
+  }
77
 
203
 
78
-    .el-form-item {
204
+  &::after {
205
+    margin-left: 10px;
206
+  }
207
+}
208
+
209
+.oidc-options {
210
+  display: flex;
211
+  flex-direction: column;
212
+  gap: 10px;
213
+}
79
 
214
 
80
-      ::v-deep(.el-form-item__label) {
81
-        color: #fff;
82
-      }
215
+.oidc-btn {
216
+  display: flex;
217
+  align-items: center;
218
+  justify-content: center;
219
+  gap: 10px;
220
+  width: 100%;
221
+  height: 50px;
222
+  background-color: white;
223
+  border: 1px solid #ddd;
224
+  border-radius: 4px;
225
+  color: black;
226
+  font-size: 14px;
227
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
228
+}
83
 
229
 
84
-      .el-input {
230
+.oidc-icon {
231
+  width: 24px;
232
+  height: 24px;
233
+}
85
 
234
 
86
-        ::v-deep(.el-input__wrapper) {
87
-          border: 1px solid rgba(255, 255, 255, 0.1);
88
-          background: transparent;
89
-        }
235
+.login-logo {
236
+  width: 80px;
237
+  height: 80px;
238
+  margin: 0 auto 20px;
239
+  display: block;
240
+}
241
+
242
+.el-form-item {
243
+  ::v-deep(.el-form-item__label) {
244
+    color: #fff;
245
+  }
246
+
247
+  .el-input {
248
+    ::v-deep(.el-input__wrapper) {
249
+      border: 1px solid rgba(255, 255, 255, 0.1);
250
+      background: transparent;
251
+    }
90
 
252
 
91
-        ::v-deep(input) {
92
-          color: #fff;
93
-        }
94
-      }
253
+    ::v-deep(input) {
254
+      color: #fff;
95
     }
255
     }
96
   }
256
   }
97
 }
257
 }
98
-</style>
258
+</style>

+ 14 - 13
src/views/oauth/index.vue

@@ -11,7 +11,7 @@
11
     <el-card class="list-body" shadow="hover">
11
     <el-card class="list-body" shadow="hover">
12
       <el-table :data="listRes.list" v-loading="listRes.loading" border>
12
       <el-table :data="listRes.list" v-loading="listRes.loading" border>
13
         <el-table-column prop="id" label="id" align="center"/>
13
         <el-table-column prop="id" label="id" align="center"/>
14
-        <el-table-column prop="op" :label="T('Op')" align="center"/>
14
+        <el-table-column prop="op" :label="T('Type')" align="center"/>
15
         <el-table-column prop="auto_register" :label="T('AutoRegister')" align="center"/>
15
         <el-table-column prop="auto_register" :label="T('AutoRegister')" align="center"/>
16
         <el-table-column prop="created_at" :label="T('CreatedAt')" align="center"/>
16
         <el-table-column prop="created_at" :label="T('CreatedAt')" align="center"/>
17
         <el-table-column prop="updated_at" :label="T('UpdatedAt')" align="center"/>
17
         <el-table-column prop="updated_at" :label="T('UpdatedAt')" align="center"/>
@@ -34,8 +34,18 @@
34
     </el-card>
34
     </el-card>
35
     <el-dialog v-model="formVisible" :title="!formData.id?T('Create') :T('Update')" width="800">
35
     <el-dialog v-model="formVisible" :title="!formData.id?T('Create') :T('Update')" width="800">
36
       <el-form class="dialog-form" ref="form" :model="formData" :rules="rules" label-width="120px">
36
       <el-form class="dialog-form" ref="form" :model="formData" :rules="rules" label-width="120px">
37
-        <el-form-item label="Issuer" prop="issuer">
38
-          <el-input v-model="formData.issuer" :placeholder="formData.op === 'oidc' ? 'Required when OIDC is selected' : 'Not required unless OIDC is selected'"></el-input>
37
+        <el-form-item label="Type" prop="op">
38
+          <el-radio-group v-model="formData.op" :disabled="!!formData.id">
39
+            <el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
40
+              {{ item.label }}
41
+            </el-radio>
42
+          </el-radio-group>
43
+        </el-form-item>
44
+        <el-form-item v-if="formData.op === 'oidc'" label="Issuer" prop="issuer">
45
+          <el-input v-model="formData.issuer" placeholder="Check your IdP docs, without '/.well-known/openid-configuration'"></el-input>
46
+        </el-form-item>
47
+        <el-form-item v-show="formData.op === 'oidc'" label="Scopes" prop="scopes">
48
+          <el-input v-model="formData.scopes" placeholder= "Optional, default is 'openid,profile,email'"></el-input>
39
         </el-form-item>
49
         </el-form-item>
40
         <el-form-item label="ClientId" prop="client_id">
50
         <el-form-item label="ClientId" prop="client_id">
41
           <el-input v-model="formData.client_id"></el-input>
51
           <el-input v-model="formData.client_id"></el-input>
@@ -46,16 +56,6 @@
46
         <el-form-item label="RedirectUrl" prop="redirect_url">
56
         <el-form-item label="RedirectUrl" prop="redirect_url">
47
           <el-input v-model="formData.redirect_url"></el-input>
57
           <el-input v-model="formData.redirect_url"></el-input>
48
         </el-form-item>
58
         </el-form-item>
49
-        <el-form-item label="Scopes" prop="scopes">
50
-          <el-input v-model="formData.scopes" :placeholder="formData.op === 'oidc' ? 'Optional when OIDC is selected, default is openid,profile,email' : 'Not required unless OIDC is selected'"></el-input>
51
-        </el-form-item>
52
-        <el-form-item label="op" prop="op">
53
-          <el-radio-group v-model="formData.op" :disabled="!!formData.id">
54
-            <el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
55
-              {{ item.label }}
56
-            </el-radio>
57
-          </el-radio-group>
58
-        </el-form-item>
59
         <el-form-item :label="T('AutoRegister')" prop="auto_register">
59
         <el-form-item :label="T('AutoRegister')" prop="auto_register">
60
           <el-switch v-model="formData.auto_register"
60
           <el-switch v-model="formData.auto_register"
61
                      :active-value="true"
61
                      :active-value="true"
@@ -146,6 +146,7 @@
146
     client_secret: [{ required: true, message: T('ParamRequired', { param: 'client_secret' }), trigger: 'blur' }],
146
     client_secret: [{ required: true, message: T('ParamRequired', { param: 'client_secret' }), trigger: 'blur' }],
147
     redirect_url: [{ required: true, message: T('ParamRequired', { param: 'redirect_url' }), trigger: 'blur' }],
147
     redirect_url: [{ required: true, message: T('ParamRequired', { param: 'redirect_url' }), trigger: 'blur' }],
148
     op: [{ required: true, message: T('ParamRequired', { param: 'op' }), trigger: 'blur' }],
148
     op: [{ required: true, message: T('ParamRequired', { param: 'op' }), trigger: 'blur' }],
149
+    issuer: [{ required: true, message: T('ParamRequired', { param: 'issuer' }), trigger: 'blur' }],
149
   }
150
   }
150
   const toEdit = (row) => {
151
   const toEdit = (row) => {
151
     formVisible.value = true
152
     formVisible.value = true