feat(ui): Add OAuth callback page and improve auth flow

- Add new OAuth callback page with loading states and error handling
    - Create reusable icon component (ico.html) for SVG icons
    - Remove deprecated public.html layout, merge into default
    - Update login page with improved third-party auth integration
    - Add i18n translations for OAuth-related messages
    - Update routes to include callback page and handle auth redirects
    - Enhance vbase.js with OAuth utilities and token management
master
veypi 3 weeks ago
parent 46f01afc9f
commit e627ab0726

@ -4,19 +4,26 @@ import VBase from './vbase.js'
export default async ($env) => { export default async ($env) => {
// Load i18n // Load i18n
try { try {
const langs = await (await fetch('/langs.json')).json() const langs = await (await fetch($env.scoped + '/langs.json')).json()
$env.$i18n.load(langs) $env.$i18n.load(langs)
} catch (e) { } catch (e) {
console.error('Failed to load langs.json', e) console.error('Failed to load langs.json', e)
} }
// Initialize VBase Service // Initialize VBase Service
const vbase = new VBase('vb', '/'); // Relative path const vbase = new VBase('vb', $env.scoped); // Relative path
$env.$vbase = vbase; $env.$vbase = vbase;
// Wrap Axios // Wrap Axios: add auth header
vbase.wrapAxios($env.$axios); vbase.wrapAxios($env.$axios);
$env.$axios.interceptors.response.use(function(response) {
return response?.data
}, function(error) {
let data = error.response ? error.response.data : error.response
return Promise.reject(data?.message || data);
});
// Router Guard // Router Guard
$env.$router.beforeEnter = async (to, from, next) => { $env.$router.beforeEnter = async (to, from, next) => {
const isAuth = to.meta && to.meta.auth; const isAuth = to.meta && to.meta.auth;

@ -41,6 +41,21 @@
"auth.register_failed": "Registration failed", "auth.register_failed": "Registration failed",
"auth.invalid_response": "Invalid server response", "auth.invalid_response": "Invalid server response",
"auth.oauth_not_ready": "{provider} login is not ready yet", "auth.oauth_not_ready": "{provider} login is not ready yet",
"auth.logging_in": "Logging in...",
"auth.back_to_login": "Back to Login",
"auth.bind_account": "Bind Account",
"auth.bind_new_desc": "Create a new account to bind with {provider}",
"auth.sign_up_bind": "Sign Up & Bind",
"auth.bind_existing": "Bind Existing",
"auth.bind_exist_desc": "Bind your {provider} to an existing account",
"auth.account": "Username/Email",
"auth.login_bind": "Login & Bind",
"auth.bind_login_tip": "To keep connected with us please login with your personal info",
"auth.bind_register_tip": "Enter your personal details and start journey with us",
"auth.bind_success": "Binding successful",
"auth.fill_required": "Please fill in all required fields",
"auth.username_required": "Username is required",
"common.processing": "Processing...",
"common.actions": "Actions", "common.actions": "Actions",
"common.back": "Back", "common.back": "Back",
"common.cancel": "Cancel", "common.cancel": "Cancel",
@ -199,7 +214,22 @@
"auth.register_success": "注册成功", "auth.register_success": "注册成功",
"auth.register_failed": "注册失败", "auth.register_failed": "注册失败",
"auth.invalid_response": "服务器响应异常", "auth.invalid_response": "服务器响应异常",
"auth.oauth_not_ready": "{provider} 登录暂未开放", "auth.oauth_not_ready": "{provider} 登录尚未就绪",
"auth.logging_in": "登录中...",
"auth.back_to_login": "返回登录",
"auth.bind_account": "绑定账号",
"auth.bind_new_desc": "创建新账号并绑定 {provider}",
"auth.sign_up_bind": "注册并绑定",
"auth.bind_existing": "绑定已有账号",
"auth.bind_exist_desc": "将 {provider} 绑定到现有账号",
"auth.account": "用户名/邮箱",
"auth.login_bind": "登录并绑定",
"auth.bind_login_tip": "请登录您的个人账号以保持连接",
"auth.bind_register_tip": "输入您的个人信息,开启您的旅程",
"auth.bind_success": "绑定成功",
"auth.fill_required": "请填写所有必填项",
"auth.username_required": "用户名必填",
"common.processing": "处理中...",
"common.actions": "操作", "common.actions": "操作",
"common.back": "返回", "common.back": "返回",
"common.cancel": "取消", "common.cancel": "取消",

@ -111,15 +111,7 @@
<div class="header-right"> <div class="header-right">
<v-lang></v-lang> <v-lang></v-lang>
<!-- User Profile --> <layout-ico></layout-ico>
<div class="user-profile" @click="goToProfile">
<i class="fas fa-user-circle" style="font-size: 24px;"></i>
<span>{{ user ? user.nickname || user.username : 'User' }}</span>
</div>
<div @click="logout" style="cursor: pointer; color: var(--color-danger);" title="Logout">
<i class="fas fa-sign-out-alt"></i>
</div>
</div> </div>
</header> </header>
@ -154,14 +146,6 @@
toggleCollapse = () => { toggleCollapse = () => {
collapsed = !collapsed; collapsed = !collapsed;
}; };
logout = () => {
$env.$vbase.logout();
};
goToProfile = () => {
$router.push('/profile');
};
</script> </script>
</html> </html>

@ -0,0 +1,186 @@
<!DOCTYPE html>
<html>
<style>
body {
position: relative;
}
.header-user {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-xl);
border-radius: var(--radius-xl);
transition: all var(--transition-base);
cursor: pointer;
}
.header-user:hover {
background: color-mix(in srgb, var(--bg-color), var(--color-primary) 10%);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border-color);
box-shadow: var(--shadow-sm);
}
.user-name {
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
color: var(--text-color);
text-decoration: none;
}
.dropdown-arrow {
font-size: var(--font-size-xs);
color: var(--text-color-secondary);
transition: transform var(--transition-base);
}
.dropdown-arrow.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: var(--bg-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color);
min-width: 160px;
z-index: 1000;
margin-top: 4px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all var(--transition-base);
}
.dropdown-menu.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.dropdown-item {
display: block;
width: 100%;
padding: 12px 16px;
text-decoration: none;
color: var(--text-color);
font-size: var(--font-size-md);
border: none;
background: none;
text-align: left;
cursor: pointer;
transition: background-color var(--transition-base);
}
.dropdown-item:hover {
background: var(--bg-color-tertiary);
}
.dropdown-item.danger {
color: var(--color-danger);
}
.dropdown-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
.auth-btn {
padding: 6px 16px;
border-radius: var(--radius-xl);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-base);
}
.login-btn {
background: var(--color-primary);
color: var(--color-primary-text);
border: none;
}
.login-btn:hover {
background: var(--color-primary-hover);
}
</style>
<body>
<div class="header-user ml-auto" v-if="user.id" @click="toggleDropdown">
<img :src="user.avatar" class="user-avatar" alt="用户头像">
<span class="user-name">{{ user.nickname || user.username }}</span>
<span class="dropdown-arrow" :class="{open: dropdownOpen}"></span>
<div class="dropdown-menu" :class="{show: dropdownOpen}">
<a v-for="menu in menus" :href="menu.path" class="dropdown-item">
{{ menu.label }}
</a>
<div class="dropdown-divider"></div>
<button @click="logout" class="dropdown-item danger">
<i class="fa-solid fa-sign-out-alt" style="margin-right: 8px;"></i>
退出登录
</button>
</div>
</div>
<div class="header-user" v-else>
<a @click='logout' class="auth-btn login-btn" reload>
登录
</a>
</div>
</body>
<script setup>
menus = [
{label: () => $t('nav.dashboard'), icon: "<i class='fas fa-tachometer-alt'></i>", path: "/"},
{label: () => $t('nav.profile'), icon: "<i class='fas fa-user'></i>", path: "/profile"},
// Admin only items would be filtered here ideally
{label: () => $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"},
{label: () => $t('nav.roles'), icon: "<i class='fas fa-user-tag'></i>", path: "/roles"},
{label: () => $t('nav.oauth'), icon: "<i class='fas fa-rocket'></i>", path: "/oauth/apps"},
{label: () => $t('nav.oauth_providers'), icon: "<i class='fas fa-id-card'></i>", path: "/oauth/providers"},
{label: () => $t('nav.settings'), icon: "<i class='fas fa-cog'></i>", path: "/settings"},
];
user = $vbase.user || {}
dropdownOpen = false
toggleDropdown = (event) => {
event.stopPropagation()
dropdownOpen = !dropdownOpen
}
logout = (event) => {
event.stopPropagation()
dropdownOpen = false
$vbase.logout(window.location.href)
}
</script>
<script>
console.log(user.avatar)
// 点击外部区域关闭下拉菜单
document.addEventListener('click', (event) => {
if (!$node.contains(event.target)) {
$data.dropdownOpen = false
}
})
</script>
</html>

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Public Layout">
<title>VBase</title>
<style>
.public-container {
display: flex;
height: 100vh;
width: 100vw;
background-color: var(--bg-color-secondary);
align-items: center;
justify-content: center;
}
.content-box {
width: 100%;
max-width: 400px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
</style>
</head>
<body>
<div class="public-container">
<vslot class="content-box">
</vslot>
</div>
</body>
</html>

@ -0,0 +1,568 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Third-party Account Binding">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $t('auth.bind_account') || 'Bind Account' }}</title>
<style>
body {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
position: relative;
background: linear-gradient(135deg, var(--color-primary-light, #e0e7ff) 0%, var(--color-primary-dark, #4338ca) 100%);
font-family: var(--font-family-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
margin: 0;
}
.background {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
overflow: hidden;
opacity: 0.3;
z-index: 0;
}
.bubble {
position: absolute;
bottom: -150px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
animation: rise linear infinite;
}
@keyframes rise {
0% {
transform: translateY(0) rotate(0);
opacity: 0.5;
}
100% {
transform: translateY(-120vh) rotate(360deg);
opacity: 0;
}
}
h1 {
font-weight: bold;
margin: 0;
margin-bottom: 15px;
color: var(--color-text, #1f2937);
}
p {
font-size: 14px;
font-weight: 100;
line-height: 20px;
letter-spacing: 0.5px;
margin: 20px 0 30px;
color: var(--color-text, #1f2937);
}
span {
font-size: 12px;
margin: 15px 0;
color: var(--color-text-light, #6b7280);
}
a {
color: var(--color-primary, #4f46e5);
font-size: 14px;
text-decoration: none;
margin: 15px 0;
cursor: pointer;
}
button {
border-radius: var(--border-radius, 8px);
border: 1px solid var(--color-primary, #4f46e5);
background-color: var(--color-primary, #4f46e5);
color: #ffffff;
font-size: 12px;
font-weight: bold;
padding: 12px 45px;
letter-spacing: 1px;
text-transform: uppercase;
transition: transform 80ms ease-in, background-color 0.2s;
cursor: pointer;
position: relative;
overflow: hidden;
margin-top: 10px;
}
button:active {
transform: scale(0.95);
}
button:focus {
outline: none;
}
button.ghost {
background-color: transparent;
border-color: #ffffff;
color: #ffffff;
}
button.ghost:hover {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled {
background-color: var(--color-border, #d1d5db);
border-color: var(--color-border, #d1d5db);
cursor: not-allowed;
}
/* Loading spinner for button */
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.button-loading {
color: transparent !important;
}
form {
background-color: var(--bg-color-secondary, #ffffff);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 50px;
height: 100%;
text-align: center;
}
input {
background-color: var(--bg-color-secondary, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
width: 100%;
padding: 10px 12px;
margin: 8px 0;
border-radius: var(--border-radius, 8px);
color: var(--color-text, #1f2937);
transition: border-color 0.2s, box-shadow 0.2s;
font-size: 14px;
}
input:focus {
outline: none;
border-color: var(--color-primary, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
}
input::placeholder {
color: var(--color-text-light, #9ca3af);
}
.container {
background-color: var(--bg-color-secondary, #ffffff);
border-radius: 10px;
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.15), 0 10px 10px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
width: 768px;
max-width: 100%;
min-height: 550px;
z-index: 1;
display: none;
/* Hidden by default, shown when loaded */
}
.container.visible {
display: block;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.form-container {
position: absolute;
top: 0;
height: 100%;
transition: all 0.6s ease-in-out;
}
.sign-in-container {
left: 0;
width: 50%;
z-index: 2;
}
.sign-up-container {
left: 0;
width: 50%;
opacity: 0;
z-index: 1;
}
.container.right-panel-active .sign-in-container {
transform: translateX(100%);
}
.container.right-panel-active .sign-up-container {
transform: translateX(100%);
opacity: 1;
z-index: 5;
}
.overlay-container {
position: absolute;
top: 0;
left: 50%;
width: 50%;
height: 100%;
overflow: hidden;
transition: transform 0.6s ease-in-out;
z-index: 100;
}
.container.right-panel-active .overlay-container {
transform: translateX(-100%);
}
.overlay {
background: var(--color-primary, #4f46e5);
background: linear-gradient(to right, var(--color-secondary, #7c3aed), var(--color-primary, #4f46e5));
background-repeat: no-repeat;
background-size: cover;
background-position: 0 0;
color: #ffffff;
position: relative;
left: -100%;
height: 100%;
width: 200%;
transform: translateX(0);
transition: transform 0.6s ease-in-out;
}
.container.right-panel-active .overlay {
transform: translateX(50%);
}
.overlay-panel {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 40px;
text-align: center;
top: 0;
height: 100%;
width: 50%;
transform: translateX(0);
transition: transform 0.6s ease-in-out;
}
.overlay-left {
transform: translateX(-20%);
}
.container.right-panel-active .overlay-left {
transform: translateX(0);
}
.overlay-right {
right: 0;
transform: translateX(0);
}
.container.right-panel-active .overlay-right {
transform: translateX(20%);
}
/* Initial Loading Screen */
.initial-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
color: white;
}
.initial-loading .loading-spinner {
position: static;
transform: none;
width: 40px;
height: 40px;
margin-bottom: 20px;
border-width: 3px;
}
.error-message {
color: #ef4444;
background: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 8px;
text-align: center;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
</style>
</head>
<body>
<div class="background" id="background">
<!-- Bubbles will be injected here -->
</div>
<!-- Initial Loading State -->
<div v-if="loading && !error" class="initial-loading">
<div class="loading-spinner"></div>
<p style="color: white; margin: 0;">{{ $t('common.processing') || 'Processing...' }}</p>
</div>
<!-- Error State -->
<div v-if="error" class="error-message">
<h3 style="margin-top: 0;">Error</h3>
<p style="color: #ef4444;">{{ error }}</p>
<a href="/login" class="button"
style="display: inline-block; padding: 10px 20px; background: var(--color-primary, #4f46e5); color: white; border-radius: 6px;">
{{ $t('auth.back_to_login') || 'Back to Login' }}
</a>
</div>
<!-- Main Container (Hidden until loaded and needs bind) -->
<div class="container" :class="{ 'right-panel-active': isRegister, 'visible': !loading && !error && needBind }">
<!-- Sign Up (Create New) Container -->
<div class="form-container sign-up-container">
<form @submit.prevent="handleRegister">
<h1>{{ $t('auth.create_account') || 'Create Account' }}</h1>
<span style="margin-bottom: 20px;">{{ $t('auth.bind_new_desc', {provider: provider}) || `Create a new account to
bind with ${provider}` }}</span>
<input type="text" v:value="regForm.username" :placeholder="$t('auth.username') || 'Username'" required />
<input type="email" v:value="regForm.email" :placeholder="$t('auth.email') || 'Email'" />
<button :disabled="submitting" :class="{ 'button-loading': submitting }">
{{ $t('auth.sign_up_bind') || 'Sign Up & Bind' }}
<div v-if="submitting" class="loading-spinner"></div>
</button>
</form>
</div>
<!-- Sign In (Bind Existing) Container -->
<div class="form-container sign-in-container">
<form @submit.prevent="handleBind">
<h1>{{ $t('auth.bind_existing') || 'Bind Existing' }}</h1>
<span style="margin-bottom: 20px;">{{ $t('auth.bind_exist_desc', {provider: provider}) || `Bind your ${provider}
to an existing account` }}</span>
<input type="text" v:value="bindForm.username" :placeholder="$t('auth.account') || 'Username/Email'" required />
<input type="password" v:value="bindForm.password" :placeholder="$t('auth.password') || 'Password'" required />
<a href="/forget" style="font-size: 12px;">{{ $t('auth.forgot_password') || 'Forgot your password?' }}</a>
<button :disabled="submitting" :class="{ 'button-loading': submitting }">
{{ $t('auth.login_bind') || 'Login & Bind' }}
<div v-if="submitting" class="loading-spinner"></div>
</button>
</form>
</div>
<!-- Overlay Container -->
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>{{ $t('auth.welcome_back') || 'Welcome Back!' }}</h1>
<p>{{ $t('auth.bind_login_tip') || 'To keep connected with us please login with your personal info' }}</p>
<button class="ghost" @click="isRegister = false">
{{ $t('auth.bind_existing') || 'Bind Existing' }}
</button>
</div>
<div class="overlay-panel overlay-right">
<h1>{{ $t('auth.hello_friend') || 'Hello, Friend!' }}</h1>
<p>{{ $t('auth.bind_register_tip') || 'Enter your personal details and start journey with us' }}</p>
<button class="ghost" @click="isRegister = true">
{{ $t('auth.create_new') || 'Create New' }}
</button>
</div>
</div>
</div>
</div>
</body>
<script setup>
loading = true
submitting = false
error = ''
needBind = false
isRegister = false // Default to Bind Existing (Sign In) view
provider = ''
providerId = ''
tempToken = ''
bindForm = {
username: '',
password: ''
}
regForm = {
username: '',
email: ''
}
// Helper to generate bubbles
createBubbles = () => {
const bg = document.getElementById('background');
if (!bg) return;
const bubbleCount = 10;
for (let i = 0; i < bubbleCount; i++) {
const bubble = document.createElement('div');
bubble.classList.add('bubble');
const size = Math.random() * 60 + 20 + 'px';
bubble.style.width = size;
bubble.style.height = size;
bubble.style.left = Math.random() * 100 + '%';
bubble.style.animationDuration = Math.random() * 10 + 5 + 's';
bubble.style.animationDelay = Math.random() * 5 + 's';
bg.appendChild(bubble);
}
}
handleCallback = async () => {
try {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
if (!code || !state) {
error = 'Invalid callback parameters';
loading = false;
return;
}
// Get provider from router params
provider = $router.params.provider;
if (!provider) {
// Fallback: try to get from path if router params not ready or using query
const pathParts = window.location.pathname.split('/');
if (pathParts.length > 2 && pathParts[pathParts.length - 1] !== 'callback') {
provider = pathParts[pathParts.length - 1];
}
}
if (!provider) {
error = 'Missing provider in URL';
loading = false;
return;
}
const data = await $env.$vbase.oauthCallback(provider, code, state);
if (data.access_token || data.token) {
// Already bound, login success (token set by vbase)
$router.push('/');
} else if (data.need_bind) {
// Need binding
needBind = true;
provider = data.provider;
providerId = data.provider_id;
tempToken = data.temp_token;
// Pre-fill username if available from provider (optional, depends on API)
// if (data.provider_username) regForm.username = data.provider_username;
loading = false;
} else {
error = 'Unknown response state';
loading = false;
}
} catch (e) {
console.error(e);
error = e.message || 'Network error';
loading = false;
}
}
handleBind = async () => {
if (!bindForm.username || !bindForm.password) {
$message.error($t('auth.fill_required') || 'Please fill in all required fields');
return;
}
submitting = true;
try {
const data = await $env.$vbase.bindAccount(tempToken, bindForm.username, bindForm.password);
$message.success($t('auth.bind_success') || 'Binding successful');
setTimeout(() => $router.push('/'), 1000);
} catch (e) {
$message.error(e.message || 'Binding error');
} finally {
submitting = false;
}
}
handleRegister = async () => {
if (!regForm.username) {
$message.error($t('auth.username_required') || 'Username is required');
return;
}
submitting = true;
try {
const data = await $env.$vbase.bindRegister(tempToken, regForm.username, regForm.email || undefined);
$message.success($t('auth.register_success') || 'Registration successful');
setTimeout(() => $router.push('/'), 1000);
} catch (e) {
$message.error(e.message || 'Registration error');
} finally {
submitting = false;
}
}
</script>
<script>
$watch(() => {
createBubbles();
handleCallback();
});
</script>
</html>

@ -7,8 +7,8 @@
<title>{{ isSignUp ? $t('auth.register') : $t('auth.login') }}</title> <title>{{ isSignUp ? $t('auth.register') : $t('auth.login') }}</title>
<style> <style>
body { body {
height: 100vh; height: 100%;
width: 100vw; width: 100%;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -292,7 +292,6 @@
.social-container a { .social-container a {
border: 1px solid #dddddd; border: 1px solid #dddddd;
font-size: 1.2rem;
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
@ -300,15 +299,25 @@
margin: 0 5px; margin: 0 5px;
height: 40px; height: 40px;
width: 40px; width: 40px;
transition: all 0.3s;
color: var(--color-text, #333);
cursor: pointer; cursor: pointer;
transition: all 0.2s;
overflow: hidden;
font-size: 14px;
} }
.social-container a:hover { .social-container a:hover {
background-color: #f2f2f2; background-color: #f3f4f6;
border-color: var(--color-primary, #4f46e5); border-color: var(--color-primary);
color: var(--color-primary, #4f46e5); color: var(--color-primary);
}
.social-btn span {
font-size: 10px;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 36px;
} }
.error-message { .error-message {
@ -413,7 +422,10 @@
<form @submit="handleSignUp"> <form @submit="handleSignUp">
<h1>{{ $t('auth.create_account') }}</h1> <h1>{{ $t('auth.create_account') }}</h1>
<div class="social-container"> <div class="social-container">
<a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name"><i :class="'fa-brands fa-' + p.icon"></i></a> <a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name" class="social-btn">
<i v-if="p.icon" :class="'fa-brands fa-' + p.icon"></i>
<span v-else>{{ p.name }}</span>
</a>
</div> </div>
<span>{{ $t('auth.use_info_register') }}</span> <span>{{ $t('auth.use_info_register') }}</span>
@ -456,7 +468,10 @@
<form @submit="handleSignIn"> <form @submit="handleSignIn">
<h1>{{ $t('auth.login') }}</h1> <h1>{{ $t('auth.login') }}</h1>
<div class="social-container"> <div class="social-container">
<a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name"><i :class="'fa-brands fa-' + p.icon"></i></a> <a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name" class="social-btn">
<i v-if="p.icon" :class="'fa-brands fa-' + p.icon"></i>
<span v-else>{{ p.name }}</span>
</a>
</div> </div>
<span>{{ $t('auth.use_account') }}</span> <span>{{ $t('auth.use_account') }}</span>
@ -652,7 +667,7 @@
console.error("Failed to load config:", e); console.error("Failed to load config:", e);
} }
}; };
// Send verification code // Send verification code
sendCode = async () => { sendCode = async () => {
if (countDown > 0) return; if (countDown > 0) return;
@ -726,7 +741,11 @@
if (data.user) $env.$vbase.user = data.user; if (data.user) $env.$vbase.user = data.user;
$message.success($t('auth.login_success')); $message.success($t('auth.login_success'));
$router.push(redirect); if (redirect === '/' || redirect.startsWith('http')) {
window.location.href = redirect;
} else {
window.location.href = redirect;
}
} else { } else {
throw new Error('Login failed: no token received'); throw new Error('Login failed: no token received');
} }
@ -740,7 +759,7 @@
if (success) { if (success) {
$message.success($t('auth.login_success')); $message.success($t('auth.login_success'));
$router.push(redirect); window.location.href = redirect;
} else { } else {
throw new Error($t('auth.login_failed')); throw new Error($t('auth.login_failed'));
} }
@ -802,7 +821,7 @@
} }
$message.success($t('auth.register_success')); $message.success($t('auth.register_success'));
$router.push(redirect); window.location.href = redirect;
} else { } else {
$message.success($t('auth.register_success')); $message.success($t('auth.register_success'));
switchToSignIn(); switchToSignIn();

@ -148,6 +148,13 @@
<div class="info-value">{{ p.client_id || '-' }}</div> <div class="info-value">{{ p.client_id || '-' }}</div>
</div> </div>
<div class="info-row">
<span class="info-label">{{ $t('oauth.provider.redirect_uri') }}</span>
<div class="info-value" @click="copy(p.code)" style="cursor: pointer;" title="Click to copy">
{{ getCallbackUrl(p.code) }}
</div>
</div>
<div class="actions"> <div class="actions">
<v-btn icon size="sm" variant="outline" :click="() => openEditModal(p)"> <v-btn icon size="sm" variant="outline" :click="() => openEditModal(p)">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
@ -193,11 +200,22 @@
loadProviders = async () => { loadProviders = async () => {
try { try {
const res = await $axios.get('/api/oauth/providers'); const res = await $env.$vbase.request('GET', '/api/oauth/providers');
providers = res.items || []; providers = res.items || [];
} catch (e) { } catch (e) {
$message.error(e.message); $message.error(e.message);
} }
}
getCallbackUrl = (code) => {
return `${window.location.origin}/callback/${code}`;
}
copy = (code) => {
const url = getCallbackUrl(code);
navigator.clipboard.writeText(url).then(() => {
$message.success('Copied!');
});
}; };
openCreateModal = () => { openCreateModal = () => {

@ -1,6 +1,7 @@
const routes = [ const routes = [
// Public // Public
{ path: '/login', component: '/page/auth/login.html' }, { path: '/login', component: '/page/auth/login.html' },
{ path: '/callback/:provider', component: '/page/auth/callback.html' },
// Dashboard (Default Layout) // Dashboard (Default Layout)
{ {
@ -30,8 +31,8 @@ const routes = [
{ path: '/oauth/providers', component: '/page/sys/oauth/providers.html', layout: 'default', meta: { auth: true } }, { path: '/oauth/providers', component: '/page/sys/oauth/providers.html', layout: 'default', meta: { auth: true } },
// Errors // Errors
{ path: '/403', component: '/page/403.html', layout: 'public' }, { path: '/403', component: '/page/403.html' },
{ path: '*', component: '/page/404.html', layout: 'public' } { path: '*', component: '/page/404.html' }
] ]
export default routes export default routes

@ -14,13 +14,14 @@ export const Level = {
}; };
class VBase { class VBase {
constructor(scope, baseURL) { constructor(scope, baseURL, login_page) {
if (!scope) throw new Error('VBase: scope is required'); if (!scope) throw new Error('VBase: scope is required');
if (!baseURL) baseURL = window.location.origin; if (!baseURL) baseURL = window.location.origin;
if (baseURL === '' || baseURL === '/') baseURL = window.location.origin; if (baseURL === '' || baseURL === '/') baseURL = window.location.origin;
this.baseURL = baseURL; this.baseURL = baseURL;
this.scope = scope; this.scope = scope;
this.login_page = login_page || (baseURL + '/login')
this.tokenKey = `vbase_token`; this.tokenKey = `vbase_token`;
this.refreshTokenKey = `vbase_refresh_token`; this.refreshTokenKey = `vbase_refresh_token`;
this.userKey = `vbase_user`; this.userKey = `vbase_user`;
@ -95,15 +96,81 @@ class VBase {
return false; return false;
} }
/**
* OAuth Callback Handler
* @param {string} provider
* @param {string} code
* @param {string} state
* @returns {Promise<Object>} Response data
*/
async oauthCallback(provider, code, state) {
const data = await this.request('GET', `/api/auth/callback/${provider}?code=${code}&state=${state}`);
// If login success directly
if (data.access_token || data.token) {
this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user;
}
return data;
}
/**
* Bind existing account
* @param {string} tempToken
* @param {string} username
* @param {string} password
* @returns {Promise<Object>}
*/
async bindAccount(tempToken, username, password) {
const data = await this.request('POST', '/api/auth/bind', {
temp_token: tempToken,
username,
password
});
if (data.access_token || data.token) {
this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user;
}
return data;
}
/**
* Register and bind new account
* @param {string} tempToken
* @param {string} username
* @param {string} email
* @returns {Promise<Object>}
*/
async bindRegister(tempToken, username, email) {
const data = await this.request('POST', '/api/auth/bind-register', {
temp_token: tempToken,
username,
email
});
if (data.access_token || data.token) {
this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user;
}
return data;
}
async logout(redirect) { async logout(redirect) {
try { try {
// await this.request('POST', '/api/auth/logout'); await this.request('POST', '/api/auth/logout');
} catch (e) { } catch (e) {
console.warn('Logout API failed', e); console.warn('Logout API failed', e);
} finally { } finally {
this.clear(); this.clear();
if (redirect) location.href = redirect; const redirectUrl = redirect || window.location.pathname + window.location.search;
else location.reload(); location.href = this.login_page + '?redirect=' + encodeURIComponent(redirectUrl);
} }
} }
@ -249,7 +316,7 @@ class VBase {
// 响应拦截器 // 响应拦截器
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
response => response.data || response, response => response,
async error => { async error => {
const originalRequest = error.config; const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
@ -260,11 +327,11 @@ class VBase {
originalRequest.headers['Authorization'] = headers['Authorization']; originalRequest.headers['Authorization'] = headers['Authorization'];
return axiosInstance(originalRequest); return axiosInstance(originalRequest);
} catch (e) { } catch (e) {
this.logout('/login?redirect=' + encodeURIComponent(window.location.pathname)); this.logout();
return Promise.reject(e); return Promise.reject(e);
} }
} }
return Promise.reject(error?.response?.data || error); return Promise.reject(error);
} }
); );
} }

Loading…
Cancel
Save