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

modify Login UI for OIDC login

Tao Chen 1 год назад
Родитель
Сommit
c165f54ce8
6 измененных файлов с 338 добавлено и 78 удалено
  1. 24 0
      src/api/login.js
  2. 55 13
      src/store/user.js
  3. 30 0
      src/utils/auth.js
  4. 4 0
      src/utils/i18n/zh_CN.json
  5. 6 0
      src/utils/request.js
  6. 219 65
      src/views/login/login.vue

+ 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
+}

+ 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 + 30 * 1000; // 30 秒后过期
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({

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

@@ -1,98 +1,252 @@
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 })
40
   }
81
   }
82
+}
83
+
84
+const handleOIDCLogin = (provider) => {
85
+  userStore.oidc(provider, platform, browser)
86
+};
41
 
87
 
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)
88
+const providerImageMap = {
89
+  google: '/google.png',
90
+  github: '/github.png',
91
+  oidc: '/oidc.png',
92
+  webauth: '/webauth.png',
93
+  default: '/default.png',
94
+};
95
+
96
+const getProviderImage = (provider) => {
97
+  return providerImageMap[provider] || providerImageMap.default;
98
+};
99
+
100
+const loadLoginOptions = async () => {
101
+  try {
102
+    const res = await loginOptions().catch(() => []);
103
+    if (!Array.isArray(res) || !res.length) return console.warn('No valid response received');
104
+
105
+    const jsonPart = res[0].split('/')[1];
106
+    if (!jsonPart) return console.error('Invalid input string:', res[0]);
107
+
108
+    // const ops = JSON.parse(jsonPart).map(option => ({ name: option.name }));
109
+    // 不确定怎么处理webauth,不显示
110
+    // 解析 JSON,并过滤掉 "webauth" 类型的选项
111
+    const ops = JSON.parse(jsonPart)
112
+      .filter(option => option.name !== "webauth") // 排除 "webauth" 类型的选项
113
+      .map(option => ({ name: option.name })); // 创建新的对象数组
114
+    if (!ops.length) return;
115
+
116
+    options.push(...ops);
117
+  } catch (error) {
118
+    console.error('Error loading login options:', error.message);
119
+  }
120
+};
121
+
122
+onMounted(async () => {
123
+  const code = getCode();
124
+  if (code) {
125
+    // 如果code存在,进行query获取user info
126
+    const res = await userStore.query(code)
50
     if (res) {
127
     if (res) {
128
+      // 删除code,确保跳转之前对code进行清楚
129
+      removeCode()
51
       ElMessage.success(T('LoginSuccess'))
130
       ElMessage.success(T('LoginSuccess'))
52
       router.push({ path: redirect || '/', replace: true })
131
       router.push({ path: redirect || '/', replace: true })
53
     }
132
     }
133
+  } else {
134
+    // 如果code不存在, 现实登陆页面
135
+    loadLoginOptions(); // 组件挂载后调用登录选项加载函数
54
   }
136
   }
137
+});
55
 </script>
138
 </script>
56
 
139
 
57
 <style scoped lang="scss">
140
 <style scoped lang="scss">
58
-.login {
59
-  width: 100vw;
141
+.login-container {
142
+  display: flex;
143
+  justify-content: center;
144
+  align-items: center;
60
   height: 100vh;
145
   height: 100vh;
61
   background-color: #2d3a4b;
146
   background-color: #2d3a4b;
62
-  padding-top: 25vh;
63
-  box-sizing: border-box;
147
+  padding: 20px;
148
+}
64
 
149
 
65
-  .tips {
66
-    font-size: 12px;
67
-    color: #fff;
68
-    margin-left: 60px;
150
+.login-card {
151
+  width: 360px;
152
+  background-color: #283342;
153
+  padding: 40px;
154
+  border-radius: 8px;
155
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
156
+  text-align: center;
157
+}
158
+
159
+h1 {
160
+  margin-bottom: 20px;
161
+  font-size: 24px;
162
+  font-weight: bold;
163
+}
164
+
165
+.login-form {
166
+  margin-bottom: 20px;
167
+}
168
+
169
+.login-input {
170
+  width: 100%;
171
+}
172
+
173
+.login-button {
174
+  width: 100%;
175
+  height: 40px;
176
+  margin-bottom: 20px;
177
+}
178
+
179
+.divider {
180
+  display: flex;
181
+  align-items: center;
182
+  margin: 20px 0;
183
+  font-size: 14px;
184
+  color: #888;
185
+
186
+  &::before,
187
+  &::after {
188
+    content: '';
189
+    flex: 1;
190
+    height: 1px;
191
+    background-color: #ddd;
69
   }
192
   }
70
 
193
 
71
-  .login-card {
72
-    max-width: 500px;
73
-    background-color: #283342;
74
-    color: #fff;
75
-    border: none;
76
-    margin: 0 auto;
194
+  &::before {
195
+    margin-right: 10px;
196
+  }
77
 
197
 
78
-    .el-form-item {
198
+  &::after {
199
+    margin-left: 10px;
200
+  }
201
+}
202
+
203
+.oidc-options {
204
+  display: flex;
205
+  flex-direction: column;
206
+  gap: 10px;
207
+}
79
 
208
 
80
-      ::v-deep(.el-form-item__label) {
81
-        color: #fff;
82
-      }
209
+.oidc-btn {
210
+  display: flex;
211
+  align-items: center;
212
+  justify-content: center;
213
+  gap: 10px;
214
+  width: 100%;
215
+  height: 50px;
216
+  background-color: white;
217
+  border: 1px solid #ddd;
218
+  border-radius: 4px;
219
+  color: black;
220
+  font-size: 14px;
221
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
222
+}
83
 
223
 
84
-      .el-input {
224
+.oidc-icon {
225
+  width: 24px;
226
+  height: 24px;
227
+}
85
 
228
 
86
-        ::v-deep(.el-input__wrapper) {
87
-          border: 1px solid rgba(255, 255, 255, 0.1);
88
-          background: transparent;
89
-        }
229
+.login-logo {
230
+  width: 80px;
231
+  height: 80px;
232
+  margin: 0 auto 20px;
233
+  display: block;
234
+}
235
+
236
+.el-form-item {
237
+  ::v-deep(.el-form-item__label) {
238
+    color: #fff;
239
+  }
240
+
241
+  .el-input {
242
+    ::v-deep(.el-input__wrapper) {
243
+      border: 1px solid rgba(255, 255, 255, 0.1);
244
+      background: transparent;
245
+    }
90
 
246
 
91
-        ::v-deep(input) {
92
-          color: #fff;
93
-        }
94
-      }
247
+    ::v-deep(input) {
248
+      color: #fff;
95
     }
249
     }
96
   }
250
   }
97
 }
251
 }
98
-</style>
252
+</style>