From 818b7c1b46833580ad44e424e91ea8cf1585cd67 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 20 Oct 2025 00:18:34 +0300 Subject: [PATCH 1/8] Implement usernames and profile sections --- backend/models.py | 3 + backend/routes/account.py | 13 +- backend/routes/messaging.py | 7 +- backend/routes/profile.py | 63 +++- backend/validation.py | 12 +- frontend/src/core/api/profileApi.ts | 29 +- frontend/src/core/types.d.ts | 4 + frontend/src/pages/auth/LoginPage.tsx | 2 +- frontend/src/pages/auth/RegisterPage.tsx | 29 +- .../src/pages/chat/css/_profile-dialog.scss | 31 +- frontend/src/pages/chat/hooks/useDM.ts | 1 + frontend/src/pages/chat/state.ts | 1 + frontend/src/pages/chat/ui/ProfileDialog.tsx | 297 +++++++++++++----- .../src/pages/chat/ui/left/ChatHeader.tsx | 3 +- frontend/src/pages/chat/ui/left/ChatTabs.tsx | 7 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 23 +- .../src/pages/chat/ui/right/ChatMessages.tsx | 4 +- frontend/src/pages/chat/ui/right/Message.tsx | 6 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 6 +- .../chat/ui/right/panels/MessagePanel.ts | 1 + .../chat/ui/right/panels/PublicChatPanel.ts | 5 +- 21 files changed, 425 insertions(+), 122 deletions(-) diff --git a/backend/models.py b/backend/models.py index c459c1b..bdc93a8 100644 --- a/backend/models.py +++ b/backend/models.py @@ -13,6 +13,7 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False, index=True) + display_name = Column(String(64), nullable=False) password_hash = Column(String(200), nullable=False) profile_picture = Column(String(255), nullable=True) bio = Column(Text, nullable=True) @@ -149,6 +150,7 @@ class LoginRequest(BaseModel): class RegisterRequest(BaseModel): username: str + display_name: str password: str confirm_password: str @@ -178,6 +180,7 @@ class PushSubscriptionRequest(BaseModel): class UserProfileResponse(BaseModel): id: int username: str + display_name: str profile_picture: str | None bio: str | None online: bool diff --git a/backend/routes/account.py b/backend/routes/account.py index 0448417..3b23b6f 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -6,7 +6,7 @@ from constants import OWNER_USERNAME from dependencies import get_current_user, get_db from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup from utils import create_token, get_password_hash, verify_password -from validation import is_valid_password, is_valid_username +from validation import is_valid_password, is_valid_username, is_valid_display_name router = APIRouter() @@ -17,6 +17,7 @@ def convert_user(user: User) -> dict: "last_seen": user.last_seen.isoformat(), "online": user.online, "username": user.username, + "display_name": user.display_name, "profile_picture": user.profile_picture, "bio": user.bio, "admin": user.username == OWNER_USERNAME @@ -58,6 +59,7 @@ def login(request: LoginRequest, db: Session = Depends(get_db)): @router.post("/register") def register(request: RegisterRequest, db: Session = Depends(get_db)): username = request.username.strip() + display_name = request.display_name.strip() password = request.password.strip() confirm_password = request.confirm_password.strip() @@ -75,7 +77,13 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)): if not is_valid_username(username): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов" + detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" + ) + + if not is_valid_display_name(display_name): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" ) if not is_valid_password(password): @@ -107,6 +115,7 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)): hashed_password = get_password_hash(password) new_user = User( username=username, + display_name=display_name, password_hash=hashed_password, online=True, last_seen=datetime.now() diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 6c1f2f2..54cc0e7 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -48,16 +48,17 @@ def convert_message(msg: Message) -> dict: reactions_dict[emoji]["count"] += 1 reactions_dict[emoji]["users"].append({ "id": reaction.user_id, - "username": reaction.user.username + "username": reaction.user.display_name }) return { "id": msg.id, + "user_id": msg.author.id, "content": msg.content, "timestamp": msg.timestamp.isoformat(), "is_read": msg.is_read, "is_edited": msg.is_edited, - "username": msg.author.username, + "username": msg.author.display_name, "profile_picture": msg.author.profile_picture, "reply_to": convert_message(msg.reply_to) if msg.reply_to else None, "reactions": list(reactions_dict.values()), @@ -88,7 +89,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: reactions_dict[emoji]["count"] += 1 reactions_dict[emoji]["users"].append({ "id": reaction.user_id, - "username": reaction.user.username + "username": reaction.user.display_name }) return { diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 8eb6563..6b0d800 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -11,12 +11,14 @@ import io from dependencies import get_db, get_current_user from models import User, UpdateBioRequest, UserProfileResponse from pydantic import BaseModel +from validation import is_valid_username, is_valid_display_name router = APIRouter() # Request models class UpdateProfileRequest(BaseModel): - nickname: str | None = None + username: str | None = None + display_name: str | None = None description: str | None = None # Create uploads directory if it doesn't exist @@ -102,6 +104,7 @@ async def get_user_profile( return { "id": current_user.id, "username": current_user.username, + "display_name": current_user.display_name, "profile_picture": current_user.profile_picture, "bio": current_user.bio, "online": current_user.online, @@ -121,19 +124,32 @@ async def update_user_profile( updated = False # Update username if provided - if request.nickname is not None: - nickname = request.nickname.strip() - if len(nickname) < 3: - raise HTTPException(status_code=400, detail="Username must be at least 3 characters long") - if len(nickname) > 50: - raise HTTPException(status_code=400, detail="Username must be 50 characters or less") + if request.username is not None: + username = request.username.strip() + if not is_valid_username(username): + raise HTTPException( + status_code=400, + detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" + ) # Check if username is already taken by another user - existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first() + existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first() if existing_user: - raise HTTPException(status_code=400, detail="Username already taken") + raise HTTPException(status_code=400, detail="Это имя пользователя уже занято") - current_user.username = nickname + current_user.username = username + updated = True + + # Update display name if provided + if request.display_name is not None: + display_name = request.display_name.strip() + if not is_valid_display_name(display_name): + raise HTTPException( + status_code=400, + detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" + ) + + current_user.display_name = display_name updated = True # Update bio if provided @@ -150,12 +166,14 @@ async def update_user_profile( return { "message": "Profile updated successfully", "username": current_user.username, + "display_name": current_user.display_name, "bio": current_user.bio } else: return { "message": "No changes made", "username": current_user.username, + "display_name": current_user.display_name, "bio": current_user.bio } @@ -197,6 +215,31 @@ async def get_user_by_username( return UserProfileResponse( id=user.id, username=user.username, + display_name=user.display_name, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at + ) + +@router.get("/user/id/{user_id}") +async def get_user_by_id( + user_id: int, + db: Session = Depends(get_db) +): + """ + Get user profile by user ID + """ + user = db.query(User).filter(User.id == user_id).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserProfileResponse( + id=user.id, + username=user.username, + display_name=user.display_name, profile_picture=user.profile_picture, bio=user.bio, online=user.online, diff --git a/backend/validation.py b/backend/validation.py index d145b6f..376fdc2 100644 --- a/backend/validation.py +++ b/backend/validation.py @@ -3,7 +3,17 @@ import re def is_valid_username(username: str) -> bool: if len(username) < 3 or len(username) > 20: return False - if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username): + # Only allow English letters, numbers, dashes and underscores + if not re.match(r'^[a-zA-Z0-9_-]+$', username): + return False + return True + + +def is_valid_display_name(display_name: str) -> bool: + if len(display_name) < 1 or len(display_name) > 64: + return False + # Check if not blank (only whitespace) + if not display_name.strip(): return False return True diff --git a/frontend/src/core/api/profileApi.ts b/frontend/src/core/api/profileApi.ts index 53c0962..98ca83e 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -4,7 +4,8 @@ import type { UserProfile } from "@/core/types"; export interface ProfileData { profile_picture?: string; - nickname?: string; + username?: string; + display_name?: string; description?: string; } @@ -26,7 +27,8 @@ export async function loadProfile(token: string): Promise { // Map backend fields to frontend fields return { profile_picture: data.profile_picture, - nickname: data.username, + username: data.username, + display_name: data.display_name, description: data.bio }; } @@ -69,7 +71,8 @@ export async function updateProfile(token: string, data: Partial): try { // Map frontend fields to backend fields const backendData = { - nickname: data.nickname, + username: data.username, + display_name: data.display_name, description: data.description }; @@ -126,3 +129,23 @@ export async function fetchUserProfile(token: string, username: string): Promise return null; } } + +/** + * Fetches user profile data by user ID + */ +export async function fetchUserProfileById(token: string, userId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, { + headers: getAuthHeaders(token) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile by ID:', error); + return null; + } +} diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 0c13a15..47fc9d6 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -62,6 +62,7 @@ export interface Reaction { export interface Message { id: number; + user_id: number; username: string; content: string; is_read: boolean; @@ -111,6 +112,7 @@ export interface User { last_seen: string; online: boolean; username: string; + display_name: string; admin?: boolean; bio?: string; profile_picture: string; @@ -130,6 +132,7 @@ export interface User { export interface UserProfile { id: number; username: string; + display_name: string; profile_picture?: string; bio?: string; online: boolean; @@ -163,6 +166,7 @@ export interface LoginRequest { */ export interface RegisterRequest { username: string; + display_name: string; password: string; confirm_password: string; } diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 0cec4c5..91400e9 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -105,7 +105,7 @@ export default function LoginPage() { } }}> { alerts.push({type: type, message: message}) }); } + const displayNameElement = useRef(null); const usernameElement = useRef(null); const passwordElement = useRef(null); const confirmPasswordElement = useRef(null); @@ -36,11 +37,12 @@ export default function RegisterPage() {
{ e.preventDefault(); + const displayName = displayNameElement.current!.value.trim(); const username = usernameElement.current!.value.trim(); const password = passwordElement.current!.value.trim(); const confirmPassword = confirmPasswordElement.current!.value.trim(); - if (!username || !password || !confirmPassword) { + if (!displayName || !username || !password || !confirmPassword) { showAlert("danger", "Пожалуйста, заполните все поля"); return; } @@ -50,11 +52,22 @@ export default function RegisterPage() { return; } + if (displayName.length < 1 || displayName.length > 64) { + showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов"); + return; + } + if (username.length < 3 || username.length > 20) { showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов"); return; } + // Validate username format (only English letters, numbers, dashes, underscores) + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания"); + return; + } + if (password.length < 5 || password.length > 50) { showAlert("danger", "Пароль должен быть от 5 до 50 символов"); return; @@ -62,6 +75,7 @@ export default function RegisterPage() { try { const request: RegisterRequest = { + display_name: displayName, username: username, password: password, confirm_password: confirmPassword @@ -97,7 +111,18 @@ export default function RegisterPage() { } }}> + void; + readOnly: boolean; + placeholder: string; + textArea?: boolean; +} + +function Section({ type, icon, label, error, value, onChange, readOnly, placeholder, textArea = false }: SectionProps) { + let valueComponent: ReactNode = null; + + if (onChange) { + if (textArea) { + valueComponent = ( + + ); + } else { + valueComponent = ( + onChange(e.target.value)} + readOnly={readOnly} /> + ); + } + } else { + valueComponent = ( + {value} + ); + } + + return ( +
+ +
+ + {valueComponent} + {error && ( +
{error}
+ )} +
+
+ ) +} + export function ProfileDialog() { - const { chat, user, closeProfileDialog } = useAppState(); + const { chat, user, closeProfileDialog, setUser } = useAppState(); const [isOpen, setIsOpen] = useState(false); const [originalData, setOriginalData] = useState(null); const [currentData, setCurrentData] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [errors, setErrors] = useState<{[key: string]: string}>({}); const fileInputRef = useRef(null); const backdropRef = useRef(null); const dialogRef = useRef(null); @@ -40,23 +98,19 @@ export function ProfileDialog() { } }, [chat.profileDialog, isOpen]); - const fetchFreshProfileData = async (profileData: ProfileDialogData) => { + async function fetchFreshProfileData(profileData: ProfileDialogData) { if (!user.authToken) return; try { let freshData = profileData; - // If it's not the public chat and has a username, fetch fresh data - if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) { - const userProfile = await fetchUserProfile(user.authToken, profileData.username); + // If it's not the public chat and has a user ID, fetch fresh data + if (profileData.userId && profileData.username !== "Общий чат") { + const userProfile = await fetchUserProfileById(user.authToken, profileData.userId); if (userProfile) { freshData = { - userId: userProfile.id, - username: userProfile.username, - profilePicture: userProfile.profile_picture, - bio: userProfile.bio, + ...userProfile, memberSince: userProfile.created_at, - online: userProfile.online, isOwnProfile: profileData.isOwnProfile }; } @@ -72,7 +126,7 @@ export function ProfileDialog() { setCurrentData(profileData); setIsOpen(true); } - }; + } // Trigger transition after component mounts useEffect(() => { @@ -90,13 +144,13 @@ export function ProfileDialog() { // Handle ESC key useEffect(() => { - const handleEsc = (e: KeyboardEvent) => { - if (e.key === "Escape" && isOpen) { - handleClose(); - } - }; - if (isOpen) { + function handleEsc(e: KeyboardEvent) { + if (e.key === "Escape") { + handleClose(); + } + } + document.addEventListener("keydown", handleEsc); return () => document.removeEventListener("keydown", handleEsc); } @@ -117,6 +171,13 @@ export function ProfileDialog() { } }, [isOpen, currentData?.userId, currentData?.isOwnProfile]); + // Validate fields when data changes + useEffect(() => { + if (currentData && isOpen) { + validateFields(); + } + }, [currentData, isOpen]); + const hasChanges = useMemo(() => { if (!originalData || !currentData) return false; @@ -127,13 +188,14 @@ export function ProfileDialog() { }; return ( + normalizeValue(originalData.display_name) !== normalizeValue(currentData.display_name) || normalizeValue(originalData.username) !== normalizeValue(currentData.username) || normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) || originalData.profilePicture !== currentData.profilePicture ); }, [originalData, currentData]); - const handleClose = async () => { + async function handleClose() { if (hasChanges) { try { await confirm({ @@ -151,7 +213,7 @@ export function ProfileDialog() { } }; - const triggerCloseAnimation = () => { + function triggerCloseAnimation() { if (backdropRef.current && dialogRef.current) { backdropRef.current.classList.remove('open'); dialogRef.current.classList.remove('open'); @@ -165,29 +227,41 @@ export function ProfileDialog() { } }; - const handleBackdropClick = (e: React.MouseEvent) => { + function handleBackdropClick(e: React.MouseEvent) { if (e.target === e.currentTarget) { handleClose(); } }; - const handleUsernameChange = (e: React.ChangeEvent) => { + function handleDisplayNameChange(e: React.ChangeEvent) { if (!currentData) return; - setCurrentData({ ...currentData, username: e.target.value }); + const newValue = e.target.value; + setCurrentData({ ...currentData, display_name: newValue }); + + // Validate display name in real-time + validateDisplayName(newValue); }; - const handleBioChange = (newBio: string) => { + function handleUsernameChange(value: string) { + if (!currentData) return; + setCurrentData({ ...currentData, username: value }); + + // Validate username in real-time + validateUsername(value); + }; + + function handleBioChange(newBio: string) { if (!currentData) return; setCurrentData({ ...currentData, bio: newBio }); }; - const handleProfilePictureClick = () => { + function handleProfilePictureClick() { if (currentData?.isOwnProfile) { fileInputRef.current?.click(); } }; - const handleFileSelect = (e: React.ChangeEvent) => { + function handleFileSelect(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (file && file.type.startsWith("image/")) { // Open cropper dialog here - for now just update the image @@ -202,15 +276,60 @@ export function ProfileDialog() { } }; - const handleSave = async () => { + function validateDisplayName(value: string) { + let error = ""; + + if (!value || value.trim().length === 0) { + error = "Отображаемое имя не может быть пустым"; + } else if (value.length > 64) { + error = "Отображаемое имя не может быть длиннее 64 символов"; + } + + setErrors(prev => ({ ...prev, display_name: error })); + }; + + function validateUsername(value: string) { + let error = ""; + + if (!value || value.trim().length === 0) { + error = "Имя пользователя не может быть пустым"; + } else if (value.length < 3) { + error = "Имя пользователя должно быть не менее 3 символов"; + } else if (value.length > 20) { + error = "Имя пользователя не может быть длиннее 20 символов"; + } else if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + error = "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания"; + } + + setErrors(prev => ({ ...prev, username: error })); + }; + + function validateFields() { + if (currentData) { + validateDisplayName(currentData.display_name || ""); + validateUsername(currentData.username || ""); + } + + return !errors.display_name && !errors.username; + }; + + async function handleSave() { if (!currentData || !user.authToken || !originalData) return; + // Validate fields first + if (!validateFields()) { + return; + } + setIsSaving(true); try { // Update profile data const updateData: any = {}; + if (originalData.display_name !== currentData.display_name) { + updateData.display_name = currentData.display_name; + } if (originalData.username !== currentData.username) { - updateData.nickname = currentData.username; + updateData.username = currentData.username; } if (originalData.bio !== currentData.bio) { updateData.description = currentData.bio; @@ -233,22 +352,51 @@ export function ProfileDialog() { // Update the original data to match current data setOriginalData(currentData); + // If this is the current user's profile and username was changed, update the current user data + if (currentData.isOwnProfile && user.currentUser && user.authToken) { + const updatedUser = { + ...user.currentUser, + username: currentData.username || user.currentUser.username, + display_name: currentData.display_name || user.currentUser.display_name, + bio: currentData.bio || user.currentUser.bio, + profile_picture: currentData.profilePicture || user.currentUser.profile_picture + }; + setUser(user.authToken, updatedUser); + } + // Close dialog with animation after successful save triggerCloseAnimation(); } catch (error) { console.error("Failed to save profile:", error); + // Handle API errors + if (error instanceof Error && error.message.includes("уже занято")) { + setErrors({ username: "Это имя пользователя уже занято" }); + } else { + setErrors({ general: "Ошибка при сохранении профиля" }); + } } finally { setIsSaving(false); } - }; + } - const formatDate = (dateString: string) => { + function formatDate(dateString: string) { return new Date(dateString).toLocaleDateString("ru-RU", { year: "numeric", month: "long", day: "numeric" }); - }; + } + + const fabVisible = useMemo(() => { + let hasErrors = false; + Object.values(errors).forEach(error => { + if (error) { + hasErrors = true; + } + }); + + return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors; + }, [hasChanges, currentData?.isOwnProfile, isSaving, errors]); if (!isOpen || !currentData) return null; @@ -281,57 +429,64 @@ export function ProfileDialog() { )} - {/* Username */} - {currentData.username && ( -
- -
- )} + {/* Display Name */} +
+ + {errors.display_name && ( +
{errors.display_name}
+ )} +
{/* Online Status */} - {currentData?.userId && ( + {(currentData?.userId || currentData?.isOwnProfile) && (
- +
)}
+
+ + {/* Bio */} {currentData.bio !== undefined && ( -
- -
- - -
-
+
)} {/* Member Since */} {currentData.memberSince && ( -
- -
- Участник с: - - {formatDate(currentData.memberSince)} - -
-
+
)}
@@ -340,7 +495,7 @@ export function ProfileDialog() { {currentData.isOwnProfile && ( diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index fd112a5..ed69d72 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -13,7 +13,8 @@ export function ChatHeader() { const handleProfileClick = () => { setProfileDialog({ userId: user.currentUser?.id, - username: profileData?.nickname || "Пользователь", + username: profileData?.username || "Пользователь", + display_name: profileData?.display_name || "Пользователь", profilePicture: profileData?.profile_picture, bio: profileData?.description, memberSince: user.currentUser?.created_at, diff --git a/frontend/src/pages/chat/ui/left/ChatTabs.tsx b/frontend/src/pages/chat/ui/left/ChatTabs.tsx index 729cf55..5733364 100644 --- a/frontend/src/pages/chat/ui/left/ChatTabs.tsx +++ b/frontend/src/pages/chat/ui/left/ChatTabs.tsx @@ -1,21 +1,16 @@ import { useAppState, type ChatTabs } from "@/pages/chat/state"; import { UnifiedChatsList } from "./UnifiedChatsList"; -import type { FormEvent } from "react"; import type { Tabs } from "mdui/components/tabs"; export function ChatTabs() { const { chat, setActiveTab } = useAppState(); - function handleChange(e: FormEvent & CustomEvent<{ value: string }>) { - setActiveTab(e.detail.value as ChatTabs); - } - return (
+ onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}> Чаты diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 4c1bcfa..f076f57 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -7,7 +7,7 @@ import { fetchUserPublicKey } from "@/core/api/dmApi"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; import { onlineStatusManager } from "@/core/onlineStatusManager"; -import { OnlineIndicator } from "../right/OnlineIndicator"; +import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import defaultAvatar from "@/images/default-avatar.png"; interface PublicChat { @@ -20,6 +20,7 @@ interface PublicChat { interface DMConversation { id: number; username: string; + display_name: string; profile_picture?: string; online?: boolean; type: "dm"; @@ -84,6 +85,7 @@ export function UnifiedChatsList() { const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({ id: user.id, username: user.username, + display_name: user.display_name, profile_picture: user.profile_picture, online: user.online, type: "dm" as const, @@ -172,13 +174,13 @@ export function UnifiedChatsList() { }; }, [allChats]); - const formatPublicChatMessage = (chatId: string): string => { + function formatPublicChatMessage(chatId: string): string { const lastMessage = lastMessages[chatId]; if (!lastMessage) { return ""; } - const isCurrentUser = lastMessage.username === user.currentUser?.username; + const isCurrentUser = lastMessage.user_id === user.currentUser?.id; const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `; const maxContentLength = 50 - prefix.length; @@ -187,14 +189,13 @@ export function UnifiedChatsList() { : lastMessage.content; return prefix + content; - }; + } - - const handlePublicChatClick = async (chatName: string) => { + async function handlePublicChatClick(chatName: string) { await switchToPublicChat(chatName); - }; + } - const handleDMClick = async (dmConversation: DMConversation) => { + async function handleDMClick(dmConversation: DMConversation) { if (!dmConversation.publicKey) { const authToken = useAppState.getState().user.authToken; if (!authToken) return; @@ -215,7 +216,7 @@ export function UnifiedChatsList() { profilePicture: dmConversation.profile_picture, online: dmConversation.online || false }); - }; + } if (isLoadingUsers) { return ( @@ -256,7 +257,7 @@ export function UnifiedChatsList() { return ( handleDMClick(chat)} style={{ cursor: "pointer" }} > @@ -266,7 +267,7 @@ export function UnifiedChatsList() {
{chat.username} m.id === -1 && m.runtimeData?.sendingState?.tempId); @@ -199,7 +199,8 @@ export class PublicChatPanel extends MessagePanel { async getProfile(): Promise { return { - username: "Общий чат", + username: "general", + display_name: "Общий чат", bio: "Общаемся со всеми пользователями FromChat!", isOwnProfile: false }; From 97f19ce52bc196feebcbeea2af063aac30d54eff Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 20 Oct 2025 21:12:40 +0300 Subject: [PATCH 2/8] Implement fromchat.ru/@username and fromchat.ru/?u=userId links, mentions --- frontend/src/App.tsx | 80 +++++++++++++++----- frontend/src/core/profileLinks.ts | 39 ++++++++++ frontend/src/pages/chat/css/_message.scss | 22 ++++++ frontend/src/pages/chat/ui/ChatPage.tsx | 60 +++++++++++++++ frontend/src/pages/chat/ui/right/Message.tsx | 80 +++++++++++++++++--- package.json | 1 + 6 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 frontend/src/core/profileLinks.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 31c4fad..a7354b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, useNavigate, matchRoutes, type RouteObject } from "react-router-dom"; import { ElectronTitleBar } from "./Electron"; import { useAppState } from "./pages/chat/state"; -import { useEffect, useState, lazy } from "react"; -import ProtectedRoute from "./pages/ProtectedRoute"; -import NotFoundPage from "./pages/not-found/NotFoundPage"; -import DownloadAppPage from "./pages/download-app/DownloadAppPage"; +import { lazy, useEffect, useState } from "react"; +import { parseProfileLink } from "./core/profileLinks.ts"; +import NotFoundPage from "./pages/not-found/NotFoundPage.tsx"; +import ProtectedRoute from "./pages/ProtectedRoute.tsx"; +import DownloadAppPage from "./pages/download-app/DownloadAppPage.tsx"; // Lazy load route components const HomePage = lazy(() => import("./pages/home/HomePage")); @@ -12,11 +13,63 @@ const LoginPage = lazy(() => import("./pages/auth/LoginPage")); const RegisterPage = lazy(() => import("./pages/auth/RegisterPage")); const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage")); +const routeConfig: RouteObject[] = [ + { path: "/", element: }, + { path: "/login", element: }, + { path: "/register", element: }, + { path: "/download-app", element: }, + { + path: "/chat", + element: ( + + + + ) + }, + { path: "*", element: } +]; + +function SmartCatchAll() { + const navigate = useNavigate(); + const [showNotFound, setShowNotFound] = useState(false); + + function isValidRoute(path: string): boolean { + const validRoutes = routeConfig.filter(route => route.path !== "*"); + const matches = matchRoutes(validRoutes, path); + + return Boolean(matches && matches.length > 0); + } + + useEffect(() => { + if (isValidRoute(location.pathname)) { + setShowNotFound(false); + return; + } + + // Check if it's a profile link + const profileInfo = parseProfileLink(); // no url specified intentionally to let it use the current url + + if (profileInfo) { + setShowNotFound(false); + navigate("/chat", { + replace: true, + state: { profileInfo } + }); + } else { + setShowNotFound(true); + } + }, [navigate]); + + // Show 404 page + if (showNotFound) { + return ; + } +} + export default function App() { const { restoreUserFromStorage } = useAppState(); const [authReady, setAuthReady] = useState(false); - // Restore user from localStorage on app initialization useEffect(() => { restoreUserFromStorage().finally(() => { setAuthReady(true); @@ -28,18 +81,9 @@ export default function App() {
- } /> - } /> - } /> - } /> - - - - - } /> - - } /> + {routeConfig.map((route, index) => ( + + ))}
diff --git a/frontend/src/core/profileLinks.ts b/frontend/src/core/profileLinks.ts new file mode 100644 index 0000000..873b92a --- /dev/null +++ b/frontend/src/core/profileLinks.ts @@ -0,0 +1,39 @@ +/** + * @fileoverview Utility functions for handling profile links + * @description Functions to parse and handle profile links in markdown content. + * Supports two formats: + * - fromchat.ru/@username (e.g., fromchat.ru/@john_doe) + * - fromchat.ru/?u= (e.g., fromchat.ru/?u=123) + * @author Cursor + * @version 1.0.0 + */ + +import escapeStringRegexp from "escape-string-regexp"; + +/** + * Parses a profile link URL and extracts user information + * @param url - The URL to parse + * @returns Object with user ID and username if it's a valid profile link, null otherwise + */ +export function parseProfileLink(url: string = location.pathname): { userId?: number; username?: string } | null { + try { + let host: string = url.startsWith("@") ? "" : !url.startsWith("/") ? "https://fromchat.ru/" : "/"; + + // Handle fromchat.ru/@username format + const usernameMatch = url.match(new RegExp(`${escapeStringRegexp(host)}@([a-zA-Z0-9_-]+)`)); + if (usernameMatch) { + return { username: usernameMatch[1] }; + } + + // Handle fromchat.ru/?u= format + const userIdMatch = url.match(new RegExp(`${escapeStringRegexp(host)}\\?u=(\\d+)`)); + if (userIdMatch) { + return { userId: Number(userIdMatch[1]) }; + } + + return null; + } catch (error) { + console.error('Error parsing profile link:', error); + return null; + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/_message.scss b/frontend/src/pages/chat/css/_message.scss index 15e0069..2662ad6 100644 --- a/frontend/src/pages/chat/css/_message.scss +++ b/frontend/src/pages/chat/css/_message.scss @@ -375,3 +375,25 @@ justify-content: center; } } + +// Mention link styling +.message-content { + .mention-link { + color: $color-dark-primary; + text-decoration: none; + font-weight: 500; + border-radius: 4px; + padding: 2px 4px; + transition: all 0.2s ease; + background-color: rgba(145, 206, 244, 0.1); // TODO adjust + + &:hover { + background-color: rgba(145, 206, 244, 0.2); // TODO adjust + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } + } +} diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 1183ab9..56486bc 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -3,9 +3,69 @@ import { RightPanel } from "./right/RightPanel"; import "@/pages/chat/css/chat.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { CallWindow } from "./right/calls/CallWindow"; +import { useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAppState } from "@/pages/chat/state"; +import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; export default function ChatPage() { const { navigate: navigateDownloadApp } = useDownloadAppScreen(); + const location = useLocation(); + const navigate = useNavigate(); + const { user, setProfileDialog } = useAppState(); + const processedProfile = useRef(null); + + // Handle profile links ONLY from navigation state (from SmartCatchAll) + useEffect(() => { + async function handleProfileLink() { + if (!user.authToken) return; + + // Only process profile links that come from navigation state (SmartCatchAll) + // This prevents re-processing on page refresh + if (!location.state?.profileInfo) return; + + const profileInfo = location.state.profileInfo; + + // Create a unique key for this profile + const profileKey = profileInfo.userId + ? `user_${profileInfo.userId}` + : `username_${profileInfo.username}`; + + // Skip if we've already processed this exact profile + if (processedProfile.current === profileKey) return; + + processedProfile.current = profileKey; // Mark this specific profile as processed + + try { + let userProfile; + + if (profileInfo.userId) { + // Fetch by user ID + userProfile = await fetchUserProfileById(user.authToken, profileInfo.userId); + } else if (profileInfo.username) { + // Fetch by username + userProfile = await fetchUserProfile(user.authToken, profileInfo.username); + } + + if (userProfile) { + setProfileDialog({ + ...userProfile, + userId: userProfile.id, + memberSince: userProfile.created_at, + isOwnProfile: userProfile.id === user.currentUser?.id + }); + } + + // Clear the navigation state to prevent re-processing on refresh + navigate(location.pathname, { replace: true, state: null }); + } catch (error) { + console.error("Failed to fetch user profile from URL:", error); + } + } + + handleProfileLink(); + }, [location.state, user.authToken, user.currentUser?.id, setProfileDialog, navigate, location.pathname]); + if (navigateDownloadApp) return navigateDownloadApp; return ( diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index e864b3b..480ab4a 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -10,10 +10,11 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { getAuthHeaders } from "@/core/api/authApi"; import { useAppState } from "@/pages/chat/state"; -import { fetchUserProfileById } from "@/core/api/profileApi"; +import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; import { createPortal } from "react-dom"; +import { parseProfileLink } from "@/core/profileLinks"; interface MessageReactionsProps { reactions?: Reaction[]; @@ -147,7 +148,6 @@ interface Rect { } export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) { - const [formattedMessage, setFormattedMessage] = useState({ __html: "" }); const [decryptedFiles, updateDecryptedFiles] = useImmer>(new Map()); const [loadedImages, updateLoadedImages] = useImmer>(new Set()); const [downloadingPaths, updateDownloadingPaths] = useImmer>(new Set()); @@ -164,15 +164,29 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD const imageRefs = useRef>(new Map()); const dmEnvelope = message.runtimeData?.dmEnvelope; - useEffect(() => { - (async () => { - setFormattedMessage({ - __html: DOMPurify.sanitize( - await parse(message.content) - ).trim() - }); - })(); - }, [message]); + const formattedMessage = useMemo(() => { + // First, temporarily replace existing fromchat.ru links to avoid conflicts + const linkPlaceholders: string[] = []; + let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { + const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`; + linkPlaceholders.push(match); + return placeholder; + }); + + // Now process @mentions that aren't in existing links + content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => { + return `${match}`; + }); + + // Restore the original links + linkPlaceholders.forEach((link, index) => { + content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link); + }); + + return { + __html: DOMPurify.sanitize(parse(content, { async: false })).trim() + }; + }, [message.content]); // Auto-decrypt images in DMs useEffect(() => { @@ -406,6 +420,44 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD } } + async function handleLinkClick(e: React.MouseEvent) { + const target = e.target as HTMLElement; + + if (target.tagName === 'A') { + const profileLink = parseProfileLink((target as HTMLAnchorElement).href); + + if (profileLink) { + e.preventDefault(); + e.stopPropagation(); + + if (!user.authToken) return; + + try { + let userProfile; + + if (profileLink.userId) { + userProfile = await fetchUserProfileById(user.authToken, profileLink.userId); + } else if (profileLink.username) { + userProfile = await fetchUserProfile(user.authToken, profileLink.username); + } + + if (userProfile) { + setProfileDialog({ + ...userProfile, + userId: userProfile.id, + memberSince: userProfile.created_at, + isOwnProfile: userProfile.id === user.currentUser?.id + }); + } else { + throw new Error("Invalid link: " + (target as HTMLAnchorElement).href); + } + } catch (error) { + console.error("Failed to fetch user profile from link:", error); + } + } + } + } + function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -460,7 +512,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD )} -
+
{message.files && message.files.length > 0 && ( diff --git a/package.json b/package.json index 0559dd5..0949b79 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "dependencies": { "dompurify": "^3.2.7", "electron-squirrel-startup": "^1.0.1", + "escape-string-regexp": "^5.0.0", "marked": "^16.3.0", "mdui": "^2.1.4", "react": "^19.1.1", From 3293d9136875eba8a319c2253916ae82b61d9c75 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 21 Oct 2025 22:31:09 +0300 Subject: [PATCH 3/8] Fix database migration --- backend/migration.py | 112 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 12 deletions(-) diff --git a/backend/migration.py b/backend/migration.py index ff1575a..a085580 100644 --- a/backend/migration.py +++ b/backend/migration.py @@ -102,6 +102,21 @@ def run_migrations(): logger.info(f"No new migrations needed or error creating migration: {e}") pass + # Check if database is in an inconsistent state (has alembic_version but no tables) + engine = create_engine(DATABASE_URL) + with engine.connect() as connection: + from sqlalchemy import text, inspect + inspector = inspect(connection) + existing_tables = inspector.get_table_names() + + # Check if we have alembic_version but no actual tables + if 'alembic_version' in existing_tables and len(existing_tables) == 1: + logger.info("Database has alembic_version but no actual tables - resetting migration state...") + # Clear alembic_version and start fresh + connection.execute(text("DELETE FROM alembic_version")) + connection.commit() + logger.info("Reset migration state - will create fresh migration") + # Run the upgrade command logger.info("Running database migrations...") try: @@ -116,6 +131,40 @@ def run_migrations(): from sqlalchemy import text connection.execute(text("DELETE FROM alembic_version")) connection.commit() + + # Set the correct revision in alembic_version table + current_dir = os.path.dirname(os.path.abspath(__file__)) + versions_dir = os.path.join(current_dir, "alembic", "versions") + migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] + + if migration_files: + # Get the latest migration file and extract its revision ID + latest_migration = max(migration_files) + migration_path = os.path.join(versions_dir, latest_migration) + + with open(migration_path, 'r') as f: + content = f.read() + # Extract revision ID from the file + import re + revision_match = re.search(r"revision: str = '([^']+)'", content) + if revision_match: + revision_id = revision_match.group(1) + logger.info(f"Setting alembic_version to {revision_id}") + connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')")) + connection.commit() + + # Try upgrade again + command.upgrade(alembic_cfg, "head") + logger.info("Database migrations completed successfully after reset.") + elif "no such table" in str(upgrade_error).lower(): + logger.info("Database tables missing - resetting migration state...") + # Clear the alembic_version table and start fresh + engine = create_engine(DATABASE_URL) + with engine.connect() as connection: + from sqlalchemy import text + connection.execute(text("DELETE FROM alembic_version")) + connection.commit() + # Try upgrade again command.upgrade(alembic_cfg, "head") logger.info("Database migrations completed successfully after reset.") @@ -134,19 +183,37 @@ def run_migrations(): connection.execute(text("DROP TABLE IF EXISTS alembic_version")) connection.commit() - # Remove any existing migration files to start fresh + # Check if we have existing migration files versions_dir = os.path.join(current_dir, "alembic", "versions") - for file in os.listdir(versions_dir): - if file.endswith('.py') and not file.startswith('__'): - os.remove(os.path.join(versions_dir, file)) + migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] - # Create a completely fresh migration with full schema - logger.info("Creating fresh migration with complete schema...") - _create_complete_migration(alembic_cfg) - - # Run the migration - command.upgrade(alembic_cfg, "head") - logger.info("Automated recovery completed successfully.") + if migration_files: + # We have migration files, just fix the alembic_version table + logger.info("Found existing migration files, fixing alembic_version table...") + latest_migration = max(migration_files) + migration_path = os.path.join(versions_dir, latest_migration) + + with open(migration_path, 'r') as f: + content = f.read() + import re + revision_match = re.search(r"revision: str = '([^']+)'", content) + if revision_match: + revision_id = revision_match.group(1) + logger.info(f"Setting alembic_version to {revision_id}") + connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')")) + connection.commit() + + # Try upgrade again + command.upgrade(alembic_cfg, "head") + logger.info("Automated recovery completed successfully.") + else: + # No migration files, create fresh ones + logger.info("No migration files found, creating fresh migration...") + _create_complete_migration(alembic_cfg) + + # Run the migration + command.upgrade(alembic_cfg, "head") + logger.info("Automated recovery completed successfully.") except Exception as recovery_error: logger.error(f"Automated recovery failed: {recovery_error}") @@ -458,7 +525,28 @@ def _create_database_directly(): CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) ) """)) - connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) + + # Get the correct revision ID from existing migration files + current_dir = os.path.dirname(os.path.abspath(__file__)) + versions_dir = os.path.join(current_dir, "alembic", "versions") + migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] + + if migration_files: + latest_migration = max(migration_files) + migration_path = os.path.join(versions_dir, latest_migration) + + with open(migration_path, 'r') as f: + content = f.read() + import re + revision_match = re.search(r"revision: str = '([^']+)'", content) + if revision_match: + revision_id = revision_match.group(1) + connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')")) + else: + connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) + else: + connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) + connection.commit() From 9e19342998606c89e00e5f05943876dafedf114d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 21 Oct 2025 22:49:26 +0300 Subject: [PATCH 4/8] Implement verification checkmark --- backend/app.py | 30 +++- backend/models.py | 2 + backend/routes/account.py | 10 +- backend/routes/messaging.py | 9 ++ backend/routes/profile.py | 64 +++++++- backend/similarity.py | 147 ++++++++++++++++++ frontend/src/core/api/profileApi.ts | 41 +++++ frontend/src/core/components/Input.tsx | 127 +++++++++++++++ .../src/core/components/MaterialTextField.tsx | 9 ++ frontend/src/core/components/StatusBadge.tsx | 51 ++++++ frontend/src/core/components/TextField.tsx | 9 -- frontend/src/core/components/VerifyButton.tsx | 46 ++++++ frontend/src/core/types.d.ts | 3 + frontend/src/css/_components.scss | 98 ++++++++++++ frontend/src/pages/auth/LoginPage.tsx | 2 +- frontend/src/pages/auth/RegisterPage.tsx | 2 +- frontend/src/pages/chat/css/_message.scss | 3 + .../src/pages/chat/css/_profile-dialog.scss | 29 ++-- frontend/src/pages/chat/state.ts | 1 + frontend/src/pages/chat/ui/ProfileDialog.tsx | 73 ++++++--- .../pages/chat/ui/left/UnifiedChatsList.tsx | 12 ++ .../src/pages/chat/ui/left/UsernameSearch.tsx | 14 +- frontend/src/pages/chat/ui/right/Message.tsx | 6 + 23 files changed, 736 insertions(+), 52 deletions(-) create mode 100644 backend/similarity.py create mode 100644 frontend/src/core/components/Input.tsx create mode 100644 frontend/src/core/components/MaterialTextField.tsx create mode 100644 frontend/src/core/components/StatusBadge.tsx delete mode 100644 frontend/src/core/components/TextField.tsx create mode 100644 frontend/src/core/components/VerifyButton.tsx diff --git a/backend/app.py b/backend/app.py index 8be682e..99279f5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4,8 +4,15 @@ from contextlib import asynccontextmanager import subprocess import sys import os - +from constants import DATABASE_URL from routes import account, messaging, profile, push, webrtc +import logging +from models import User +from constants import OWNER_USERNAME +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +logger = logging.getLogger("uvicorn.error") @asynccontextmanager async def lifespan(app: FastAPI): @@ -20,13 +27,30 @@ async def lifespan(app: FastAPI): "import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()" ], cwd=os.path.dirname(os.path.abspath(__file__)) - # No capture_output - let it stream to terminal in real-time - # No text=True - let it use the terminal's encoding ) except Exception as e: print(f"Failed to run database migrations: {e}") raise + try: + engine = create_engine(DATABASE_URL) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + with SessionLocal() as db: + # Find the owner user + owner = db.query(User).filter(User.username == OWNER_USERNAME).first() + if owner and not owner.verified: + owner.verified = True + db.commit() + logger.info(f"Owner user '{OWNER_USERNAME}' has been verified") + elif owner and owner.verified: + logger.info(f"Owner user '{OWNER_USERNAME}' is already verified") + else: + logger.warning(f"Owner user '{OWNER_USERNAME}' not found") + + except Exception as e: + logger.error(f"Failed to ensure owner verification: {e}") + yield # Shutdown (if needed in the future) diff --git a/backend/models.py b/backend/models.py index bdc93a8..243707c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -20,6 +20,7 @@ class User(Base): online = Column(Boolean, default=False) last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) + verified = Column(Boolean, default=False) messages = relationship("Message", back_populates="author", lazy="select") @@ -186,6 +187,7 @@ class UserProfileResponse(BaseModel): online: bool last_seen: datetime created_at: datetime + verified: bool class Config: from_attributes = True diff --git a/backend/routes/account.py b/backend/routes/account.py index 3b23b6f..d7f02e2 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -20,7 +20,8 @@ def convert_user(user: User) -> dict: "display_name": user.display_name, "profile_picture": user.profile_picture, "bio": user.bio, - "admin": user.username == OWNER_USERNAME + "admin": user.username == OWNER_USERNAME, + "verified": user.verified } @router.get("/check_auth") @@ -113,12 +114,17 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)): ) hashed_password = get_password_hash(password) + + # Set verified=True for the owner (first user to register) + is_owner = not owner_exists and username == OWNER_USERNAME + new_user = User( username=username, display_name=display_name, password_hash=hashed_password, online=True, - last_seen=datetime.now() + last_seen=datetime.now(), + verified=is_owner ) db.add(new_user) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 54cc0e7..eb41ddf 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -60,6 +60,7 @@ def convert_message(msg: Message) -> dict: "is_edited": msg.is_edited, "username": msg.author.display_name, "profile_picture": msg.author.profile_picture, + "verified": msg.author.verified, "reply_to": convert_message(msg.reply_to) if msg.reply_to else None, "reactions": list(reactions_dict.values()), "files": [ @@ -92,6 +93,13 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: "username": reaction.user.display_name }) + # Get sender info for verified status + from models import User + from dependencies import get_db + db = next(get_db()) + sender = db.query(User).filter(User.id == envelope.sender_id).first() + sender_verified = sender.verified if sender else False + return { "id": envelope.id, "senderId": envelope.sender_id, @@ -102,6 +110,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: "iv2": envelope.iv2_b64, "wrappedMk": envelope.wrapped_mk_b64, "timestamp": envelope.timestamp.isoformat(), + "verified": sender_verified, "reactions": list(reactions_dict.values()), "files": [ { diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 6b0d800..70ad75f 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -12,6 +12,7 @@ from dependencies import get_db, get_current_user from models import User, UpdateBioRequest, UserProfileResponse from pydantic import BaseModel from validation import is_valid_username, is_valid_display_name +from similarity import is_user_similar_to_verified router = APIRouter() @@ -244,5 +245,66 @@ async def get_user_by_id( bio=user.bio, online=user.online, last_seen=user.last_seen, - created_at=user.created_at + created_at=user.created_at, + verified=user.verified ) + + +@router.post("/user/{user_id}/verify") +async def verify_user( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Toggle verification status for a user (owner only) + """ + # Only user with ID 1 (owner) can verify users + if current_user.id != 1: + raise HTTPException(status_code=403, detail="Only owner can verify users") + + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # Toggle verification status + target_user.verified = not target_user.verified + db.commit() + + return { + "verified": target_user.verified, + "message": f"User verification {'enabled' if target_user.verified else 'disabled'}" + } + + +@router.get("/user/check-similarity/{user_id}") +async def check_user_similarity( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Check if a user is similar to any verified user + """ + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # Get all verified users + verified_users = db.query(User).filter(User.verified == True).all() + verified_users_data = [ + {"username": user.username, "display_name": user.display_name} + for user in verified_users + ] + + # Check similarity + is_similar, similar_to = is_user_similar_to_verified( + target_user.username, + target_user.display_name, + verified_users_data + ) + + return { + "isSimilar": is_similar, + "similarTo": similar_to if is_similar else None + } diff --git a/backend/similarity.py b/backend/similarity.py new file mode 100644 index 0000000..7e15c24 --- /dev/null +++ b/backend/similarity.py @@ -0,0 +1,147 @@ +""" +Similarity detection utilities for username and display name comparison. +Implements both edit distance and visual similarity detection. +""" + +def levenshtein_distance(s1: str, s2: str) -> int: + """Calculate Levenshtein distance between two strings.""" + if len(s1) < len(s2): + return levenshtein_distance(s2, s1) + + if len(s2) == 0: + return len(s1) + + previous_row = list(range(len(s2) + 1)) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + + return previous_row[-1] + + +def check_visual_similarity(s1: str, s2: str) -> bool: + """ + Check if two strings are visually similar using common homoglyphs. + Returns True if strings are visually similar. + """ + if len(s1) != len(s2): + return False + + # Common homoglyph mappings + homoglyphs = { + '0': ['O', 'o', 'Q'], + 'O': ['0', 'o', 'Q'], + 'o': ['0', 'O', 'Q'], + '1': ['l', 'I', '|'], + 'l': ['1', 'I', '|'], + 'I': ['1', 'l', '|'], + '5': ['S', 's'], + 'S': ['5', 's'], + 's': ['5', 'S'], + '6': ['G', 'g'], + 'G': ['6', 'g'], + 'g': ['6', 'G'], + '8': ['B', 'b'], + 'B': ['8', 'b'], + 'b': ['8', 'B'], + '9': ['g', 'q'], + 'g': ['9', 'q'], + 'q': ['9', 'g'], + '2': ['Z', 'z'], + 'Z': ['2', 'z'], + 'z': ['2', 'Z'], + '3': ['E'], + 'E': ['3'], + '4': ['A'], + 'A': ['4'], + '7': ['T', 't'], + 'T': ['7', 't'], + 't': ['7', 'T'], + } + + for i in range(len(s1)): + c1, c2 = s1[i], s2[i] + if c1 == c2: + continue + + # Check if characters are homoglyphs + if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \ + (c2 in homoglyphs and c1 in homoglyphs[c2]): + continue + + return False + + return True + + +def check_username_similarity(username1: str, username2: str) -> bool: + """ + Check if two usernames are similar using both edit distance and visual similarity. + Returns True if usernames are considered similar. + """ + if username1 == username2: + return False + + # Check edit distance (Levenshtein distance <= 2) + edit_distance = levenshtein_distance(username1.lower(), username2.lower()) + if edit_distance <= 2: + return True + + # Check visual similarity + if check_visual_similarity(username1, username2): + return True + + return False + + +def check_display_name_similarity(display_name1: str, display_name2: str) -> bool: + """ + Check if two display names are similar using both edit distance and visual similarity. + Returns True if display names are considered similar. + """ + if display_name1 == display_name2: + return False + + # Check edit distance (Levenshtein distance <= 2) + edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower()) + if edit_distance <= 2: + return True + + # Check visual similarity + if check_visual_similarity(display_name1, display_name2): + return True + + return False + + +def is_user_similar_to_verified(user_username: str, user_display_name: str, + verified_users: list[dict]) -> tuple[bool, str]: + """ + Check if a user is similar to any verified user. + + Args: + user_username: Username to check + user_display_name: Display name to check + verified_users: List of verified user dictionaries with 'username' and 'display_name' keys + + Returns: + Tuple of (is_similar, similar_to_username) + """ + for verified_user in verified_users: + verified_username = verified_user.get('username', '') + verified_display_name = verified_user.get('display_name', '') + + # Check username similarity + if check_username_similarity(user_username, verified_username): + return True, verified_username + + # Check display name similarity + if check_display_name_similarity(user_display_name, verified_display_name): + return True, verified_username + + return False, "" diff --git a/frontend/src/core/api/profileApi.ts b/frontend/src/core/api/profileApi.ts index 98ca83e..f16f03b 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -149,3 +149,44 @@ export async function fetchUserProfileById(token: string, userId: number): Promi return null; } } + +/** + * Toggles verification status for a user (owner only) + */ +export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, { + method: 'POST', + headers: getAuthHeaders(token) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error verifying user:', error); + return null; + } +} + +/** + * Checks if a user is similar to any verified user + */ +export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { + headers: getAuthHeaders(token) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error checking user similarity:', error); + return null; + } +} diff --git a/frontend/src/core/components/Input.tsx b/frontend/src/core/components/Input.tsx new file mode 100644 index 0000000..35c818b --- /dev/null +++ b/frontend/src/core/components/Input.tsx @@ -0,0 +1,127 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import useCombinedRefs from '@/core/hooks/useCombinedRefs'; +import { id } from '@/utils/utils'; + +interface AutoResizeInputProps extends React.InputHTMLAttributes { + autoresizing?: true; + placeholderMinWidth?: boolean; + onAutosize?: (width: number) => void; +} + +interface InputProps extends React.InputHTMLAttributes { + autoresizing?: false; + placeholderMinWidth?: false; + onAutosize?: undefined; +} + +export function Input({ + autoresizing = false, + placeholderMinWidth = false, + onAutosize, + style: inputStyle, + ...inputProps +}: AutoResizeInputProps | InputProps) { + const [inputWidth, setInputWidth] = useState(0); + + const sizerRef = useRef(null); + const placeholderSizerRef = useRef(null); + const [inputRef, inputElement] = useCombinedRefs(); + + const sizerStyle: React.CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + visibility: 'hidden', + height: 0, + overflow: 'scroll', + whiteSpace: 'pre', + }; + + const copyStyles = useCallback((styles: CSSStyleDeclaration, node: HTMLElement) => { + node.style.fontSize = styles.fontSize; + node.style.fontFamily = styles.fontFamily; + node.style.fontWeight = styles.fontWeight; + node.style.fontStyle = styles.fontStyle; + node.style.letterSpacing = styles.letterSpacing; + node.style.textTransform = styles.textTransform; + }, []); + + const updateInputWidth = useCallback(() => { + if (!sizerRef.current || typeof sizerRef.current.scrollWidth === 'undefined') { + return; + } + + let newInputWidth: number; + + if (inputProps.placeholder && (!inputProps.value || (inputProps.value && placeholderMinWidth))) { + const sizerWidth = sizerRef.current.scrollWidth; + const placeholderWidth = placeholderSizerRef.current?.scrollWidth || 0; + newInputWidth = Math.max(sizerWidth, placeholderWidth) + 2; + } else { + newInputWidth = sizerRef.current.scrollWidth + 2; + } + + + if (newInputWidth !== inputWidth) { + setInputWidth(newInputWidth); + onAutosize?.(newInputWidth); + } + }, [inputProps.placeholder, inputProps.value, inputProps.type, placeholderMinWidth, inputWidth, onAutosize]); + + const copyInputStyles = useCallback(() => { + if (!inputElement.current || !window.getComputedStyle) { + return; + } + + const inputStyles = window.getComputedStyle(inputElement.current); + if (!inputStyles) { + return; + } + + copyStyles(inputStyles, sizerRef.current!); + if (placeholderSizerRef.current) { + copyStyles(inputStyles, placeholderSizerRef.current); + } + }, [inputElement]); + + useEffect(() => { + if (autoresizing) { + copyInputStyles(); + updateInputWidth(); + } + }, [autoresizing, copyInputStyles, updateInputWidth]); + + useEffect(() => { + if (autoresizing) { + updateInputWidth(); + } + }, [inputProps.value, inputProps.placeholder, autoresizing, updateInputWidth]); + + return ( + <> + + {autoresizing && createPortal( + <> +
+ {inputProps.defaultValue || inputProps.value || ''} +
+ {inputProps.placeholder && ( +
+ {inputProps.placeholder} +
+ )} + , + id("root") + )} + + ); +} diff --git a/frontend/src/core/components/MaterialTextField.tsx b/frontend/src/core/components/MaterialTextField.tsx new file mode 100644 index 0000000..287716d --- /dev/null +++ b/frontend/src/core/components/MaterialTextField.tsx @@ -0,0 +1,9 @@ +import type { TextField } from "mdui/components/text-field"; + +interface TextFieldProps extends React.ComponentPropsWithoutRef<"mdui-text-field"> { + ref?: React.Ref +} + +export function MaterialTextField({ ref, ...props }: TextFieldProps) { + return } {...props} /> +} \ No newline at end of file diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx new file mode 100644 index 0000000..6ec9839 --- /dev/null +++ b/frontend/src/core/components/StatusBadge.tsx @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; +import { checkUserSimilarity } from "@/core/api/profileApi"; +import { useAppState } from "@/pages/chat/state"; + +interface StatusBadgeProps { + verified: boolean; + userId?: number; + size?: "small" | "medium" | "large"; +} + +export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) { + const [isSimilarToVerified, setIsSimilarToVerified] = useState(false); + const { user } = useAppState(); + + const className = `status-badge ${size}`; + + // Check similarity for unverified users + useEffect(() => { + if (!verified && userId && user.authToken) { + checkUserSimilarity(userId, user.authToken) + .then(result => { + setIsSimilarToVerified(result?.isSimilar || false); + }) + .catch(error => { + console.error('Error checking similarity:', error); + setIsSimilarToVerified(false); + }); + } else { + setIsSimilarToVerified(false); + } + }, [verified, userId, user.authToken]); + + if (verified) { + return ( + + + + ); + } + + if (isSimilarToVerified) { + return ( + + + + ); + } + + // Don't show anything if not verified and not similar + return null; +} \ No newline at end of file diff --git a/frontend/src/core/components/TextField.tsx b/frontend/src/core/components/TextField.tsx deleted file mode 100644 index a9022f6..0000000 --- a/frontend/src/core/components/TextField.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { TextField } from "mdui/components/text-field"; - -type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field"> - -export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref }) { - return })} /> -} \ No newline at end of file diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx new file mode 100644 index 0000000..8f42bb8 --- /dev/null +++ b/frontend/src/core/components/VerifyButton.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { verifyUser } from "@/core/api/profileApi"; +import { useAppState } from "@/pages/chat/state"; + +interface VerifyButtonProps { + userId: number; + verified: boolean; + onVerificationChange?: (verified: boolean) => void; +} + +export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) { + const [isVerifying, setIsVerifying] = useState(false); + const { user } = useAppState(); + + // Only show for owner + if (user.currentUser?.id !== 1) { + return null; + } + + async function handleVerifyToggle() { + if (!user.authToken || isVerifying) return; + + setIsVerifying(true); + try { + const result = await verifyUser(userId, user.authToken); + if (result) { + onVerificationChange?.(result.verified); + } + } catch (error) { + console.error('Error toggling verification:', error); + } finally { + setIsVerifying(false); + } + } + + return ( + + {verified ? "Отменить подтверждение" : "Подтвердить"} + + ); +} \ No newline at end of file diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 47fc9d6..3198743 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -69,6 +69,7 @@ export interface Message { is_edited: boolean; timestamp: string; profile_picture?: string; + verified?: boolean; reply_to?: Message; files?: Attachment[]; reactions?: Reaction[]; @@ -116,6 +117,7 @@ export interface User { admin?: boolean; bio?: string; profile_picture: string; + verified?: boolean; } /** @@ -138,6 +140,7 @@ export interface UserProfile { online: boolean; last_seen: string; created_at: string; + verified?: boolean; } // ---------- diff --git a/frontend/src/css/_components.scss b/frontend/src/css/_components.scss index a8c64b1..0f08732 100644 --- a/frontend/src/css/_components.scss +++ b/frontend/src/css/_components.scss @@ -81,4 +81,102 @@ button, input { border-left: 3px solid $color-dark-primary; padding: 0.5rem; } +} + +// Verified badge styles +.verified-badge { + display: inline-flex; + align-items: center; + color: $color-dark-primary; + vertical-align: middle; + user-select: none; + + &.small { + font-size: 14px; + width: 14px; + height: 14px; + } + + &.medium { + font-size: 18px; + width: 18px; + height: 18px; + } + + &.large { + font-size: 24px; + width: 24px; + height: 24px; + } +} + +// Status badge styles (unified for verified and warning) +.status-badge { + display: inline-flex; + align-items: center; + user-select: none; + + &.verified { + color: $color-dark-primary; + } + + &.warning { + color: #ff9800; // Orange color for warnings + } + + &.small mdui-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &.medium mdui-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &.large mdui-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + +// Profile dialog specific styles +.username-with-badge { + display: flex; + align-items: center; + gap: 8px; +} + +.similarity-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + background-color: $color-dark-error-container; + color: $color-dark-on-error-container; + border-radius: 8px; + margin: 12px 0; + font-size: 0.9rem; + line-height: 1.4; +} + +.verify-section { + margin: 16px 0; + display: flex; + justify-content: center; +} + +.search-result-headline { + display: flex; + align-items: center; + gap: 6px; +} + +.dm-list-headline { + display: flex; + align-items: center; + gap: 6px; } \ No newline at end of file diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 91400e9..3974109 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -7,7 +7,7 @@ import { API_BASE_URL } from "@/core/config"; import { useRef } from "react"; import type { TextField } from "mdui/components/text-field"; import { useAppState } from "@/pages/chat/state"; -import { MaterialTextField } from "@/core/components/TextField"; +import { MaterialTextField } from "@/core/components/MaterialTextField"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; import { useNavigate } from "react-router-dom"; diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx index a119cf0..453b174 100644 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ b/frontend/src/pages/auth/RegisterPage.tsx @@ -6,7 +6,7 @@ import { TextField } from "mdui/components/text-field"; import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; import { API_BASE_URL } from "@/core/config"; import { useAppState } from "@/pages/chat/state"; -import { MaterialTextField } from "@/core/components/TextField"; +import { MaterialTextField } from "@/core/components/MaterialTextField"; import { ensureKeysOnLogin } from "@/core/api/authApi"; import { useNavigate } from "react-router-dom"; import "./auth.scss"; diff --git a/frontend/src/pages/chat/css/_message.scss b/frontend/src/pages/chat/css/_message.scss index 2662ad6..b316d2d 100644 --- a/frontend/src/pages/chat/css/_message.scss +++ b/frontend/src/pages/chat/css/_message.scss @@ -69,6 +69,9 @@ margin: 10px; cursor: pointer; transition: transform 0.2s ease; + display: flex; + align-items: center; + gap: 4px; &:hover { transform: scale(1.05); diff --git a/frontend/src/pages/chat/css/_profile-dialog.scss b/frontend/src/pages/chat/css/_profile-dialog.scss index bfb9257..97ea0ce 100644 --- a/frontend/src/pages/chat/css/_profile-dialog.scss +++ b/frontend/src/pages/chat/css/_profile-dialog.scss @@ -99,18 +99,22 @@ .username-section { text-align: center; - .username-input { - background: none; - border: none; - font-size: 1.5rem; - font-weight: 500; - color: $color-dark-on-surface; - text-align: center; - outline: none; - padding: 8px; - border-radius: 4px; - transition: background-color 0.2s ease; - cursor: text; + .username-with-badge { + gap: 0; + + .username-input { + background: none; + border: none; + font-size: 1.5rem; + font-weight: 500; + color: $color-dark-on-surface; + text-align: center; + outline: none; + padding: 8px; + border-radius: 4px; + transition: background-color 0.2s ease; + cursor: text; + } } } @@ -168,6 +172,7 @@ .label { font-size: small; color: $color-dark-on-surface-variant; + user-select: none; } .value { diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 70ded4f..1d50472 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -25,6 +25,7 @@ export interface ProfileDialogData { memberSince?: string; online?: boolean; isOwnProfile: boolean; + verified?: boolean; } interface ActiveDM { diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 0790ae9..1e6d156 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -6,8 +6,11 @@ import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi"; import { RichTextArea } from "@/core/components/RichTextArea"; +import { StatusBadge } from "@/core/components/StatusBadge"; +import { VerifyButton } from "@/core/components/VerifyButton"; import { onlineStatusManager } from "@/core/onlineStatusManager"; import { OnlineStatus } from "./right/OnlineStatus"; +import { Input } from "@/core/components/Input"; interface SectionProps { type: string; @@ -17,7 +20,7 @@ interface SectionProps { value?: string; onChange?: (value: string) => void; readOnly: boolean; - placeholder: string; + placeholder?: string; textArea?: boolean; } @@ -33,23 +36,20 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol placeholder={placeholder} className="value" rows={1} - readOnly={readOnly} - /> + readOnly={readOnly} /> ); } else { valueComponent = ( onChange(e.target.value)} - readOnly={readOnly} /> + className="value" + type="text" + value={value} + onChange={e => onChange(e.target.value)} + readOnly={readOnly} /> ); } } else { - valueComponent = ( - {value} - ); + valueComponent = {value} } return ( @@ -104,6 +104,7 @@ export function ProfileDialog() { if (userProfile) { freshData = { ...userProfile, + userId: userProfile.id, // Preserve the userId field memberSince: userProfile.created_at, isOwnProfile: profileData.isOwnProfile }; @@ -162,6 +163,7 @@ export function ProfileDialog() { } }, [isOpen, currentData?.userId, currentData?.isOwnProfile]); + // Validate fields when data changes useEffect(() => { if (currentData && isOpen) { @@ -373,6 +375,7 @@ export function ProfileDialog() { }); } + const fabVisible = useMemo(() => { let hasErrors = false; Object.values(errors).forEach(error => { @@ -413,14 +416,20 @@ export function ProfileDialog() {
- +
+ + +
{errors.display_name && (
{errors.display_name}
)} @@ -432,6 +441,19 @@ export function ProfileDialog() {
)} + {/* Verify button for owner */} + {!currentData.isOwnProfile && currentData.userId && ( +
+ { + setCurrentData({ ...currentData, verified }); + }} + /> +
+ )} +
+ textArea /> )} {currentData.memberSince && ( @@ -462,8 +483,16 @@ export function ProfileDialog() { icon="calendar_month--filled" label="Участник с:" value={formatDate(currentData.memberSince)} + readOnly={true} /> + )} + + {currentData.verified && ( +
)}
diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index f076f57..58dea0b 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -4,6 +4,7 @@ import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "@/core/api/authApi"; import { fetchUserPublicKey } from "@/core/api/dmApi"; +import { StatusBadge } from "@/core/components/StatusBadge"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -19,6 +20,7 @@ interface PublicChat { interface DMConversation { id: number; + userId: number; username: string; display_name: string; profile_picture?: string; @@ -27,6 +29,7 @@ interface DMConversation { lastMessage?: string; unreadCount: number; publicKey?: string | null; + verified?: boolean; } type ChatItem = PublicChat | DMConversation; @@ -84,6 +87,7 @@ export function UnifiedChatsList() { const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({ id: user.id, + userId: user.id, // Add userId field username: user.username, display_name: user.display_name, profile_picture: user.profile_picture, @@ -261,6 +265,14 @@ export function UnifiedChatsList() { onClick={() => handleDMClick(chat)} style={{ cursor: "pointer" }} > +
+ {chat.display_name} + +
{chat.lastMessage || "Нет сообщений"} diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index 800c194..f91cfcf 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,14 +1,16 @@ import { useState, useEffect } from "react"; import { useAppState } from "@/pages/chat/state"; import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; +import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; import { onlineStatusManager } from "@/core/onlineStatusManager"; -import { OnlineIndicator } from "../right/OnlineIndicator"; +import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import defaultAvatar from "@/images/default-avatar.png"; import SearchBar from "@/core/components/SearchBar"; interface SearchUser extends User { publicKey?: string | null; + verified?: boolean; } export function UsernameSearch() { @@ -68,10 +70,12 @@ export function UsernameSearch() { }; }, [searchResults]); + async function handleUserClick(searchUser: SearchUser) { if (!user.authToken) return; try { + let publicKey = searchUser.publicKey; if (!publicKey) { const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken); @@ -150,6 +154,14 @@ export function UsernameSearch() { onClick={() => handleUserClick(searchUser)} style={{ cursor: "pointer" }} > +
+ {searchUser.username} + +
{message.username} +
)} From 27e1cf7a680706028d93790dc78b5b5661bdce70 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 20 Oct 2025 22:08:12 +0300 Subject: [PATCH 5/8] Clean up --- frontend/src/App.tsx | 15 +++--- frontend/src/pages/chat/ui/ChatPage.tsx | 4 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 52 +++++-------------- .../src/pages/chat/ui/right/ChatMessages.tsx | 1 - frontend/src/pages/chat/ui/right/Message.tsx | 7 ++- 5 files changed, 26 insertions(+), 53 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a7354b3..f99b514 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,10 +2,10 @@ import { BrowserRouter, Routes, Route, useNavigate, matchRoutes, type RouteObjec import { ElectronTitleBar } from "./Electron"; import { useAppState } from "./pages/chat/state"; import { lazy, useEffect, useState } from "react"; -import { parseProfileLink } from "./core/profileLinks.ts"; -import NotFoundPage from "./pages/not-found/NotFoundPage.tsx"; -import ProtectedRoute from "./pages/ProtectedRoute.tsx"; -import DownloadAppPage from "./pages/download-app/DownloadAppPage.tsx"; +import { parseProfileLink } from "./core/profileLinks"; +import NotFoundPage from "./pages/not-found/NotFoundPage"; +import ProtectedRoute from "./pages/ProtectedRoute"; +import DownloadAppPage from "./pages/download-app/DownloadAppPage"; // Lazy load route components const HomePage = lazy(() => import("./pages/home/HomePage")); @@ -19,12 +19,12 @@ const routeConfig: RouteObject[] = [ { path: "/register", element: }, { path: "/download-app", element: }, { - path: "/chat", + path: "/chat", element: ( - ) + ) }, { path: "*", element: } ]; @@ -46,8 +46,7 @@ function SmartCatchAll() { return; } - // Check if it's a profile link - const profileInfo = parseProfileLink(); // no url specified intentionally to let it use the current url + const profileInfo = parseProfileLink(); // No URL specified intentionally to let it use the current URL if (profileInfo) { setShowNotFound(false); diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 56486bc..6bfe5c8 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -27,8 +27,8 @@ export default function ChatPage() { const profileInfo = location.state.profileInfo; // Create a unique key for this profile - const profileKey = profileInfo.userId - ? `user_${profileInfo.userId}` + const profileKey = profileInfo.userId + ? `user_${profileInfo.userId}` : `username_${profileInfo.username}`; // Skip if we've already processed this exact profile diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 836279d..0790ae9 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -74,8 +74,7 @@ export function ProfileDialog() { const [isSaving, setIsSaving] = useState(false); const [errors, setErrors] = useState<{[key: string]: string}>({}); const fileInputRef = useRef(null); - const backdropRef = useRef(null); - const dialogRef = useRef(null); + const [openClass, setOpenClass] = useState(false); // Handle dialog open/close based on state useEffect(() => { @@ -84,17 +83,12 @@ export function ProfileDialog() { fetchFreshProfileData(chat.profileDialog); } else if (!chat.profileDialog && isOpen) { // Start close animation - if (backdropRef.current && dialogRef.current) { - backdropRef.current.classList.remove('open'); - dialogRef.current.classList.remove('open'); + setOpenClass(false); // Wait for animation to complete before closing setTimeout(() => { setIsOpen(false); - }, 300); // Match CSS transition duration - } else { - setIsOpen(false); - } + }, 300); } }, [chat.profileDialog, isOpen]); @@ -132,13 +126,10 @@ export function ProfileDialog() { useEffect(() => { if (isOpen) { // Small delay to ensure DOM is ready for transition - const timer = setTimeout(() => { - if (backdropRef.current && dialogRef.current) { - backdropRef.current.classList.add('open'); - dialogRef.current.classList.add('open'); - } - }, 10); - return () => clearTimeout(timer); + const timer = requestAnimationFrame(() => { + setOpenClass(true); + }); + return () => cancelAnimationFrame(timer); } }, [isOpen]); @@ -214,17 +205,12 @@ export function ProfileDialog() { }; function triggerCloseAnimation() { - if (backdropRef.current && dialogRef.current) { - backdropRef.current.classList.remove('open'); - dialogRef.current.classList.remove('open'); + setOpenClass(false); - // Wait for animation to complete before closing - setTimeout(() => { - closeProfileDialog(); - }, 300); // Match CSS transition duration - } else { + // Wait for animation to complete before closing + setTimeout(() => { closeProfileDialog(); - } + }, 300); // Match CSS transition duration }; function handleBackdropClick(e: React.MouseEvent) { @@ -402,13 +388,10 @@ export function ProfileDialog() { return createPortal(
-
+ className={`profile-dialog-backdrop ${openClass ? "open" : ""}`} + onClick={handleBackdropClick}> +
- {/* Profile Picture */}
- {/* Display Name */}
- {/* Online Status */} {(currentData?.userId || currentData?.isOwnProfile) && (
@@ -462,8 +443,6 @@ export function ProfileDialog() { readOnly={!currentData.isOwnProfile} placeholder="username" /> - - {/* Bio */} {currentData.bio !== undefined && (
)} - {/* Member Since */} {currentData.memberSince && (
- {/* Save FAB */} {currentData.isOwnProfile && ( )} - {/* Hidden file input */} - )} -
+ onClick={handleLinkClick} /> {message.files && message.files.length > 0 && ( From a95efcdcf8081abf9affdb3ecd687a9391a06eb7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 22 Oct 2025 19:50:45 +0300 Subject: [PATCH 6/8] Implement account suspension, right to delete any message for owner, account deletion --- backend/dependencies.py | 16 + backend/models.py | 10 +- backend/routes/account.py | 5 +- backend/routes/messaging.py | 41 +- backend/routes/profile.py | 178 +++++++- frontend/src/App.tsx | 12 +- frontend/src/core/components/StyledDialog.tsx | 69 +++ .../core/components/css/_styled-dialog.scss | 45 ++ frontend/src/core/types.d.ts | 3 + frontend/src/core/websocket.ts | 14 + frontend/src/css/style.scss | 5 + frontend/src/pages/auth/LoginPage.tsx | 9 + .../src/pages/chat/css/_profile-dialog.scss | 396 +++++++++--------- .../pages/chat/css/_suspension-dialog.scss | 72 ++++ frontend/src/pages/chat/css/chat.scss | 3 +- frontend/src/pages/chat/state.ts | 45 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 297 ++++++++----- .../src/pages/chat/ui/SuspensionDialog.tsx | 42 ++ frontend/src/pages/chat/ui/right/Message.tsx | 2 +- .../chat/ui/right/MessageContextMenu.tsx | 4 +- package.json | 1 + 21 files changed, 932 insertions(+), 337 deletions(-) create mode 100644 frontend/src/core/components/StyledDialog.tsx create mode 100644 frontend/src/core/components/css/_styled-dialog.scss create mode 100644 frontend/src/pages/chat/css/_suspension-dialog.scss create mode 100644 frontend/src/pages/chat/ui/SuspensionDialog.tsx diff --git a/backend/dependencies.py b/backend/dependencies.py index f1b47ab..5a40ea2 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -35,4 +35,20 @@ def get_current_user( detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) + + # Check if user is suspended + if user.suspended: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account suspended", + headers={"suspension_reason": user.suspension_reason or "No reason provided"}, + ) + + # Check if user is deleted + if user.deleted: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account deleted", + ) + return user \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 243707c..5e4533e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -21,6 +21,9 @@ class User(Base): last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) verified = Column(Boolean, default=False) + suspended = Column(Boolean, default=False) + suspension_reason = Column(Text, nullable=True) + deleted = Column(Boolean, default=False) messages = relationship("Message", back_populates="author", lazy="select") @@ -185,9 +188,12 @@ class UserProfileResponse(BaseModel): profile_picture: str | None bio: str | None online: bool - last_seen: datetime - created_at: datetime + last_seen: datetime | None + created_at: datetime | None verified: bool + suspended: bool + suspension_reason: str | None + deleted: bool class Config: from_attributes = True diff --git a/backend/routes/account.py b/backend/routes/account.py index d7f02e2..607c805 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -21,7 +21,10 @@ def convert_user(user: User) -> dict: "profile_picture": user.profile_picture, "bio": user.bio, "admin": user.username == OWNER_USERNAME, - "verified": user.verified + "verified": user.verified, + "suspended": user.suspended or False, + "suspension_reason": user.suspension_reason, + "deleted": user.deleted or False } @router.get("/check_auth") diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index eb41ddf..b7072c0 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -51,6 +51,16 @@ def convert_message(msg: Message) -> dict: "username": reaction.user.display_name }) + # Handle deleted users + if msg.author.deleted: + username = f"Deleted User #{msg.author.id}" + profile_picture = None + verified = False + else: + username = msg.author.display_name + profile_picture = msg.author.profile_picture + verified = msg.author.verified + return { "id": msg.id, "user_id": msg.author.id, @@ -58,9 +68,9 @@ def convert_message(msg: Message) -> dict: "timestamp": msg.timestamp.isoformat(), "is_read": msg.is_read, "is_edited": msg.is_edited, - "username": msg.author.display_name, - "profile_picture": msg.author.profile_picture, - "verified": msg.author.verified, + "username": username, + "profile_picture": profile_picture, + "verified": verified, "reply_to": convert_message(msg.reply_to) if msg.reply_to else None, "reactions": list(reactions_dict.values()), "files": [ @@ -98,7 +108,12 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: from dependencies import get_db db = next(get_db()) sender = db.query(User).filter(User.id == envelope.sender_id).first() - sender_verified = sender.verified if sender else False + + # Handle deleted users + if sender and sender.deleted: + sender_verified = False + else: + sender_verified = sender.verified if sender else False return { "id": envelope.id, @@ -1238,6 +1253,24 @@ class MessaggingSocketManager: if self.user_by_ws.get(websocket) == user_id: await websocket.send_json(message) + async def send_suspension_to_user(self, user_id: int, reason: str): + """Send suspension message to user's WebSocket connections""" + message = { + "type": "suspended", + "data": { + "reason": reason + } + } + await self.send_to_user(user_id, message) + + async def send_deletion_to_user(self, user_id: int): + """Send account deletion message to user's WebSocket connections""" + message = { + "type": "account_deleted", + "data": {} + } + await self.send_to_user(user_id, message) + async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str): """Broadcast status change to all connections that are subscribed to this user""" message = { diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 70ad75f..d37479e 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -237,6 +237,23 @@ async def get_user_by_id( if not user: raise HTTPException(status_code=404, detail="User not found") + # Handle deleted users + if user.deleted: + return UserProfileResponse( + id=user.id, + username="deleted", + display_name="Deleted User", + profile_picture=None, + bio=None, + online=False, + last_seen=None, # Clear last seen timestamp + created_at=None, # Clear member since timestamp + verified=False, + suspended=False, + suspension_reason=None, + deleted=True + ) + return UserProfileResponse( id=user.id, username=user.username, @@ -246,7 +263,10 @@ async def get_user_by_id( online=user.online, last_seen=user.last_seen, created_at=user.created_at, - verified=user.verified + verified=user.verified, + suspended=user.suspended or False, + suspension_reason=user.suspension_reason, + deleted=user.deleted or False ) @@ -308,3 +328,159 @@ async def check_user_similarity( "isSimilar": is_similar, "similarTo": similar_to if is_similar else None } + + +# Admin endpoints for user management +class SuspendUserRequest(BaseModel): + reason: str + +@router.post("/user/{user_id}/suspend") +async def suspend_user( + user_id: int, + request: SuspendUserRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Suspend a user account (admin only) + """ + # Only user with ID 1 (admin) can suspend users + if current_user.id != 1: + raise HTTPException(status_code=403, detail="Only admin can suspend users") + + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # Cannot suspend admin + if target_user.id == 1: + raise HTTPException(status_code=400, detail="Cannot suspend admin account") + + # Suspend the user + target_user.suspended = True + target_user.suspension_reason = request.reason + db.commit() + + # Send WebSocket suspension message + try: + from .messaging import messagingManager + await messagingManager.send_suspension_to_user(user_id, request.reason) + except Exception as e: + # Log error but don't fail the request + print(f"Failed to send suspension WebSocket message: {e}") + + return { + "status": "success", + "message": f"User {target_user.username} has been suspended", + "reason": request.reason + } + + +@router.post("/user/{user_id}/unsuspend") +async def unsuspend_user( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Unsuspend a user account (admin only) + """ + # Only user with ID 1 (admin) can unsuspend users + if current_user.id != 1: + raise HTTPException(status_code=403, detail="Only admin can unsuspend users") + + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # Unsuspend the user + target_user.suspended = False + target_user.suspension_reason = None + db.commit() + + return { + "status": "success", + "message": f"User {target_user.username} has been unsuspended" + } + + +@router.post("/user/{user_id}/delete") +async def delete_user( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Delete a user account (admin only) - preserves messages/DMs/reactions/files + """ + # Only user with ID 1 (admin) can delete users + if current_user.id != 1: + raise HTTPException(status_code=403, detail="Only admin can delete users") + + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # Cannot delete admin + if target_user.id == 1: + raise HTTPException(status_code=400, detail="Cannot delete admin account") + + # Mark user as deleted and clear sensitive data + target_user.deleted = True + target_user.display_name = f"Deleted User #{user_id}" + target_user.bio = None + target_user.password_hash = "" + target_user.username = f"deleted_{user_id}" + target_user.profile_picture = None + target_user.last_seen = None # Clear last seen timestamp + target_user.created_at = None # Clear member since timestamp + + # Delete profile picture file if exists + if target_user.profile_picture and target_user.profile_picture.startswith("/api/profile-picture/"): + try: + import os + filename = target_user.profile_picture.split("/")[-1] + filepath = os.path.join("data/uploads/pfp", filename) + if os.path.exists(filepath): + os.remove(filepath) + except Exception as e: + print(f"Failed to delete profile picture: {e}") + + # Dynamic deletion of all non-whitelist data + WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"} + + try: + from sqlalchemy import inspect, text + inspector = inspect(db.bind) + all_tables = inspector.get_table_names() + + for table_name in all_tables: + if table_name in WHITELIST_TABLES or table_name == "user": + continue + + # Check if table has user_id column + columns = inspector.get_columns(table_name) + has_user_id = any(col['name'] == 'user_id' for col in columns) + + if has_user_id: + # Delete all records for this user + db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id}) + + db.commit() + except Exception as e: + print(f"Failed to delete user data: {e}") + db.rollback() + raise HTTPException(status_code=500, detail="Failed to delete user data") + + # Send WebSocket deletion message + try: + from .messaging import messagingManager + await messagingManager.send_deletion_to_user(user_id) + except Exception as e: + # Log error but don't fail the request + print(f"Failed to send deletion WebSocket message: {e}") + + return { + "status": "success", + "message": f"User {target_user.username} has been deleted" + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f99b514..7cb4c96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { parseProfileLink } from "./core/profileLinks"; import NotFoundPage from "./pages/not-found/NotFoundPage"; import ProtectedRoute from "./pages/ProtectedRoute"; import DownloadAppPage from "./pages/download-app/DownloadAppPage"; +import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog"; // Lazy load route components const HomePage = lazy(() => import("./pages/home/HomePage")); @@ -66,7 +67,7 @@ function SmartCatchAll() { } export default function App() { - const { restoreUserFromStorage } = useAppState(); + const { restoreUserFromStorage, user } = useAppState(); const [authReady, setAuthReady] = useState(false); useEffect(() => { @@ -74,7 +75,7 @@ export default function App() { setAuthReady(true); }); }, [restoreUserFromStorage]); - + return authReady && ( @@ -85,6 +86,13 @@ export default function App() { ))}
+ {user.isSuspended && ( + {}} // Suspended users can't close the dialog + /> + )} ) } \ No newline at end of file diff --git a/frontend/src/core/components/StyledDialog.tsx b/frontend/src/core/components/StyledDialog.tsx new file mode 100644 index 0000000..1262c01 --- /dev/null +++ b/frontend/src/core/components/StyledDialog.tsx @@ -0,0 +1,69 @@ +import { createPortal } from "react-dom"; +import { useEffect, type ReactNode } from "react"; +import { motion, AnimatePresence, type Transition } from "motion/react"; + +interface StyledDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + onBackdropClick?: () => void; + className?: string; +} + +export function StyledDialog({ + open, + onOpenChange, + children, + onBackdropClick, + className = "" +}: StyledDialogProps) { + const transition: Transition = { duration: 0.3, type: "tween", ease: "easeInOut" }; + + // Handle ESC key + useEffect(() => { + if (open) { + function handleEsc(e: KeyboardEvent) { + if (e.key === "Escape") { + onOpenChange(false); + } + } + + document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + } + }, [open, onOpenChange]); + + return createPortal( + + {open && ( + { + if (e.target === e.currentTarget) { + if (onBackdropClick) { + onBackdropClick(); + } else { + onOpenChange(false); + } + } + }} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={transition}> + +
+ {children} +
+
+
+ )} +
, + document.getElementById("root")! + ); +} diff --git a/frontend/src/core/components/css/_styled-dialog.scss b/frontend/src/core/components/css/_styled-dialog.scss new file mode 100644 index 0000000..50386eb --- /dev/null +++ b/frontend/src/core/components/css/_styled-dialog.scss @@ -0,0 +1,45 @@ +@use "../../../css/colors" as *; +@use "../../../css/material" as *; +@use "sass:color"; + +// Base Styled Dialog Styles +.styled-dialog-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(20px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 30px; + box-sizing: border-box; + + .styled-dialog { + width: 100%; + max-width: 500px; + max-height: calc(100vh - 60px); + background: $color-dark-surface-container; + border-radius: 16px; + box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14), + 0 9px 46px 8px rgba(0, 0, 0, 0.12), + 0 11px 15px -7px rgba(0, 0, 0, 0.2); + overflow: hidden; + display: flex; + flex-direction: column; + // Framer Motion handles all animations + // Removed CSS transitions to prevent interference + + .styled-dialog-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + width: 100%; + position: relative; + } + } +} \ No newline at end of file diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 3198743..90a89b8 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -118,6 +118,9 @@ export interface User { bio?: string; profile_picture: string; verified?: boolean; + suspended?: boolean; + suspension_reason?: string | null; + deleted?: boolean; } /** diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index f205de1..435a113 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -11,6 +11,7 @@ import { delay } from "@/utils/utils"; import { CallSignalingHandler } from "./calls/signaling"; import { onlineStatusManager } from "./onlineStatusManager"; import { typingManager } from "./typingManager"; +import { useAppState } from "@/pages/chat/state"; /** * Creates a new WebSocket connection to the chat server @@ -129,6 +130,19 @@ websocket.addEventListener("message", (e) => { typingManager.handleDmTyping(response as any); } else if (response.type === "stopDmTyping") { typingManager.handleStopDmTyping(response as any); + } else if (response.type === "suspended") { + // Handle account suspension + const { setSuspended } = useAppState.getState(); + const reason = response.data?.reason || "No reason provided"; + setSuspended(reason); + // Close WebSocket connection + websocket.close(); + } else if (response.type === "account_deleted") { + // Handle account deletion - silent logout + const { logout } = useAppState.getState(); + logout(); + // Close WebSocket connection + websocket.close(); } // Route message to global handler if set diff --git a/frontend/src/css/style.scss b/frontend/src/css/style.scss index 993bfec..41491d5 100644 --- a/frontend/src/css/style.scss +++ b/frontend/src/css/style.scss @@ -2,6 +2,7 @@ @use "components"; @use "colors" as *; @use "material" as *; +@use "../core/components/css/styled-dialog"; @use "fonts/montserrat"; @use "fonts/material-symbols"; @@ -41,4 +42,8 @@ mdui-dialog { > *:last-child { margin-block-end: 0; } +} + +mdui-icon { + user-select: none; } \ No newline at end of file diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 3974109..01537ba 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -98,6 +98,15 @@ export default function LoginPage() { } } else { const data: ErrorResponse = await response.json(); + + // Check for suspension + if (response.status === 403 && response.headers.get("suspension_reason")) { + const suspensionReason = response.headers.get("suspension_reason"); + const setSuspended = useAppState.getState().setSuspended; + setSuspended(suspensionReason || "No reason provided"); + return; // Don't show alert, SuspensionDialog will be shown + } + showAlert("danger", data.message || "Неверное имя пользователя или пароль"); } } catch (error) { diff --git a/frontend/src/pages/chat/css/_profile-dialog.scss b/frontend/src/pages/chat/css/_profile-dialog.scss index 97ea0ce..0341f24 100644 --- a/frontend/src/pages/chat/css/_profile-dialog.scss +++ b/frontend/src/pages/chat/css/_profile-dialog.scss @@ -2,219 +2,173 @@ @use "../../../css/material" as *; @use "sass:color"; -// Profile Dialog Styles -.profile-dialog-backdrop { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(20px); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: 30px; - box-sizing: border-box; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; +// Profile Dialog Specific Styles +// Base dialog styles are now in _styled-dialog.scss - &.open { - opacity: 1; - visibility: visible; +.styled-dialog-content { + align-items: center; + + .error-message { + color: $color-dark-error; + font-size: small; + } + + .profile-picture-section { + position: relative; + display: flex; + justify-content: center; + align-items: center; + margin: 16px; + + .profile-picture { + width: 120px; + height: 120px; + border-radius: 60px; + object-fit: cover; + border: 3px solid $color-dark-outline; + } + + .profile-picture-edit-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 60px; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + } + + .username-section { + text-align: center; + + .username-with-badge { + gap: 0; + + .username-input { + background: none; + border: none; + font-size: 1.5rem; + font-weight: 500; + color: $color-dark-on-surface; + text-align: center; + outline: none; + padding: 8px; + border-radius: 4px; + transition: background-color 0.2s ease; + cursor: text; + } + } + } + + .online-status-section { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + .online-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: $color-dark-primary; + + &.offline { + background: $color-dark-on-surface-variant; + } + } + + .status-text { + font-size: 0.875rem; + color: $color-dark-on-surface; + } + } + + .profile-sections { + margin: 16px; + display: flex; + flex-direction: column; + gap: 4px; + width: calc(100% - (16px * 2)); + box-sizing: border-box; + + .section { + $edge-radius: 24px; + + background: $color-dark-surface-container-high; + border-radius: 10px; + padding: 8px 16px; + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + transition: outline 0.1s ease; + outline: 0px solid transparent; + outline-offset: -1px; + + .content-container { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + + .label { + font-size: small; + color: $color-dark-on-surface-variant; + user-select: none; + } + + .value { + color: $color-dark-on-surface; + font-size: medium; + width: 100%; + line-height: 1.4; + font-family: inherit; + cursor: text; + outline: none; + background: transparent; + border: none; + caret-color: $color-dark-primary; + + &::placeholder { + color: $color-dark-on-surface-variant; + } + } + } + + // First and last section + &:first-child { + border-top-left-radius: $edge-radius; + border-top-right-radius: $edge-radius; + } + + &:last-child { + border-bottom-left-radius: $edge-radius; + border-bottom-right-radius: $edge-radius; + } + + &.error { + outline: 1px solid $color-dark-error; + + .error-message { + margin-top: 4px; + } + } + } } } -.profile-dialog { - width: 100%; - max-width: 500px; - max-height: calc(100vh - 60px); - background: $color-dark-surface-container; - border-radius: 16px; - box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14), - 0 9px 46px 8px rgba(0, 0, 0, 0.12), - 0 11px 15px -7px rgba(0, 0, 0, 0.2); - overflow: hidden; - display: flex; - flex-direction: column; - transform: scale(0.9); - opacity: 0; - transition: transform 0.3s ease, opacity 0.3s ease; - - &.open { - transform: scale(1); - opacity: 1; - } - - .profile-dialog-content { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - align-items: center; - - .error-message { - color: $color-dark-error; - font-size: small; - } - - .profile-picture-section { - position: relative; - display: flex; - justify-content: center; - align-items: center; - margin: 16px; - - .profile-picture { - width: 120px; - height: 120px; - border-radius: 60px; - object-fit: cover; - border: 3px solid $color-dark-outline; - } - - .profile-picture-edit-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 60px; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.2s ease; - cursor: pointer; - - &:hover { - opacity: 1; - } - } - } - - .username-section { - text-align: center; - - .username-with-badge { - gap: 0; - - .username-input { - background: none; - border: none; - font-size: 1.5rem; - font-weight: 500; - color: $color-dark-on-surface; - text-align: center; - outline: none; - padding: 8px; - border-radius: 4px; - transition: background-color 0.2s ease; - cursor: text; - } - } - } - - .online-status-section { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - - .online-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background: $color-dark-primary; - - &.offline { - background: $color-dark-on-surface-variant; - } - } - - .status-text { - font-size: 0.875rem; - color: $color-dark-on-surface; - } - } - - .profile-sections { - margin: 16px; - display: flex; - flex-direction: column; - gap: 4px; - width: calc(100% - (16px * 2)); - box-sizing: border-box; - - .section { - $edge-radius: 24px; - - background: $color-dark-surface-container-high; - border-radius: 10px; - padding: 8px 16px; - display: flex; - flex-direction: row; - gap: 16px; - align-items: center; - transition: outline 0.1s ease; - outline: 0px solid transparent; - outline-offset: -1px; - - .content-container { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; - - .label { - font-size: small; - color: $color-dark-on-surface-variant; - user-select: none; - } - - .value { - color: $color-dark-on-surface; - font-size: medium; - width: 100%; - line-height: 1.4; - font-family: inherit; - cursor: text; - outline: none; - background: transparent; - border: none; - caret-color: $color-dark-primary; - - &::placeholder { - color: $color-dark-on-surface-variant; - } - } - } - - // First and last section - &:first-child { - border-top-left-radius: $edge-radius; - border-top-right-radius: $edge-radius; - } - - &:last-child { - border-bottom-left-radius: $edge-radius; - border-bottom-right-radius: $edge-radius; - } - - &.error { - outline: 1px solid $color-dark-error; - - .error-message { - margin-top: 4px; - } - } - } - } - } - +.styled-dialog { .profile-dialog-fab { position: absolute; bottom: 24px; @@ -227,4 +181,28 @@ transform: translateY(0); } } + + // Admin Actions Section + .admin-actions-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid #e0e0e0; + } + + .admin-actions-header { + font-size: 16px; + font-weight: 600; + margin: 0 0 16px 0; + color: #f44336; + } + + .admin-buttons { + display: flex; + flex-direction: column; + gap: 12px; + } + + .admin-buttons mdui-button { + width: 100%; + } } \ No newline at end of file diff --git a/frontend/src/pages/chat/css/_suspension-dialog.scss b/frontend/src/pages/chat/css/_suspension-dialog.scss new file mode 100644 index 0000000..ef845c8 --- /dev/null +++ b/frontend/src/pages/chat/css/_suspension-dialog.scss @@ -0,0 +1,72 @@ +@use "../../../css/colors" as *; +@use "../../../css/material" as *; +@use "sass:color"; + +// Suspension Dialog Content Styles +.suspension-dialog-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: 100%; + padding: 24px; + + .suspension-icon-section { + margin-bottom: 24px; + + .suspension-icon { + font-size: 80px; + color: #f44336; + display: block; + } + } + + .suspension-text { + max-width: 400px; + + .suspension-headline { + font-size: 28px; + font-weight: 600; + margin: 0 0 16px 0; + color: #f44336; + line-height: 1.2; + } + + .suspension-body { + font-size: 16px; + margin: 0 0 20px 0; + color: $color-dark-on-surface; + line-height: 1.5; + } + + .suspension-reason { + text-align: left; + + strong { + color: $color-dark-on-surface; + font-weight: 600; + } + + .suspension-reason-text { + background: $color-dark-surface-container-high; + border-radius: 12px; + padding: 16px; + margin-top: 5px; + font-size: 14px; + color: $color-dark-on-surface; + text-align: left; + } + } + + .suspension-secondary { + font-size: 14px; + margin: 20px 0 0 0; + color: $color-dark-on-surface-variant; + line-height: 1.4; + + a { + margin-left: 4px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/chat.scss b/frontend/src/pages/chat/css/chat.scss index 6709f6b..8c1d58f 100644 --- a/frontend/src/pages/chat/css/chat.scss +++ b/frontend/src/pages/chat/css/chat.scss @@ -10,4 +10,5 @@ @use "animations"; @use "callWindow"; @use "profile-dialog"; -@use "typing-indicators"; \ No newline at end of file +@use "typing-indicators"; +@use "suspension-dialog"; \ No newline at end of file diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 1d50472..717180e 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -26,6 +26,9 @@ export interface ProfileDialogData { online?: boolean; isOwnProfile: boolean; verified?: boolean; + suspended?: boolean; + suspension_reason?: string | null; + deleted?: boolean; } interface ActiveDM { @@ -73,6 +76,8 @@ interface ChatState { export interface UserState { currentUser: User | null; authToken: string | null; + isSuspended: boolean; + suspensionReason: string | null; } interface AppState { @@ -112,6 +117,7 @@ interface AppState { setUser: (token: string, user: User) => void; logout: () => void; restoreUserFromStorage: () => Promise; + setSuspended: (reason: string) => void; // Profile dialog state setProfileDialog: (data: ProfileDialogData | null) => void; @@ -226,13 +232,17 @@ export const useAppState = create((set, get) => ({ // User state user: { currentUser: null, - authToken: null + authToken: null, + isSuspended: false, + suspensionReason: null }, setUser: (token: string, user: User) => { set(() => ({ user: { currentUser: user, - authToken: token + authToken: token, + isSuspended: user.suspended || false, + suspensionReason: user.suspension_reason || null } })); @@ -279,7 +289,9 @@ export const useAppState = create((set, get) => ({ set(() => ({ user: { currentUser: null, - authToken: null + authToken: null, + isSuspended: false, + suspensionReason: null } })); }, @@ -296,10 +308,25 @@ export const useAppState = create((set, get) => ({ const user: User = await response.json(); restoreKeys(); + // Check if user is suspended + if (user.suspended) { + set(() => ({ + user: { + currentUser: user, + authToken: token, + isSuspended: true, + suspensionReason: user.suspension_reason || null + } + })); + return; // Don't initialize managers or notifications for suspended users + } + set(() => ({ user: { currentUser: user, - authToken: token + authToken: token, + isSuspended: false, + suspensionReason: null } })); @@ -679,5 +706,13 @@ export const useAppState = create((set, get) => ({ dmTypingUsers: newDmTypingUsers } }; - }) + }), + + setSuspended: (reason: string) => set((state) => ({ + user: { + ...state.user, + isSuspended: true, + suspensionReason: reason + } + })) })); \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 1e6d156..412e5a4 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; -import { createPortal } from "react-dom"; import { useAppState } from "@/pages/chat/state"; import type { ProfileDialogData } from "@/pages/chat/state"; import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; +import { prompt } from "mdui/functions/prompt"; import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi"; import { RichTextArea } from "@/core/components/RichTextArea"; import { StatusBadge } from "@/core/components/StatusBadge"; @@ -11,6 +11,7 @@ import { VerifyButton } from "@/core/components/VerifyButton"; import { onlineStatusManager } from "@/core/onlineStatusManager"; import { OnlineStatus } from "./right/OnlineStatus"; import { Input } from "@/core/components/Input"; +import { StyledDialog } from "@/core/components/StyledDialog"; interface SectionProps { type: string; @@ -74,7 +75,6 @@ export function ProfileDialog() { const [isSaving, setIsSaving] = useState(false); const [errors, setErrors] = useState<{[key: string]: string}>({}); const fileInputRef = useRef(null); - const [openClass, setOpenClass] = useState(false); // Handle dialog open/close based on state useEffect(() => { @@ -82,13 +82,7 @@ export function ProfileDialog() { // Fetch fresh data when opening dialog fetchFreshProfileData(chat.profileDialog); } else if (!chat.profileDialog && isOpen) { - // Start close animation - setOpenClass(false); - - // Wait for animation to complete before closing - setTimeout(() => { - setIsOpen(false); - }, 300); + setIsOpen(false); } }, [chat.profileDialog, isOpen]); @@ -123,30 +117,7 @@ export function ProfileDialog() { } } - // Trigger transition after component mounts - useEffect(() => { - if (isOpen) { - // Small delay to ensure DOM is ready for transition - const timer = requestAnimationFrame(() => { - setOpenClass(true); - }); - return () => cancelAnimationFrame(timer); - } - }, [isOpen]); - // Handle ESC key - useEffect(() => { - if (isOpen) { - function handleEsc(e: KeyboardEvent) { - if (e.key === "Escape") { - handleClose(); - } - } - - document.addEventListener("keydown", handleEsc); - return () => document.removeEventListener("keydown", handleEsc); - } - }, [isOpen]); // Subscribe to user's online status when dialog opens useEffect(() => { @@ -197,35 +168,21 @@ export function ProfileDialog() { confirmText: "Закрыть", cancelText: "Отмена" }); - triggerCloseAnimation(); + closeProfileDialog(); } catch { // User cancelled, do nothing } } else { - triggerCloseAnimation(); - } - }; - - function triggerCloseAnimation() { - setOpenClass(false); - - // Wait for animation to complete before closing - setTimeout(() => { closeProfileDialog(); - }, 300); // Match CSS transition duration - }; - - function handleBackdropClick(e: React.MouseEvent) { - if (e.target === e.currentTarget) { - handleClose(); } }; + function handleDisplayNameChange(e: React.ChangeEvent) { if (!currentData) return; const newValue = e.target.value; setCurrentData({ ...currentData, display_name: newValue }); - + // Validate display name in real-time validateDisplayName(newValue); }; @@ -233,7 +190,7 @@ export function ProfileDialog() { function handleUsernameChange(value: string) { if (!currentData) return; setCurrentData({ ...currentData, username: value }); - + // Validate username in real-time validateUsername(value); }; @@ -266,19 +223,19 @@ export function ProfileDialog() { function validateDisplayName(value: string) { let error = ""; - + if (!value || value.trim().length === 0) { error = "Отображаемое имя не может быть пустым"; } else if (value.length > 64) { error = "Отображаемое имя не может быть длиннее 64 символов"; } - + setErrors(prev => ({ ...prev, display_name: error })); }; function validateUsername(value: string) { let error = ""; - + if (!value || value.trim().length === 0) { error = "Имя пользователя не может быть пустым"; } else if (value.length < 3) { @@ -288,7 +245,7 @@ export function ProfileDialog() { } else if (!/^[a-zA-Z0-9_-]+$/.test(value)) { error = "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания"; } - + setErrors(prev => ({ ...prev, username: error })); }; @@ -297,7 +254,7 @@ export function ProfileDialog() { validateDisplayName(currentData.display_name || ""); validateUsername(currentData.username || ""); } - + return !errors.display_name && !errors.username; }; @@ -352,8 +309,8 @@ export function ProfileDialog() { setUser(user.authToken, updatedUser); } - // Close dialog with animation after successful save - triggerCloseAnimation(); + // Close dialog after successful save + closeProfileDialog(); } catch (error) { console.error("Failed to save profile:", error); // Handle API errors @@ -375,6 +332,90 @@ export function ProfileDialog() { }); } + async function handleSuspend() { + if (!currentData?.userId || !user.authToken) return; + + const isSuspending = !currentData.suspended; + + try { + if (isSuspending) { + const reason = await prompt({ + headline: "Suspend Account", + description: "Enter the reason for suspending this account:", + confirmText: "Suspend", + cancelText: "Cancel" + }); + + if (reason) { + const response = await fetch(`/api/user/${currentData.userId}/suspend`, { + method: "POST", + headers: { + "Authorization": `Bearer ${user.authToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + closeProfileDialog(); + } else { + const error = await response.json(); + console.error("Failed to suspend user:", error); + } + } + } else { + // Unsuspend user + const response = await fetch(`/api/user/${currentData.userId}/unsuspend`, { + method: "POST", + headers: { + "Authorization": `Bearer ${user.authToken}`, + "Content-Type": "application/json" + } + }); + + if (response.ok) { + closeProfileDialog(); + } else { + const error = await response.json(); + console.error("Failed to unsuspend user:", error); + } + } + } catch (error) { + console.error(`Failed to ${isSuspending ? 'suspend' : 'unsuspend'} user:`, error); + } + } + + async function handleDelete() { + if (!currentData?.userId || !user.authToken) return; + + try { + await confirm({ + headline: "Delete Account", + description: "This will permanently delete user data but preserve messages and conversations. If the user is online, they will be immediately logged out. This action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel" + }); + + const response = await fetch(`/api/user/${currentData.userId}/delete`, { + method: "POST", + headers: { + "Authorization": `Bearer ${user.authToken}`, + "Content-Type": "application/json" + } + }); + + if (response.ok) { + closeProfileDialog(); + } else { + const error = await response.json(); + console.error("Failed to delete user:", error); + } + } catch (error) { + // User cancelled or error occurred + console.error("Failed to delete user:", error); + } + } + const fabVisible = useMemo(() => { let hasErrors = false; @@ -387,15 +428,19 @@ export function ProfileDialog() { return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors; }, [hasChanges, currentData?.isOwnProfile, isSaving, errors]); - if (!isOpen || !currentData) return null; + if (!currentData) return null; - return createPortal( -
-
-
-
+ return ( + { + if (!open) { + handleClose(); + } + }} + onBackdropClick={handleClose} + > +
- {(currentData?.userId || currentData?.isOwnProfile) && ( + {(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && (
)} - {/* Verify button for owner */} - {!currentData.isOwnProfile && currentData.userId && ( + {/* Admin Actions Section - Hide for deleted users */} + {!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && ( +
+

Admin Actions

+
+ + {currentData.suspended ? "Unsuspend Account" : "Suspend Account"} + + + Delete Account + + { + setCurrentData({ ...currentData, verified }); + }} + /> +
+
+ )} + + {/* Verify button for non-admin owner */} + {!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && (
)} -
-
- - {currentData.bio !== undefined && ( + {/* Hide profile sections for deleted users */} + {!currentData.deleted && ( +
- )} + placeholder="username" /> - {currentData.memberSince && ( -
- )} + {currentData.bio !== undefined && ( +
+ )} - {currentData.verified && ( -
- )} -
-
+ {currentData.memberSince && ( +
+ )} + + {currentData.verified && ( +
+ )} +
+ )} {currentData.isOwnProfile && ( -
-
, - document.getElementById("root")! + ); } diff --git a/frontend/src/pages/chat/ui/SuspensionDialog.tsx b/frontend/src/pages/chat/ui/SuspensionDialog.tsx new file mode 100644 index 0000000..b9d1e22 --- /dev/null +++ b/frontend/src/pages/chat/ui/SuspensionDialog.tsx @@ -0,0 +1,42 @@ +import { StyledDialog } from "@/core/components/StyledDialog"; + +interface SuspensionDialogProps { + reason: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialogProps) { + return ( + +
+
+ +
+ +
+

Аккаунт заблокирован

+

+ Ваш аккаунт был заблокирован за нарушение правил сообщества. + Вы не можете отправлять сообщения или взаимодействовать с другими пользователями. +

+ {reason && reason !== "No reason provided" && ( +
+ Причина блокировки: +
+ {reason} +
+
+ )} +

+ Если вы считаете, что блокировка была применена по ошибке, + обратитесь к администратору для рассмотрения вашего случая. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index f3767b4..4411f49 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -487,7 +487,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD {!isAuthor && !isDm && (
{message.username} { const target = e.target as HTMLImageElement; diff --git a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx index 3ac3022..a999063 100644 --- a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx +++ b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from "react"; import type { Message, Size2D } from "@/core/types"; import { EmojiMenu } from "./EmojiMenu"; +import { useAppState } from "@/pages/chat/state"; interface MessageContextMenuProps { message: Message; @@ -33,6 +34,7 @@ export function MessageContextMenu({ isOpen, onOpenChange }: MessageContextMenuProps) { + const { user } = useAppState(); // Internal state for closing animation const [isClosing, setIsClosing] = useState(false); const [calculatedPosition, setCalculatedPosition] = useState(position); @@ -209,7 +211,7 @@ export function MessageContextMenu({ onDelete(message); handleClose(); }, - show: isAuthor + show: isAuthor || user.currentUser?.id === 1 }, ]; diff --git a/package.json b/package.json index 0949b79..f4b9d31 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "escape-string-regexp": "^5.0.0", "marked": "^16.3.0", "mdui": "^2.1.4", + "motion": "^12.23.24", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.3", From 87f3ddc2f0d8282e61aa6aa15b45cb78caddee5d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 22 Oct 2025 22:07:52 +0300 Subject: [PATCH 7/8] Clean up --- backend/app.py | 4 ++-- backend/routes/profile.py | 14 +++++++------- frontend/src/pages/chat/hooks/useDM.ts | 1 - frontend/src/pages/chat/state.ts | 4 ---- frontend/src/pages/chat/ui/right/Message.tsx | 3 --- 5 files changed, 9 insertions(+), 17 deletions(-) diff --git a/backend/app.py b/backend/app.py index 99279f5..1cbb41a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -18,7 +18,7 @@ logger = logging.getLogger("uvicorn.error") async def lifespan(app: FastAPI): # Startup - run migration in separate process to avoid logging interference try: - print("Starting database migration check...") + logger.info("Starting database migration check...") # Run migration in a separate process subprocess.run( [ @@ -29,7 +29,7 @@ async def lifespan(app: FastAPI): cwd=os.path.dirname(os.path.abspath(__file__)) ) except Exception as e: - print(f"Failed to run database migrations: {e}") + logger.error(f"Failed to run database migrations: {e}") raise try: diff --git a/backend/routes/profile.py b/backend/routes/profile.py index d37479e..0dac908 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -3,6 +3,7 @@ import re from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import FileResponse from sqlalchemy.orm import Session +from sqlalchemy import inspect, text from PIL import Image import os import uuid @@ -13,6 +14,7 @@ from models import User, UpdateBioRequest, UserProfileResponse from pydantic import BaseModel from validation import is_valid_username, is_valid_display_name from similarity import is_user_similar_to_verified +from messaging import messagingManager router = APIRouter() @@ -363,11 +365,10 @@ async def suspend_user( # Send WebSocket suspension message try: - from .messaging import messagingManager await messagingManager.send_suspension_to_user(user_id, request.reason) except Exception as e: # Log error but don't fail the request - print(f"Failed to send suspension WebSocket message: {e}") + pass return { "status": "success", @@ -444,13 +445,13 @@ async def delete_user( if os.path.exists(filepath): os.remove(filepath) except Exception as e: - print(f"Failed to delete profile picture: {e}") + # Log error but don't fail the request + pass # Dynamic deletion of all non-whitelist data WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"} try: - from sqlalchemy import inspect, text inspector = inspect(db.bind) all_tables = inspector.get_table_names() @@ -468,17 +469,16 @@ async def delete_user( db.commit() except Exception as e: - print(f"Failed to delete user data: {e}") + # Log error and rollback db.rollback() raise HTTPException(status_code=500, detail="Failed to delete user data") # Send WebSocket deletion message try: - from .messaging import messagingManager await messagingManager.send_deletion_to_user(user_id) except Exception as e: # Log error but don't fail the request - print(f"Failed to send deletion WebSocket message: {e}") + pass return { "status": "success", diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 25ac7fe..28dfb5f 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -69,7 +69,6 @@ export function useDM() { try { lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content; - console.log(lastPlaintext); } catch (error) { console.error("Failed to decrypt last message:", error); } diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 717180e..14577b9 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -266,8 +266,6 @@ export const useAppState = create((set, get) => ({ credentials: token }, data: {} - }).then(() => { - console.log("Ping succeeded") }) } catch {} }, @@ -342,8 +340,6 @@ export const useAppState = create((set, get) => ({ credentials: token }, data: {} - }).then(() => { - console.log("Ping succeeded") }) } catch {} diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 4411f49..40c49c2 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -193,12 +193,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD useEffect(() => { if (isDm && message.files) { message.files.forEach(async (file) => { - console.log(file); const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); if (isImage && file.encrypted && !decryptedFiles.has(file.path)) { - console.log("Decrypting..."); const decryptedUrl = await decryptFile(file); - console.log(decryptedUrl); if (decryptedUrl) { updateDecryptedFiles(draft => { draft.set(file.path, decryptedUrl); From 6d188777bc825b45d3a2c40283f99ae474efce2d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 22 Oct 2025 22:12:28 +0300 Subject: [PATCH 8/8] Clean up --- backend/app.py | 1 - frontend/src/pages/chat/ui/right/Message.tsx | 20 ++++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/backend/app.py b/backend/app.py index 1cbb41a..8ab9db0 100644 --- a/backend/app.py +++ b/backend/app.py @@ -54,7 +54,6 @@ async def lifespan(app: FastAPI): yield # Shutdown (if needed in the future) - # logger.info("Application shutdown") # Инициализация FastAPI app = FastAPI(title="FromChat", lifespan=lifespan) diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 40c49c2..bdc0d81 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -207,11 +207,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD }, [message.files, isDm, decryptedFiles]); async function decryptFile(file: Attachment): Promise { - if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) { - debugger; - console.warn("Conditions not met") - return null; - } + if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null; // Check if already decrypted if (decryptedFiles.has(file.path)) { @@ -420,25 +416,25 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD async function handleLinkClick(e: React.MouseEvent) { const target = e.target as HTMLElement; - + if (target.tagName === 'A') { const profileLink = parseProfileLink((target as HTMLAnchorElement).href); - + if (profileLink) { e.preventDefault(); e.stopPropagation(); - + if (!user.authToken) return; - + try { let userProfile; - + if (profileLink.userId) { userProfile = await fetchUserProfileById(user.authToken, profileLink.userId); } else if (profileLink.username) { userProfile = await fetchUserProfile(user.authToken, profileLink.username); } - + if (userProfile) { setProfileDialog({ ...userProfile, @@ -500,7 +496,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD className="message-username" onClick={handleProfileClick}> {message.username} -