feat: add onboarding setup flow with nickname and password

Replace default auto-generated password with a first-run setup page that
lets users choose their own nickname and password. The /auth/setup endpoint
now accepts an optional nickname field (also sets site_name). Remove
set_default_password() since setup is now mandatory before login.
This commit is contained in:
祀梦
2026-05-17 19:45:36 +08:00
parent bfdf0c9987
commit f838840bda
8 changed files with 330 additions and 27 deletions

View File

@@ -14,6 +14,10 @@ export interface AuthStatusResponse {
user_id: string
}
export interface SetupStatusResponse {
has_password: boolean
}
export function login(password: string): Promise<LoginResponse> {
return post<LoginResponse>('/auth/login', { password })
}
@@ -29,3 +33,13 @@ export function checkAuth(): Promise<AuthStatusResponse> {
export function changePassword(data: ChangePasswordData): Promise<{ message: string }> {
return post<{ message: string }>('/auth/change-password', data)
}
export function checkSetupStatus(): Promise<SetupStatusResponse> {
return get<SetupStatusResponse>('/auth/status')
}
export function setupPassword(password: string, nickname?: string): Promise<{ message: string }> {
const data: Record<string, string> = { password }
if (nickname) data.nickname = nickname
return post<{ message: string }>('/auth/setup', data)
}

View File

@@ -10,6 +10,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/LoginView.vue'),
meta: { title: '登录', noAuth: true }
},
{
path: '/setup',
name: 'setup',
component: () => import('@/views/SetupView.vue'),
meta: { title: '首次设置', noAuth: true }
},
{
path: '/',
redirect: '/tasks'
@@ -90,6 +96,17 @@ router.beforeEach(async (to, from) => {
if (to.meta.noAuth) return
const authStore = useAuthStore()
// 首次访问:检查是否需要设置密码
if (!authStore.setupChecked) {
const needSetup = await authStore.checkSetup()
if (needSetup) {
return { path: '/setup', query: { redirect: to.fullPath } }
}
} else if (authStore.needSetup) {
return { path: '/setup', query: { redirect: to.fullPath } }
}
if (!authStore.checked) {
const ok = await authStore.checkAuth()
if (!ok) {

View File

@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth } from '@/api/auth'
import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth, checkSetupStatus, setupPassword as apiSetupPassword } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = ref(false)
const checked = ref(false)
const loading = ref(false)
const error = ref('')
const needSetup = ref(false)
const setupChecked = ref(false)
async function checkAuth(): Promise<boolean> {
try {
@@ -21,6 +23,19 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function checkSetup(): Promise<boolean> {
try {
const res = await checkSetupStatus()
needSetup.value = !res.has_password
setupChecked.value = true
return needSetup.value
} catch {
needSetup.value = false
setupChecked.value = true
return false
}
}
async function login(password: string): Promise<boolean> {
loading.value = true
error.value = ''
@@ -37,6 +52,23 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function setupPassword(password: string, nickname?: string): Promise<boolean> {
loading.value = true
error.value = ''
try {
await apiSetupPassword(password, nickname)
isLoggedIn.value = true
checked.value = true
needSetup.value = false
return true
} catch (e: any) {
error.value = e?.response?.data?.detail || '设置失败'
return false
} finally {
loading.value = false
}
}
async function logout() {
try {
await apiLogout()
@@ -47,5 +79,5 @@ export const useAuthStore = defineStore('auth', () => {
}
}
return { isLoggedIn, checked, loading, error, login, logout, checkAuth }
return { isLoggedIn, checked, loading, error, needSetup, setupChecked, login, logout, checkAuth, checkSetup, setupPassword }
})

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { User, Lock, Check } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const userSettingsStore = useUserSettingsStore()
const nickname = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const error = ref('')
const redirect = (route.query.redirect as string) || '/'
function validate(): string | null {
if (!password.value) return '请输入密码'
if (password.value.length < 6) return '密码长度至少6位'
if (new Set(password.value).size < 3) return '密码不能过于简单需包含至少3种不同字符'
if (password.value !== confirmPassword.value) return '两次输入的密码不一致'
return null
}
async function handleSetup() {
const msg = validate()
if (msg) {
error.value = msg
return
}
loading.value = true
error.value = ''
try {
const name = nickname.value.trim() || undefined
const ok = await authStore.setupPassword(password.value, name)
if (ok) {
await userSettingsStore.fetchAndSync()
} else {
error.value = authStore.error || '设置失败,请重试'
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-wrapper">
<div class="decoration-star" style="top: 10%; right: 15%; animation-delay: 0s;"></div>
<div class="decoration-star" style="top: 70%; left: 10%; animation-delay: 1s;"></div>
<div class="decoration-star" style="top: 30%; left: 20%; animation-delay: 2s;"></div>
<div class="login-card">
<div class="login-header">
<div class="logo-icon"></div>
<h1 class="site-name">欢迎到来</h1>
<p class="subtitle">首次使用请设置你的账户信息~</p>
</div>
<el-form @submit.prevent="handleSetup" class="login-form">
<el-input
v-model="nickname"
placeholder="你的昵称(不填默认为爱莉希雅)"
size="large"
:prefix-icon="User"
@keyup.enter="handleSetup"
/>
<el-input
v-model="password"
type="password"
placeholder="请设置密码至少6位"
size="large"
show-password
:prefix-icon="Lock"
@keyup.enter="handleSetup"
/>
<el-input
v-model="confirmPassword"
type="password"
placeholder="再次输入密码"
size="large"
show-password
:prefix-icon="Check"
@keyup.enter="handleSetup"
/>
<p v-if="error" class="error-msg">{{ error }}</p>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-btn"
@click="handleSetup"
>
开始使用
</el-button>
</el-form>
</div>
</div>
</template>
<style scoped lang="scss">
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fce4ec 0%, #f8bbd0 30%, #f48fb1 60%, #fce4ec 100%);
position: relative;
overflow: hidden;
}
.login-card {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
border-radius: 24px;
padding: 48px 40px;
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(255, 183, 197, 0.3);
z-index: 1;
}
.login-header {
text-align: center;
margin-bottom: 32px;
.logo-icon {
font-size: 48px;
color: var(--primary);
animation: twinkle 2s ease-in-out infinite;
margin-bottom: 12px;
}
.site-name {
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 8px;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.error-msg {
color: #f56c6c;
font-size: 13px;
margin: 0;
text-align: center;
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border: none;
border-radius: 12px;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(255, 183, 197, 0.5);
}
}
@keyframes twinkle {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.05); }
}
</style>

View File

@@ -98,7 +98,7 @@ async def auth_middleware(request: Request, call_next):
path = request.url.path
# 不拦截:健康检查、静态文件、公开的 auth 端点
public_paths = {"/health", "/api/auth/login", "/api/auth/logout"}
public_paths = {"/health", "/api/auth/login", "/api/auth/logout", "/api/auth/status", "/api/auth/setup"}
if path in public_paths or not path.startswith("/api/"):
return await call_next(request)

View File

@@ -4,10 +4,10 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user_settings import UserSettings
from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse
from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse, SetupPasswordRequest, AuthSetupStatusResponse
from app.utils.auth import (
hash_password, verify_password, create_access_token,
get_current_user, set_default_password
get_current_user,
)
from app.utils.datetime import utcnow
from app.utils.rate_limiter import login_limiter
@@ -22,6 +22,58 @@ def _get_client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _get_or_create_settings(db: Session) -> UserSettings:
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings:
settings = UserSettings(id=1)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@router.get("/status", response_model=AuthSetupStatusResponse)
def auth_status(db: Session = Depends(get_db)):
"""检查系统密码是否已设置"""
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
has_password = bool(settings and settings.password_hash)
return AuthSetupStatusResponse(has_password=has_password)
@router.post("/setup")
def setup_password(data: SetupPasswordRequest, db: Session = Depends(get_db)):
"""首次设置密码(仅在无密码时可用),设置成功后自动登录"""
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if settings and settings.password_hash:
raise HTTPException(status_code=400, detail="密码已设置,请使用登录接口")
if not settings:
settings = UserSettings(id=1)
db.add(settings)
settings.password_hash = hash_password(data.password)
settings.token_version = 1
if data.nickname and data.nickname.strip():
nick = data.nickname.strip()
settings.nickname = nick
settings.site_name = f"{nick}待办"
settings.updated_at = utcnow()
db.commit()
token = create_access_token({
"sub": str(settings.id),
"tv": settings.token_version,
})
response = JSONResponse(content={"message": "密码设置成功"})
response.set_cookie(
key="access_token",
value=token,
httponly=True,
samesite="strict",
max_age=86400,
path="/",
)
return response
@router.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)):
ip = _get_client_ip(request)
@@ -37,14 +89,10 @@ def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)):
headers={"Retry-After": str(retry_after)},
)
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings:
settings = UserSettings(id=1)
db.add(settings)
db.commit()
db.refresh(settings)
settings = _get_or_create_settings(db)
set_default_password(db, settings)
if not settings.password_hash:
raise HTTPException(status_code=400, detail="请先设置密码")
if not verify_password(data.password, settings.password_hash):
login_limiter.record_failure(ip)

View File

@@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel, Field, field_validator
@@ -26,3 +28,21 @@ class ChangePasswordRequest(BaseModel):
class AuthStatusResponse(BaseModel):
authenticated: bool
user_id: str
class SetupPasswordRequest(BaseModel):
password: str = Field(..., min_length=6, max_length=100)
nickname: Optional[str] = Field(None, min_length=1, max_length=50)
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("密码长度至少6位")
if len(set(v)) < 3:
raise ValueError("密码不能过于简单需包含至少3种不同字符")
return v
class AuthSetupStatusResponse(BaseModel):
has_password: bool

View File

@@ -1,15 +1,11 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import secrets
import bcrypt
from jose import JWTError, jwt
from fastapi import Request, HTTPException
from sqlalchemy.orm import Session
from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES
from app.models.user_settings import UserSettings
from app.utils.logger import logger
ALGORITHM = "HS256"
@@ -42,14 +38,3 @@ def get_current_user(request: Request) -> dict:
return payload
except JWTError:
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
def set_default_password(db: Session, settings: UserSettings):
if not settings.password_hash:
password = secrets.token_urlsafe(8)[:8]
settings.password_hash = hash_password(password)
db.commit()
logger.warning("=" * 50)
logger.warning(f" 初始密码: {password}")
logger.warning(" 请登录后立即修改!")
logger.warning("=" * 50)