You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
OneAuth/ui/page/auth/login.html

796 lines
20 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Login and Register Page">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ isSignUp ? $t('auth.register') : $t('auth.login') }}</title>
<style>
body {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
h1 {
font-weight: bold;
margin: 0;
margin-bottom: 15px;
color: var(--text-color);
}
p {
font-size: 14px;
font-weight: 100;
line-height: 20px;
letter-spacing: 0.5px;
margin: 20px 0 30px;
color: var(--text-color);
}
span {
font-size: 12px;
margin: 15px 0;
}
a {
color: var(--color-primary);
font-size: 14px;
text-decoration: none;
margin: 15px 0;
}
button {
border-radius: var(--radius-lg);
border: 1px solid var(--color-primary);
background-color: var(--color-primary);
color: var(--color-primary-text);
font-size: 12px;
font-weight: bold;
padding: 12px 45px;
letter-spacing: 1px;
text-transform: uppercase;
transition: transform 80ms ease-in, background-color var(--transition-fast);
cursor: pointer;
overflow: hidden;
}
button:active {
transform: scale(0.95);
}
button:focus {
outline: none;
}
button.ghost {
background-color: transparent;
border-color: #fff;
color: #fff;
}
button.ghost:hover {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled {
background-color: var(--text-color-disabled);
border-color: var(--text-color-disabled);
cursor: not-allowed;
}
.code-input-row {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.code-input-row input {
flex: 1;
margin: var(--spacing-sm) 0;
}
button.send-code-btn {
width: auto;
min-width: 90px;
padding: 10px 14px;
margin: var(--spacing-sm) 0;
white-space: nowrap;
text-transform: none;
font-size: 13px;
letter-spacing: 0;
}
.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 #fff;
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);
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);
border: 1px solid var(--border-color);
width: 100%;
padding: 10px 12px;
margin: var(--spacing-sm) 0;
border-radius: var(--radius-lg);
color: var(--text-color);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
font-size: 14px;
}
input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary), transparent 85%);
}
input::placeholder {
color: var(--text-color-disabled);
}
/* Chrome 自动填充覆盖 */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 30px var(--bg-color-secondary) inset !important;
-webkit-text-fill-color: var(--text-color) !important;
}
.container {
position: relative;
background-color: var(--bg-color-secondary);
border-radius: 10px;
box-shadow: var(--shadow-lg);
overflow: hidden;
width: 768px;
max-width: 100%;
min-height: 550px;
z-index: 1;
}
.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 {
position: relative;
background: linear-gradient(to right, var(--color-secondary), var(--color-primary));
color: var(--color-primary-text);
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%);
}
.social-container {
margin: 15px 0;
}
.social-container a {
border: 1px solid var(--border-color);
border-radius: 50%;
display: inline-flex;
justify-content: center;
align-items: center;
margin: 0 5px;
height: 40px;
width: 40px;
cursor: pointer;
transition: all var(--transition-fast);
overflow: hidden;
font-size: 14px;
}
.social-container a:hover {
background-color: var(--bg-color-tertiary);
border-color: var(--color-primary);
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 {
color: var(--color-danger);
font-size: 12px;
margin-top: var(--spacing-sm);
min-height: 18px;
}
.login-tab {
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid var(--border-color);
width: 100%;
}
.tab-item {
padding: 10px 15px;
cursor: pointer;
flex: 1;
text-align: center;
color: var(--text-color-secondary);
font-size: 13px;
border-bottom: 2px solid transparent;
transition: all var(--transition-base);
}
.tab-item.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.input-group {
width: 100%;
}
.forgot-link {
font-size: 12px;
margin-top: 10px;
}
@media (max-width: 768px) {
.container {
width: 100%;
min-height: 100vh;
border-radius: 0;
}
.form-container {
width: 100%;
}
.sign-in-container,
.sign-up-container {
width: 100%;
}
.container.right-panel-active .sign-in-container {
transform: translateX(-100%);
}
.container.right-panel-active .sign-up-container {
transform: translateX(0);
opacity: 1;
z-index: 5;
}
.overlay-container {
display: none;
}
form {
padding: 0 30px;
}
.mobile-toggle {
display: block !important;
margin-top: 20px;
font-size: 14px;
color: var(--color-primary);
cursor: pointer;
}
}
@media (min-width: 769px) {
.mobile-toggle {
display: none !important;
}
}
</style>
</head>
<body>
<div class="container" :class="{ 'right-panel-active': isSignUp }">
<!-- Register Form -->
<div class="form-container sign-up-container">
<form @submit.prevent="handleSignUp">
<h1>{{ $t('auth.create_account') }}</h1>
<div class="social-container">
<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>
<span>{{ $t('auth.use_info_register') }}</span>
<div class="input-group">
<input type="text" :placeholder="$t('auth.username')" v:value="signUpForm.username" required />
</div>
<div class="input-group" v-if="emailEnabled">
<input type="email" :placeholder="$t('auth.email')" v:value="signUpForm.email" />
</div>
<div class="input-group" v-if="smsEnabled">
<input type="text" :placeholder="$t('auth.phone_placeholder')" v:value="signUpForm.phone" />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.password')" v:value="signUpForm.password" required />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.confirm_password')" v:value="signUpForm.confirmPassword"
required />
</div>
<div class="error-message">{{ signUpError }}</div>
<button type="submit" :class="{ 'button-loading': signUpLoading }" :disabled="signUpLoading">
<span v-if="!signUpLoading">{{ $t('auth.register') }}</span>
<div v-if="signUpLoading" class="loading-spinner"></div>
</button>
<div class="mobile-toggle" @click="switchToSignIn">
{{ $t('auth.have_account') }} <a>{{ $t('auth.login') }}</a>
</div>
</form>
</div>
<!-- Login Form -->
<div class="form-container sign-in-container">
<form @submit.prevent="handleSignIn">
<h1>{{ $t('auth.login') }}</h1>
<div class="social-container">
<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>
<span>{{ $t('auth.use_account') }}</span>
<!-- Login Type Tabs -->
<div class="login-tab" v-if="smsEnabled || emailEnabled">
<div class="tab-item" :class="{ active: loginType === 'username' }" @click="switchLoginType('username')">
{{ $t('auth.username_login') }}
</div>
<div class="tab-item" :class="{ active: loginType === 'code' }" @click="switchLoginType('code')">
{{ $t('auth.code_login') }}
</div>
</div>
<!-- Username/Password Login -->
<div v-if="loginType === 'username'" style="width: 100%;">
<div class="input-group">
<input type="text" :placeholder="$t('auth.username_or_email')" v:value="signInForm.username" required />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.password')" v:value="signInForm.password" required />
</div>
</div>
<!-- Code Login -->
<div v-if="loginType === 'code'" style="width: 100%;">
<div class="input-group code-input-row">
<input type="text" :placeholder="codePlaceholder" v:value="signInForm.target" required />
<button type="button" class="send-code-btn" @click="sendCode" :disabled="countDown > 0 || sendCodeLoading">
{{ countDown > 0 ? countDown + 's' : sendCodeLoading ? '...' : $t('auth.send_code') }}
</button>
</div>
<div class="input-group">
<input type="text" :placeholder="$t('auth.verification_code')" v:value="signInForm.code" required />
</div>
</div>
<a href="#" class="forgot-link">{{ $t('auth.forgot_password') }}</a>
<div class="error-message">{{ signInError }}</div>
<button type="submit" :class="{ 'button-loading': signInLoading }" :disabled="signInLoading">
<span v-if="!signInLoading">{{ $t('auth.login') }}</span>
<div v-if="signInLoading" class="loading-spinner"></div>
</button>
<div class="mobile-toggle" @click="switchToSignUp">
{{ $t('auth.no_account') }} <a>{{ $t('auth.register') }}</a>
</div>
</form>
</div>
<!-- Overlay -->
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>{{ $t('auth.welcome_back') }}</h1>
<p>{{ $t('auth.keep_connected') }}</p>
<button class="ghost" @click="switchToSignIn">{{ $t('auth.login') }}</button>
</div>
<div class="overlay-panel overlay-right">
<h1>{{ $t('auth.hello_friend') }}</h1>
<p>{{ $t('auth.start_journey') }}</p>
<button class="ghost" @click="switchToSignUp">{{ $t('auth.register') }}</button>
</div>
</div>
</div>
</div>
</body>
<script setup>
isSignUp = false;
loginType = 'username';
redirect = $router.query.redirect || '/';
smsEnabled = false;
emailEnabled = false;
regRequireEmail = false;
regRequirePhone = false;
codePlaceholder = $t('auth.email_or_phone');
countDown = 0;
timer = null;
sendCodeLoading = false;
signInForm = {
username: '',
password: '',
target: '',
code: ''
};
signUpForm = {
username: '',
email: '',
phone: '',
password: '',
confirmPassword: ''
};
signInError = '';
signUpError = '';
signInLoading = false;
signUpLoading = false;
providers = [];
switchToSignUp = () => {
if (signInLoading || signUpLoading) return;
isSignUp = true;
signUpError = '';
signInError = '';
};
switchToSignIn = () => {
if (signInLoading || signUpLoading) return;
isSignUp = false;
signUpError = '';
signInError = '';
};
switchLoginType = (type) => {
if (signInLoading) return;
loginType = type;
signInError = '';
signInForm.username = '';
signInForm.password = '';
signInForm.target = '';
signInForm.code = '';
};
handleOAuth = async (provider) => {
try {
const params = new URLSearchParams({provider, redirect});
const res = await fetch('/api/auth/authorize/thirdparty?' + params);
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const data = await res.json();
if (data?.auth_url) {
window.location.href = data.auth_url;
}
} catch (e) {
$message.error(e.message || $t('auth.oauth_failed'));
}
};
loadConfig = async () => {
try {
const res = await fetch('/api/info');
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const info = await res.json();
if (info.oauth_providers) {
providers = info.oauth_providers;
}
smsEnabled = info.sms_enabled;
emailEnabled = info.email_enabled;
regRequireEmail = info.reg_require_email;
regRequirePhone = info.reg_require_phone;
if (smsEnabled && emailEnabled) {
codePlaceholder = $t('auth.email_or_phone');
} else if (smsEnabled) {
codePlaceholder = $t('auth.phone_placeholder');
} else if (emailEnabled) {
codePlaceholder = $t('auth.email');
}
} catch (e) {
console.error('Failed to load config:', e);
}
};
sendCode = async () => {
if (countDown > 0 || sendCodeLoading) return;
if (!signInForm.target) {
signInError = $t('auth.target_required');
return;
}
const type = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(signInForm.target) ? 'email' : 'sms';
if (type === 'email' && !emailEnabled) {
signInError = $t('auth.email_disabled');
return;
}
if (type === 'sms' && !smsEnabled) {
signInError = $t('auth.sms_disabled');
return;
}
sendCodeLoading = true;
try {
await $mod.$auth.request('POST', '/api/verification/send', {
type: type,
target: signInForm.target,
purpose: 'login'
});
signInError = '';
$message.success($t('auth.code_sent'));
countDown = 60;
if (timer) clearInterval(timer);
timer = setInterval(() => {
countDown--;
if (countDown <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
} catch (e) {
signInError = e.message || 'Failed to send code';
} finally {
sendCodeLoading = false;
}
};
handleSignIn = async (e) => {
if (signInLoading) return;
signInError = '';
signInLoading = true;
try {
if (loginType === 'code') {
if (!signInForm.target || !signInForm.code) {
throw new Error($t('auth.target_and_code_required'));
}
const type = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(signInForm.target) ? 'email' : 'phone';
const res = await fetch('/api/auth/login/code', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type, target: signInForm.target, code: signInForm.code})
});
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const data = await res.json();
if (data?.user) {
await $mod.$auth.onAuthSuccess(data.user);
$message.success($t('auth.login_success'));
window.location.href = redirect;
} else {
throw new Error('Login failed');
}
} else {
if (!signInForm.username || !signInForm.password) {
throw new Error($t('auth.fill_all_fields'));
}
const success = await $mod.$auth.login(signInForm.username, signInForm.password);
if (success) {
$message.success($t('auth.login_success'));
window.location.href = redirect;
} else {
throw new Error($t('auth.login_failed'));
}
}
} catch (error) {
signInError = error.message || $t('auth.login_failed');
} finally {
signInLoading = false;
}
};
handleSignUp = async (e) => {
if (signUpLoading) return;
signUpError = '';
if (!signUpForm.username || !signUpForm.password || !signUpForm.confirmPassword) {
signUpError = $t('auth.fill_all_fields');
return;
}
if (signUpForm.username.length < 3) {
signUpError = $t('auth.username_too_short');
return;
}
if (signUpForm.password.length < 8) {
signUpError = $t('auth.password_too_short');
return;
}
if (signUpForm.password !== signUpForm.confirmPassword) {
signUpError = $t('auth.passwords_not_match');
return;
}
signUpLoading = true;
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: signUpForm.username,
email: signUpForm.email || undefined,
phone: signUpForm.phone || undefined,
password: signUpForm.password
})
});
if (res.status !== 200) {
const err = await res.json();
throw new Error(err.message);
}
const data = await res.json();
if (data?.user) {
await $mod.$auth.onAuthSuccess(data.user);
$message.success($t('auth.register_success'));
window.location.href = redirect;
} else {
$message.success($t('auth.register_success'));
switchToSignIn();
}
} catch (error) {
signUpError = error.message || $t('auth.register_failed');
} finally {
signUpLoading = false;
}
};
loadConfig();
</script>
</html>