From 67bf8319aa7b7d7e735014f4e14ae17959d0f8fb 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: Tue, 17 Mar 2026 11:16:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=B2,=20=D0=BE=D0=BF=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Host/Controllers/AdminController.cs | 50 ++++- .../src/Host/Controllers/AuthController.cs | 10 +- .../src/Host/Controllers/ConfigController.cs | 4 +- .../src/Modules/Identity/Domain/Story.cs | 2 +- .../src/Modules/Identity/Domain/User.cs | 5 + .../Persistence/IdentityDbContext.cs | 6 +- .../Configuration/SystemSettingsDto.cs | 3 + apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/MessageBubble.tsx | 8 +- apps/web/src/pages/AdminPage.tsx | 200 +++++++++++++++++- apps/web/src/pages/AuthPage.tsx | 126 ++++++----- apps/web/src/stores/chatStore.ts | 2 +- 12 files changed, 348 insertions(+), 72 deletions(-) diff --git a/apps/server-net/src/Host/Controllers/AdminController.cs b/apps/server-net/src/Host/Controllers/AdminController.cs index c7d5704..3fa2a88 100644 --- a/apps/server-net/src/Host/Controllers/AdminController.cs +++ b/apps/server-net/src/Host/Controllers/AdminController.cs @@ -3,6 +3,9 @@ using Knot.Shared.Kernel.Configuration; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Knot.Modules.Chats.Infrastructure.Persistence; +using MediatR; +using Knot.Modules.Identity.Application.Users.Register; +using Knot.Modules.Identity.Application.Abstractions; namespace Host.Controllers; @@ -14,17 +17,23 @@ public class AdminController : ControllerBase private readonly Knot.Shared.Kernel.Services.IStatisticsService _statisticsService; private readonly IUserRepository _userRepository; private readonly ChatsDbContext _chatsDbContext; + private readonly ISender _sender; + private readonly IIdentityUnitOfWork _identityUnitOfWork; public AdminController( ISettingsService settingsService, Knot.Shared.Kernel.Services.IStatisticsService statisticsService, IUserRepository userRepository, - ChatsDbContext chatsDbContext) + ChatsDbContext chatsDbContext, + ISender sender, + IIdentityUnitOfWork identityUnitOfWork) { _settingsService = settingsService; _statisticsService = statisticsService; _userRepository = userRepository; _chatsDbContext = chatsDbContext; + _sender = sender; + _identityUnitOfWork = identityUnitOfWork; } [HttpGet("settings")] @@ -47,6 +56,40 @@ public class AdminController : ControllerBase var stats = await _statisticsService.GetDashboardStatsAsync(ct); return Ok(stats); } + + [HttpPost("users")] + public async Task CreateUser([FromBody] RegisterUserCommand command) + { + var result = await _sender.Send(command); + if (result.IsFailure) + { + return BadRequest(new { error = result.Error.Description }); + } + + var user = await _userRepository.GetByIdAsync(result.Value, default); + return Ok(new { + user.Id, + user.Username, + user.DisplayName, + user.Email, + user.CreatedAt + }); + } + + [HttpPost("users/{userId:guid}/reset-password")] + public async Task ResetUserPassword(Guid userId, [FromBody] ResetPasswordDto dto, CancellationToken ct) + { + var user = await _userRepository.GetByIdAsync(userId, ct); + if (user == null) return NotFound(new { error = "User not found" }); + + if (string.IsNullOrWhiteSpace(dto.NewPassword)) return BadRequest(new { error = "Password cannot be empty" }); + + var hash = BCrypt.Net.BCrypt.HashPassword(dto.NewPassword); + user.ChangePassword(hash); + + await _identityUnitOfWork.SaveChangesAsync(ct); + return Ok(new { success = true }); + } [HttpGet("users")] public async Task SearchUsers([FromQuery] string query = "", CancellationToken ct = default) @@ -115,3 +158,8 @@ public class AdminController : ControllerBase }); } } + +public class ResetPasswordDto +{ + public string NewPassword { get; set; } = string.Empty; +} diff --git a/apps/server-net/src/Host/Controllers/AuthController.cs b/apps/server-net/src/Host/Controllers/AuthController.cs index 1124ce8..d7432f5 100644 --- a/apps/server-net/src/Host/Controllers/AuthController.cs +++ b/apps/server-net/src/Host/Controllers/AuthController.cs @@ -5,7 +5,9 @@ using Knot.Modules.Identity.Application.Users.Register; using Knot.Modules.Identity.Application.Users.Login; using Knot.Modules.Identity.Application.Abstractions; using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Domain; using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; namespace Host.Controllers; @@ -16,17 +18,23 @@ public sealed class AuthController : ControllerBase private readonly ISender _sender; private readonly IJwtTokenProvider _tokenProvider; private readonly IUserRepository _userRepository; + private readonly ISettingsService _settings; - public AuthController(ISender sender, IJwtTokenProvider tokenProvider, IUserRepository userRepository) + public AuthController(ISender sender, IJwtTokenProvider tokenProvider, IUserRepository userRepository, ISettingsService settings) { _sender = sender; _tokenProvider = tokenProvider; _userRepository = userRepository; + _settings = settings; } [HttpPost("register")] public async Task Register([FromBody] RegisterUserCommand command) { + if (!_settings.Current.EnableRegistration) + { + return BadRequest(new { error = "Registration is disabled by the administrator." }); + } Result result = await _sender.Send(command); if (result.IsFailure) diff --git a/apps/server-net/src/Host/Controllers/ConfigController.cs b/apps/server-net/src/Host/Controllers/ConfigController.cs index b1618bd..885db7c 100644 --- a/apps/server-net/src/Host/Controllers/ConfigController.cs +++ b/apps/server-net/src/Host/Controllers/ConfigController.cs @@ -17,6 +17,7 @@ public class ConfigController : ControllerBase } [HttpGet] + [AllowAnonymous] public IActionResult GetPublicConfig() { var conf = _settings.Current; @@ -26,7 +27,8 @@ public class ConfigController : ControllerBase conf.EnableKlipy, conf.MaxFileSizeMb, conf.MaxGroupMembers, - conf.EnableConfederation + conf.EnableConfederation, + conf.EnableRegistration }); } } diff --git a/apps/server-net/src/Modules/Identity/Domain/Story.cs b/apps/server-net/src/Modules/Identity/Domain/Story.cs index 93a0407..50f8848 100644 --- a/apps/server-net/src/Modules/Identity/Domain/Story.cs +++ b/apps/server-net/src/Modules/Identity/Domain/Story.cs @@ -20,7 +20,7 @@ public class Story : Entity public IReadOnlyCollection Reactions => _reactions.AsReadOnly(); public IReadOnlyCollection Replies => _replies.AsReadOnly(); - protected Story() : base(Guid.NewGuid()) { } + protected Story() : base(Guid.NewGuid()) { Type = string.Empty; } internal Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id) { diff --git a/apps/server-net/src/Modules/Identity/Domain/User.cs b/apps/server-net/src/Modules/Identity/Domain/User.cs index 5d9f0fe..fc64fdd 100644 --- a/apps/server-net/src/Modules/Identity/Domain/User.cs +++ b/apps/server-net/src/Modules/Identity/Domain/User.cs @@ -52,4 +52,9 @@ public sealed class User : AggregateRoot { HideStoryViews = hideStoryViews; } + + public void ChangePassword(string newPasswordHash) + { + PasswordHash = newPasswordHash; + } } diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs index b7ade0f..db9da30 100644 --- a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs +++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs @@ -58,7 +58,7 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork builder.Property(s => s.Content) .HasConversion( v => v == null ? null : _encryptionService.EncryptMessage(v), - v => v == null ? null : _encryptionService.DecryptMessage(v) + v => v == null ? null : _encryptionService.DecryptMessage(v)! ); // Configure backing fields for collections @@ -106,8 +106,8 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork .IsRequired() .HasMaxLength(500) .HasConversion( - v => v == null ? null : _encryptionService.EncryptMessage(v), - v => v == null ? null : _encryptionService.DecryptMessage(v) + v => _encryptionService.EncryptMessage(v) ?? string.Empty, + v => _encryptionService.DecryptMessage(v) ?? string.Empty ); }); diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs index dbe67bb..60bf0b2 100644 --- a/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs @@ -22,4 +22,7 @@ public class SystemSettingsDto // Confederation public bool EnableConfederation { get; set; } = false; public List AllowedDomains { get; set; } = new(); + + // Auth + public bool EnableRegistration { get; set; } = true; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2c441f8..c5196c8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -116,6 +116,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; const lastObservedMessageIdRef = useRef(null); const prevScrollHeightRef = useRef(0); + const initialScrollChatId = useRef(null); // Load muted state useEffect(() => { @@ -185,7 +186,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal }, [activeChat]); useLayoutEffect(() => { - if (!isLoadingMessages && messagesContainerRef.current) { + if (!isLoadingMessages && messagesContainerRef.current && activeChat !== initialScrollChatId.current) { + initialScrollChatId.current = activeChat; const container = messagesContainerRef.current; const unreadId = sessionUnreadRef.current.msgId; diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index bb0dd82..f1c3881 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -397,7 +397,7 @@ function MessageBubble({ )} -
+
{!isMine && showAvatar && (
)} -

