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",