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

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

View File

@@ -10,9 +10,4 @@ public interface IChatQueryService
Task<List<ChatInfo>> GetAllChatsAsync(CancellationToken cancellationToken);
}
public interface IUserDeleterService
{
Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}
public record ChatInfo(Guid Id, string? Avatar);

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -18,7 +18,7 @@
</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" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,9 @@
using System.Text.Json.Serialization;
using Knot.Contracts.Conversations.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
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.Queries;
using MediatR;
@@ -56,6 +58,12 @@ public static class AdminEndpoints
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) =>
{
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");
});
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) =>
{
var result = await sender.Send(new CleanDryRunQuery(), ct);

View File

@@ -43,6 +43,7 @@ public static class DependencyInjection
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<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;
}
}

View File

@@ -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);
}
}

View File

@@ -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
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
private readonly IMediator? _mediator;
private readonly IEncryptionService? _encryptionService;
public ChatsDbContext(DbContextOptions<ChatsDbContext> options)
: base(options)
{
}
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options)
: this(options)
{
_mediator = mediator;
_encryptionService = encryptionService;
@@ -99,9 +104,12 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
int result = await base.SaveChangesAsync(cancellationToken);
foreach (var domainEvent in domainEvents)
if (_mediator != null)
{
await _mediator.Publish(domainEvent, cancellationToken);
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
}
return result;

View File

@@ -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);
}
}

View File

@@ -19,6 +19,10 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MediatR" Version="12.0.1" />
<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.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />

View File

@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

View File

