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:
Leonid Pershin
2026-07-24 05:40:34 +03:00
commit 8a3eebc48f
156 changed files with 9335 additions and 0 deletions
@@ -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>
)
}
+22
View File
@@ -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>
)
}
+34
View File
@@ -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' })
}
+60
View File
@@ -0,0 +1,60 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { HttpError } from '@/shared/api/client'
import { applyAuthResponse, login } from './api'
const schema = z.object({
userName: z.string().min(1),
password: z.string().min(1),
})
type FormValues = z.infer<typeof schema>
export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
const { t } = useTranslation()
const {
register: registerField,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = async (values: FormValues) => {
try {
const auth = await login(values.userName, values.password)
applyAuthResponse(auth)
onSuccess()
} catch (error) {
const message =
error instanceof HttpError && error.status === 401
? t('auth.invalidCredentials')
: error instanceof HttpError && error.status === 403
? t('auth.blocked')
: t('auth.genericError')
toast.error(message)
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="userName">{t('auth.userName')}</Label>
<Input id="userName" autoComplete="username" {...registerField('userName')} />
{errors.userName && <p className="text-xs text-red-500">{errors.userName.message}</p>}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">{t('auth.password')}</Label>
<Input id="password" type="password" autoComplete="current-password" {...registerField('password')} />
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
</div>
<Button type="submit" disabled={isSubmitting}>
{t('auth.submitLogin')}
</Button>
</form>
)
}
@@ -0,0 +1,58 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { HttpError } from '@/shared/api/client'
import { applyAuthResponse, register } from './api'
const schema = z.object({
userName: z.string().min(3).max(64),
password: z.string().min(8),
})
type FormValues = z.infer<typeof schema>
export function RegisterForm({ onSuccess }: { onSuccess: () => void }) {
const { t } = useTranslation()
const {
register: registerField,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = async (values: FormValues) => {
try {
const auth = await register(values.userName, values.password)
applyAuthResponse(auth)
onSuccess()
} catch (error) {
const message =
error instanceof HttpError && error.status === 409
? t('auth.userNameTaken')
: t('auth.genericError')
toast.error(message)
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="userName">{t('auth.userName')}</Label>
<Input id="userName" autoComplete="username" {...registerField('userName')} />
{errors.userName && <p className="text-xs text-red-500">{errors.userName.message}</p>}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">{t('auth.password')}</Label>
<Input id="password" type="password" autoComplete="new-password" {...registerField('password')} />
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
</div>
<Button type="submit" disabled={isSubmitting}>
{t('auth.submitRegister')}
</Button>
</form>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { apiRequest, setAccessToken } from '@/shared/api/client'
import type { AuthResponse, CurrentUser } from '@/shared/api/types'
import { useAuthStore } from './store'
export function login(userName: string, password: string) {
return apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { userName, password } })
}
export function register(userName: string, password: string) {
return apiRequest<AuthResponse>('/auth/register', { method: 'POST', body: { userName, password } })
}
export function logout() {
return apiRequest<void>('/auth/logout', { method: 'POST' })
}
export function fetchCurrentUser() {
return apiRequest<CurrentUser>('/auth/me')
}
export function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
}
export function changeUserName(newUserName: string) {
return apiRequest<void>('/auth/change-username', { method: 'POST', body: { newUserName } })
}
export function deleteAccount() {
return apiRequest<void>('/auth/me', { method: 'DELETE' })
}
export function applyAuthResponse(auth: AuthResponse) {
setAccessToken(auth.accessToken)
useAuthStore.getState().setUser(auth.user)
}
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
export async function bootstrapSession() {
try {
const auth = await apiRequest<AuthResponse>('/auth/refresh', { method: 'POST', skipRefresh: true })
applyAuthResponse(auth)
} catch {
setAccessToken(null)
useAuthStore.getState().setUser(null)
} finally {
useAuthStore.getState().finishBootstrap()
}
}
export function clearSession() {
setAccessToken(null)
useAuthStore.getState().setUser(null)
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useAuthStore } from './store'
/** Редиректит на /login, если пользователь не вошёл (после завершения bootstrap-попытки refresh). */
export function useRequireAuth() {
const { user, isBootstrapping } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
if (!isBootstrapping && !user) void navigate({ to: '/login' })
}, [isBootstrapping, user, navigate])
return { user, isReady: !isBootstrapping && !!user }
}
/** Редиректит уже вошедшего пользователя с login/register на дашборд. */
export function useRequireGuest() {
const { user, isBootstrapping } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
if (!isBootstrapping && user) void navigate({ to: '/dashboard' })
}, [isBootstrapping, user, navigate])
}
/** Как useRequireAuth, но дополнительно требует роль admin — иначе редирект на дашборд. */
export function useRequireAdmin() {
const { user, isBootstrapping } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
if (isBootstrapping) return
if (!user) void navigate({ to: '/login' })
else if (user.role !== 'admin') void navigate({ to: '/dashboard' })
}, [isBootstrapping, user, navigate])
return { user, isReady: !isBootstrapping && !!user && user.role === 'admin' }
}
+17
View File
@@ -0,0 +1,17 @@
import { create } from 'zustand'
import type { CurrentUser } from '@/shared/api/types'
type AuthState = {
user: CurrentUser | null
/** Пока не завершилась попытка тихого восстановления сессии при старте приложения. */
isBootstrapping: boolean
setUser: (user: CurrentUser | null) => void
finishBootstrap: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isBootstrapping: true,
setUser: (user) => set({ user }),
finishBootstrap: () => set({ isBootstrapping: false }),
}))