Initial commit: base slice (auth, roles, users, admin) scaffold
Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL + Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4 with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's conventions, scoped down to the current base feature set.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
||||
|
||||
const schema = z.object({ name: z.string().min(1).max(64) })
|
||||
|
||||
export function RolesPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => createRole(name),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRole(id),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
},
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
},
|
||||
})
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const { register, handleSubmit, reset } = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onCreate = async (values: z.infer<typeof schema>) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(values.name)
|
||||
reset()
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.roles.title')}</h2>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4" /> {t('admin.roles.create')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.roles.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onCreate)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="roleName">{t('admin.roles.name')}</Label>
|
||||
<Input id="roleName" {...register('name')} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.roles.name')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.roles.system')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={3}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{roles?.map((role) => (
|
||||
<tr key={role.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{role.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
{role.isSystem ? <Badge variant="muted">{t('common.yes')}</Badge> : t('common.no')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={role.isSystem}
|
||||
onClick={() => {
|
||||
const nextName = window.prompt(t('admin.roles.rename'), role.name)
|
||||
if (nextName && nextName !== role.name)
|
||||
renameMutation.mutate({ id: role.id, name: nextName })
|
||||
}}
|
||||
>
|
||||
{t('admin.roles.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={role.isSystem}
|
||||
onClick={() => deleteMutation.mutate(role.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { RoleDto } from '@/shared/api/types'
|
||||
|
||||
export function listRoles() {
|
||||
return apiRequest<RoleDto[]>('/admin/roles')
|
||||
}
|
||||
|
||||
export function createRole(name: string) {
|
||||
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name } })
|
||||
}
|
||||
|
||||
export function updateRole(id: string, name: string) {
|
||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { name } })
|
||||
}
|
||||
|
||||
export function deleteRole(id: string) {
|
||||
return apiRequest<void>(`/admin/roles/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function changeUserRole(userId: string, roleId: string) {
|
||||
return apiRequest<void>(`/admin/users/${userId}/role`, { method: 'PATCH', body: { roleId } })
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { changeUserRole } from '@/features/admin/roles/api'
|
||||
import { listRoles } from '@/features/admin/roles/api'
|
||||
import { blockUser, deleteUser, listUsers, unblockUser } from './api'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function UsersPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleId, setRoleId] = useState<string>('')
|
||||
|
||||
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'users', page, search, roleId],
|
||||
queryFn: () => listUsers({ page, pageSize: PAGE_SIZE, search: search || undefined, roleId: roleId || undefined }),
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
|
||||
const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const blockMutation = useMutation({ mutationFn: blockUser, onSuccess: invalidate, onError })
|
||||
const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError })
|
||||
const deleteMutation = useMutation({ mutationFn: deleteUser, onSuccess: invalidate, onError })
|
||||
const changeRoleMutation = useMutation({
|
||||
mutationFn: ({ userId, roleId: newRoleId }: { userId: string; roleId: string }) =>
|
||||
changeUserRole(userId, newRoleId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.users.title')}</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setPage(1)
|
||||
setSearch(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={roleId || 'all'}
|
||||
onValueChange={(value) => {
|
||||
setPage(1)
|
||||
setRoleId(value === 'all' ? '' : value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="max-w-48">
|
||||
<SelectValue placeholder={t('admin.users.filterAll')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('admin.users.filterAll')}</SelectItem>
|
||||
{roles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.userName')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.role')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.status')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.createdAt')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.items.map((user) => (
|
||||
<tr key={user.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{user.userName}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Select
|
||||
value={roles?.find((r) => r.name === user.role)?.id}
|
||||
onValueChange={(newRoleId) => changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32">
|
||||
<SelectValue>{user.role}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{user.isBlocked ? (
|
||||
<Badge variant="destructive">{t('admin.users.blocked')}</Badge>
|
||||
) : (
|
||||
<Badge>{t('admin.users.active')}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
{user.isBlocked ? (
|
||||
<Button size="sm" variant="outline" onClick={() => unblockMutation.mutate(user.id)}>
|
||||
{t('admin.users.unblock')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => blockMutation.mutate(user.id)}>
|
||||
{t('admin.users.block')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
‹
|
||||
</Button>
|
||||
<span>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PagedList, UserSummaryDto } from '@/shared/api/types'
|
||||
|
||||
export type ListUsersParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
roleId?: string
|
||||
isBlocked?: boolean
|
||||
}
|
||||
|
||||
export function listUsers(params: ListUsersParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.roleId) query.set('roleId', params.roleId)
|
||||
if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked))
|
||||
|
||||
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function blockUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/block`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function unblockUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/unblock`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function deleteUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
Reference in New Issue
Block a user