Правка чатов, опциональная регистрация
This commit is contained in:
@@ -3,6 +3,9 @@ using Knot.Shared.Kernel.Configuration;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Knot.Modules.Chats.Infrastructure.Persistence;
|
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||||
|
using MediatR;
|
||||||
|
using Knot.Modules.Identity.Application.Users.Register;
|
||||||
|
using Knot.Modules.Identity.Application.Abstractions;
|
||||||
|
|
||||||
namespace Host.Controllers;
|
namespace Host.Controllers;
|
||||||
|
|
||||||
@@ -14,17 +17,23 @@ public class AdminController : ControllerBase
|
|||||||
private readonly Knot.Shared.Kernel.Services.IStatisticsService _statisticsService;
|
private readonly Knot.Shared.Kernel.Services.IStatisticsService _statisticsService;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ChatsDbContext _chatsDbContext;
|
private readonly ChatsDbContext _chatsDbContext;
|
||||||
|
private readonly ISender _sender;
|
||||||
|
private readonly IIdentityUnitOfWork _identityUnitOfWork;
|
||||||
|
|
||||||
public AdminController(
|
public AdminController(
|
||||||
ISettingsService settingsService,
|
ISettingsService settingsService,
|
||||||
Knot.Shared.Kernel.Services.IStatisticsService statisticsService,
|
Knot.Shared.Kernel.Services.IStatisticsService statisticsService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ChatsDbContext chatsDbContext)
|
ChatsDbContext chatsDbContext,
|
||||||
|
ISender sender,
|
||||||
|
IIdentityUnitOfWork identityUnitOfWork)
|
||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_statisticsService = statisticsService;
|
_statisticsService = statisticsService;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_chatsDbContext = chatsDbContext;
|
_chatsDbContext = chatsDbContext;
|
||||||
|
_sender = sender;
|
||||||
|
_identityUnitOfWork = identityUnitOfWork;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
@@ -48,6 +57,40 @@ public class AdminController : ControllerBase
|
|||||||
return Ok(stats);
|
return Ok(stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("users")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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")]
|
[HttpGet("users")]
|
||||||
public async Task<IActionResult> SearchUsers([FromQuery] string query = "", CancellationToken ct = default)
|
public async Task<IActionResult> 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ using Knot.Modules.Identity.Application.Users.Register;
|
|||||||
using Knot.Modules.Identity.Application.Users.Login;
|
using Knot.Modules.Identity.Application.Users.Login;
|
||||||
using Knot.Modules.Identity.Application.Abstractions;
|
using Knot.Modules.Identity.Application.Abstractions;
|
||||||
using Knot.Modules.Identity.Domain;
|
using Knot.Modules.Identity.Domain;
|
||||||
|
using Knot.Modules.Identity.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
|
||||||
namespace Host.Controllers;
|
namespace Host.Controllers;
|
||||||
|
|
||||||
@@ -16,17 +18,23 @@ public sealed class AuthController : ControllerBase
|
|||||||
private readonly ISender _sender;
|
private readonly ISender _sender;
|
||||||
private readonly IJwtTokenProvider _tokenProvider;
|
private readonly IJwtTokenProvider _tokenProvider;
|
||||||
private readonly IUserRepository _userRepository;
|
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;
|
_sender = sender;
|
||||||
_tokenProvider = tokenProvider;
|
_tokenProvider = tokenProvider;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command)
|
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command)
|
||||||
{
|
{
|
||||||
|
if (!_settings.Current.EnableRegistration)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Registration is disabled by the administrator." });
|
||||||
|
}
|
||||||
Result<Guid> result = await _sender.Send(command);
|
Result<Guid> result = await _sender.Send(command);
|
||||||
|
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class ConfigController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult GetPublicConfig()
|
public IActionResult GetPublicConfig()
|
||||||
{
|
{
|
||||||
var conf = _settings.Current;
|
var conf = _settings.Current;
|
||||||
@@ -26,7 +27,8 @@ public class ConfigController : ControllerBase
|
|||||||
conf.EnableKlipy,
|
conf.EnableKlipy,
|
||||||
conf.MaxFileSizeMb,
|
conf.MaxFileSizeMb,
|
||||||
conf.MaxGroupMembers,
|
conf.MaxGroupMembers,
|
||||||
conf.EnableConfederation
|
conf.EnableConfederation,
|
||||||
|
conf.EnableRegistration
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class Story : Entity<Guid>
|
|||||||
public IReadOnlyCollection<StoryReaction> Reactions => _reactions.AsReadOnly();
|
public IReadOnlyCollection<StoryReaction> Reactions => _reactions.AsReadOnly();
|
||||||
public IReadOnlyCollection<StoryReply> Replies => _replies.AsReadOnly();
|
public IReadOnlyCollection<StoryReply> 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)
|
internal Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,4 +52,9 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
{
|
{
|
||||||
HideStoryViews = hideStoryViews;
|
HideStoryViews = hideStoryViews;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ChangePassword(string newPasswordHash)
|
||||||
|
{
|
||||||
|
PasswordHash = newPasswordHash;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -58,7 +58,7 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
|
|||||||
builder.Property(s => s.Content)
|
builder.Property(s => s.Content)
|
||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => v == null ? null : _encryptionService.EncryptMessage(v),
|
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
|
// Configure backing fields for collections
|
||||||
@@ -106,8 +106,8 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => v == null ? null : _encryptionService.EncryptMessage(v),
|
v => _encryptionService.EncryptMessage(v) ?? string.Empty,
|
||||||
v => v == null ? null : _encryptionService.DecryptMessage(v)
|
v => _encryptionService.DecryptMessage(v) ?? string.Empty
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ public class SystemSettingsDto
|
|||||||
// Confederation
|
// Confederation
|
||||||
public bool EnableConfederation { get; set; } = false;
|
public bool EnableConfederation { get; set; } = false;
|
||||||
public List<string> AllowedDomains { get; set; } = new();
|
public List<string> AllowedDomains { get; set; } = new();
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
public bool EnableRegistration { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||||
const lastObservedMessageIdRef = useRef<string | null>(null);
|
const lastObservedMessageIdRef = useRef<string | null>(null);
|
||||||
const prevScrollHeightRef = useRef<number>(0);
|
const prevScrollHeightRef = useRef<number>(0);
|
||||||
|
const initialScrollChatId = useRef<string | null>(null);
|
||||||
|
|
||||||
// Load muted state
|
// Load muted state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,7 +186,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}, [activeChat]);
|
}, [activeChat]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isLoadingMessages && messagesContainerRef.current) {
|
if (!isLoadingMessages && messagesContainerRef.current && activeChat !== initialScrollChatId.current) {
|
||||||
|
initialScrollChatId.current = activeChat;
|
||||||
const container = messagesContainerRef.current;
|
const container = messagesContainerRef.current;
|
||||||
const unreadId = sessionUnreadRef.current.msgId;
|
const unreadId = sessionUnreadRef.current.msgId;
|
||||||
|
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`max-w-[65%] ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||||
{!isMine && showAvatar && (
|
{!isMine && showAvatar && (
|
||||||
<button
|
<button
|
||||||
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
||||||
@@ -412,7 +412,7 @@ function MessageBubble({
|
|||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onDoubleClick={handleReply}
|
onDoubleClick={handleReply}
|
||||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||||
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
|
className={`cursor-pointer max-w-full min-w-0 rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
|
||||||
hasImage && !message.content && !message.forwardedFrom && !message.replyTo
|
hasImage && !message.content && !message.forwardedFrom && !message.replyTo
|
||||||
? 'p-0 shadow-none border-none'
|
? 'p-0 shadow-none border-none'
|
||||||
: isMine
|
: isMine
|
||||||
@@ -467,7 +467,7 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<p className={`text-[13px] truncate ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
||||||
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => {
|
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => {
|
||||||
const m = message.replyTo.media[0];
|
const m = message.replyTo.media[0];
|
||||||
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF';
|
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF';
|
||||||
@@ -508,7 +508,7 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className={`text-[13px] truncate ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
||||||
{message.quote}
|
{message.quote}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 } from 'lucide-react';
|
import { Settings, Shield, Activity, Users, Database, Globe, Search, Trash2, Plus, Download, Upload, Loader2, User, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
storageUsedBytes: number;
|
storageUsedBytes: number;
|
||||||
@@ -24,6 +24,7 @@ interface Conf {
|
|||||||
klipyCustomerId: string;
|
klipyCustomerId: string;
|
||||||
enableConfederation: boolean;
|
enableConfederation: boolean;
|
||||||
allowedDomains: string[];
|
allowedDomains: string[];
|
||||||
|
enableRegistration: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppUser {
|
interface AppUser {
|
||||||
@@ -96,7 +97,19 @@ const translations = {
|
|||||||
mediaSent: 'Media Sent',
|
mediaSent: 'Media Sent',
|
||||||
filesSent: 'Files Sent',
|
filesSent: 'Files Sent',
|
||||||
linksSent: 'Links 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: {
|
ru: {
|
||||||
loginTitle: 'Вход администратора',
|
loginTitle: 'Вход администратора',
|
||||||
@@ -149,7 +162,19 @@ const translations = {
|
|||||||
mediaSent: 'Отправлено медиа',
|
mediaSent: 'Отправлено медиа',
|
||||||
filesSent: 'Отправлено файлов',
|
filesSent: 'Отправлено файлов',
|
||||||
linksSent: 'Отправлено ссылок',
|
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('');
|
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) => {
|
const fetchDashboard = async (header: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
|
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
|
||||||
@@ -213,7 +289,7 @@ export default function AdminPage() {
|
|||||||
fetchSettings(header);
|
fetchSettings(header);
|
||||||
searchUsers('', header); // Fetch initial users list
|
searchUsers('', header); // Fetch initial users list
|
||||||
} else {
|
} else {
|
||||||
alert(t.errorInvalidLogin);
|
showToast(t.errorInvalidLogin, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,9 +312,9 @@ export default function AdminPage() {
|
|||||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
});
|
});
|
||||||
if (res.ok) alert(t.successSave);
|
if (res.ok) showToast(t.successSave, 'success');
|
||||||
} catch {
|
} catch {
|
||||||
alert(t.errorSave);
|
showToast(t.errorSave, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -415,6 +491,16 @@ export default function AdminPage() {
|
|||||||
<motion.div key="settings" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 max-w-3xl pb-20">
|
<motion.div key="settings" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 max-w-3xl pb-20">
|
||||||
<h1 className="text-3xl font-bold mb-4">{t.moduleSettings}</h1>
|
<h1 className="text-3xl font-bold mb-4">{t.moduleSettings}</h1>
|
||||||
|
|
||||||
|
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-white">{t.authSecurity}</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">{t.registrationDesc}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={config.enableRegistration} onChange={e => setConfig({...config, enableRegistration: e})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
|
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
|
||||||
<h3 className="text-xl font-semibold mb-2">{t.limits}</h3>
|
<h3 className="text-xl font-semibold mb-2">{t.limits}</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -565,6 +651,9 @@ export default function AdminPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-3xl font-bold">{t.userAnalytics}</h1>
|
<h1 className="text-3xl font-bold">{t.userAnalytics}</h1>
|
||||||
|
<button onClick={() => setShowAddUserModal(true)} className="flex items-center gap-2 bg-accent hover:bg-accentLight text-black font-semibold py-2 px-4 rounded-xl transition-colors">
|
||||||
|
<Plus className="w-5 h-5" /> {t.addUser}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -666,6 +755,30 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 border-t border-white/10 pt-8 flex flex-col gap-4">
|
||||||
|
<h3 className="text-xl font-semibold text-red-400">{t.authSecurity}</h3>
|
||||||
|
<div className="bg-red-400/5 border border-red-400/20 p-5 rounded-xl flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-white">{t.resetPassword}</h4>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Generate a new strong password for this user and invalidate the old one.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleResetPassword(selectedUser.id)} className="bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-6 py-2 rounded-xl transition-colors font-semibold whitespace-nowrap">
|
||||||
|
{t.resetPassword}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{generatedPass && (
|
||||||
|
<div className="bg-green-400/10 border border-green-400/20 p-4 rounded-xl flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 font-mono text-green-400 tracking-wider">
|
||||||
|
<span className="text-xs text-gray-500 block mb-1 uppercase font-sans tracking-normal">{t.newPassword}</span>
|
||||||
|
{generatedPass}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { navigator.clipboard.writeText(generatedPass); showToast(t.copied, 'success'); }} className="text-accent bg-accent/10 px-4 py-2 rounded-lg text-sm hover:bg-accent/20 transition-colors cursor-pointer">
|
||||||
|
{t.copyPassword}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -676,6 +789,81 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Add User Modal */}
|
||||||
|
{showAddUserModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" onClick={() => setShowAddUserModal(false)}>
|
||||||
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} onClick={e => 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">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">{t.addUser}</h2>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
|
||||||
|
value={newUser.username}
|
||||||
|
onChange={e => setNewUser({...newUser, username: e.target.value.toLowerCase()})}
|
||||||
|
placeholder="Ex: john_doe"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||||
|
Display Name
|
||||||
|
<input
|
||||||
|
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
|
||||||
|
value={newUser.displayName}
|
||||||
|
onChange={e => setNewUser({...newUser, displayName: e.target.value})}
|
||||||
|
placeholder="Ex: John Doe"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||||
|
Password
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent flex-1 font-mono"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={e => setNewUser({...newUser, password: e.target.value})}
|
||||||
|
placeholder="Strong password..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setNewUser({...newUser, password: generatePassword()})}
|
||||||
|
className="bg-white/10 hover:bg-white/20 px-4 rounded-xl transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-white/10">
|
||||||
|
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">Cancel</button>
|
||||||
|
<button onClick={handleAddUser} disabled={!newUser.username || !newUser.displayName || !newUser.password} className="bg-accent text-black font-semibold px-6 py-2 rounded-xl hover:bg-accentLight transition-colors disabled:opacity-50">
|
||||||
|
{t.createUser}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Toast */}
|
||||||
|
<div className="fixed top-4 right-4 z-[9999] pointer-events-none flex flex-col items-end gap-2">
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
|
||||||
|
className={`pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-2xl shadow-xl shadow-black/50 border backdrop-blur-md font-medium text-[15px]
|
||||||
|
${toast.type === 'success'
|
||||||
|
? 'bg-green-500/10 border-green-500/20 text-green-400'
|
||||||
|
: 'bg-red-500/10 border-red-500/20 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? <CheckCircle className="w-5 h-5" /> : <XCircle className="w-5 h-5" />}
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
@@ -14,7 +14,20 @@ export default function AuthPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { login, register } = useAuthStore();
|
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) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,15 +52,13 @@ export default function AuthPage() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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]"
|
||||||
>
|
>
|
||||||
{/* Анимированный фон */}
|
{/* Переключатель языка сверху по центру */}
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] opacity-20">
|
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
|
||||||
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-accent/30 to-purple-600/30 blur-[120px] animate-pulse" />
|
<div className="w-px h-4 bg-white/10 self-center" />
|
||||||
</div>
|
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
|
||||||
<div className="absolute top-20 left-20 w-72 h-72 bg-accent/10 rounded-full blur-[100px]" />
|
|
||||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-purple-500/10 rounded-full blur-[100px]" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Карточка авторизации */}
|
{/* Карточка авторизации */}
|
||||||
@@ -55,24 +66,23 @@ export default function AuthPage() {
|
|||||||
initial={{ scale: 0.95, y: 20 }}
|
initial={{ scale: 0.95, y: 20 }}
|
||||||
animate={{ scale: 1, y: 0 }}
|
animate={{ scale: 1, y: 0 }}
|
||||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
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"
|
||||||
>
|
>
|
||||||
<div className="glass-strong rounded-3xl p-8 shadow-2xl shadow-accent/5">
|
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
|
||||||
|
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-10">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ rotate: -180, scale: 0 }}
|
initial={{ rotate: -180, scale: 0 }}
|
||||||
animate={{ rotate: 0, scale: 1 }}
|
animate={{ rotate: 0, scale: 1 }}
|
||||||
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
||||||
className="w-24 h-24 rounded-[2rem] bg-gradient-to-br from-accent/20 to-purple-600/20 flex items-center justify-center shadow-2xl shadow-accent/20 border-2 border-white/10 relative overflow-hidden"
|
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-white/[0.05] to-transparent pointer-events-none" />
|
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
|
||||||
<MessageSquare className="w-10 h-10 text-accent drop-shadow-md relative z-10" />
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-3xl font-bold gradient-text mt-6">Knot Messenger</h1>
|
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
|
||||||
<p className="text-zinc-500 text-sm mt-2 tracking-wide uppercase">
|
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
|
||||||
{isLogin ? t('login') : t('register')}
|
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,7 +93,7 @@ export default function AuthPage() {
|
|||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
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"
|
role="alert"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
@@ -94,15 +104,15 @@ export default function AuthPage() {
|
|||||||
{/* Форма */}
|
{/* Форма */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
Username {!isLogin && <span className="text-zinc-600 font-normal">{t('latinOnly')}</span>}
|
Username {!isLogin && <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
|
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
|
||||||
placeholder="username"
|
placeholder="username"
|
||||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/25 transition-all outline-hidden"
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -118,31 +128,33 @@ export default function AuthPage() {
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="pt-2">
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
{t('displayNameLabel')}
|
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder={t('displayNamePlaceholder')}
|
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
|
||||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-knot-500/50 focus:ring-1 focus:ring-knot-500/25 transition-all outline-hidden"
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div>
|
<div className={!isLogin ? "pt-2" : ""}>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">{t('password')}</label>
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
|
{lang === 'ru' ? 'Пароль' : 'Password'}
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={t('passwordPlaceholder')}
|
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
|
||||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/25 transition-all pr-12 outline-hidden"
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-hidden text-[15px]"
|
||||||
required
|
required
|
||||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||||
minLength={8}
|
minLength={8}
|
||||||
@@ -150,15 +162,15 @@ export default function AuthPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!isLogin && (
|
{!isLogin && (
|
||||||
<p className="mt-1.5 text-xs text-zinc-500 flex items-center gap-1.5">
|
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
|
||||||
<div className="w-1 h-1 rounded-full bg-accent" />
|
<div className="w-1 h-1 rounded-full bg-[#9b66ff]" />
|
||||||
{t('passwordRequirements')}
|
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -171,14 +183,18 @@ export default function AuthPage() {
|
|||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">{t('aboutMe')}</label>
|
<div className="pt-2">
|
||||||
<input
|
<label className="block text-sm font-semibold text-zinc-300 mb-2">
|
||||||
type="text"
|
{lang === 'ru' ? 'О себе' : 'About me'}
|
||||||
value={bio}
|
</label>
|
||||||
onChange={(e) => setBio(e.target.value)}
|
<input
|
||||||
placeholder={t('bioPlaceholder')}
|
type="text"
|
||||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/25 transition-all outline-hidden"
|
value={bio}
|
||||||
/>
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
placeholder={lang === 'ru' ? 'Расскажите о себе (необязательно)' : 'Tell about yourself (optional)'}
|
||||||
|
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-hidden text-[15px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -188,13 +204,14 @@ export default function AuthPage() {
|
|||||||
whileTap={{ scale: 0.99 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-accent to-purple-600 text-white font-medium shadow-lg shadow-accent/25 hover:shadow-accent/40 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
|
||||||
|
style={{ marginTop: '32px' }}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isLogin ? t('loginBtn') : t('createAccount')}
|
{isLogin ? (lang === 'ru' ? 'Войти' : 'Login') : (lang === 'ru' ? 'Создать аккаунт' : 'Create account')}
|
||||||
<ArrowRight size={18} />
|
<ArrowRight size={18} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -202,9 +219,11 @@ export default function AuthPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Футер с переключателем */}
|
{/* Футер с переключателем */}
|
||||||
<div className="mt-8 pt-6 border-t border-white/5 text-center">
|
{enableRegistration && (
|
||||||
<p className="text-zinc-500 text-sm">
|
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
|
||||||
{isLogin ? t('dontHaveAccount') : t('alreadyHaveAccount')}{' '}
|
<p className="text-zinc-500 text-[13px] font-medium">
|
||||||
|
{isLogin ? (lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?") : (lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?')}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsLogin(!isLogin);
|
setIsLogin(!isLogin);
|
||||||
@@ -213,12 +232,13 @@ export default function AuthPage() {
|
|||||||
setDisplayName('');
|
setDisplayName('');
|
||||||
setBio('');
|
setBio('');
|
||||||
}}
|
}}
|
||||||
className="text-accent hover:text-accent font-medium transition-colors ml-1"
|
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors type-button"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{isLogin ? t('registerNow') : t('loginNow')}
|
{isLogin ? (lang === 'ru' ? 'Зарегистрироваться' : 'Register') : (lang === 'ru' ? 'Войти' : 'Login')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
loadMessages: async (chatId, reset = false, isHistory = false) => {
|
loadMessages: async (chatId, reset = false, isHistory = false) => {
|
||||||
try {
|
try {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (!reset && !isHistory && state.messages[chatId] && state.messages[chatId].length > 0) return;
|
if (!reset && !isHistory && typeof state.hasMoreMessages[chatId] !== 'undefined') return;
|
||||||
if (!reset && isHistory && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
|
if (!reset && isHistory && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
|
||||||
if (state.isLoadingMessages) return;
|
if (state.isLoadingMessages) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user