From 9e19342998606c89e00e5f05943876dafedf114d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 21 Oct 2025 22:49:26 +0300 Subject: [PATCH] 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} +
)}