mirror of
https://github.com/VaalaCat/frp-panel.git
synced 2025-09-26 19:31:18 +08:00
feat: user info page
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
} from "lucide-react"
|
||||
|
||||
User as UserIcon, // 别名避免和 User 类型冲突
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,26 +13,21 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'
|
||||
import { User } from '@/lib/pb/common'
|
||||
import { Avatar } from "./ui/avatar"
|
||||
import { UserAvatar } from "./base/avatar"
|
||||
import { $token, $userInfo } from "@/store/user"
|
||||
import { logout } from "@/api/auth"
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Avatar } from './ui/avatar'
|
||||
import { UserAvatar } from './base/avatar'
|
||||
import { $token, $userInfo } from '@/store/user'
|
||||
import { logout } from '@/api/auth'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface NavUserProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export function NavUser({ user }: NavUserProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation()
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
@@ -55,7 +51,7 @@ export function NavUser({ user }: NavUserProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
@@ -70,16 +66,30 @@ export function NavUser({ user }: NavUserProps) {
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{/* 使用 next/link 创建 “User Info” 菜单项 */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/user-info" className="w-full flex items-center space-x-2">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span>{t('common.userInfo')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<div onClick={
|
||||
async () => {
|
||||
<div
|
||||
onClick={async () => {
|
||||
$userInfo.set(undefined)
|
||||
$token.set(undefined)
|
||||
await logout()
|
||||
window.location.reload()
|
||||
}
|
||||
} className="w-full flex flex-row space-x-2 items-center"><LogOut className="h-4 w-4" /><p>{t('common.logout')}</p></div>
|
||||
}}
|
||||
className="w-full flex items-center space-x-4"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>{t('common.logout')}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
298
www/components/user/user-info.tsx
Normal file
298
www/components/user/user-info.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import { getUserInfo, updateUserInfo } from '@/api/user'
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter as DialogFooterUI,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { UserAvatar } from '@/components/base/avatar'
|
||||
import { toast } from 'sonner'
|
||||
import { User } from '@/lib/pb/common'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
UserID: z.string().optional(),
|
||||
TenantID: z.string().optional(),
|
||||
UserName: z.string().min(2, 'Name too short'),
|
||||
Email: z.string().email('Invalid email address'),
|
||||
Status: z.string().optional(),
|
||||
Role: z.string().optional(),
|
||||
NewPassword: z.string().optional(),
|
||||
ConfirmPassword: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const np = data.NewPassword
|
||||
const cp = data.ConfirmPassword
|
||||
if (np) {
|
||||
if (np.length < 6) {
|
||||
ctx.addIssue({
|
||||
path: ['NewPassword'],
|
||||
message: 'Password must be at least 6 characters',
|
||||
code: z.ZodIssueCode.custom,
|
||||
})
|
||||
}
|
||||
if (np !== cp) {
|
||||
ctx.addIssue({
|
||||
path: ['ConfirmPassword'],
|
||||
message: 'Passwords do not match',
|
||||
code: z.ZodIssueCode.custom,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type UserFormValues = z.infer<typeof userSchema>
|
||||
|
||||
export function UserProfileForm() {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [initial, setInitial] = useState<UserFormValues | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
UserID: '',
|
||||
TenantID: '',
|
||||
UserName: '',
|
||||
Email: '',
|
||||
Role: '',
|
||||
NewPassword: '',
|
||||
ConfirmPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
getUserInfo({})
|
||||
.then((res) => {
|
||||
const u = res.userInfo! as User
|
||||
form.reset({
|
||||
UserID: u.userID?.toString(),
|
||||
TenantID: u.tenantID?.toString(),
|
||||
UserName: u.userName || '',
|
||||
Email: u.email || '',
|
||||
Role: u.role || '',
|
||||
NewPassword: '',
|
||||
ConfirmPassword: '',
|
||||
})
|
||||
setInitial(form.getValues())
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (values: UserFormValues) => {
|
||||
try {
|
||||
const payload: any = {
|
||||
userID: values.UserID ? BigInt(values.UserID) : undefined,
|
||||
tenantID: values.TenantID ? BigInt(values.TenantID) : undefined,
|
||||
userName: values.UserName,
|
||||
email: values.Email,
|
||||
}
|
||||
if (values.NewPassword) {
|
||||
payload.rawPassword = values.NewPassword
|
||||
}
|
||||
await updateUserInfo({ userInfo: payload })
|
||||
toast.success(t('userInfo.profileUpdated'))
|
||||
form.reset({
|
||||
...values,
|
||||
NewPassword: '',
|
||||
ConfirmPassword: '',
|
||||
})
|
||||
setInitial(form.getValues())
|
||||
} catch {
|
||||
toast(t('userInfo.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-500">{t('userInfo.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto">
|
||||
<CardHeader className="flex flex-row items-center space-x-4 border-b">
|
||||
<Avatar className="h-12 w-12 rounded-lg">
|
||||
<UserAvatar
|
||||
className="h-12 w-12"
|
||||
userInfo={{ userName: form.getValues().UserName, email: form.getValues().Email } as User}
|
||||
/>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle>{t('userInfo.yourProfile')}</CardTitle>
|
||||
<CardDescription>{t('userInfo.manageAccountDetails')}</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 pt-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* UserID (read-only) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="UserID"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('userInfo.userID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled className="bg-gray-100 dark:bg-gray-700" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* TenantID (read-only) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="TenantID"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('userInfo.tenantID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled className="bg-gray-100 dark:bg-gray-700" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UserName */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="UserName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('userInfo.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('userInfo.placeholderName')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('userInfo.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder={t('userInfo.placeholderEmail')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Role (read-only badge style) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>{t('userInfo.role')}</FormLabel>
|
||||
<FormDescription>{t('userInfo.roleDescription')}</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled
|
||||
className="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Change Password Section */}
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300">{t('userInfo.changePassword')}</h3>
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="NewPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('userInfo.newPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={t('userInfo.placeholderNewPassword')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ConfirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('userInfo.confirmPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={t('userInfo.placeholderConfirmNewPassword')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={form.formState.isSubmitting || JSON.stringify(form.getValues()) === JSON.stringify(initial)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{t('userInfo.saveChanges')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('userInfo.confirmSaveTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('userInfo.confirmSaveDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooterUI>
|
||||
<Button variant={'destructive'} onClick={() => setOpen(false)}>
|
||||
{t('userInfo.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
onClick={() => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('userInfo.confirm')}
|
||||
</Button>
|
||||
</DialogFooterUI>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
@@ -175,6 +175,7 @@
|
||||
"submit": "Submit",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"userInfo": "User Information",
|
||||
"logout": "Logout",
|
||||
"clientType": "Client Type",
|
||||
"disconnect": "Disconnect",
|
||||
@@ -444,6 +445,31 @@
|
||||
"team": {
|
||||
"title": "Teams"
|
||||
},
|
||||
"userInfo": {
|
||||
"profileUpdated": "Profile Updated",
|
||||
"updateFailed": "Update Failed",
|
||||
"loading": "Loading",
|
||||
"yourProfile": "Your Profile",
|
||||
"manageAccountDetails": "Manage Account Details",
|
||||
"userID": "User ID",
|
||||
"tenantID": "Tenant ID",
|
||||
"name": "Name",
|
||||
"placeholderName": "Enter your name",
|
||||
"email": "Email",
|
||||
"placeholderEmail": "Enter your email",
|
||||
"role": "Role",
|
||||
"roleDescription": "Administrator(admin) or Normal User(normal)",
|
||||
"changePassword": "Change Password",
|
||||
"newPassword": "New Password",
|
||||
"placeholderNewPassword": "Enter your new password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"placeholderConfirmNewPassword": "Confirm your new password",
|
||||
"saveChanges": "Save Changes",
|
||||
"confirmSaveTitle": "Confirm Save",
|
||||
"confirmSaveDescription": "Are you sure you want to save your changes?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"nav": {
|
||||
"clients": "Clients",
|
||||
"servers": "Servers",
|
||||
|
@@ -175,6 +175,7 @@
|
||||
"submit": "提交",
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"userInfo": "用户信息",
|
||||
"logout": "退出登录",
|
||||
"clientType": "客户端类型",
|
||||
"disconnect": "断开连接",
|
||||
@@ -442,6 +443,31 @@
|
||||
"team": {
|
||||
"title": "租户"
|
||||
},
|
||||
"userInfo": {
|
||||
"profileUpdated": "用户信息已更新",
|
||||
"updateFailed": "更新用户信息失败",
|
||||
"loading": "正在加载用户信息",
|
||||
"yourProfile": "您的用户信息",
|
||||
"manageAccountDetails": "管理账户详情",
|
||||
"userID": "用户ID",
|
||||
"tenantID": "租户ID",
|
||||
"name": "姓名",
|
||||
"placeholderName": "请输入姓名",
|
||||
"email": "邮箱",
|
||||
"placeholderEmail": "请输入邮箱",
|
||||
"role": "角色",
|
||||
"roleDescription": "管理员(admin)或普通用户(normal)",
|
||||
"changePassword": "修改密码",
|
||||
"newPassword": "新密码",
|
||||
"placeholderNewPassword": "请输入新密码",
|
||||
"confirmPassword": "确认新密码",
|
||||
"placeholderConfirmNewPassword": "请再次输入新密码",
|
||||
"saveChanges": "保存更改",
|
||||
"confirmSaveTitle": "确认保存",
|
||||
"confirmSaveDescription": "您确定要保存更改吗?",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认"
|
||||
},
|
||||
"nav": {
|
||||
"clients": "客户端",
|
||||
"servers": "服务端",
|
||||
|
19
www/pages/user-info.tsx
Normal file
19
www/pages/user-info.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Providers } from '@/components/providers'
|
||||
import { RootLayout } from '@/components/layout'
|
||||
import { Header } from '@/components/header'
|
||||
import { UserProfileForm } from '@/components/user/user-info'
|
||||
|
||||
export default function ClientListPage() {
|
||||
return (
|
||||
<Providers>
|
||||
<RootLayout mainHeader={<Header />}>
|
||||
<div className="w-full">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-row mb-2 gap-2"></div>
|
||||
</div>
|
||||
<UserProfileForm />
|
||||
</div>
|
||||
</RootLayout>
|
||||
</Providers>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user