Implement role and user management enhancements
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s

- Added MaxIpLimit to roles, allowing for the configuration of simultaneous IP limits for users.
- Updated role creation and update commands to include MaxIpLimit, ensuring proper handling in the application logic.
- Enhanced user management by introducing a DELETE endpoint for user accounts, with appropriate checks to prevent self-deletion.
- Updated documentation to reflect changes in role and user management, clarifying the new IP limit functionality and user deletion process.
- Adjusted related tests to cover new functionality and ensure robust validation of role and user management features.
This commit is contained in:
Leonid Pershin
2026-07-13 07:18:13 +03:00
parent 48e8d06a41
commit 24d9ea1099
48 changed files with 1240 additions and 171 deletions
@@ -23,6 +23,7 @@ export function RoleFormDialog({
const queryClient = useQueryClient()
const [name, setName] = useState(role?.name ?? '')
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
const [internalOpen, setInternalOpen] = useState(false)
const isControlled = open !== undefined
@@ -30,13 +31,17 @@ export function RoleFormDialog({
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
const mutation = useMutation({
mutationFn: () => (role ? updateRole(role.id, Number(maxConfigs)) : createRole(name.trim(), Number(maxConfigs))),
mutationFn: () =>
role
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit))
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)),
onSuccess: async () => {
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
setDialogOpen(false)
setName('')
setMaxConfigs('3')
setMaxIpLimit('2')
},
onError: () => toast.error(t('auth.genericError')),
})
@@ -70,6 +75,11 @@ export function RoleFormDialog({
<Input id="maxConfigs" type="number" value={maxConfigs} onChange={(e) => setMaxConfigs(e.target.value)} />
<p className="text-xs text-muted-foreground">{t('admin.roles.maxConfigsHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxIpLimit">{t('admin.roles.maxIpLimit')}</Label>
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
<p className="text-xs text-muted-foreground">{t('admin.roles.maxIpLimitHint')}</p>
</div>
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
{role ? t('admin.roles.save') : t('admin.roles.create')}
</Button>
+4 -4
View File
@@ -5,12 +5,12 @@ export function listRoles() {
return apiRequest<RoleDto[]>('/admin/roles')
}
export function createRole(name: string, maxConfigs: number) {
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs } })
export function createRole(name: string, maxConfigs: number, maxIpLimit: number) {
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } })
}
export function updateRole(id: string, maxConfigs: number) {
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs } })
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) {
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } })
}
export function deleteRole(id: string) {
@@ -9,10 +9,12 @@ import { Label } from '@/shared/ui/label'
import { Badge } from '@/shared/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listRoles } from '@/features/admin/roles/api'
import { useAuthStore } from '@/features/auth/store'
import type { UserSummaryDto } from '@/shared/api/types'
import {
blockUser,
changeUserRole,
deleteUser,
forceRevokeConfig,
getUserConfigs,
resetUserPassword,
@@ -23,6 +25,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
const { t } = useTranslation()
const queryClient = useQueryClient()
const [newPassword, setNewPassword] = useState('')
const currentUserId = useAuthStore((state) => state.user?.id)
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
const configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open })
@@ -65,6 +68,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
onError: () => toast.error(t('auth.genericError')),
})
const deleteMutation = useMutation({
mutationFn: () => deleteUser(user.id),
onSuccess: async () => {
toast.success(t('admin.users.deleted'))
onOpenChange(false)
await invalidateUsers()
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
@@ -147,6 +160,18 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
</div>
))}
</div>
{user.id !== currentUserId && (
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.users.confirmDelete'))) deleteMutation.mutate()
}}
>
{t('admin.users.delete')}
</Button>
)}
</div>
</DialogContent>
</Dialog>
+4
View File
@@ -30,3 +30,7 @@ export function forceRevokeConfig(id: string) {
export function changeUserRole(id: string, roleId: string) {
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } })
}
export function deleteUser(id: string) {
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
}
+2
View File
@@ -51,6 +51,7 @@ function AdminRolesPage() {
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.roles.name')}</th>
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
<th className="py-2 font-medium">{t('admin.roles.maxIpLimit')}</th>
<th className="py-2" />
<th className="py-2" />
</tr>
@@ -62,6 +63,7 @@ function AdminRolesPage() {
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
</td>
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
{t('admin.roles.edit')}
+6
View File
@@ -1854,6 +1854,8 @@ export interface components {
name: string;
/** Format: int32 */
maxConfigs: number | string;
/** Format: int32 */
maxIpLimit: number | string;
};
CurrentUserDto: {
/** Format: uuid */
@@ -1987,6 +1989,8 @@ export interface components {
name: string;
/** Format: int32 */
maxConfigs: number | string;
/** Format: int32 */
maxIpLimit: number | string;
isSystem: boolean;
};
StatsDto: {
@@ -2050,6 +2054,8 @@ export interface components {
UpdateRoleBody: {
/** Format: int32 */
maxConfigs: number | string;
/** Format: int32 */
maxIpLimit: number | string;
};
UserSummaryDto: {
/** Format: uuid */
+1
View File
@@ -136,6 +136,7 @@ export type RoleDto = {
id: string
name: string
maxConfigs: number
maxIpLimit: number
isSystem: boolean
}
+10
View File
@@ -182,6 +182,9 @@ const resources = {
reset: 'Сбросить',
passwordReset: 'Пароль сброшен.',
configs: 'Конфиги',
delete: 'Удалить пользователя',
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
deleted: 'Пользователь удалён.',
},
activation: {
empty: 'Нет ожидающих запросов на активацию.',
@@ -195,6 +198,8 @@ const resources = {
name: 'Название',
maxConfigs: 'Квота конфигов',
maxConfigsHint: '1 = без лимита.',
maxIpLimit: 'Лимит IP на конфиг',
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
system: 'системная',
edit: 'Изменить',
save: 'Сохранить',
@@ -469,6 +474,9 @@ const resources = {
reset: 'Reset',
passwordReset: 'Password reset.',
configs: 'Configs',
delete: 'Delete user',
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
deleted: 'User deleted.',
},
activation: {
empty: 'No pending activation requests.',
@@ -482,6 +490,8 @@ const resources = {
name: 'Name',
maxConfigs: 'Config quota',
maxConfigsHint: '1 = unlimited.',
maxIpLimit: 'IP limit per config',
maxIpLimitHint: '1 = unlimited. Applies to new configs only.',
system: 'system',
edit: 'Edit',
save: 'Save',