From 3293d9136875eba8a319c2253916ae82b61d9c75 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 21 Oct 2025 22:31:09 +0300 Subject: [PATCH 1/4] 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 2/4] 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 a95efcdcf8081abf9affdb3ecd687a9391a06eb7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 22 Oct 2025 19:50:45 +0300 Subject: [PATCH 3/4] 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 4/4] 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);