diff --git a/api/routes/auth.py b/api/routes/auth.py index 08ac2d4..b6067bf 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -1,5 +1,6 @@ from typing import Annotated from fastapi import APIRouter, HTTPException, Depends, Form +import hashlib from fastapi.security import OAuth2PasswordRequestForm from services.auth import ( authenticate_user_db, @@ -7,10 +8,14 @@ from services.auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, register_user, Token, + get_current_active_user, + User, ) from pydantic import BaseModel from datetime import timedelta 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"]) @@ -21,6 +26,7 @@ class RegisterRequest(BaseModel): email: str | None = None full_name: str | None = None + @router.post("/register", summary="注册第一个管理员用户") async def register(data: RegisterRequest): """ @@ -51,3 +57,66 @@ async def login_for_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) 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, + }) diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 533f765..b07a49c 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -17,6 +17,21 @@ export interface AuthResponse { 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 = { register: async (username: string, password: string, email?: string, full_name?: string): Promise => { return request('/auth/register', { @@ -42,4 +57,15 @@ export const authApi = { logout: () => { localStorage.removeItem('token'); }, + me: async () => { + return await request('/auth/me', { + method: 'GET', + }); + }, + updateMe: async (payload: UpdateMePayload) => { + return await request('/auth/me', { + method: 'PUT', + json: payload, + }); + }, }; diff --git a/web/src/components/ProfileModal.tsx b/web/src/components/ProfileModal.tsx new file mode 100644 index 0000000..f38e0bb --- /dev/null +++ b/web/src/components/ProfileModal.tsx @@ -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 ( + +
+ + + + + + + + { + const newPwd = form.getFieldValue('new_password'); + if ((value && !newPwd) || (!value && newPwd)) { + throw new Error(t('Please fill both old and new password')); + } + } + }]} + > + + + { + const oldPwd = form.getFieldValue('old_password'); + if ((value && !oldPwd) || (!value && oldPwd)) { + throw new Error(t('Please fill both old and new password')); + } + } + }]} + > + + + + ) + }]} /> + +
+ ); +}); + +export default ProfileModal; diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 1a7b185..ef730ab 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; -import { authApi } from '../api/auth'; +import { authApi, type MeResponse } from '../api/auth'; interface AuthContextType { token: string | null; @@ -7,12 +7,15 @@ interface AuthContextType { login: (username: string, password: string) => Promise; logout: () => void; register: (username: string, password: string, email?: string, full_name?: string) => Promise; + user: MeResponse | null; + refreshUser: () => Promise; } const AuthContext = createContext({} as any); export function AuthProvider({ children }: { children: React.ReactNode }) { const [token, setToken] = useState(() => localStorage.getItem('token')); + const [user, setUser] = useState(null); const isAuthenticated = !!token; useEffect(() => { @@ -22,20 +25,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = async (username: string, password: string) => { const res = await authApi.login({ username, password }); - if (res) + if (res) { setToken(res.access_token); + try { await refreshUser(); } catch (_) {} + } }; const logout = () => { setToken(null); + setUser(null); }; const register = async (username: string, password: string, email?: string, full_name?: string) => { 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 ( - + {children} ); diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 5119072..13972c5 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -17,9 +17,17 @@ export const en = { 'Search files / tags / types': 'Search files / tags / types', 'Log Out': 'Log Out', 'Admin': 'Admin', + 'Profile': 'Profile', + 'Account Settings': 'Account Settings', 'Language': 'Language', 'Chinese': '中文', '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 'Welcome Back': 'Welcome Back', @@ -348,8 +356,6 @@ export const en = { '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': 'Full Name', - 'Email': 'Email', 'Please input a valid email!': 'Please input a valid email!', 'Confirm Password': 'Confirm Password', 'Please confirm your password!': 'Please confirm your password!', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 7a3551b..f62a649 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -21,9 +21,17 @@ export const zh = { 'Search files / tags / types': '搜索文件 / 标签 / 类型', 'Log Out': '退出登录', 'Admin': '管理员', + 'Profile': '个人资料', + 'Account Settings': '账户设置', 'Language': '语言', 'Chinese': '中文', 'English': '英文', + 'Full Name': '昵称', + 'Email': '邮箱', + 'Change Password': '修改密码', + 'Old Password': '原密码', + 'New Password': '新密码', + 'Please fill both old and new password': '请同时填写原密码和新密码', // Auth / Login 'Welcome Back': '欢迎回来', @@ -350,8 +358,6 @@ export const zh = { 'Create admin account': '创建管理员账户', 'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。', 'Username': '用户名', - 'Full Name': '昵称', - 'Email': '邮箱', 'Please input a valid email!': '请输入有效的邮箱地址!', 'Confirm Password': '确认密码', 'Please confirm your password!': '请确认您的密码!', diff --git a/web/src/layout/TopHeader.tsx b/web/src/layout/TopHeader.tsx index c18c876..a10a8c1 100644 --- a/web/src/layout/TopHeader.tsx +++ b/web/src/layout/TopHeader.tsx @@ -1,11 +1,13 @@ -import { Layout, Button, Dropdown, theme, Flex } from 'antd'; -import { SearchOutlined, UserOutlined, MenuUnfoldOutlined, LogoutOutlined } from '@ant-design/icons'; +import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography } from 'antd'; +import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons'; import { memo, useState } from 'react'; import SearchDialog from './SearchDialog.tsx'; import { authApi } from '../api/auth.ts'; import { useNavigate } from 'react-router'; import { useI18n } from '../i18n'; import LanguageSwitcher from '../components/LanguageSwitcher'; +import { useAuth } from '../contexts/AuthContext'; +import ProfileModal from '../components/ProfileModal'; const { Header } = Layout; @@ -19,12 +21,16 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp const [searchOpen, setSearchOpen] = useState(false); const navigate = useNavigate(); const { t } = useI18n(); + const { user } = useAuth(); + const [profileOpen, setProfileOpen] = useState(false); const handleLogout = () => { authApi.logout(); navigate('/login', { replace: true }); }; + const openProfile = () => setProfileOpen(true); + return (
{collapsed && ( @@ -48,12 +54,23 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp , onClick: openProfile }, { key: 'logout', label: t('Log Out'), icon: , onClick: handleLogout } ] }} > - + + setProfileOpen(false)} />
);