Админка рабочая
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
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;
|
||||||
@@ -11,7 +12,9 @@ using Microsoft.AspNetCore.Routing;
|
|||||||
|
|
||||||
namespace Knot.Host.Presentation.Endpoints;
|
namespace Knot.Host.Presentation.Endpoints;
|
||||||
|
|
||||||
public record KlipyTestDto(string ApiKey, string AppName);
|
public record KlipyTestDto(
|
||||||
|
[property: JsonPropertyName("apiKey")] string ApiKey,
|
||||||
|
[property: JsonPropertyName("appName")] string AppName);
|
||||||
public record ResetPasswordRequest(string NewPassword);
|
public record ResetPasswordRequest(string NewPassword);
|
||||||
|
|
||||||
public static class AdminEndpoints
|
public static class AdminEndpoints
|
||||||
@@ -34,7 +37,16 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) =>
|
group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"[Admin] TestKlipy received: ApiKey={(string.IsNullOrEmpty(dto.ApiKey) ? "EMPTY" : "present")}, AppName={(string.IsNullOrEmpty(dto.AppName) ? "EMPTY" : dto.AppName)}");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.AppName))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Admin] TestKlipy: Missing required fields - ApiKey={dto?.ApiKey}, AppName={dto?.AppName}");
|
||||||
|
return Results.BadRequest(new { error = "ApiKey and AppName are required" });
|
||||||
|
}
|
||||||
|
|
||||||
var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct);
|
var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct);
|
||||||
|
Console.WriteLine($"[Admin] TestKlipy result: IsSuccess={result.IsSuccess}, Error={result.Error?.Description}");
|
||||||
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description });
|
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,36 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Knot.Modules.Klipy.Application.Abstractions;
|
using Knot.Modules.Klipy.Application.Abstractions;
|
||||||
using Knot.Modules.Klipy.Infrastructure.External;
|
using Knot.Modules.Klipy.Infrastructure.External;
|
||||||
|
|
||||||
|
// Псевдонимы для устранения неоднозначности
|
||||||
|
using ContractIKlipyClient = Knot.Contracts.Klipy.Application.Abstractions.IKlipyClient;
|
||||||
|
using ModuleIKlipyClient = Knot.Modules.Klipy.Application.Abstractions.IKlipyClient;
|
||||||
|
|
||||||
namespace Knot.Modules.Klipy;
|
namespace Knot.Modules.Klipy;
|
||||||
|
|
||||||
public static class DependencyInjection {
|
public static class DependencyInjection
|
||||||
public static IServiceCollection AddKlipyModule(this IServiceCollection services) {
|
{
|
||||||
services.AddHttpClient<IKlipyClient, KlipyClient>();
|
public static IServiceCollection AddKlipyModule(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddHttpClient<ModuleIKlipyClient, KlipyClient>();
|
||||||
|
// Также регистрируем contract-интерфейс для Admin модуля
|
||||||
|
services.AddScoped<ContractIKlipyClient>(sp =>
|
||||||
|
new ContractKlipyClientAdapter(sp.GetRequiredService<ModuleIKlipyClient>()));
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Адаптер для преобразования между интерфейсами
|
||||||
|
internal sealed class ContractKlipyClientAdapter : ContractIKlipyClient
|
||||||
|
{
|
||||||
|
private readonly ModuleIKlipyClient _inner;
|
||||||
|
|
||||||
|
public ContractKlipyClientAdapter(ModuleIKlipyClient inner)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TestConnectionAsync(string apiKey, string appName, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _inner.TestConnectionAsync(apiKey, appName, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,14 +20,21 @@ public sealed class KlipyClient : Knot.Modules.Klipy.Application.Abstractions.IK
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.klipy.com/api/v1/{appName}/gifs/trending?page=1&per_page=1&customer_id=knot_admin_test");
|
var url = $"https://api.klipy.com/api/v1/{apiKey}/gifs/trending?page=1&per_page=1&customer_id={appName}&locale=en";
|
||||||
request.Headers.Add("X-KLIPY-API-KEY", apiKey);
|
Console.WriteLine($"[Klipy] TestConnection URL: {url}");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Add("User-Agent", "KnotMessenger/1.0");
|
||||||
|
request.Headers.Add("Accept", "application/json");
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(request, ct);
|
using var response = await _httpClient.SendAsync(request, ct);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
Console.WriteLine($"[Klipy] TestConnection response: {response.StatusCode}, body: {responseBody}");
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"[Klipy] TestConnection exception: {ex.Message}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
|
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -47,7 +47,13 @@ export class HttpClient {
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonData = await response.json();
|
// Handle empty responses (e.g., 200 OK with no body)
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = JSON.parse(text);
|
||||||
return deepNormalize(jsonData);
|
return deepNormalize(jsonData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ const translations = {
|
|||||||
banUser: 'Ban User',
|
banUser: 'Ban User',
|
||||||
unbanUser: 'Unban User',
|
unbanUser: 'Unban User',
|
||||||
isBanned: 'Banned',
|
isBanned: 'Banned',
|
||||||
|
blockUser: 'Block User',
|
||||||
|
unblockUser: 'Unblock User',
|
||||||
|
blockedTooltip: 'Blocked',
|
||||||
authSecurity: 'Security',
|
authSecurity: 'Security',
|
||||||
bio: 'Bio',
|
bio: 'Bio',
|
||||||
messagesSent: 'Messages',
|
messagesSent: 'Messages',
|
||||||
@@ -348,6 +351,9 @@ const translations = {
|
|||||||
banUser: 'Забанить',
|
banUser: 'Забанить',
|
||||||
unbanUser: 'Разбанить',
|
unbanUser: 'Разбанить',
|
||||||
isBanned: 'Забанен',
|
isBanned: 'Забанен',
|
||||||
|
blockUser: 'Заблокировать',
|
||||||
|
unblockUser: 'Разблокировать',
|
||||||
|
blockedTooltip: 'Заблокирован',
|
||||||
authSecurity: 'Безопасность',
|
authSecurity: 'Безопасность',
|
||||||
bio: 'О себе',
|
bio: 'О себе',
|
||||||
messagesSent: 'Сообщения',
|
messagesSent: 'Сообщения',
|
||||||
@@ -1393,17 +1399,21 @@ export default function AdminPage() {
|
|||||||
{users.length === 0 && !isSearching && <div className="text-center py-10 text-gray-500">{t.noUsersFound}</div>}
|
{users.length === 0 && !isSearching && <div className="text-center py-10 text-gray-500">{t.noUsersFound}</div>}
|
||||||
{users.map(u => (
|
{users.map(u => (
|
||||||
<button key={u.id} onClick={() => fetchUserDetails(u.id)} className={`flex items-center gap-3 p-3 rounded-xl transition-all text-left group ${selectedUser?.id === u.id ? 'bg-accent/10' : 'hover:bg-white/5'}`}>
|
<button key={u.id} onClick={() => fetchUserDetails(u.id)} className={`flex items-center gap-3 p-3 rounded-xl transition-all text-left group ${selectedUser?.id === u.id ? 'bg-accent/10' : 'hover:bg-white/5'}`}>
|
||||||
|
<div className="relative">
|
||||||
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center text-accent font-bold shrink-0">
|
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center text-accent font-bold shrink-0">
|
||||||
{u.avatar ? <img src={u.avatar} className="w-full h-full rounded-full" /> : u.username.charAt(0).toUpperCase()}
|
{u.avatar ? <img src={u.avatar} className="w-full h-full rounded-full" /> : u.username.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
{u.isBanned && (
|
||||||
|
<div className="absolute -bottom-0.5 -left-0.5 w-4 h-4 bg-surface rounded-full flex items-center justify-center" title={t.blockedTooltip}>
|
||||||
|
<XCircle className="w-3.5 h-3.5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-semibold truncate text-white">{u.displayName}</div>
|
<div className="font-semibold truncate text-white">{u.displayName}</div>
|
||||||
<div className="text-xs text-gray-500 truncate">@{u.username}</div>
|
<div className="text-xs text-gray-500 truncate">@{u.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{u.isBanned && (
|
|
||||||
<span className="text-[10px] bg-red-500/20 text-red-400 px-2 py-0.5 rounded-full font-bold">{t.isBanned.toUpperCase()}</span>
|
|
||||||
)}
|
|
||||||
{u.isOnline ? (
|
{u.isOnline ? (
|
||||||
<span className="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full font-bold">{t.online.toUpperCase()}</span>
|
<span className="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full font-bold">{t.online.toUpperCase()}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -1429,14 +1439,20 @@ export default function AdminPage() {
|
|||||||
{selectedUser ? (
|
{selectedUser ? (
|
||||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} className="w-80 bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-6 shrink-0 sticky top-0 overflow-y-auto max-h-full">
|
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} className="w-80 bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-6 shrink-0 sticky top-0 overflow-y-auto max-h-full">
|
||||||
<div className="flex flex-col items-center gap-4 text-center pb-6 border-b border-white/10">
|
<div className="flex flex-col items-center gap-4 text-center pb-6 border-b border-white/10">
|
||||||
|
<div className="relative">
|
||||||
<div className="w-20 h-20 rounded-full bg-accent/20 flex items-center justify-center text-2xl font-bold text-accent">
|
<div className="w-20 h-20 rounded-full bg-accent/20 flex items-center justify-center text-2xl font-bold text-accent">
|
||||||
{selectedUser.avatar ? <img src={selectedUser.avatar} className="w-full h-full rounded-full" /> : selectedUser.username.charAt(0).toUpperCase()}
|
{selectedUser.avatar ? <img src={selectedUser.avatar} className="w-full h-full rounded-full" /> : selectedUser.username.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedUser.isBanned && (
|
||||||
|
<div className="absolute -bottom-1 -left-1 w-6 h-6 bg-surface rounded-full flex items-center justify-center" title={t.blockedTooltip}>
|
||||||
|
<XCircle className="w-5 h-5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white leading-tight">{selectedUser.displayName}</h2>
|
<h2 className="text-xl font-bold text-white leading-tight">{selectedUser.displayName}</h2>
|
||||||
<div className="text-gray-500 text-sm">@{selectedUser.username}</div>
|
<div className="text-gray-500 text-sm">@{selectedUser.username}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedUser.isBanned && <span className="text-xs bg-red-500 text-white px-2 py-1 rounded-lg font-bold uppercase tracking-wider">{t.isBanned}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -1462,11 +1478,11 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
{selectedUser.isBanned ? (
|
{selectedUser.isBanned ? (
|
||||||
<button onClick={() => handleUnbanUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-green-500/10 hover:bg-green-500/20 text-green-500 font-bold transition-all border border-green-500/20 text-sm mt-2">
|
<button onClick={() => handleUnbanUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-green-500/10 hover:bg-green-500/20 text-green-500 font-bold transition-all border border-green-500/20 text-sm mt-2">
|
||||||
{t.unban}
|
{t.unblockUser}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => handleBanUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-500 font-bold transition-all border border-red-500/20 text-sm mt-2">
|
<button onClick={() => handleBanUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-500 font-bold transition-all border border-red-500/20 text-sm mt-2">
|
||||||
{t.ban}
|
{t.blockUser}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => handleDeleteUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-500 font-bold transition-all border border-red-500/20 text-sm mt-4">
|
<button onClick={() => handleDeleteUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-500 font-bold transition-all border border-red-500/20 text-sm mt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user