@@ -543,6 +543,9 @@ export default function AdminPage() {
const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' });
const [generatedPass, setGeneratedPass] = useState('');
// Delete User Modal State
const [deleteUserModal, setDeleteUserModal] = useState<{ userId: string; username: string } | null>(null);
const [timezones, setTimezones] = useState<TimezoneDto[]>([]);
const [tzSearch, setTzSearch] = useState('');
const [showTzDropdown, setShowTzDropdown] = useState(false);
@@ -616,7 +619,7 @@ export default function AdminPage() {
};
const handleDeleteUser = async (userId: string) => {
if (!confirm(t.confirmDelete)) return;
setDeleteUserModal(null);
try {
await httpClient.request(`/admin/users/${userId}`, { method: 'DELETE' });
showToast(t.successSave, 'success');
@@ -843,7 +846,7 @@ export default function AdminPage() {
<div className="w-16 h-16 rounded-3xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<span className="material-symbols-outlined text-4xl">hub</span>
</div>
<div className="text-center">
<h1 className="text-3xl font-black font-headline tracking-tighter uppercase mb-2">{t.loginTitle}</h1>
<p className="text-[10px] font-black text-on-surface-variant/40 uppercase tracking-[0.3em]">Administrator</p>
@@ -889,7 +892,7 @@ export default function AdminPage() {
<span className="font-black uppercase tracking-widest text-[14px]">{t.loginBtn}</span>
<span className="material-symbols-outlined text-[20px]">arrow_forward</span>
</button>
<div className="flex gap-4 p-1.5 bg-surface-container-highest/20 rounded-2xl w-full">
<button type="button" onClick={() => setLang('ru')} className={`flex-1 py-3 text-[11px] font-black rounded-xl transition-all ${lang === 'ru' ? 'bg-primary text-on-primary' : 'text-on-surface-variant/40 hover:text-on-surface'}`}>RU</button>
<button type="button" onClick={() => setLang('en')} className={`flex-1 py-3 text-[11px] font-black rounded-xl transition-all ${lang === 'en' ? 'bg-primary text-on-primary' : 'text-on-surface-variant/40 hover:text-on-surface'}`}>EN</button>
@@ -1440,14 +1443,13 @@ export default function AdminPage() {
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
{users.length === 0 && !isSearching && <div className="text-center py-10 text-gray-500">{t.noUsersFound}</div>}
{users.map(u => (
<button
key={u.id}
onClick={() => fetchUserStats(u.id)}
className={`flex items-center gap-3 p-4 rounded-2xl transition-all text-left group border shadow-sm ${
selectedUser?.id === u.id
? 'bg-primary/10 border-primary/30 scale-[1.01] shadow-primary/5'
: 'hover:bg-surface-container-highest/10 border-transparent'
}`}
<button
key={u.id}
onClick={() => fetchUserStats(u.id)}
className={`flex items-center gap-3 p-4 rounded-2xl transition-all text-left group border shadow-sm ${selectedUser?.id === u.id
? 'bg-primary/10 border-primary/30 scale-[1.01] shadow-primary/5'
: 'hover:bg-surface-container-highest/10 border-transparent'
}`}
>
<div className="relative">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-black shrink-0 slide-on-ice border border-primary/10">
@@ -1535,7 +1537,7 @@ export default function AdminPage() {
{t.blockUser}
</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}
</button>
</div>
@@ -1566,7 +1568,7 @@ export default function AdminPage() {
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.displayName}</span>
<div className="knot-input-group">
<input value={newUser.displayName} onChange={e => setNewUser({ ...newUser, displayName: e.target.value })} placeholder={t.displayNamePlaceholder} />
<input value={newUser.displayName} onChange={e => setNewUser({ ...newUser, displayName: e.target.value })} placeholder={t.displayNamePlaceholder} />
</div>
</label>
<label className="flex flex-col gap-2">
@@ -1588,9 +1590,9 @@ export default function AdminPage() {
{/* Global Toast */}
{toast && (
<motion.div
initial={{ opacity: 0, y: 20, x: '-50%' }}
animate={{ opacity: 1, y: 0, x: '-50%' }}
<motion.div
initial={{ opacity: 0, y: 20, x: '-50%' }}
animate={{ opacity: 1, y: 0, x: '-50%' }}
exit={{ opacity: 0, y: 20, x: '-50%' }}
className="fixed bottom-10 left-1/2 z-[9999] px-6 py-4 rounded-3xl bg-surface-container-high border border-outline-variant/20 text-on-surface shadow-[0_24px_64px_rgba(0,0,0,0.8)] flex items-center gap-4 min-w-[280px] backdrop-blur-3xl"
>
@@ -1605,24 +1607,78 @@ export default function AdminPage() {
</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>
);
}
function NavButton({ icon, label, active, onClick }: { icon: string, label: string, active: boolean, onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all duration-400 ease-ice group relative ${
active
? 'bg-primary/10 text-primary'
: 'text-on-surface-variant/40 hover:text-on-surface hover:bg-surface-container-highest/10'
}`}
<button
onClick={onClick}
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all duration-400 ease-ice group relative ${active
? 'bg-primary/10 text-primary'
: 'text-on-surface-variant/40 hover:text-on-surface hover:bg-surface-container-highest/10'
}`}
>
{active && (
<motion.div
layoutId="nav-active"
className="absolute left-0 w-1 h-6 bg-primary rounded-full"
<motion.div
layoutId="nav-active"
className="absolute left-0 w-1 h-6 bg-primary rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
@@ -1636,18 +1692,16 @@ function NavButton({ icon, label, active, onClick }: { icon: string, label: stri
function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) {
return (
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full relative transition-all duration-300 ${
checked ? 'bg-primary' : 'bg-surface-container-highest'
}`}
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full relative transition-all duration-300 ${checked ? 'bg-primary' : 'bg-surface-container-highest'
}`}
>
<motion.div
animate={{ x: checked ? 22 : 3 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
className={`absolute top-1 w-4 h-4 rounded-full ${
checked ? 'bg-surface-container-lowest' : 'bg-on-surface-variant/40'
}`}
<motion.div
animate={{ x: checked ? 22 : 3 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
className={`absolute top-1 w-4 h-4 rounded-full ${checked ? 'bg-surface-container-lowest' : 'bg-on-surface-variant/40'
}`}
/>
</button>
);

View File

@@ -3,17 +3,37 @@ import type { User } from '../../../core/domain/types';
export class AuthApi {
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',
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) {
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',
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() {