Правки админки, вход

This commit is contained in:
Халимов Рустам
2026-03-31 23:55:38 +03:00
parent bce8f92101
commit 2c6d6f831f
13 changed files with 264 additions and 51 deletions
@@ -10,9 +10,4 @@ public interface IChatQueryService
Task<List<ChatInfo>> GetAllChatsAsync(CancellationToken cancellationToken); Task<List<ChatInfo>> GetAllChatsAsync(CancellationToken cancellationToken);
} }
public interface IUserDeleterService
{
Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}
public record ChatInfo(Guid Id, string? Avatar); public record ChatInfo(Guid Id, string? Avatar);
@@ -0,0 +1,11 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.Abstractions;
public interface IUserDeleterService
{
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,68 @@
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands.CreateUser;
public record CreateUserCommand(
string Username,
string Password,
string DisplayName,
string? Email = null,
string? Bio = null
) : ICommand<AdminUserDto>;
internal sealed class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, AdminUserDto>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
public CreateUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<AdminUserDto>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
// Проверка уникальности username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AdminUserDto>(new Error("Admin.CreateUser.UsernameNotUnique", "Username is already taken"));
}
// Хеширование пароля
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// Создание контракта пользователя
var userContract = new UserContract
{
Id = Guid.NewGuid(),
Username = request.Username,
PasswordHash = passwordHash,
DisplayName = request.DisplayName,
Email = request.Email,
Bio = request.Bio,
CreatedAt = DateTime.UtcNow,
IsBanned = false,
IsOnline = false
};
_unitOfWork.Add(userContract);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(new AdminUserDto(
userContract.Id,
userContract.Username,
userContract.DisplayName,
userContract.Email,
userContract.Avatar,
userContract.CreatedAt,
userContract.IsOnline,
userContract.LastSeen ?? DateTime.UtcNow,
userContract.IsBanned));
}
}
@@ -18,7 +18,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,7 +1,9 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Knot.Contracts.Conversations.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Admin.Application.Admin.Commands; using Knot.Modules.Admin.Application.Admin.Commands;
using Knot.Modules.Admin.Application.Admin.Commands.CreateUser;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
using Knot.Modules.Admin.Application.Admin.Queries; using Knot.Modules.Admin.Application.Admin.Queries;
using MediatR; using MediatR;
@@ -56,6 +58,12 @@ public static class AdminEndpoints
return Results.Ok(result.Value); return Results.Ok(result.Value);
}); });
group.MapPost("users", async ([FromBody] CreateUserCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Created($"/api/admin/users/{result.Value.Id}", result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapPost("users/{userId:guid}/reset-password", async ([FromRoute] Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) => group.MapPost("users/{userId:guid}/reset-password", async ([FromRoute] Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
{ {
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct); var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
@@ -106,6 +114,12 @@ public static class AdminEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found"); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found");
}); });
group.MapDelete("users/{userId:guid}", async ([FromRoute] Guid userId, IUserDeleterService userDeleter, CancellationToken ct) =>
{
var result = await userDeleter.DeleteUserAsync(userId, ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("clean/dry-run", async (ISender sender, CancellationToken ct) => group.MapGet("clean/dry-run", async (ISender sender, CancellationToken ct) =>
{ {
var result = await sender.Send(new CleanDryRunQuery(), ct); var result = await sender.Send(new CleanDryRunQuery(), ct);
@@ -43,6 +43,7 @@ public static class DependencyInjection
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>(); services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
services.AddScoped<ConversationsAbstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>(); services.AddScoped<ConversationsAbstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>(); services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, Knot.Modules.Conversations.Infrastructure.Services.UserDeleterService>();
return services; return services;
} }
} }
@@ -0,0 +1,17 @@
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Knot.Modules.Conversations.Infrastructure;
[DbContext(typeof(ChatsDbContext))]
public class ChatsDbContextFactory : IDesignTimeDbContextFactory<ChatsDbContext>
{
public ChatsDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<ChatsDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Database=knot_db;Username=knot;Password=knot_pass");
return new ChatsDbContext(optionsBuilder.Options);
}
}
@@ -18,11 +18,16 @@ namespace Knot.Modules.Conversations.Infrastructure.Persistence;
public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
{ {
private readonly IMediator _mediator; private readonly IMediator? _mediator;
private readonly IEncryptionService _encryptionService; private readonly IEncryptionService? _encryptionService;
public ChatsDbContext(DbContextOptions<ChatsDbContext> options)
: base(options)
{
}
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService) public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options) : this(options)
{ {
_mediator = mediator; _mediator = mediator;
_encryptionService = encryptionService; _encryptionService = encryptionService;
@@ -99,10 +104,13 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
int result = await base.SaveChangesAsync(cancellationToken); int result = await base.SaveChangesAsync(cancellationToken);
if (_mediator != null)
{
foreach (var domainEvent in domainEvents) foreach (var domainEvent in domainEvents)
{ {
await _mediator.Publish(domainEvent, cancellationToken); await _mediator.Publish(domainEvent, cancellationToken);
} }
}
return result; return result;
} }
@@ -0,0 +1,21 @@
using Knot.Contracts.Conversations.Abstractions;
using Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Infrastructure.Services;
public sealed class UserDeleterService : IUserDeleterService
{
private readonly ISender _sender;
public UserDeleterService(ISender sender)
{
_sender = sender;
}
public async Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
{
return await _sender.Send(new DeleteUserCommand(userId), cancellationToken);
}
}
@@ -19,6 +19,10 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MediatR" Version="12.0.1" /> <PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -543,6 +543,9 @@ export default function AdminPage() {
const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' }); const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' });
const [generatedPass, setGeneratedPass] = useState(''); const [generatedPass, setGeneratedPass] = useState('');
// Delete User Modal State
const [deleteUserModal, setDeleteUserModal] = useState<{ userId: string; username: string } | null>(null);
const [timezones, setTimezones] = useState<TimezoneDto[]>([]); const [timezones, setTimezones] = useState<TimezoneDto[]>([]);
const [tzSearch, setTzSearch] = useState(''); const [tzSearch, setTzSearch] = useState('');
const [showTzDropdown, setShowTzDropdown] = useState(false); const [showTzDropdown, setShowTzDropdown] = useState(false);
@@ -616,7 +619,7 @@ export default function AdminPage() {
}; };
const handleDeleteUser = async (userId: string) => { const handleDeleteUser = async (userId: string) => {
if (!confirm(t.confirmDelete)) return; setDeleteUserModal(null);
try { try {
await httpClient.request(`/admin/users/${userId}`, { method: 'DELETE' }); await httpClient.request(`/admin/users/${userId}`, { method: 'DELETE' });
showToast(t.successSave, 'success'); showToast(t.successSave, 'success');
@@ -1443,8 +1446,7 @@ export default function AdminPage() {
<button <button
key={u.id} key={u.id}
onClick={() => fetchUserStats(u.id)} onClick={() => fetchUserStats(u.id)}
className={`flex items-center gap-3 p-4 rounded-2xl transition-all text-left group border shadow-sm ${ className={`flex items-center gap-3 p-4 rounded-2xl transition-all text-left group border shadow-sm ${selectedUser?.id === u.id
selectedUser?.id === u.id
? 'bg-primary/10 border-primary/30 scale-[1.01] shadow-primary/5' ? 'bg-primary/10 border-primary/30 scale-[1.01] shadow-primary/5'
: 'hover:bg-surface-container-highest/10 border-transparent' : 'hover:bg-surface-container-highest/10 border-transparent'
}`} }`}
@@ -1535,7 +1537,7 @@ export default function AdminPage() {
{t.blockUser} {t.blockUser}
</button> </button>
)} )}
<button onClick={() => handleDeleteUser(selectedUser.id)} className="w-full py-4 rounded-2xl bg-error/5 hover:bg-error/10 text-error/60 hover:text-error font-black uppercase text-[10px] tracking-widest transition-all slide-on-ice"> <button onClick={() => setDeleteUserModal({ userId: selectedUser.id, username: selectedUser.username })} className="w-full py-4 rounded-2xl bg-error/5 hover:bg-error/10 text-error/60 hover:text-error font-black uppercase text-[10px] tracking-widest transition-all slide-on-ice">
{t.deleteUser} {t.deleteUser}
</button> </button>
</div> </div>
@@ -1605,6 +1607,61 @@ export default function AdminPage() {
</div> </div>
</motion.div> </motion.div>
)} )}
{/* Delete User Confirmation Modal */}
<AnimatePresence>
{deleteUserModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={() => setDeleteUserModal(null)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-surface-container-high border border-outline-variant/20 rounded-3xl p-8 max-w-md w-full shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-2xl bg-error/10 flex items-center justify-center">
<span className="material-symbols-outlined text-error text-2xl">warning</span>
</div>
<div>
<h3 className="text-xl font-black">{lang === 'ru' ? 'Удаление пользователя' : 'Delete User'}</h3>
<p className="text-sm text-on-surface-variant/60">{lang === 'ru' ? 'Это действие необратимо' : 'This action is irreversible'}</p>
</div>
</div>
<div className="bg-surface-container-lowest/50 rounded-2xl p-4 mb-6">
<p className="text-sm text-on-surface-variant/60 mb-2">{lang === 'ru' ? 'Вы собираетесь удалить:' : 'You are about to delete:'}</p>
<p className="text-lg font-black text-on-surface">@{deleteUserModal.username}</p>
</div>
<p className="text-sm text-on-surface-variant/40 mb-8">
{t.confirmDelete}
</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteUserModal(null)}
className="flex-1 py-4 rounded-2xl bg-surface-container-highest hover:bg-surface-container-high transition-all font-black uppercase text-[12px] tracking-widest"
>
{t.cancel}
</button>
<button
onClick={() => handleDeleteUser(deleteUserModal.userId)}
className="flex-1 py-4 rounded-2xl bg-error hover:bg-error/90 text-white font-black uppercase text-[12px] tracking-widest transition-all"
>
{lang === 'ru' ? 'Удалить' : 'Delete'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
@@ -1613,8 +1670,7 @@ function NavButton({ icon, label, active, onClick }: { icon: string, label: stri
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all duration-400 ease-ice group relative ${ className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all duration-400 ease-ice group relative ${active
active
? 'bg-primary/10 text-primary' ? 'bg-primary/10 text-primary'
: 'text-on-surface-variant/40 hover:text-on-surface hover:bg-surface-container-highest/10' : 'text-on-surface-variant/40 hover:text-on-surface hover:bg-surface-container-highest/10'
}`} }`}
@@ -1638,15 +1694,13 @@ function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean
return ( return (
<button <button
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full relative transition-all duration-300 ${ className={`w-11 h-6 rounded-full relative transition-all duration-300 ${checked ? 'bg-primary' : 'bg-surface-container-highest'
checked ? 'bg-primary' : 'bg-surface-container-highest'
}`} }`}
> >
<motion.div <motion.div
animate={{ x: checked ? 22 : 3 }} animate={{ x: checked ? 22 : 3 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }} transition={{ type: 'spring', stiffness: 500, damping: 30 }}
className={`absolute top-1 w-4 h-4 rounded-full ${ className={`absolute top-1 w-4 h-4 rounded-full ${checked ? 'bg-surface-container-lowest' : 'bg-on-surface-variant/40'
checked ? 'bg-surface-container-lowest' : 'bg-on-surface-variant/40'
}`} }`}
/> />
</button> </button>
@@ -3,17 +3,37 @@ import type { User } from '../../../core/domain/types';
export class AuthApi { export class AuthApi {
static async login(username: string, password: string) { static async login(username: string, password: string) {
return httpClient.request<{ token: string; user: User }>('/auth/login', { const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
return {
token: response.accessToken,
user: {
id: response.userId,
username: response.username,
displayName: response.displayName,
avatar: null
} as User
};
} }
static async register(username: string, displayName: string, password: string, bio?: string) { static async register(username: string, displayName: string, password: string, bio?: string) {
return httpClient.request<{ token: string; user: User }>('/auth/register', { const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }), body: JSON.stringify({ username, displayName, password, bio }),
}); });
return {
token: response.accessToken,
user: {
id: response.userId,
username: response.username,
displayName: response.displayName,
avatar: null
} as User
};
} }
static async getMe() { static async getMe() {