feat: add user profile management

This commit is contained in:
shiyu
2025-09-14 12:54:49 +08:00
parent e43b68beda
commit 917b542dab
7 changed files with 272 additions and 10 deletions
+69
View File
@@ -1,5 +1,6 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends, Form from fastapi import APIRouter, HTTPException, Depends, Form
import hashlib
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from services.auth import ( from services.auth import (
authenticate_user_db, authenticate_user_db,
@@ -7,10 +8,14 @@ from services.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES, ACCESS_TOKEN_EXPIRE_MINUTES,
register_user, register_user,
Token, Token,
get_current_active_user,
User,
) )
from pydantic import BaseModel from pydantic import BaseModel
from datetime import timedelta from datetime import timedelta
from api.response import success from api.response import success
from models.database import UserAccount
from services.auth import verify_password, get_password_hash
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -21,6 +26,7 @@ class RegisterRequest(BaseModel):
email: str | None = None email: str | None = None
full_name: str | None = None full_name: str | None = None
@router.post("/register", summary="注册第一个管理员用户") @router.post("/register", summary="注册第一个管理员用户")
async def register(data: RegisterRequest): async def register(data: RegisterRequest):
""" """
@@ -51,3 +57,66 @@ async def login_for_access_token(
data={"sub": user.username}, expires_delta=access_token_expires data={"sub": user.username}, expires_delta=access_token_expires
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
@router.get("/me", summary="获取当前登录用户信息")
async def get_me(current_user: Annotated[User, Depends(get_current_active_user)]):
"""
返回当前登录用户的基本信息,并附带 gravatar 头像链接。
"""
email = (current_user.email or "").strip().lower()
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
gravatar_url = f"https://www.gravatar.com/avatar/{md5_hash}?s=64&d=identicon"
return success({
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"full_name": current_user.full_name,
"gravatar_url": gravatar_url,
})
class UpdateMeRequest(BaseModel):
email: str | None = None
full_name: str | None = None
old_password: str | None = None
new_password: str | None = None
@router.put("/me", summary="更新当前登录用户信息")
async def update_me(
payload: UpdateMeRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
):
db_user = await UserAccount.get_or_none(id=current_user.id)
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
if payload.email is not None:
exists = await UserAccount.filter(email=payload.email).exclude(id=db_user.id).exists()
if exists:
raise HTTPException(status_code=400, detail="邮箱已被占用")
db_user.email = payload.email
if payload.full_name is not None:
db_user.full_name = payload.full_name
if payload.new_password:
if not payload.old_password:
raise HTTPException(status_code=400, detail="请提供原密码")
if not verify_password(payload.old_password, db_user.hashed_password):
raise HTTPException(status_code=400, detail="原密码错误")
db_user.hashed_password = get_password_hash(payload.new_password)
await db_user.save()
email = (db_user.email or "").strip().lower()
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
gravatar_url = f"https://cn.cravatar.com/avatar/{md5_hash}?s=64&d=identicon"
return success({
"id": db_user.id,
"username": db_user.username,
"email": db_user.email,
"full_name": db_user.full_name,
"gravatar_url": gravatar_url,
})
+26
View File
@@ -17,6 +17,21 @@ export interface AuthResponse {
token_type: string; token_type: string;
} }
export interface MeResponse {
id: number;
username: string;
email?: string | null;
full_name?: string | null;
gravatar_url: string;
}
export interface UpdateMePayload {
email?: string | null;
full_name?: string | null;
old_password?: string;
new_password?: string;
}
export const authApi = { export const authApi = {
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => { register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
return request('/auth/register', { return request('/auth/register', {
@@ -42,4 +57,15 @@ export const authApi = {
logout: () => { logout: () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
}, },
me: async () => {
return await request<MeResponse>('/auth/me', {
method: 'GET',
});
},
updateMe: async (payload: UpdateMePayload) => {
return await request<MeResponse>('/auth/me', {
method: 'PUT',
json: payload,
});
},
}; };
+117
View File
@@ -0,0 +1,117 @@
import { memo, useEffect, useState } from 'react';
import { Modal, Form, Input, message, Collapse } from 'antd';
import { useAuth } from '../contexts/AuthContext';
import { authApi } from '../api/auth';
import { useI18n } from '../i18n';
export interface ProfileModalProps {
open: boolean;
onClose: () => void;
}
const ProfileModal = memo(function ProfileModal({ open, onClose }: ProfileModalProps) {
const { user, refreshUser } = useAuth();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { t } = useI18n();
useEffect(() => {
if (open && user) {
form.setFieldsValue({
full_name: user.full_name || '',
email: user.email || '',
old_password: '',
new_password: '',
});
} else if (!open) {
form.resetFields();
}
}, [open, user, form]);
const handleOk = async () => {
try {
const values = await form.validateFields();
const payload: any = {};
if (values.full_name !== (user?.full_name || '')) payload.full_name = values.full_name || null;
if (values.email !== (user?.email || '')) payload.email = values.email || null;
if (values.old_password || values.new_password) {
payload.old_password = values.old_password || '';
payload.new_password = values.new_password || '';
}
setLoading(true);
await authApi.updateMe(payload);
await refreshUser();
message.success(t('Saved successfully'));
onClose();
} catch (e) {
if (e instanceof Error) {
message.error(e.message || t('Save failed'));
}
} finally {
setLoading(false);
}
};
return (
<Modal
title={t('Profile')}
open={open}
onCancel={onClose}
onOk={handleOk}
confirmLoading={loading}
okText={t('Save')}
cancelText={t('Cancel')}
>
<Form form={form} layout="vertical">
<Form.Item name="full_name" label={t('Full Name')}>
<Input placeholder={t('Full Name')} />
</Form.Item>
<Form.Item name="email" label={t('Email')}>
<Input placeholder={t('Email')} type="email" />
</Form.Item>
<Collapse
size="small"
items={[{
key: 'pwd',
label: t('Change Password'),
children: (
<>
<Form.Item
name="old_password"
label={t('Old Password')}
dependencies={["new_password"]}
rules={[{
validator: async (_, value) => {
const newPwd = form.getFieldValue('new_password');
if ((value && !newPwd) || (!value && newPwd)) {
throw new Error(t('Please fill both old and new password'));
}
}
}]}
>
<Input.Password placeholder={t('Old Password')} autoComplete="current-password" />
</Form.Item>
<Form.Item
name="new_password"
label={t('New Password')}
dependencies={["old_password"]}
rules={[{
validator: async (_, value) => {
const oldPwd = form.getFieldValue('old_password');
if ((value && !oldPwd) || (!value && oldPwd)) {
throw new Error(t('Please fill both old and new password'));
}
}
}]}
>
<Input.Password placeholder={t('New Password')} autoComplete="new-password" />
</Form.Item>
</>
)
}]} />
</Form>
</Modal>
);
});
export default ProfileModal;
+24 -3
View File
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import { authApi } from '../api/auth'; import { authApi, type MeResponse } from '../api/auth';
interface AuthContextType { interface AuthContextType {
token: string | null; token: string | null;
@@ -7,12 +7,15 @@ interface AuthContextType {
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
register: (username: string, password: string, email?: string, full_name?: string) => Promise<void>; register: (username: string, password: string, email?: string, full_name?: string) => Promise<void>;
user: MeResponse | null;
refreshUser: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType>({} as any); const AuthContext = createContext<AuthContextType>({} as any);
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token')); const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
const [user, setUser] = useState<MeResponse | null>(null);
const isAuthenticated = !!token; const isAuthenticated = !!token;
useEffect(() => { useEffect(() => {
@@ -22,20 +25,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = async (username: string, password: string) => { const login = async (username: string, password: string) => {
const res = await authApi.login({ username, password }); const res = await authApi.login({ username, password });
if (res) if (res) {
setToken(res.access_token); setToken(res.access_token);
try { await refreshUser(); } catch (_) {}
}
}; };
const logout = () => { const logout = () => {
setToken(null); setToken(null);
setUser(null);
}; };
const register = async (username: string, password: string, email?: string, full_name?: string) => { const register = async (username: string, password: string, email?: string, full_name?: string) => {
await authApi.register(username, password, email, full_name); await authApi.register(username, password, email, full_name);
}; };
const refreshUser = async () => {
if (!localStorage.getItem('token')) { setUser(null); return; }
const me = await authApi.me();
setUser(me);
};
useEffect(() => {
if (token) {
refreshUser().catch(() => setUser(null));
} else {
setUser(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return ( return (
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register }}> <AuthContext.Provider value={{ token, isAuthenticated, login, logout, register, user, refreshUser }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+8 -2
View File
@@ -17,9 +17,17 @@ export const en = {
'Search files / tags / types': 'Search files / tags / types', 'Search files / tags / types': 'Search files / tags / types',
'Log Out': 'Log Out', 'Log Out': 'Log Out',
'Admin': 'Admin', 'Admin': 'Admin',
'Profile': 'Profile',
'Account Settings': 'Account Settings',
'Language': 'Language', 'Language': 'Language',
'Chinese': '中文', 'Chinese': '中文',
'English': 'English', 'English': 'English',
'Full Name': 'Full Name',
'Email': 'Email',
'Change Password': 'Change Password',
'Old Password': 'Old Password',
'New Password': 'New Password',
'Please fill both old and new password': 'Please fill both old and new password',
// Auth / Login // Auth / Login
'Welcome Back': 'Welcome Back', 'Welcome Back': 'Welcome Back',
@@ -348,8 +356,6 @@ export const en = {
'Create admin account': 'Create admin account', 'Create admin account': 'Create admin account',
'This is the first account with full permissions': 'This is the first account with full permissions', 'This is the first account with full permissions': 'This is the first account with full permissions',
'Username': 'Username', 'Username': 'Username',
'Full Name': 'Full Name',
'Email': 'Email',
'Please input a valid email!': 'Please input a valid email!', 'Please input a valid email!': 'Please input a valid email!',
'Confirm Password': 'Confirm Password', 'Confirm Password': 'Confirm Password',
'Please confirm your password!': 'Please confirm your password!', 'Please confirm your password!': 'Please confirm your password!',
+8 -2
View File
@@ -21,9 +21,17 @@ export const zh = {
'Search files / tags / types': '搜索文件 / 标签 / 类型', 'Search files / tags / types': '搜索文件 / 标签 / 类型',
'Log Out': '退出登录', 'Log Out': '退出登录',
'Admin': '管理员', 'Admin': '管理员',
'Profile': '个人资料',
'Account Settings': '账户设置',
'Language': '语言', 'Language': '语言',
'Chinese': '中文', 'Chinese': '中文',
'English': '英文', 'English': '英文',
'Full Name': '昵称',
'Email': '邮箱',
'Change Password': '修改密码',
'Old Password': '原密码',
'New Password': '新密码',
'Please fill both old and new password': '请同时填写原密码和新密码',
// Auth / Login // Auth / Login
'Welcome Back': '欢迎回来', 'Welcome Back': '欢迎回来',
@@ -350,8 +358,6 @@ export const zh = {
'Create admin account': '创建管理员账户', 'Create admin account': '创建管理员账户',
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。', 'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
'Username': '用户名', 'Username': '用户名',
'Full Name': '昵称',
'Email': '邮箱',
'Please input a valid email!': '请输入有效的邮箱地址!', 'Please input a valid email!': '请输入有效的邮箱地址!',
'Confirm Password': '确认密码', 'Confirm Password': '确认密码',
'Please confirm your password!': '请确认您的密码!', 'Please confirm your password!': '请确认您的密码!',
+20 -3
View File
@@ -1,11 +1,13 @@
import { Layout, Button, Dropdown, theme, Flex } from 'antd'; import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography } from 'antd';
import { SearchOutlined, UserOutlined, MenuUnfoldOutlined, LogoutOutlined } from '@ant-design/icons'; import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons';
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx'; import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts'; import { authApi } from '../api/auth.ts';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal';
const { Header } = Layout; const { Header } = Layout;
@@ -19,12 +21,16 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const { user } = useAuth();
const [profileOpen, setProfileOpen] = useState(false);
const handleLogout = () => { const handleLogout = () => {
authApi.logout(); authApi.logout();
navigate('/login', { replace: true }); navigate('/login', { replace: true });
}; };
const openProfile = () => setProfileOpen(true);
return ( return (
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}> <Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
{collapsed && ( {collapsed && (
@@ -48,12 +54,23 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout } { key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
] ]
}} }}
> >
<Button icon={<UserOutlined />}>{t('Admin')}</Button> <Button type="text" style={{ paddingInline: 8, height: 40 }}>
<Flex align="center" gap={8}>
<Avatar size={28} src={user?.gravatar_url}>
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
</Avatar>
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
{user?.full_name || user?.username || t('Admin')}
</Typography.Text>
</Flex>
</Button>
</Dropdown> </Dropdown>
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
</Flex> </Flex>
</Header> </Header>
); );