mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-07 23:31:52 +08:00
feat: add user profile management
This commit is contained in:
@@ -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<any> => {
|
||||
return request('/auth/register', {
|
||||
@@ -42,4 +57,15 @@ export const authApi = {
|
||||
logout: () => {
|
||||
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
web/src/components/ProfileModal.tsx
Normal file
117
web/src/components/ProfileModal.tsx
Normal 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;
|
||||
@@ -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<void>;
|
||||
logout: () => 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);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
|
||||
const [user, setUser] = useState<MeResponse | null>(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 (
|
||||
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register }}>
|
||||
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register, user, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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!': '请确认您的密码!',
|
||||
|
||||
@@ -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 (
|
||||
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
|
||||
{collapsed && (
|
||||
@@ -48,12 +54,23 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
|
||||
{ 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>
|
||||
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user