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">
|
<nav class="navbar">
|
||||||
<div class="nav-brand">MIROFISH</div>
|
<div class="nav-brand">MIROFISH</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="login-main">
|
<main class="login-main">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="tag">AUTH</span>
|
<span class="tag">AUTH</span>
|
||||||
<h1 class="title">{{ $t('login.title') }}</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="handleLogin">
|
<form class="login-form" @submit.prevent="handleLogin">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="login-username">{{ $t('login.username') }}</label>
|
<label class="field-label" for="login-email">{{ $t('login.email') }}</label>
|
||||||
<input
|
<input id="login-email" v-model="form.email" type="email" class="field-input"
|
||||||
id="login-username"
|
autocomplete="email" :disabled="loading" :placeholder="$t('login.emailPlaceholder')" />
|
||||||
v-model="form.username"
|
|
||||||
type="text"
|
|
||||||
class="field-input"
|
|
||||||
autocomplete="username"
|
|
||||||
:disabled="loading"
|
|
||||||
:placeholder="$t('login.usernamePlaceholder')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="login-password">{{ $t('login.password') }}</label>
|
<label class="field-label" for="login-password">{{ $t('login.password') }}</label>
|
||||||
<input
|
<input id="login-password" v-model="form.password" type="password" class="field-input"
|
||||||
id="login-password"
|
autocomplete="current-password" :disabled="loading" :placeholder="$t('login.passwordPlaceholder')" />
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
class="field-input"
|
|
||||||
autocomplete="current-password"
|
|
||||||
:disabled="loading"
|
|
||||||
:placeholder="$t('login.passwordPlaceholder')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="error" class="error-msg" role="alert">{{ error }}</div>
|
||||||
<div v-if="error" class="error-msg" role="alert">
|
<button type="submit" class="submit-btn" :disabled="loading || !canSubmit">
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="submit-btn"
|
|
||||||
:disabled="loading || !canSubmit"
|
|
||||||
>
|
|
||||||
<span v-if="loading">{{ $t('login.loading') }}</span>
|
<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>
|
</button>
|
||||||
|
<router-link to="/forgot-password" class="forgot-link">{{ $t('login.forgotPassword') }}</router-link>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -62,33 +39,29 @@ import { ref, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import service from '../api/index'
|
import service from '../api/index'
|
||||||
import { setToken } from '../store/auth'
|
import { setAuth } from '../store/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const form = ref({ username: '', password: '' })
|
const form = ref({ email: '', password: '' })
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const activated = computed(() => route.query.activated === '1')
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(() => form.value.email.trim() !== '' && form.value.password !== '')
|
||||||
() => form.value.username.trim() !== '' && form.value.password !== ''
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!canSubmit.value || loading.value) return
|
if (!canSubmit.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await service.post('/api/auth/login', {
|
const res = await service.post('/api/auth/login', {
|
||||||
username: form.value.username,
|
email: form.value.email,
|
||||||
password: form.value.password
|
password: form.value.password
|
||||||
})
|
})
|
||||||
setToken(res.token)
|
setAuth(res.token, res.user)
|
||||||
const redirect = route.query.redirect || '/'
|
router.push(route.query.redirect || '/')
|
||||||
router.push(redirect)
|
|
||||||
} catch {
|
} catch {
|
||||||
error.value = t('login.invalidCredentials')
|
error.value = t('login.invalidCredentials')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -98,157 +71,26 @@ async function handleLogin() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-container {
|
.login-container { min-height: 100vh; background: #fff; font-family: 'Space Grotesk', system-ui, sans-serif; color: #000; display: flex; flex-direction: column; }
|
||||||
min-height: 100vh;
|
.navbar { height: 60px; background: #000; color: #fff; display: flex; align-items: center; padding: 0 40px; }
|
||||||
background: #ffffff;
|
.nav-brand { font-family: 'JetBrains Mono', monospace; font-weight: 800; letter-spacing: 1px; font-size: 1.2rem; }
|
||||||
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
|
.login-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
|
||||||
color: #000000;
|
.login-card { width: 100%; max-width: 400px; border: 1px solid #e5e5e5; padding: 48px 40px; }
|
||||||
display: flex;
|
.card-header { margin-bottom: 32px; }
|
||||||
flex-direction: column;
|
.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; }
|
||||||
.navbar {
|
.login-form { display: flex; flex-direction: column; gap: 20px; }
|
||||||
height: 60px;
|
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||||
background: #000000;
|
.field-label { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
color: #ffffff;
|
.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; }
|
||||||
display: flex;
|
.field-input:focus { border-color: #000; background: #fff; }
|
||||||
align-items: center;
|
.error-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #ff4500; border-left: 3px solid #ff4500; padding-left: 12px; }
|
||||||
padding: 0 40px;
|
.success-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #22c55e; border-left: 3px solid #22c55e; padding-left: 12px; }
|
||||||
flex-shrink: 0;
|
.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; }
|
||||||
.nav-brand {
|
.btn-arrow { margin-left: 8px; }
|
||||||
font-family: 'JetBrains Mono', monospace;
|
.forgot-link { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #666; text-align: center; text-decoration: none; }
|
||||||
font-weight: 800;
|
.forgot-link:hover { color: #ff4500; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -778,11 +778,38 @@
|
||||||
"subtitle": "// Authenticated access required",
|
"subtitle": "// Authenticated access required",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"usernamePlaceholder": "demo",
|
"usernamePlaceholder": "demo",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordPlaceholder": "••••••••",
|
"passwordPlaceholder": "••••••••",
|
||||||
"submit": "Enter",
|
"submit": "Enter",
|
||||||
"loading": "Authenticating...",
|
"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": {
|
"error": {
|
||||||
"filesLostAfterRefresh": "Files were lost after page refresh. Redirecting to home to re-select files…"
|
"filesLostAfterRefresh": "Files were lost after page refresh. Redirecting to home to re-select files…"
|
||||||
|
|
|
||||||
|
|
@ -778,11 +778,38 @@
|
||||||
"subtitle": "// 需要身份验证",
|
"subtitle": "// 需要身份验证",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"usernamePlaceholder": "demo",
|
"usernamePlaceholder": "demo",
|
||||||
|
"email": "邮箱",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"passwordPlaceholder": "••••••••",
|
"passwordPlaceholder": "••••••••",
|
||||||
"submit": "登录",
|
"submit": "登录",
|
||||||
"loading": "验证中...",
|
"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": {
|
"error": {
|
||||||
"filesLostAfterRefresh": "刷新页面后文件丢失,正在跳转到首页重新选择文件…"
|
"filesLostAfterRefresh": "刷新页面后文件丢失,正在跳转到首页重新选择文件…"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue