From 030ae1e4e49809516015fd1b240a589563d6d5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Fri, 27 Mar 2026 16:04:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D1=80=D0=BE=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Host/Program.cs | 2 + .../Abstractions/IProfileRepository.cs | 7 +- .../UserRegisteredDomainEventHandler.cs | 22 ++--- .../Modules/Profiles/DependencyInjection.cs | 6 -- .../Domain/Events/ProfileDomainEvents.cs | 11 +-- .../src/Modules/Profiles/Domain/Profile.cs | 9 +- .../Profiles/Domain/ProfileDocument.cs | 17 +--- backend/src/Modules/Profiles/GlobalUsings.cs | 18 +++- .../Database/AvatarStorageService.cs | 28 ++---- .../Database/ProfileRepository.cs | 30 +++---- .../Database/ProfilesUnitOfWork.cs | 10 +-- client-web/src/core/domain/types.ts | 23 +++-- .../src/core/infrastructure/httpClient.ts | 4 +- .../src/core/presentation/layouts/Sidebar.tsx | 7 +- client-web/src/core/utils/normalize.ts | 67 ++++++++++++++ .../admin/presentation/pages/AdminPage.tsx | 90 ++++++++++--------- .../presentation/components/ChatListItem.tsx | 6 +- .../modules/users/infrastructure/userApi.ts | 33 ++++--- .../presentation/components/UserProfile.tsx | 18 ++-- 19 files changed, 234 insertions(+), 174 deletions(-) create mode 100644 client-web/src/core/utils/normalize.ts diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index c534e42..1b00412 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -2,6 +2,7 @@ using Knot.Modules.Messaging; using Carter; using Knot.Shared.Infrastructure; using Knot.Modules.Auth; +using Knot.Modules.Profiles; using Knot.Modules.Settings; using Knot.Modules.Admin; using Knot.Modules.Conversations; @@ -54,6 +55,7 @@ builder.Configuration.AddInMemoryCollection( builder.Services.AddAuthModule(builder.Configuration); builder.Services.AddSettingsModule(builder.Configuration); builder.Services.AddConversationsModule(builder.Configuration); +builder.Services.AddProfilesModule(builder.Configuration); builder.Services.AddSharedInfrastructure(builder.Configuration); // CQRS / MediatR для команд в Host (например, AdminController) diff --git a/backend/src/Modules/Profiles/Application/Abstractions/IProfileRepository.cs b/backend/src/Modules/Profiles/Application/Abstractions/IProfileRepository.cs index d412e41..2f8e22d 100644 --- a/backend/src/Modules/Profiles/Application/Abstractions/IProfileRepository.cs +++ b/backend/src/Modules/Profiles/Application/Abstractions/IProfileRepository.cs @@ -1,14 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Knot.Modules.Profiles.Domain; - namespace Knot.Modules.Profiles.Application.Abstractions; public interface IProfileRepository { Task GetByIdAsync(Guid userId, CancellationToken ct = default); + Task GetByUsernameAsync(string username, CancellationToken ct = default); Task AddAsync(ProfileDocument profile, CancellationToken ct = default); Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default); Task> SearchAsync(string query, CancellationToken ct = default); diff --git a/backend/src/Modules/Profiles/Application/Profiles/Integration/UserRegisteredDomainEventHandler.cs b/backend/src/Modules/Profiles/Application/Profiles/Integration/UserRegisteredDomainEventHandler.cs index ec6ac20..5f13c39 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/Integration/UserRegisteredDomainEventHandler.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/Integration/UserRegisteredDomainEventHandler.cs @@ -1,17 +1,10 @@ -using Knot.Modules.Profiles.Application.Abstractions; -using Knot.Modules.Profiles.Domain; -using Knot.Shared.Kernel.Events; -using MediatR; -using System.Threading; -using System.Threading.Tasks; - namespace Knot.Modules.Profiles.Application.Profiles.Integration; /// -/// Реакция модуля Profiles на создание пользователя в модуле Auth. -/// Создает соответствующий документ в MongoDB. +/// Обработчик события из модуля Auth. +/// Создаёт пустой профиль при регистрации пользователя. /// -internal sealed class UserRegisteredDomainEventHandler : INotificationHandler +public class UserRegisteredDomainEventHandler : INotificationHandler { private readonly IProfileRepository _repository; @@ -22,17 +15,12 @@ internal sealed class UserRegisteredDomainEventHandler : INotificationHandler -/// Доменное событие: профиль пользователя создан при регистрации. +/// Профиль пользователя создан. /// -public sealed record ProfileCreatedDomainEvent(Guid UserId, string Username) : IDomainEvent; +public record ProfileCreatedDomainEvent(Guid ProfileId) : IDomainEvent; /// -/// Доменное событие: аватар профиля был изменён (для возможной инвалидации CDN-кэша). +/// Профиль пользователя обновлен. /// -public sealed record ProfileAvatarChangedDomainEvent(Guid UserId, string? OldAvatarFileId, string? NewAvatarFileId) : IDomainEvent; +public record ProfileUpdatedDomainEvent(Guid ProfileId) : IDomainEvent; diff --git a/backend/src/Modules/Profiles/Domain/Profile.cs b/backend/src/Modules/Profiles/Domain/Profile.cs index a32a36a..3eaaf22 100644 --- a/backend/src/Modules/Profiles/Domain/Profile.cs +++ b/backend/src/Modules/Profiles/Domain/Profile.cs @@ -1,6 +1,3 @@ -using Knot.Shared.Kernel; -using System; - namespace Knot.Modules.Profiles.Domain; public sealed class Profile : AggregateRoot @@ -13,13 +10,17 @@ public sealed class Profile : AggregateRoot public bool HideStoryViews { get; private set; } public string Profilename => Username; public string Name => DisplayName; - public string AvatarUrl => Avatar; + public string? AvatarUrl => Avatar; public bool IsPrivate => false; public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; public int FollowersCount => 0; public int FollowingCount => 0; public int FriendsCount => 0; +#pragma warning disable CS8618 + private Profile() : base(Guid.Empty) { } +#pragma warning restore CS8618 + private Profile(Guid id, string username, string displayName, string? bio = null) : base(id) { diff --git a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs index 352d5f7..b593e36 100644 --- a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs +++ b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs @@ -1,13 +1,8 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using System; - namespace Knot.Modules.Profiles.Domain; /// /// MongoDB-документ профиля пользователя. /// Id совпадает с UserId из модуля Auth (Postgres). -/// Аватар хранится в S3 — здесь лежит только ссылка. /// public sealed class ProfileDocument { @@ -21,23 +16,17 @@ public sealed class ProfileDocument public string? Bio { get; private set; } - /// - /// URL или ключ объекта в S3-хранилище (MinIO). - /// Пример: "/api/files/{fileId}" - /// public string? AvatarUrl { get; private set; } public DateTime? Birthday { get; private set; } - /// - /// Скрывать ли просмотры сторис от других пользователей. - /// public bool HideStoryViews { get; private set; } public DateTime CreatedAt { get; private set; } - // Для MongoDB — protected-конструктор через BSON-десериализацию - protected ProfileDocument() { } +#pragma warning disable CS8618 + private ProfileDocument() { } +#pragma warning restore CS8618 private ProfileDocument(Guid id, string username, string displayName, string? bio) { diff --git a/backend/src/Modules/Profiles/GlobalUsings.cs b/backend/src/Modules/Profiles/GlobalUsings.cs index c31b71e..687f9d5 100644 --- a/backend/src/Modules/Profiles/GlobalUsings.cs +++ b/backend/src/Modules/Profiles/GlobalUsings.cs @@ -1,3 +1,19 @@ +global using Knot.Shared.Kernel; +global using Knot.Shared.Kernel.Events; +global using Knot.Shared.Kernel.Storage; global using Knot.Modules.Profiles.Application.Abstractions; -global using Knot.Modules.Profiles.Application.Profiles; global using Knot.Modules.Profiles.Domain; +global using Knot.Modules.Profiles.Domain.Events; +global using Knot.Modules.Profiles.Infrastructure.Database; +global using MongoDB.Bson; +global using MongoDB.Bson.Serialization.Attributes; +global using MongoDB.Driver; +global using MediatR; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Configuration; +global using System; +global using System.Collections.Generic; +global using System.Threading; +global using System.Threading.Tasks; +global using System.IO; +global using System.Linq; diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/AvatarStorageService.cs b/backend/src/Modules/Profiles/Infrastructure/Database/AvatarStorageService.cs index 41df150..06b369b 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Database/AvatarStorageService.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Database/AvatarStorageService.cs @@ -1,33 +1,23 @@ -using Knot.Modules.Profiles.Application.Abstractions; -using Knot.Shared.Kernel.Storage; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - namespace Knot.Modules.Profiles.Infrastructure.Database; -/// -/// Адаптер к S3-хранилищу для аватаров. -/// Инжектирует общий IFileStorageService и передает ему управление. -/// -internal sealed class AvatarStorageService : IAvatarStorageService +public class AvatarStorageService : IAvatarStorageService { - private readonly IFileStorageService _storage; + private readonly IFileStorageService _fileStorage; - public AvatarStorageService(IFileStorageService storage) + public AvatarStorageService(IFileStorageService fileStorage) { - _storage = storage; + _fileStorage = fileStorage; } - public async Task UploadAsync(Stream stream, string fileName, string contentType, CancellationToken ct = default) + public async Task UploadAsync(Stream content, string fileName, string contentType, CancellationToken ct = default) { - return await _storage.UploadFileAsync(stream, fileName, contentType); + // IFileStorageService не принимает CancellationToken в UploadFileAsync + return await _fileStorage.UploadFileAsync(content, fileName, contentType); } public async Task DeleteAsync(string fileId, CancellationToken ct = default) { - // Если fileId содержит "/api/files/", обрезаем его до чистого ID - var cleanId = fileId.Replace("/api/files/", ""); - await _storage.DeleteFileAsync(cleanId); + // IFileStorageService не принимает CancellationToken в DeleteFileAsync + await _fileStorage.DeleteFileAsync(fileId); } } diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs index 96f70b8..daea3d5 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs @@ -1,14 +1,6 @@ -using Knot.Modules.Profiles.Application.Abstractions; -using Knot.Modules.Profiles.Domain; -using MongoDB.Driver; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - namespace Knot.Modules.Profiles.Infrastructure.Database; -internal sealed class ProfileRepository : IProfileRepository +public class ProfileRepository : IProfileRepository { private readonly IMongoCollection _profiles; @@ -17,9 +9,14 @@ internal sealed class ProfileRepository : IProfileRepository _profiles = database.GetCollection("profiles"); } - public async Task GetByIdAsync(Guid userId, CancellationToken ct = default) + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { - return await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct); + return await _profiles.Find(p => p.Id == id).FirstOrDefaultAsync(ct); + } + + public async Task GetByUsernameAsync(string username, CancellationToken ct = default) + { + return await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct); } public async Task AddAsync(ProfileDocument profile, CancellationToken ct = default) @@ -29,20 +26,19 @@ internal sealed class ProfileRepository : IProfileRepository public async Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default) { - await _profiles.ReplaceOneAsync(p => p.Id == profile.Id, profile, new ReplaceOptions { IsUpsert = false }, ct); + await _profiles.ReplaceOneAsync(p => p.Id == profile.Id, profile, new ReplaceOptions { IsUpsert = true }, ct); } public async Task> SearchAsync(string query, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(query)) - return new List(); + return await _profiles.Find(_ => true).Limit(50).ToListAsync(ct); - // Простой регистронезависимый поиск по Regex (в реальной системе лучше использовать Text Index) var filter = Builders.Filter.Or( - Builders.Filter.Regex(p => p.Username, new MongoDB.Bson.BsonRegularExpression(query, "i")), - Builders.Filter.Regex(p => p.DisplayName, new MongoDB.Bson.BsonRegularExpression(query, "i")) + Builders.Filter.Regex(p => p.Username, new BsonRegularExpression(query, "i")), + Builders.Filter.Regex(p => p.DisplayName, new BsonRegularExpression(query, "i")) ); - return await _profiles.Find(filter).Limit(20).ToListAsync(ct); + return await _profiles.Find(filter).Limit(50).ToListAsync(ct); } } diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/ProfilesUnitOfWork.cs b/backend/src/Modules/Profiles/Infrastructure/Database/ProfilesUnitOfWork.cs index 230459e..feaff52 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Database/ProfilesUnitOfWork.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Database/ProfilesUnitOfWork.cs @@ -1,12 +1,8 @@ -using Knot.Modules.Profiles.Application.Abstractions; -using System.Threading; -using System.Threading.Tasks; - namespace Knot.Modules.Profiles.Infrastructure.Database; -internal sealed class ProfilesUnitOfWork : IProfilesUnitOfWork +public class ProfilesUnitOfWork : IProfilesUnitOfWork { - // MongoDB updates are atomic per document by default in the driver, - // so for simple ProfileDocument updates, we don't need distributed transactions. + // MongoDB не поддерживает транзакции без репликации в простом виде, + // поэтому UnitOfWork здесь формальный для соответствия интерфейсу. public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask; } diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index cc81fc8..21918eb 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -2,9 +2,12 @@ export interface UserBasic { id: string; - username: string; + userName: string; displayName: string; - avatar: string | null; + avatarUrl: string | null; + // Fallbacks for compatibility + username?: string; + avatar?: string | null; } export interface UserPresence extends UserBasic { @@ -48,14 +51,23 @@ export interface Reaction { id: string; emoji: string; userId: string; - user: { id: string; username: string; displayName: string; avatar?: string | null }; + user: { + id: string; + username: string; + userName?: string; + displayName: string; + avatar?: string | null; + avatarUrl?: string | null; + }; } export interface MessageSender { id: string; username: string; + userName?: string; displayName: string; avatar?: string | null; + avatarUrl?: string | null; } export interface Message { @@ -93,10 +105,11 @@ export interface Message { export interface Chat { id: string; - type: string; + type: 'personal' | 'group' | 'channel' | 'favorites'; name: string | null; description?: string | null; avatar: string | null; + avatarUrl?: string | null; createdAt: string; members: ChatMember[]; messages: Message[]; @@ -152,8 +165,6 @@ export interface StoryGroup { hasUnviewed: boolean; } -// ─── Utility types ───────────────────────────────────────────────── - // ─── Friend types ────────────────────────────────────────────────── export interface FriendRequest { diff --git a/client-web/src/core/infrastructure/httpClient.ts b/client-web/src/core/infrastructure/httpClient.ts index d18d668..51dc10c 100644 --- a/client-web/src/core/infrastructure/httpClient.ts +++ b/client-web/src/core/infrastructure/httpClient.ts @@ -1,3 +1,4 @@ +import { deepNormalize } from '../utils/normalize'; const API_BASE = '/api'; export class HttpClient { @@ -44,7 +45,8 @@ export class HttpClient { throw new Error(errorMessage); } - return response.json(); + const jsonData = await response.json(); + return deepNormalize(jsonData); } } diff --git a/client-web/src/core/presentation/layouts/Sidebar.tsx b/client-web/src/core/presentation/layouts/Sidebar.tsx index 8645ca4..0f6dc45 100644 --- a/client-web/src/core/presentation/layouts/Sidebar.tsx +++ b/client-web/src/core/presentation/layouts/Sidebar.tsx @@ -73,7 +73,7 @@ export default function Sidebar() { return chat.members.some( (m) => m.user.id !== user?.id && - (m.user.username.toLowerCase().includes(q) || + ((m.user.username || m.user.userName || '').toLowerCase().includes(q) || m.user.displayName.toLowerCase().includes(q)) ); }).sort((a, b) => { @@ -163,7 +163,8 @@ export default function Sidebar() { {storyGroups.map((group, idx) => { - const avatarUrl = group.user.avatar ? `${API_URL}${group.user.avatar}` : null; + const avatar = group.user.avatarUrl || group.user.avatar; + const avatarUrl = avatar ? `${API_URL}${avatar}` : null; const isMine = group.user.id === user?.id; return ( ); diff --git a/client-web/src/core/utils/normalize.ts b/client-web/src/core/utils/normalize.ts new file mode 100644 index 0000000..c5d4068 --- /dev/null +++ b/client-web/src/core/utils/normalize.ts @@ -0,0 +1,67 @@ +/** + * Глобальная нормализация данных пользователя. + * Приводит данные от разных модулей (Profiles, Auth, Message) к единому виду. + * + * ВАЖНО: Мы не импортируем здесь типы из types.ts, чтобы избежать круговых зависимостей. + */ +export function normalizeUser(user: any): any { + if (!user || typeof user !== 'object') return user; + + const result = { ...user }; + + // 1. Приведение username (Auth -> userName, Profiles -> userName) + if (!result.userName && result.username) { + result.userName = result.username; + } + if (!result.username && result.userName) { + result.username = result.userName; + } + + // 2. Приведение avatar (Auth -> avatar, Profiles -> avatarUrl) + if (!result.avatarUrl && result.avatar) { + result.avatarUrl = result.avatar; + } + if (!result.avatar && result.avatarUrl) { + result.avatar = result.avatarUrl; + } + + return result; +} + +/** + * Рекурсивная нормализация любого объекта/массива на наличие полей пользователя. + */ +export function deepNormalize(obj: any): any { + if (!obj || typeof obj !== 'object') { + return obj; + } + + // Если это File, Blob или FormData - не трогаем + if (obj instanceof Blob || obj instanceof FormData) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(deepNormalize); + } + + // Создаем копию для мутации + const result = { ...obj }; + + // Если это объект, похожий на пользователя (есть id и userName/username) + if (result.id && (result.userName || result.username)) { + const normalized = normalizeUser(result); + // Продолжаем рекурсию по остальным полям (например, если у пользователя есть вложенные объекты) + for (const key in normalized) { + normalized[key] = deepNormalize(normalized[key]); + } + return normalized; + } + + // Рекурсивно проходим по всем полям + for (const key in result) { + result[key] = deepNormalize(result[key]); + } + + return result; +} diff --git a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx index 6bc3e46..1af6dc1 100644 --- a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx +++ b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Settings, Shield, Activity, Users, Database, Globe, Search, Trash2, Plus, Download, Upload, Loader2, User, CheckCircle, XCircle } from 'lucide-react'; +import { httpClient } from '../../../core/infrastructure/httpClient'; +import { deepNormalize } from '../../../core/utils/normalize'; interface Stats { storageUsedBytes: number; @@ -17,8 +19,11 @@ interface Conf { enableCalls: boolean; turnHost: string; turnPort: number; + // Support both cases turnUser: string; + turnUsername?: string; turnSecret: string; + turnPassword?: string; enableKlipy: boolean; klipyApiKey: string; klipyCustomerId: string; @@ -29,9 +34,11 @@ interface Conf { interface AppUser { id: string; - username: string; + userName: string; displayName: string; - avatar: string | null; + avatarUrl: string | null; + username?: string; + avatar?: string | null; bio?: string; createdAt: string; lastOnlineAt: string; @@ -288,12 +295,10 @@ export default function AdminPage() { const handleAddUser = async () => { try { - const res = await fetch('/api/admin/users', { + await httpClient.request('/admin/users', { method: 'POST', - headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...newUser, email: null, bio: null }) }); - if (!res.ok) throw new Error(); showToast(t.createUserSuccess, 'success'); setShowAddUserModal(false); setNewUser({ username: '', displayName: '', password: '' }); @@ -306,29 +311,27 @@ export default function AdminPage() { const handleResetPassword = async (userId: string) => { const newPass = generatePassword(); try { - const res = await fetch(`/api/admin/users/${userId}/reset-password`, { + await httpClient.request(`/admin/users/${userId}/reset-password`, { method: 'POST', - headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ newPassword: newPass }) }); - if (!res.ok) throw new Error(); setGeneratedPass(newPass); } catch { showToast('Error resetting password', 'error'); } }; - const fetchDashboard = async (header: string) => { + const fetchDashboard = async () => { try { - const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } }); - if (res.ok) setStats(await res.json()); + const res = await httpClient.request('/admin/dashboard'); + setStats(res); } catch {} }; - const fetchSettings = async (header: string) => { + const fetchSettings = async () => { try { - const res = await fetch('/api/admin/settings', { headers: { Authorization: header } }); - if (res.ok) setConfig(await res.json()); + const res = await httpClient.request('/admin/settings'); + setConfig(res); } catch {} }; @@ -438,15 +441,19 @@ export default function AdminPage() { const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`; - const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } }); - if (res.ok) { + // Store in httpClient + httpClient.setToken(header); // Note: Admin uses Basic auth in this specific screen + + try { + await httpClient.request('/admin/dashboard'); setAuthHeader(header); setAuthenticated(true); - fetchDashboard(header); - fetchSettings(header); - searchUsers('', header); // Fetch initial users list - } else { + fetchDashboard(); + fetchSettings(); + searchUsers(''); + } catch { showToast(t.errorInvalidLogin, 'error'); + httpClient.setToken(null); } }; @@ -455,7 +462,7 @@ export default function AdminPage() { const interval = setInterval(() => { // Refresh dashboard if we are on dashboard tab if (activeTab === 'dashboard') { - fetchDashboard(authHeader); + fetchDashboard(); } }, 10000); return () => clearInterval(interval); @@ -464,24 +471,21 @@ export default function AdminPage() { const saveSettings = async () => { if (!config) return; try { - const res = await fetch('/api/admin/settings', { + await httpClient.request('/admin/settings', { method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: authHeader }, body: JSON.stringify(config) }); - if (res.ok) showToast(t.successSave, 'success'); + showToast(t.successSave, 'success'); } catch { showToast(t.errorSave, 'error'); } }; - const searchUsers = async (q: string, header: string = authHeader) => { + const searchUsers = async (q: string) => { setIsSearching(true); try { - const res = await fetch(`/api/admin/users?query=${encodeURIComponent(q)}`, { headers: { Authorization: header } }); - if (res.ok) { - setUsers(await res.json()); - } + const res = await httpClient.request(`/admin/users?query=${encodeURIComponent(q)}`); + setUsers(res); } catch {} finally { setIsSearching(false); } @@ -489,28 +493,24 @@ export default function AdminPage() { const handleCalcCleanup = async () => { try { - const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } }); - if (res.ok) setCleanStats(await res.json()); + const res = await httpClient.request('/admin/clean/dry-run'); + setCleanStats(res); } catch {} }; const handleRunCleanup = async () => { try { - const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } }); - if (res.ok) { - showToast(t.cleanSuccess, 'success'); - setCleanStats(null); - fetchDashboard(authHeader); - } + await httpClient.request('/admin/clean/run', { method: 'POST' }); + showToast(t.cleanSuccess, 'success'); + setCleanStats(null); + fetchDashboard(); } catch {} }; const fetchUserDetails = async (id: string) => { try { - const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } }); - if (res.ok) { - setSelectedUser(await res.json()); - } + const res = await httpClient.request(`/admin/users/${id}`); + setSelectedUser(res); } catch {} }; @@ -744,12 +744,16 @@ export default function AdminPage() {