Browse Source

modify Login UI for OIDC login

Tao Chen 1 year ago
parent
commit
c165f54ce8
6 changed files with 338 additions and 78 deletions
  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 1
 import { defineStore, acceptHMRUpdate } from 'pinia'
2 2
 import { current, login } from '@/api/user'
3
-import { setToken, removeToken } from '@/utils/auth'
3
+import { setToken, removeToken, setCode, removeCode } from '@/utils/auth'
4 4
 import { useRouteStore } from '@/store/router'
5 5
 import { useAppStore } from '@/store/app'
6
+import { oidcAuth, oidcQuery } from '@/api/login';
6 7
 
7 8
 export const useUserStore = defineStore({
8 9
   id: 'user',
@@ -16,34 +17,40 @@ export const useUserStore = defineStore({
16 17
   }),
17 18
 
18 19
   actions: {
19
-    logout () {
20
+    logout() {
20 21
       removeToken()
22
+      removeCode()
21 23
       this.$patch({
22 24
         name: '',
23 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 43
       const res = await login(form).catch(_ => false)
29 44
       if (res) {
30 45
         useAppStore().getAppConfig()
31 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 48
         return userData
42 49
       } else {
43 50
         return false
44 51
       }
45 52
     },
46
-    async info () {
53
+    async info() {
47 54
       const res = await current().catch(_ => false)
48 55
       if (res) {
49 56
         useAppStore().getAppConfig()
@@ -57,6 +64,41 @@ export const useUserStore = defineStore({
57 64
       }
58 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 1
 const TokenKey = 'access_token'
2
+const OidcCode = 'oidc_code'
3
+const OidcCodeExpiry = 'oidc_code_expiry';
2 4
 
3 5
 export function getToken () {
4 6
   return localStorage.getItem(TokenKey)
@@ -11,3 +13,31 @@ export function setToken (token) {
11 13
 export function removeToken () {
12 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 428
   "LastOnlineIp": {
429 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 55
   response => {
56 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 64
     // if the custom code is not 20000, it is judged as an error.
59 65
     if (res.code !== 0) {
60 66
       ElMessage({

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

@@ -1,98 +1,252 @@
1 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 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 14
         </el-form-item>
15
+
12 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 18
         </el-form-item>
15 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 34
   </div>
18 35
 </template>
19 36
 
20 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 127
     if (res) {
128
+      // 删除code,确保跳转之前对code进行清楚
129
+      removeCode()
51 130
       ElMessage.success(T('LoginSuccess'))
52 131
       router.push({ path: redirect || '/', replace: true })
53 132
     }
133
+  } else {
134
+    // 如果code不存在, 现实登陆页面
135
+    loadLoginOptions(); // 组件挂载后调用登录选项加载函数
54 136
   }
137
+});
55 138
 </script>
56 139
 
57 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 145
   height: 100vh;
61 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>