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

View File

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

View File

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