@@ -0,0 +1,24 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export function loginOptions() {
|
||||
return request({
|
||||
url: '/login-options',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function oidcAuth (data) {
|
||||
return request({
|
||||
url: '/oidc/auth',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function oidcQuery(params){
|
||||
return request({
|
||||
url: '/oidc/auth-query',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
+50
-8
@@ -1,8 +1,9 @@
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { current, login } from '@/api/user'
|
||||
import { setToken, removeToken } from '@/utils/auth'
|
||||
import { setToken, removeToken, setCode, removeCode } from '@/utils/auth'
|
||||
import { useRouteStore } from '@/store/router'
|
||||
import { useAppStore } from '@/store/app'
|
||||
import { oidcAuth, oidcQuery } from '@/api/login';
|
||||
|
||||
export const useUserStore = defineStore({
|
||||
id: 'user',
|
||||
@@ -16,19 +17,17 @@ export const useUserStore = defineStore({
|
||||
}),
|
||||
|
||||
actions: {
|
||||
logout () {
|
||||
logout() {
|
||||
removeToken()
|
||||
removeCode()
|
||||
this.$patch({
|
||||
name: '',
|
||||
role: {},
|
||||
})
|
||||
},
|
||||
|
||||
async login (form) {
|
||||
const res = await login(form).catch(_ => false)
|
||||
if (res) {
|
||||
useAppStore().getAppConfig()
|
||||
const userData = res.data
|
||||
saveUserData(userData) {
|
||||
// useAppStore().getAppConfig()
|
||||
setToken(userData.token)
|
||||
//
|
||||
localStorage.setItem('user_info', JSON.stringify({ name: userData.username }))
|
||||
@@ -38,12 +37,20 @@ export const useUserStore = defineStore({
|
||||
if (userData.route_names && userData.route_names.length) {
|
||||
useRouteStore().addRoutes(userData.route_names)
|
||||
}
|
||||
},
|
||||
|
||||
async login(form) {
|
||||
const res = await login(form).catch(_ => false)
|
||||
if (res) {
|
||||
useAppStore().getAppConfig()
|
||||
const userData = res.data
|
||||
this.saveUserData(userData)
|
||||
return userData
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
async info () {
|
||||
async info() {
|
||||
const res = await current().catch(_ => false)
|
||||
if (res) {
|
||||
useAppStore().getAppConfig()
|
||||
@@ -57,6 +64,41 @@ export const useUserStore = defineStore({
|
||||
}
|
||||
return false
|
||||
},
|
||||
async oidc(provider, platform, browser) {
|
||||
// oidc data need to be implement
|
||||
const data = {
|
||||
deviceInfo: {
|
||||
name: navigator.userAgent, // 使用浏览器的 User-Agent 作为设备名
|
||||
os: platform, // 获取操作系统信息
|
||||
type: 'webadmin', // any vaule
|
||||
},
|
||||
id: `${platform}-${browser}`,
|
||||
op: provider, // 传入的 provider
|
||||
uuid: crypto.randomUUID(), // 自动生成 UUID
|
||||
};
|
||||
const res = await oidcAuth(data).catch(_ => false)
|
||||
if (res) {
|
||||
const { code, url } = res.data
|
||||
setCode(code)
|
||||
if (provider == 'webauth') {
|
||||
window.open(url)
|
||||
} else {
|
||||
window.location.href = url
|
||||
}
|
||||
}
|
||||
},
|
||||
async query(code) {
|
||||
const params = { "code": code, "uuid": crypto.randomUUID(), "Id": "999" }
|
||||
const res = await oidcQuery(params).catch(_ => false)
|
||||
if (res) {
|
||||
removeCode()
|
||||
useAppStore().getAppConfig()
|
||||
const userData = res.data
|
||||
this.saveUserData(userData)
|
||||
return userData
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const TokenKey = 'access_token'
|
||||
const OidcCode = 'oidc_code'
|
||||
const OidcCodeExpiry = 'oidc_code_expiry';
|
||||
|
||||
export function getToken () {
|
||||
return localStorage.getItem(TokenKey)
|
||||
@@ -11,3 +13,31 @@ export function setToken (token) {
|
||||
export function removeToken () {
|
||||
return localStorage.removeItem(TokenKey)
|
||||
}
|
||||
|
||||
// 设置 code,并存储当前时间戳(单位:毫秒)
|
||||
export function setCode(code) {
|
||||
const now = Date.now(); // 当前时间戳(毫秒)
|
||||
const expiry = now + 60 * 1000; // 60 秒后过期
|
||||
|
||||
localStorage.setItem(OidcCode, code); // 存储 code
|
||||
localStorage.setItem(OidcCodeExpiry, expiry); // 存储过期时间戳
|
||||
}
|
||||
|
||||
// 获取 code,如果已过期则删除并返回 null
|
||||
export function getCode() {
|
||||
const expiry = localStorage.getItem(OidcCodeExpiry); // 获取过期时间戳
|
||||
const now = Date.now(); // 当前时间戳
|
||||
|
||||
if (expiry && now > parseInt(expiry)) {
|
||||
// 如果已过期,删除 code 和过期时间
|
||||
removeCode();
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(OidcCode); // 返回 code(如果未过期)
|
||||
}
|
||||
|
||||
// 删除 code 和过期时间
|
||||
export function removeCode() {
|
||||
localStorage.removeItem(OidcCode);
|
||||
localStorage.removeItem(OidcCodeExpiry);
|
||||
}
|
||||
@@ -427,5 +427,9 @@
|
||||
},
|
||||
"LastOnlineIp": {
|
||||
"One": "最后在线IP"
|
||||
},
|
||||
"or login in with" :
|
||||
{
|
||||
"One": "或使用以下登陆"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ service.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
|
||||
// for the endpoint /login-options
|
||||
// I'm not sure if this is a good idea
|
||||
if (Array.isArray(res)) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// if the custom code is not 20000, it is judged as an error.
|
||||
if (res.code !== 0) {
|
||||
ElMessage({
|
||||
|
||||
+206
-46
@@ -1,88 +1,250 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<el-card class="login-card">
|
||||
<h1>{{ T('Login') }}</h1>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label=" T('Username') ">
|
||||
<el-input v-model="form.username"></el-input>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<img src="@/assets/logo.png" alt="logo" class="login-logo" />
|
||||
|
||||
<el-form label-position="top" class="login-form">
|
||||
<el-form-item :label="T('Username')">
|
||||
<el-input v-model="form.username" class="login-input"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label=" T('Password') ">
|
||||
<el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password></el-input>
|
||||
|
||||
<el-form-item :label="T('Password')">
|
||||
<el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password
|
||||
class="login-input"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button @click="login" type="primary">{{ T('Login') }}</el-button>
|
||||
<el-button @click="login" type="primary" class="login-button">{{ T('Login') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="divider" v-if="options.length > 0">
|
||||
<span>{{ T('or login in with') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="oidc-options">
|
||||
<div v-for="(option, index) in options" :key="index" class="oidc-option">
|
||||
<el-button @click="handleOIDCLogin(option.name)" class="oidc-btn">
|
||||
<img :src="getProviderImage(option.name)" alt="provider" class="oidc-icon" />
|
||||
{{ T(option.name) }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineComponent, reactive } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { T } from '@/utils/i18n'
|
||||
import { reactive, onMounted, ref } from 'vue';
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { T } from '@/utils/i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { loginOptions, oidcAuth, oidcQuery } from '@/api/login';
|
||||
import { getCode, removeCode } from '@/utils/auth'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const oauthInfo = ref({})
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const options = reactive([]); // 存储 OIDC 登录选项
|
||||
|
||||
let platform = window.navigator.platform
|
||||
if (navigator.platform.indexOf('Mac') === 0) {
|
||||
let platform = window.navigator.platform
|
||||
if (navigator.platform.indexOf('Mac') === 0) {
|
||||
platform = 'mac'
|
||||
} else if (navigator.platform.indexOf('Win') === 0) {
|
||||
} else if (navigator.platform.indexOf('Win') === 0) {
|
||||
platform = 'windows'
|
||||
} else if (navigator.platform.indexOf('Linux armv') === 0) {
|
||||
} else if (navigator.platform.indexOf('Linux armv') === 0) {
|
||||
platform = 'android'
|
||||
} else if (navigator.platform.indexOf('Linux') === 0) {
|
||||
} else if (navigator.platform.indexOf('Linux') === 0) {
|
||||
platform = 'linux'
|
||||
}
|
||||
}
|
||||
const userAgent = navigator.userAgent;
|
||||
let browser = 'Unknown Browser';
|
||||
if (/chrome|crios/i.test(userAgent)) browser = 'Chrome';
|
||||
else if (/firefox|fxios/i.test(userAgent)) browser = 'Firefox';
|
||||
else if (/safari/i.test(userAgent) && !/chrome/i.test(userAgent)) browser = 'Safari';
|
||||
else if (/edg/i.test(userAgent)) browser = 'Edge';
|
||||
|
||||
const form = reactive({
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
platform: platform,
|
||||
})
|
||||
const redirect = route.query?.redirect
|
||||
const login = async () => {
|
||||
})
|
||||
|
||||
const redirect = route.query?.redirect
|
||||
const login = async () => {
|
||||
const res = await userStore.login(form)
|
||||
if (res) {
|
||||
ElMessage.success(T('LoginSuccess'))
|
||||
router.push({ path: redirect || '/', replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const handleOIDCLogin = (provider) => {
|
||||
userStore.oidc(provider, platform, browser)
|
||||
};
|
||||
|
||||
import googleImage from '@/assets/google.png';
|
||||
import githubImage from '@/assets/github.png';
|
||||
import oidcImage from '@/assets/oidc.png';
|
||||
import webauthImage from '@/assets/webauth.png';
|
||||
import defaultImage from '@/assets/oidc.png';
|
||||
|
||||
const providerImageMap = {
|
||||
google: googleImage,
|
||||
github: githubImage,
|
||||
oidc: oidcImage,
|
||||
webauth: webauthImage,
|
||||
default: defaultImage,
|
||||
};
|
||||
|
||||
const getProviderImage = (provider) => {
|
||||
return providerImageMap[provider] || providerImageMap.default;
|
||||
};
|
||||
|
||||
const loadLoginOptions = async () => {
|
||||
try {
|
||||
const res = await loginOptions().catch(() => []);
|
||||
if (!Array.isArray(res) || !res.length) return console.warn('No valid response received');
|
||||
|
||||
const jsonPart = res[0].split('/')[1];
|
||||
if (!jsonPart) return console.error('Invalid input string:', res[0]);
|
||||
|
||||
// const ops = JSON.parse(jsonPart).map(option => ({ name: option.name }));
|
||||
// 不确定怎么处理webauth,不显示
|
||||
// 解析 JSON,并过滤掉 "webauth" 类型的选项
|
||||
const ops = JSON.parse(jsonPart)
|
||||
.filter(option => option.name !== "webauth") // 排除 "webauth" 类型的选项
|
||||
.map(option => ({ name: option.name })); // 创建新的对象数组
|
||||
if (!ops.length) return;
|
||||
|
||||
options.push(...ops);
|
||||
} catch (error) {
|
||||
console.error('Error loading login options:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const code = getCode();
|
||||
if (code) {
|
||||
// 如果code存在,进行query获取user info
|
||||
const res = await userStore.query(code)
|
||||
if (res) {
|
||||
// 删除code,确保跳转之前对code进行清楚
|
||||
removeCode()
|
||||
ElMessage.success(T('LoginSuccess'))
|
||||
router.push({ path: redirect || '/', replace: true })
|
||||
}
|
||||
} else {
|
||||
// 如果code不存在, 现实登陆页面
|
||||
loadLoginOptions(); // 组件挂载后调用登录选项加载函数
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login {
|
||||
width: 100vw;
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-color: #2d3a4b;
|
||||
padding-top: 25vh;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
margin-left: 60px;
|
||||
.login-card {
|
||||
width: 360px;
|
||||
background-color: #283342;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 500px;
|
||||
background-color: #283342;
|
||||
color: #fff;
|
||||
border: none;
|
||||
margin: 0 auto;
|
||||
&::before {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
&::after {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.oidc-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oidc-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
color: black;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.oidc-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
::v-deep(.el-form-item__label) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
|
||||
::v-deep(.el-input__wrapper) {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: transparent;
|
||||
@@ -92,7 +254,5 @@
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+14
-13
@@ -11,7 +11,7 @@
|
||||
<el-card class="list-body" shadow="hover">
|
||||
<el-table :data="listRes.list" v-loading="listRes.loading" border>
|
||||
<el-table-column prop="id" label="id" align="center"/>
|
||||
<el-table-column prop="op" :label="T('Op')" align="center"/>
|
||||
<el-table-column prop="op" :label="T('Type')" align="center"/>
|
||||
<el-table-column prop="auto_register" :label="T('AutoRegister')" align="center"/>
|
||||
<el-table-column prop="created_at" :label="T('CreatedAt')" align="center"/>
|
||||
<el-table-column prop="updated_at" :label="T('UpdatedAt')" align="center"/>
|
||||
@@ -34,8 +34,18 @@
|
||||
</el-card>
|
||||
<el-dialog v-model="formVisible" :title="!formData.id?T('Create') :T('Update')" width="800">
|
||||
<el-form class="dialog-form" ref="form" :model="formData" :rules="rules" label-width="120px">
|
||||
<el-form-item label="Issuer" prop="issuer">
|
||||
<el-input v-model="formData.issuer" :placeholder="formData.op === 'oidc' ? 'Required when OIDC is selected' : 'Not required unless OIDC is selected'"></el-input>
|
||||
<el-form-item label="Type" prop="op">
|
||||
<el-radio-group v-model="formData.op" :disabled="!!formData.id">
|
||||
<el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.op === 'oidc'" label="Issuer" prop="issuer">
|
||||
<el-input v-model="formData.issuer" placeholder="Check your IdP docs, without '/.well-known/openid-configuration'"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item v-show="formData.op === 'oidc'" label="Scopes" prop="scopes">
|
||||
<el-input v-model="formData.scopes" placeholder= "Optional, default is 'openid,profile,email'"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="ClientId" prop="client_id">
|
||||
<el-input v-model="formData.client_id"></el-input>
|
||||
@@ -46,16 +56,6 @@
|
||||
<el-form-item label="RedirectUrl" prop="redirect_url">
|
||||
<el-input v-model="formData.redirect_url"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scopes" prop="scopes">
|
||||
<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>
|
||||
</el-form-item>
|
||||
<el-form-item label="op" prop="op">
|
||||
<el-radio-group v-model="formData.op" :disabled="!!formData.id">
|
||||
<el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="T('AutoRegister')" prop="auto_register">
|
||||
<el-switch v-model="formData.auto_register"
|
||||
:active-value="true"
|
||||
@@ -146,6 +146,7 @@
|
||||
client_secret: [{ required: true, message: T('ParamRequired', { param: 'client_secret' }), trigger: 'blur' }],
|
||||
redirect_url: [{ required: true, message: T('ParamRequired', { param: 'redirect_url' }), trigger: 'blur' }],
|
||||
op: [{ required: true, message: T('ParamRequired', { param: 'op' }), trigger: 'blur' }],
|
||||
issuer: [{ required: true, message: T('ParamRequired', { param: 'issuer' }), trigger: 'blur' }],
|
||||
}
|
||||
const toEdit = (row) => {
|
||||
formVisible.value = true
|
||||
|
||||
Reference in New Issue
Block a user