+

{message.quote}

diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index c265400..83d590a 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -1,6 +1,6 @@ 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 } from 'lucide-react'; +import { Settings, Shield, Activity, Users, Database, Globe, Search, Trash2, Plus, Download, Upload, Loader2, User, CheckCircle, XCircle } from 'lucide-react'; interface Stats { storageUsedBytes: number; @@ -24,6 +24,7 @@ interface Conf { klipyCustomerId: string; enableConfederation: boolean; allowedDomains: string[]; + enableRegistration: boolean; } interface AppUser { @@ -96,7 +97,19 @@ const translations = { mediaSent: 'Media Sent', filesSent: 'Files Sent', linksSent: 'Links Sent', - userStorageOccupied: 'Storage Occupied' + userStorageOccupied: 'Storage Occupied', + authSecurity: 'Auth & Security', + registration: 'User Registration', + registrationDesc: 'Allow new users to create accounts', + addUser: 'Add User', + resetPassword: 'Reset Password', + newPassword: 'New Password', + copyPassword: 'Copy', + copied: 'Copied!', + createUserSuccess: 'User created successfully', + passwordResetSuccess: 'Password reset successfully', + createUserError: 'Error creating user', + createUser: 'Create User', }, ru: { loginTitle: 'Вход администратора', @@ -149,7 +162,19 @@ const translations = { mediaSent: 'Отправлено медиа', filesSent: 'Отправлено файлов', linksSent: 'Отправлено ссылок', - userStorageOccupied: 'Места на диске' + userStorageOccupied: 'Места на диске', + authSecurity: 'Авторизация', + registration: 'Регистрация пользователей', + registrationDesc: 'Разрешить регистрацию новых аккаунтов', + addUser: 'Добавить пользователя', + resetPassword: 'Сбросить пароль', + newPassword: 'Новый пароль', + copyPassword: 'Копировать', + copied: 'Скопировано!', + createUserSuccess: 'Пользователь успешно создан', + passwordResetSuccess: 'Пароль успешно сброшен', + createUserError: 'Ошибка при создании', + createUser: 'Создать', } }; @@ -188,6 +213,57 @@ export default function AdminPage() { const [domainInput, setDomainInput] = useState(''); + // Add User State + const [showAddUserModal, setShowAddUserModal] = useState(false); + const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' }); + const [generatedPass, setGeneratedPass] = useState(''); + + const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null); + + const showToast = (message: string, type: 'success' | 'error' = 'success') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 3000); + }; + + const generatePassword = () => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()'; + let pass = ''; + for(let i=0; i<12; i++) pass += chars[Math.floor(Math.random() * chars.length)]; + return pass; + }; + + const handleAddUser = async () => { + try { + const res = await fetch('/api/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: '' }); + searchUsers(searchQuery); + } catch { + showToast(t.createUserError, 'error'); + } + }; + + const handleResetPassword = async (userId: string) => { + const newPass = generatePassword(); + try { + const res = await fetch(`/api/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) => { try { const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } }); @@ -213,7 +289,7 @@ export default function AdminPage() { fetchSettings(header); searchUsers('', header); // Fetch initial users list } else { - alert(t.errorInvalidLogin); + showToast(t.errorInvalidLogin, 'error'); } }; @@ -236,9 +312,9 @@ export default function AdminPage() { headers: { 'Content-Type': 'application/json', Authorization: authHeader }, body: JSON.stringify(config) }); - if (res.ok) alert(t.successSave); + if (res.ok) showToast(t.successSave, 'success'); } catch { - alert(t.errorSave); + showToast(t.errorSave, 'error'); } }; @@ -415,6 +491,16 @@ export default function AdminPage() {

{t.moduleSettings}

+
+
+
+

{t.authSecurity}

+

{t.registrationDesc}

+
+ setConfig({...config, enableRegistration: e})} /> +
+
+

{t.limits}

@@ -565,6 +651,9 @@ export default function AdminPage() { <>

{t.userAnalytics}

+
@@ -666,6 +755,30 @@ export default function AdminPage() {
)} + +
+

{t.authSecurity}

+
+
+

{t.resetPassword}

+

Generate a new strong password for this user and invalidate the old one.

+
+ +
+ {generatedPass && ( +
+
+ {t.newPassword} + {generatedPass} +
+ +
+ )} +
@@ -676,6 +789,81 @@ export default function AdminPage() { + + {/* Add User Modal */} + {showAddUserModal && ( +
setShowAddUserModal(false)}> + e.stopPropagation()} className="w-full max-w-md bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-4 shadow-2xl"> +

{t.addUser}

+ + + + + + + +
+ + +
+
+
+ )} + + {/* Global Toast */} +
+ + {toast && ( + + {toast.type === 'success' ? : } + {toast.message} + + )} + +
); } diff --git a/apps/web/src/pages/AuthPage.tsx b/apps/web/src/pages/AuthPage.tsx index 3e3e719..f68f6b3 100644 --- a/apps/web/src/pages/AuthPage.tsx +++ b/apps/web/src/pages/AuthPage.tsx @@ -1,4 +1,4 @@ -import { useState, FormEvent } from 'react'; +import { useState, FormEvent, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useAuthStore } from '../stores/authStore'; import { useLang } from '../lib/i18n'; @@ -14,7 +14,20 @@ export default function AuthPage() { const [error, setError] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const { login, register } = useAuthStore(); - const { t } = useLang(); + const { t, lang, setLang } = useLang(); + const [enableRegistration, setEnableRegistration] = useState(true); + + useEffect(() => { + fetch('/api/config') + .then(res => res.json()) + .then(data => { + if (data && typeof data.enableRegistration === 'boolean') { + setEnableRegistration(data.enableRegistration); + if (!data.enableRegistration) setIsLogin(true); + } + }) + .catch(() => {}); + }, []); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -39,15 +52,13 @@ export default function AuthPage() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="h-full flex items-center justify-center relative overflow-hidden bg-surface" + className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]" > - {/* Анимированный фон */} -
-
-
-
-
-
+ {/* Переключатель языка сверху по центру */} +
+ +
+
{/* Карточка авторизации */} @@ -55,24 +66,23 @@ export default function AuthPage() { initial={{ scale: 0.95, y: 20 }} animate={{ scale: 1, y: 0 }} transition={{ duration: 0.4, ease: 'easeOut' }} - className="relative z-10 w-full max-w-md mx-4" + className="relative z-10 w-full max-w-[420px] mx-4" > -
+
{/* Заголовок */} -
+
-
- + -

Knot Messenger

-

- {isLogin ? t('login') : t('register')} +

Knot Messenger

+

+ {isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}

@@ -83,7 +93,7 @@ export default function AuthPage() { initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} - className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm" + className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center" role="alert" > {error} @@ -94,15 +104,15 @@ export default function AuthPage() { {/* Форма */}
-