Фронт

This commit is contained in:
Халимов Рустам
2026-03-27 16:04:46 +03:00
parent 28fb8c25de
commit 030ae1e4e4
19 changed files with 234 additions and 174 deletions
+2
View File
@@ -2,6 +2,7 @@ using Knot.Modules.Messaging;
using Carter; using Carter;
using Knot.Shared.Infrastructure; using Knot.Shared.Infrastructure;
using Knot.Modules.Auth; using Knot.Modules.Auth;
using Knot.Modules.Profiles;
using Knot.Modules.Settings; using Knot.Modules.Settings;
using Knot.Modules.Admin; using Knot.Modules.Admin;
using Knot.Modules.Conversations; using Knot.Modules.Conversations;
@@ -54,6 +55,7 @@ builder.Configuration.AddInMemoryCollection(
builder.Services.AddAuthModule(builder.Configuration); builder.Services.AddAuthModule(builder.Configuration);
builder.Services.AddSettingsModule(builder.Configuration); builder.Services.AddSettingsModule(builder.Configuration);
builder.Services.AddConversationsModule(builder.Configuration); builder.Services.AddConversationsModule(builder.Configuration);
builder.Services.AddProfilesModule(builder.Configuration);
builder.Services.AddSharedInfrastructure(builder.Configuration); builder.Services.AddSharedInfrastructure(builder.Configuration);
// CQRS / MediatR для команд в Host (например, AdminController) // CQRS / MediatR для команд в Host (например, AdminController)
@@ -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; namespace Knot.Modules.Profiles.Application.Abstractions;
public interface IProfileRepository public interface IProfileRepository
{ {
Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default); Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default);
Task<ProfileDocument?> GetByUsernameAsync(string username, CancellationToken ct = default);
Task AddAsync(ProfileDocument profile, CancellationToken ct = default); Task AddAsync(ProfileDocument profile, CancellationToken ct = default);
Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default); Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default);
Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default); Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default);
@@ -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; namespace Knot.Modules.Profiles.Application.Profiles.Integration;
/// <summary> /// <summary>
/// Реакция модуля Profiles на создание пользователя в модуле Auth. /// Обработчик события из модуля Auth.
/// Создает соответствующий документ в MongoDB. /// Создаёт пустой профиль при регистрации пользователя.
/// </summary> /// </summary>
internal sealed class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent> public class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent>
{ {
private readonly IProfileRepository _repository; private readonly IProfileRepository _repository;
@@ -22,17 +15,12 @@ internal sealed class UserRegisteredDomainEventHandler : INotificationHandler<Us
public async Task Handle(UserRegisteredDomainEvent notification, CancellationToken cancellationToken) public async Task Handle(UserRegisteredDomainEvent notification, CancellationToken cancellationToken)
{ {
// Проверяем, существует ли уже профиль (защита от дублей)
var existing = await _repository.GetByIdAsync(notification.UserId, cancellationToken);
if (existing is not null) return;
var profile = ProfileDocument.Create( var profile = ProfileDocument.Create(
notification.UserId, notification.UserId,
notification.Username, notification.Username,
notification.DisplayName, notification.DisplayName ?? notification.Username
notification.Bio
); );
await _repository.AddAsync(profile, cancellationToken); await _repository.AddAsync(profile);
} }
} }
@@ -1,9 +1,3 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Infrastructure.Database;
using MongoDB.Driver;
namespace Knot.Modules.Profiles; namespace Knot.Modules.Profiles;
public static class DependencyInjection public static class DependencyInjection
@@ -1,14 +1,11 @@
using Knot.Shared.Kernel;
using System;
namespace Knot.Modules.Profiles.Domain.Events; namespace Knot.Modules.Profiles.Domain.Events;
/// <summary> /// <summary>
/// Доменное событие: профиль пользователя создан при регистрации. /// Профиль пользователя создан.
/// </summary> /// </summary>
public sealed record ProfileCreatedDomainEvent(Guid UserId, string Username) : IDomainEvent; public record ProfileCreatedDomainEvent(Guid ProfileId) : IDomainEvent;
/// <summary> /// <summary>
/// Доменное событие: аватар профиля был изменён (для возможной инвалидации CDN-кэша). /// Профиль пользователя обновлен.
/// </summary> /// </summary>
public sealed record ProfileAvatarChangedDomainEvent(Guid UserId, string? OldAvatarFileId, string? NewAvatarFileId) : IDomainEvent; public record ProfileUpdatedDomainEvent(Guid ProfileId) : IDomainEvent;
@@ -1,6 +1,3 @@
using Knot.Shared.Kernel;
using System;
namespace Knot.Modules.Profiles.Domain; namespace Knot.Modules.Profiles.Domain;
public sealed class Profile : AggregateRoot<Guid> public sealed class Profile : AggregateRoot<Guid>
@@ -13,13 +10,17 @@ public sealed class Profile : AggregateRoot<Guid>
public bool HideStoryViews { get; private set; } public bool HideStoryViews { get; private set; }
public string Profilename => Username; public string Profilename => Username;
public string Name => DisplayName; public string Name => DisplayName;
public string AvatarUrl => Avatar; public string? AvatarUrl => Avatar;
public bool IsPrivate => false; public bool IsPrivate => false;
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
public int FollowersCount => 0; public int FollowersCount => 0;
public int FollowingCount => 0; public int FollowingCount => 0;
public int FriendsCount => 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) private Profile(Guid id, string username, string displayName, string? bio = null)
: base(id) : base(id)
{ {
@@ -1,13 +1,8 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System;
namespace Knot.Modules.Profiles.Domain; namespace Knot.Modules.Profiles.Domain;
/// <summary> /// <summary>
/// MongoDB-документ профиля пользователя. /// MongoDB-документ профиля пользователя.
/// Id совпадает с UserId из модуля Auth (Postgres). /// Id совпадает с UserId из модуля Auth (Postgres).
/// Аватар хранится в S3 — здесь лежит только ссылка.
/// </summary> /// </summary>
public sealed class ProfileDocument public sealed class ProfileDocument
{ {
@@ -21,23 +16,17 @@ public sealed class ProfileDocument
public string? Bio { get; private set; } public string? Bio { get; private set; }
/// <summary>
/// URL или ключ объекта в S3-хранилище (MinIO).
/// Пример: "/api/files/{fileId}"
/// </summary>
public string? AvatarUrl { get; private set; } public string? AvatarUrl { get; private set; }
public DateTime? Birthday { get; private set; } public DateTime? Birthday { get; private set; }
/// <summary>
/// Скрывать ли просмотры сторис от других пользователей.
/// </summary>
public bool HideStoryViews { get; private set; } public bool HideStoryViews { get; private set; }
public DateTime CreatedAt { get; private set; } public DateTime CreatedAt { get; private set; }
// Для MongoDB — protected-конструктор через BSON-десериализацию #pragma warning disable CS8618
protected ProfileDocument() { } private ProfileDocument() { }
#pragma warning restore CS8618
private ProfileDocument(Guid id, string username, string displayName, string? bio) private ProfileDocument(Guid id, string username, string displayName, string? bio)
{ {
+17 -1
View File
@@ -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.Abstractions;
global using Knot.Modules.Profiles.Application.Profiles;
global using Knot.Modules.Profiles.Domain; 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;
@@ -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; namespace Knot.Modules.Profiles.Infrastructure.Database;
/// <summary> public class AvatarStorageService : IAvatarStorageService
/// Адаптер к S3-хранилищу для аватаров.
/// Инжектирует общий IFileStorageService и передает ему управление.
/// </summary>
internal sealed 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<string> UploadAsync(Stream stream, string fileName, string contentType, CancellationToken ct = default) public async Task<string> 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) public async Task DeleteAsync(string fileId, CancellationToken ct = default)
{ {
// Если fileId содержит "/api/files/", обрезаем его до чистого ID // IFileStorageService не принимает CancellationToken в DeleteFileAsync
var cleanId = fileId.Replace("/api/files/", ""); await _fileStorage.DeleteFileAsync(fileId);
await _storage.DeleteFileAsync(cleanId);
} }
} }
@@ -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; namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class ProfileRepository : IProfileRepository public class ProfileRepository : IProfileRepository
{ {
private readonly IMongoCollection<ProfileDocument> _profiles; private readonly IMongoCollection<ProfileDocument> _profiles;
@@ -17,9 +9,14 @@ internal sealed class ProfileRepository : IProfileRepository
_profiles = database.GetCollection<ProfileDocument>("profiles"); _profiles = database.GetCollection<ProfileDocument>("profiles");
} }
public async Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default) public async Task<ProfileDocument?> 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<ProfileDocument?> 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) 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) 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<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default) public async Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
return new List<ProfileDocument>(); return await _profiles.Find(_ => true).Limit(50).ToListAsync(ct);
// Простой регистронезависимый поиск по Regex (в реальной системе лучше использовать Text Index)
var filter = Builders<ProfileDocument>.Filter.Or( var filter = Builders<ProfileDocument>.Filter.Or(
Builders<ProfileDocument>.Filter.Regex(p => p.Username, new MongoDB.Bson.BsonRegularExpression(query, "i")), Builders<ProfileDocument>.Filter.Regex(p => p.Username, new BsonRegularExpression(query, "i")),
Builders<ProfileDocument>.Filter.Regex(p => p.DisplayName, new MongoDB.Bson.BsonRegularExpression(query, "i")) Builders<ProfileDocument>.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);
} }
} }
@@ -1,12 +1,8 @@
using Knot.Modules.Profiles.Application.Abstractions;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Infrastructure.Database; 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, // MongoDB не поддерживает транзакции без репликации в простом виде,
// so for simple ProfileDocument updates, we don't need distributed transactions. // поэтому UnitOfWork здесь формальный для соответствия интерфейсу.
public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask; public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask;
} }
+17 -6
View File
@@ -2,9 +2,12 @@
export interface UserBasic { export interface UserBasic {
id: string; id: string;
username: string; userName: string;
displayName: string; displayName: string;
avatar: string | null; avatarUrl: string | null;
// Fallbacks for compatibility
username?: string;
avatar?: string | null;
} }
export interface UserPresence extends UserBasic { export interface UserPresence extends UserBasic {
@@ -48,14 +51,23 @@ export interface Reaction {
id: string; id: string;
emoji: string; emoji: string;
userId: 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 { export interface MessageSender {
id: string; id: string;
username: string; username: string;
userName?: string;
displayName: string; displayName: string;
avatar?: string | null; avatar?: string | null;
avatarUrl?: string | null;
} }
export interface Message { export interface Message {
@@ -93,10 +105,11 @@ export interface Message {
export interface Chat { export interface Chat {
id: string; id: string;
type: string; type: 'personal' | 'group' | 'channel' | 'favorites';
name: string | null; name: string | null;
description?: string | null; description?: string | null;
avatar: string | null; avatar: string | null;
avatarUrl?: string | null;
createdAt: string; createdAt: string;
members: ChatMember[]; members: ChatMember[];
messages: Message[]; messages: Message[];
@@ -152,8 +165,6 @@ export interface StoryGroup {
hasUnviewed: boolean; hasUnviewed: boolean;
} }
// ─── Utility types ─────────────────────────────────────────────────
// ─── Friend types ────────────────────────────────────────────────── // ─── Friend types ──────────────────────────────────────────────────
export interface FriendRequest { export interface FriendRequest {
@@ -1,3 +1,4 @@
import { deepNormalize } from '../utils/normalize';
const API_BASE = '/api'; const API_BASE = '/api';
export class HttpClient { export class HttpClient {
@@ -44,7 +45,8 @@ export class HttpClient {
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return response.json(); const jsonData = await response.json();
return deepNormalize(jsonData);
} }
} }
@@ -73,7 +73,7 @@ export default function Sidebar() {
return chat.members.some( return chat.members.some(
(m) => (m) =>
m.user.id !== user?.id && 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)) m.user.displayName.toLowerCase().includes(q))
); );
}).sort((a, b) => { }).sort((a, b) => {
@@ -163,7 +163,8 @@ export default function Sidebar() {
</button> </button>
{storyGroups.map((group, idx) => { {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; const isMine = group.user.id === user?.id;
return ( return (
<button <button
@@ -187,7 +188,7 @@ export default function Sidebar() {
</div> </div>
</div> </div>
<span className="text-[11px] text-zinc-400 truncate w-full text-center"> <span className="text-[11px] text-zinc-400 truncate w-full text-center">
{isMine ? t('myStory') : (group.user.displayName || group.user.username).split(' ')[0]} {isMine ? t('myStory') : (group.user.displayName || group.user.userName || group.user.username || '').split(' ')[0]}
</span> </span>
</button> </button>
); );
+67
View File
@@ -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;
}
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; 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 { 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 { interface Stats {
storageUsedBytes: number; storageUsedBytes: number;
@@ -17,8 +19,11 @@ interface Conf {
enableCalls: boolean; enableCalls: boolean;
turnHost: string; turnHost: string;
turnPort: number; turnPort: number;
// Support both cases
turnUser: string; turnUser: string;
turnUsername?: string;
turnSecret: string; turnSecret: string;
turnPassword?: string;
enableKlipy: boolean; enableKlipy: boolean;
klipyApiKey: string; klipyApiKey: string;
klipyCustomerId: string; klipyCustomerId: string;
@@ -29,9 +34,11 @@ interface Conf {
interface AppUser { interface AppUser {
id: string; id: string;
username: string; userName: string;
displayName: string; displayName: string;
avatar: string | null; avatarUrl: string | null;
username?: string;
avatar?: string | null;
bio?: string; bio?: string;
createdAt: string; createdAt: string;
lastOnlineAt: string; lastOnlineAt: string;
@@ -288,12 +295,10 @@ export default function AdminPage() {
const handleAddUser = async () => { const handleAddUser = async () => {
try { try {
const res = await fetch('/api/admin/users', { await httpClient.request('/admin/users', {
method: 'POST', method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newUser, email: null, bio: null }) body: JSON.stringify({ ...newUser, email: null, bio: null })
}); });
if (!res.ok) throw new Error();
showToast(t.createUserSuccess, 'success'); showToast(t.createUserSuccess, 'success');
setShowAddUserModal(false); setShowAddUserModal(false);
setNewUser({ username: '', displayName: '', password: '' }); setNewUser({ username: '', displayName: '', password: '' });
@@ -306,29 +311,27 @@ export default function AdminPage() {
const handleResetPassword = async (userId: string) => { const handleResetPassword = async (userId: string) => {
const newPass = generatePassword(); const newPass = generatePassword();
try { try {
const res = await fetch(`/api/admin/users/${userId}/reset-password`, { await httpClient.request(`/admin/users/${userId}/reset-password`, {
method: 'POST', method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: newPass }) body: JSON.stringify({ newPassword: newPass })
}); });
if (!res.ok) throw new Error();
setGeneratedPass(newPass); setGeneratedPass(newPass);
} catch { } catch {
showToast('Error resetting password', 'error'); showToast('Error resetting password', 'error');
} }
}; };
const fetchDashboard = async (header: string) => { const fetchDashboard = async () => {
try { try {
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } }); const res = await httpClient.request<Stats>('/admin/dashboard');
if (res.ok) setStats(await res.json()); setStats(res);
} catch {} } catch {}
}; };
const fetchSettings = async (header: string) => { const fetchSettings = async () => {
try { try {
const res = await fetch('/api/admin/settings', { headers: { Authorization: header } }); const res = await httpClient.request<Conf>('/admin/settings');
if (res.ok) setConfig(await res.json()); setConfig(res);
} catch {} } catch {}
}; };
@@ -438,15 +441,19 @@ export default function AdminPage() {
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`; const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } }); // Store in httpClient
if (res.ok) { httpClient.setToken(header); // Note: Admin uses Basic auth in this specific screen
try {
await httpClient.request('/admin/dashboard');
setAuthHeader(header); setAuthHeader(header);
setAuthenticated(true); setAuthenticated(true);
fetchDashboard(header); fetchDashboard();
fetchSettings(header); fetchSettings();
searchUsers('', header); // Fetch initial users list searchUsers('');
} else { } catch {
showToast(t.errorInvalidLogin, 'error'); showToast(t.errorInvalidLogin, 'error');
httpClient.setToken(null);
} }
}; };
@@ -455,7 +462,7 @@ export default function AdminPage() {
const interval = setInterval(() => { const interval = setInterval(() => {
// Refresh dashboard if we are on dashboard tab // Refresh dashboard if we are on dashboard tab
if (activeTab === 'dashboard') { if (activeTab === 'dashboard') {
fetchDashboard(authHeader); fetchDashboard();
} }
}, 10000); }, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
@@ -464,24 +471,21 @@ export default function AdminPage() {
const saveSettings = async () => { const saveSettings = async () => {
if (!config) return; if (!config) return;
try { try {
const res = await fetch('/api/admin/settings', { await httpClient.request('/admin/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify(config) body: JSON.stringify(config)
}); });
if (res.ok) showToast(t.successSave, 'success'); showToast(t.successSave, 'success');
} catch { } catch {
showToast(t.errorSave, 'error'); showToast(t.errorSave, 'error');
} }
}; };
const searchUsers = async (q: string, header: string = authHeader) => { const searchUsers = async (q: string) => {
setIsSearching(true); setIsSearching(true);
try { try {
const res = await fetch(`/api/admin/users?query=${encodeURIComponent(q)}`, { headers: { Authorization: header } }); const res = await httpClient.request<AppUser[]>(`/admin/users?query=${encodeURIComponent(q)}`);
if (res.ok) { setUsers(res);
setUsers(await res.json());
}
} catch {} finally { } catch {} finally {
setIsSearching(false); setIsSearching(false);
} }
@@ -489,28 +493,24 @@ export default function AdminPage() {
const handleCalcCleanup = async () => { const handleCalcCleanup = async () => {
try { try {
const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } }); const res = await httpClient.request<CleanStats>('/admin/clean/dry-run');
if (res.ok) setCleanStats(await res.json()); setCleanStats(res);
} catch {} } catch {}
}; };
const handleRunCleanup = async () => { const handleRunCleanup = async () => {
try { try {
const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } }); await httpClient.request('/admin/clean/run', { method: 'POST' });
if (res.ok) { showToast(t.cleanSuccess, 'success');
showToast(t.cleanSuccess, 'success'); setCleanStats(null);
setCleanStats(null); fetchDashboard();
fetchDashboard(authHeader);
}
} catch {} } catch {}
}; };
const fetchUserDetails = async (id: string) => { const fetchUserDetails = async (id: string) => {
try { try {
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } }); const res = await httpClient.request<AppUser>(`/admin/users/${id}`);
if (res.ok) { setSelectedUser(res);
setSelectedUser(await res.json());
}
} catch {} } catch {}
}; };
@@ -744,12 +744,16 @@ export default function AdminPage() {
</label> </label>
<label className="flex flex-col gap-1 text-sm text-gray-400"> <label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnUser} {t.turnUser}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnUser} onChange={e => setConfig({...config, turnUser: e.target.value})} /> <input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={config.turnUser || config.turnUsername || ''}
onChange={e => setConfig({...config, turnUser: e.target.value, turnUsername: e.target.value})} />
</label> </label>
</div> </div>
<label className="flex flex-col gap-1 text-sm text-gray-400"> <label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnSecret} {t.turnSecret}
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} /> <input type="password" name="turnSecret" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={config.turnSecret || config.turnPassword || ''}
onChange={e => setConfig({...config, turnSecret: e.target.value, turnPassword: e.target.value})} />
</label> </label>
<div className="flex justify-start mt-2"> <div className="flex justify-start mt-2">
<button onClick={handleTestTurn} disabled={isTestingTurn} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"> <button onClick={handleTestTurn} disabled={isTestingTurn} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
@@ -35,14 +35,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const chatName = isFavorites const chatName = isFavorites
? t('favorites') ? t('favorites')
: chat.type === 'personal' : chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat') ? otherMember?.user.displayName || otherMember?.user.userName || otherMember?.user.username || t('chat')
: chat.name || t('group'); : chat.name || t('group');
const chatAvatar = isFavorites const chatAvatar = isFavorites
? null ? null
: chat.type === 'personal' : chat.type === 'personal'
? otherMember?.user.avatar ? otherMember?.user.avatarUrl || otherMember?.user.avatar
: chat.avatar; : chat.avatarUrl || chat.avatar;
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline; const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;
@@ -3,46 +3,52 @@ import type { User, UserPresence } from '../../../core/domain/types';
export class UserApi { export class UserApi {
static async searchUsers(query: string) { static async searchUsers(query: string) {
return httpClient.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`); // Конечная точка в новом бэкенде: GET /api/profiles/search?q=...
return httpClient.request<UserPresence[]>(`/profiles/search?q=${encodeURIComponent(query)}`);
} }
static async getUser(id: string) { static async getUser(id: string) {
return httpClient.request<User>(`/users/${id}`); // Конечная точка в новом бэкенде: GET /api/profiles/{id}
return httpClient.request<User>(`/profiles/${id}`);
} }
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) { static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
return httpClient.request<User>('/users/profile', { // Конечная точка в новом бэкенде: PUT /api/profiles/profile
return httpClient.request<User>('/profiles/profile', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
static async updateSettings(settings: any) { static async updateSettings(settings: any) {
return httpClient.request('/users/settings', { // Конечная точка в новом бэкенде: PUT /api/profiles/settings
return httpClient.request('/profiles/settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(settings), body: JSON.stringify(settings),
}); });
} }
static async uploadAvatar(file: File) { static async uploadAvatar(file: File) {
// Конечная точка в новом бэкенде: POST /api/profiles/avatar
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', file); formData.append('avatar', file);
return httpClient.request<User>('/users/avatar', { return httpClient.request<User>('/profiles/avatar', {
method: 'POST', method: 'POST',
body: formData, body: formData,
timeout: 120_000, timeout: 120_000, // Аватар может быть большим, даем время на обработку
}); });
} }
static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) { static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
// Конечная точка в новом бэкенде: POST /api/profiles/avatar/crop
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', file); formData.append('avatar', file);
formData.append('cropX', cropData.x.toString()); formData.append('x', cropData.x.toString());
formData.append('cropY', cropData.y.toString()); formData.append('y', cropData.y.toString());
formData.append('cropWidth', cropData.width.toString()); formData.append('width', cropData.width.toString());
formData.append('cropHeight', cropData.height.toString()); formData.append('height', cropData.height.toString());
return httpClient.request<User>('/users/avatar/crop', { return httpClient.request<User>('/profiles/avatar/crop', {
method: 'POST', method: 'POST',
body: formData, body: formData,
timeout: 120_000, timeout: 120_000,
@@ -50,10 +56,13 @@ export class UserApi {
} }
static async removeAvatar() { static async removeAvatar() {
return httpClient.request<User>('/users/avatar', { method: 'DELETE' }); // Конечная точка в новом бэкенде: DELETE /api/profiles/avatar
return httpClient.request<User>('/profiles/avatar', { method: 'DELETE' });
} }
static async getIceServers() { static async getIceServers() {
// Конфигурация WebRTC теперь возвращается через эндпоинт админки или настроек,
// но если бэк не меняли в этой части, оставляем старый путь.
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers'); return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
} }
} }
@@ -350,9 +350,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
try { try {
setTabLoading(true); setTabLoading(true);
await UserApi.removeAvatar(); await UserApi.removeAvatar();
const updatedUser = { ...profile!, avatar: null }; const updatedUser = { ...profile!, avatar: null, avatarUrl: null };
setProfile(updatedUser); setProfile(updatedUser);
useAuthStore.getState().updateUser({ avatar: null }); useAuthStore.getState().updateUser({ avatar: null, avatarUrl: null });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
@@ -360,7 +360,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
} }
}; };
const initials = (profile?.displayName || profile?.username || '??') const initials = (profile?.displayName || profile?.userName || profile?.username || '??')
.split(' ') .split(' ')
.map((w: string) => w[0]) .map((w: string) => w[0])
.join('') .join('')
@@ -448,9 +448,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
<div className="relative group"> <div className="relative group">
<div className="relative"> <div className="relative">
{profile.avatar ? ( { (profile.avatarUrl || profile.avatar) ? (
<img <img
src={getMediaUrl(profile.avatar)} src={getMediaUrl(profile.avatarUrl || profile.avatar!)}
alt="" alt=""
className="w-32 h-32 rounded-full object-cover border-4 border-surface bg-surface" className="w-32 h-32 rounded-full object-cover border-4 border-surface bg-surface"
/> />
@@ -501,14 +501,14 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
</div> </div>
) : ( ) : (
<h3 className="mt-5 text-[24px] font-semibold text-white tracking-tight text-center px-4"> <h3 className="mt-5 text-[24px] font-semibold text-white tracking-tight text-center px-4">
{profile.displayName || profile.username} {profile.displayName || profile.userName || profile.username}
</h3> </h3>
)} )}
{/* Username (неизменяемый) */} {/* Username (неизменяемый) */}
<div className="flex items-center gap-1.5 mt-2 bg-accent/10 px-4 py-1.5 rounded-full border border-accent/20 cursor-default"> <div className="flex items-center gap-1.5 mt-2 bg-accent/10 px-4 py-1.5 rounded-full border border-accent/20 cursor-default">
<AtSign size={14} className="text-accent" /> <AtSign size={14} className="text-accent" />
<span className="text-sm font-medium text-accent">{profile.username}</span> <span className="text-sm font-medium text-accent">{profile.userName || profile.username}</span>
</div> </div>
{/* Онлайн статус */} {/* Онлайн статус */}
@@ -984,9 +984,11 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
stories={[{ stories={[{
user: { user: {
id: profile.id, id: profile.id,
userName: profile.userName || profile.username || '',
username: profile.username, username: profile.username,
displayName: profile.displayName, displayName: profile.displayName,
avatar: profile.avatar avatar: profile.avatar,
avatarUrl: profile.avatarUrl || profile.avatar
}, },
stories: sortedStories, stories: sortedStories,
hasUnviewed: false hasUnviewed: false