mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-08 17:16:50 +08:00
feat: add user profile management
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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!',
|
||||||
|
|||||||
@@ -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!': '请确认您的密码!',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user