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:
Ubuntu 2026-05-16 09:27:00 +00:00
parent 746c3bd286
commit 15551bd1fd
3 changed files with 96 additions and 200 deletions

View File

@ -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>

View File

@ -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…"

View File

@ -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": "刷新页面后文件丢失,正在跳转到首页重新选择文件…"