feat(frontend): login with email, forgot/reset/set-password views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
746c3bd286
commit
15551bd1fd
|
|
@ -3,54 +3,31 @@
|
|||
<nav class="navbar">
|
||||
<div class="nav-brand">MIROFISH</div>
|
||||
</nav>
|
||||
|
||||
<main class="login-main">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<span class="tag">AUTH</span>
|
||||
<h1 class="title">{{ $t('login.title') }}</h1>
|
||||
<p class="subtitle">{{ $t('login.subtitle') }}</p>
|
||||
<p v-if="activated" class="success-msg">{{ $t('login.accountActivated') }}</p>
|
||||
<p v-else class="subtitle">{{ $t('login.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<div class="field">
|
||||
<label class="field-label" for="login-username">{{ $t('login.username') }}</label>
|
||||
<input
|
||||
id="login-username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
class="field-input"
|
||||
autocomplete="username"
|
||||
:disabled="loading"
|
||||
:placeholder="$t('login.usernamePlaceholder')"
|
||||
/>
|
||||
<label class="field-label" for="login-email">{{ $t('login.email') }}</label>
|
||||
<input id="login-email" v-model="form.email" type="email" class="field-input"
|
||||
autocomplete="email" :disabled="loading" :placeholder="$t('login.emailPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="login-password">{{ $t('login.password') }}</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="current-password"
|
||||
:disabled="loading"
|
||||
:placeholder="$t('login.passwordPlaceholder')"
|
||||
/>
|
||||
<input id="login-password" v-model="form.password" type="password" class="field-input"
|
||||
autocomplete="current-password" :disabled="loading" :placeholder="$t('login.passwordPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-msg" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-btn"
|
||||
:disabled="loading || !canSubmit"
|
||||
>
|
||||
<div v-if="error" class="error-msg" role="alert">{{ error }}</div>
|
||||
<button type="submit" class="submit-btn" :disabled="loading || !canSubmit">
|
||||
<span v-if="loading">{{ $t('login.loading') }}</span>
|
||||
<span v-else>{{ $t('login.submit') }} <span class="btn-arrow" aria-hidden="true">→</span></span>
|
||||
<span v-else>{{ $t('login.submit') }} <span class="btn-arrow">→</span></span>
|
||||
</button>
|
||||
<router-link to="/forgot-password" class="forgot-link">{{ $t('login.forgotPassword') }}</router-link>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -62,33 +39,29 @@ import { ref, computed } from 'vue'
|
|||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import service from '../api/index'
|
||||
import { setToken } from '../store/auth'
|
||||
import { setAuth } from '../store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = ref({ username: '', password: '' })
|
||||
const form = ref({ email: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const canSubmit = computed(
|
||||
() => form.value.username.trim() !== '' && form.value.password !== ''
|
||||
)
|
||||
const activated = computed(() => route.query.activated === '1')
|
||||
const canSubmit = computed(() => form.value.email.trim() !== '' && form.value.password !== '')
|
||||
|
||||
async function handleLogin() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await service.post('/api/auth/login', {
|
||||
username: form.value.username,
|
||||
email: form.value.email,
|
||||
password: form.value.password
|
||||
})
|
||||
setToken(res.token)
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
setAuth(res.token, res.user)
|
||||
router.push(route.query.redirect || '/')
|
||||
} catch {
|
||||
error.value = t('login.invalidCredentials')
|
||||
} finally {
|
||||
|
|
@ -98,157 +71,26 @@ async function handleLogin() {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
|
||||
color: #000000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
height: 60px;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.login-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: 1px solid #e5e5e5;
|
||||
padding: 48px 40px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #ff4500;
|
||||
color: #ffffff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
border: 1px solid #e5e5e5;
|
||||
background: #fafafa;
|
||||
padding: 12px 16px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #000000;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: #000000;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.field-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #ff4500;
|
||||
border-left: 3px solid #ff4500;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 14px 24px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #ff4500;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: #e5e5e5;
|
||||
color: #999999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-arrow {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.login-container { min-height: 100vh; background: #fff; font-family: 'Space Grotesk', system-ui, sans-serif; color: #000; display: flex; flex-direction: column; }
|
||||
.navbar { height: 60px; background: #000; color: #fff; display: flex; align-items: center; padding: 0 40px; }
|
||||
.nav-brand { font-family: 'JetBrains Mono', monospace; font-weight: 800; letter-spacing: 1px; font-size: 1.2rem; }
|
||||
.login-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
|
||||
.login-card { width: 100%; max-width: 400px; border: 1px solid #e5e5e5; padding: 48px 40px; }
|
||||
.card-header { margin-bottom: 32px; }
|
||||
.tag { display: inline-block; background: #ff4500; color: #fff; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; letter-spacing: 1px; margin-bottom: 16px; }
|
||||
.title { font-size: 1.8rem; font-weight: 500; margin-bottom: 8px; }
|
||||
.subtitle { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #666; }
|
||||
.login-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field-label { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.field-input { border: 1px solid #e5e5e5; background: #fafafa; padding: 12px 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; outline: none; transition: border-color 0.15s; width: 100%; box-sizing: border-box; }
|
||||
.field-input:focus { border-color: #000; background: #fff; }
|
||||
.error-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #ff4500; border-left: 3px solid #ff4500; padding-left: 12px; }
|
||||
.success-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #22c55e; border-left: 3px solid #22c55e; padding-left: 12px; }
|
||||
.submit-btn { background: #000; color: #fff; border: none; padding: 14px 24px; font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 0.95rem; cursor: pointer; transition: background 0.15s; width: 100%; }
|
||||
.submit-btn:hover:not(:disabled) { background: #ff4500; }
|
||||
.submit-btn:disabled { background: #e5e5e5; color: #999; cursor: not-allowed; }
|
||||
.btn-arrow { margin-left: 8px; }
|
||||
.forgot-link { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #666; text-align: center; text-decoration: none; }
|
||||
.forgot-link:hover { color: #ff4500; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -778,11 +778,38 @@
|
|||
"subtitle": "// Authenticated access required",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "demo",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"submit": "Enter",
|
||||
"loading": "Authenticating...",
|
||||
"invalidCredentials": "Invalid username or password"
|
||||
"invalidCredentials": "Invalid username or password",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"accountActivated": "Account activated. You can now log in."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Forgot Password",
|
||||
"subtitle": "Enter your email and we'll send you a reset link.",
|
||||
"submit": "Send reset link",
|
||||
"sent": "If an account exists with this email, you will receive a reset link shortly."
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset Password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"submit": "Set new password",
|
||||
"done": "Password updated. You can now log in.",
|
||||
"goToLogin": "Go to login",
|
||||
"invalidToken": "This link is invalid or has expired.",
|
||||
"passwordMismatch": "Passwords do not match."
|
||||
},
|
||||
"setPassword": {
|
||||
"title": "Welcome to MiroFish",
|
||||
"newPassword": "Choose a password",
|
||||
"submit": "Activate account",
|
||||
"done": "Account activated! Redirecting to login...",
|
||||
"invalidToken": "This invitation link is invalid or has expired."
|
||||
},
|
||||
"error": {
|
||||
"filesLostAfterRefresh": "Files were lost after page refresh. Redirecting to home to re-select files…"
|
||||
|
|
|
|||
|
|
@ -778,11 +778,38 @@
|
|||
"subtitle": "// 需要身份验证",
|
||||
"username": "用户名",
|
||||
"usernamePlaceholder": "demo",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"submit": "登录",
|
||||
"loading": "验证中...",
|
||||
"invalidCredentials": "用户名或密码错误"
|
||||
"invalidCredentials": "用户名或密码错误",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"accountActivated": "账户已激活,现在可以登录。"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "忘记密码",
|
||||
"subtitle": "请输入您的邮箱,我们将发送重置链接。",
|
||||
"submit": "发送重置链接",
|
||||
"sent": "如果该邮箱已注册,您将收到重置链接。"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "重置密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"submit": "设置新密码",
|
||||
"done": "密码已更新,现在可以登录。",
|
||||
"goToLogin": "前往登录",
|
||||
"invalidToken": "此链接无效或已过期。",
|
||||
"passwordMismatch": "两次密码不一致。"
|
||||
},
|
||||
"setPassword": {
|
||||
"title": "欢迎使用 MiroFish",
|
||||
"newPassword": "设置密码",
|
||||
"submit": "激活账户",
|
||||
"done": "账户已激活!正在跳转到登录页面...",
|
||||
"invalidToken": "此邀请链接无效或已过期。"
|
||||
},
|
||||
"error": {
|
||||
"filesLostAfterRefresh": "刷新页面后文件丢失,正在跳转到首页重新选择文件…"
|
||||
|
|
|
|||
Loading…
Reference in New Issue