mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement account suspension, right to delete any message for owner, account deletion
This commit is contained in:
@@ -35,4 +35,20 @@ def get_current_user(
|
|||||||
detail="User not found",
|
detail="User not found",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
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
|
return user
|
||||||
+8
-2
@@ -21,6 +21,9 @@ class User(Base):
|
|||||||
last_seen = Column(DateTime, default=datetime.now)
|
last_seen = Column(DateTime, default=datetime.now)
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
verified = Column(Boolean, default=False)
|
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")
|
messages = relationship("Message", back_populates="author", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
@@ -185,9 +188,12 @@ class UserProfileResponse(BaseModel):
|
|||||||
profile_picture: str | None
|
profile_picture: str | None
|
||||||
bio: str | None
|
bio: str | None
|
||||||
online: bool
|
online: bool
|
||||||
last_seen: datetime
|
last_seen: datetime | None
|
||||||
created_at: datetime
|
created_at: datetime | None
|
||||||
verified: bool
|
verified: bool
|
||||||
|
suspended: bool
|
||||||
|
suspension_reason: str | None
|
||||||
|
deleted: bool
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ def convert_user(user: User) -> dict:
|
|||||||
"profile_picture": user.profile_picture,
|
"profile_picture": user.profile_picture,
|
||||||
"bio": user.bio,
|
"bio": user.bio,
|
||||||
"admin": user.username == OWNER_USERNAME,
|
"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")
|
@router.get("/check_auth")
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ def convert_message(msg: Message) -> dict:
|
|||||||
"username": reaction.user.display_name
|
"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 {
|
return {
|
||||||
"id": msg.id,
|
"id": msg.id,
|
||||||
"user_id": msg.author.id,
|
"user_id": msg.author.id,
|
||||||
@@ -58,9 +68,9 @@ def convert_message(msg: Message) -> dict:
|
|||||||
"timestamp": msg.timestamp.isoformat(),
|
"timestamp": msg.timestamp.isoformat(),
|
||||||
"is_read": msg.is_read,
|
"is_read": msg.is_read,
|
||||||
"is_edited": msg.is_edited,
|
"is_edited": msg.is_edited,
|
||||||
"username": msg.author.display_name,
|
"username": username,
|
||||||
"profile_picture": msg.author.profile_picture,
|
"profile_picture": profile_picture,
|
||||||
"verified": msg.author.verified,
|
"verified": verified,
|
||||||
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
||||||
"reactions": list(reactions_dict.values()),
|
"reactions": list(reactions_dict.values()),
|
||||||
"files": [
|
"files": [
|
||||||
@@ -98,7 +108,12 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
|||||||
from dependencies import get_db
|
from dependencies import get_db
|
||||||
db = next(get_db())
|
db = next(get_db())
|
||||||
sender = db.query(User).filter(User.id == envelope.sender_id).first()
|
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 {
|
return {
|
||||||
"id": envelope.id,
|
"id": envelope.id,
|
||||||
@@ -1238,6 +1253,24 @@ class MessaggingSocketManager:
|
|||||||
if self.user_by_ws.get(websocket) == user_id:
|
if self.user_by_ws.get(websocket) == user_id:
|
||||||
await websocket.send_json(message)
|
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):
|
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"""
|
"""Broadcast status change to all connections that are subscribed to this user"""
|
||||||
message = {
|
message = {
|
||||||
|
|||||||
+177
-1
@@ -237,6 +237,23 @@ async def get_user_by_id(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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(
|
return UserProfileResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
@@ -246,7 +263,10 @@ async def get_user_by_id(
|
|||||||
online=user.online,
|
online=user.online,
|
||||||
last_seen=user.last_seen,
|
last_seen=user.last_seen,
|
||||||
created_at=user.created_at,
|
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,
|
"isSimilar": is_similar,
|
||||||
"similarTo": similar_to if is_similar else None
|
"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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { parseProfileLink } from "./core/profileLinks";
|
|||||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||||
|
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
||||||
|
|
||||||
// Lazy load route components
|
// Lazy load route components
|
||||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||||
@@ -66,7 +67,7 @@ function SmartCatchAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { restoreUserFromStorage } = useAppState();
|
const { restoreUserFromStorage, user } = useAppState();
|
||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,6 +86,13 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
{user.isSuspended && (
|
||||||
|
<SuspensionDialog
|
||||||
|
reason={user.suspensionReason || "No reason provided"}
|
||||||
|
open={true}
|
||||||
|
onOpenChange={() => {}} // Suspended users can't close the dialog
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className={`styled-dialog-backdrop ${className}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
if (onBackdropClick) {
|
||||||
|
onBackdropClick();
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={transition}>
|
||||||
|
<motion.div
|
||||||
|
className={`styled-dialog ${className}`}
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
transition={transition}>
|
||||||
|
<div className="styled-dialog-content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.getElementById("root")!
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
@@ -118,6 +118,9 @@ export interface User {
|
|||||||
bio?: string;
|
bio?: string;
|
||||||
profile_picture: string;
|
profile_picture: string;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
|
suspended?: boolean;
|
||||||
|
suspension_reason?: string | null;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { delay } from "@/utils/utils";
|
|||||||
import { CallSignalingHandler } from "./calls/signaling";
|
import { CallSignalingHandler } from "./calls/signaling";
|
||||||
import { onlineStatusManager } from "./onlineStatusManager";
|
import { onlineStatusManager } from "./onlineStatusManager";
|
||||||
import { typingManager } from "./typingManager";
|
import { typingManager } from "./typingManager";
|
||||||
|
import { useAppState } from "@/pages/chat/state";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new WebSocket connection to the chat server
|
* Creates a new WebSocket connection to the chat server
|
||||||
@@ -129,6 +130,19 @@ websocket.addEventListener("message", (e) => {
|
|||||||
typingManager.handleDmTyping(response as any);
|
typingManager.handleDmTyping(response as any);
|
||||||
} else if (response.type === "stopDmTyping") {
|
} else if (response.type === "stopDmTyping") {
|
||||||
typingManager.handleStopDmTyping(response as any);
|
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
|
// Route message to global handler if set
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@use "components";
|
@use "components";
|
||||||
@use "colors" as *;
|
@use "colors" as *;
|
||||||
@use "material" as *;
|
@use "material" as *;
|
||||||
|
@use "../core/components/css/styled-dialog";
|
||||||
|
|
||||||
@use "fonts/montserrat";
|
@use "fonts/montserrat";
|
||||||
@use "fonts/material-symbols";
|
@use "fonts/material-symbols";
|
||||||
@@ -42,3 +43,7 @@ mdui-dialog {
|
|||||||
margin-block-end: 0;
|
margin-block-end: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mdui-icon {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
@@ -98,6 +98,15 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const data: ErrorResponse = await response.json();
|
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 || "Неверное имя пользователя или пароль");
|
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,219 +2,173 @@
|
|||||||
@use "../../../css/material" as *;
|
@use "../../../css/material" as *;
|
||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
// Profile Dialog Styles
|
// Profile Dialog Specific Styles
|
||||||
.profile-dialog-backdrop {
|
// Base dialog styles are now in _styled-dialog.scss
|
||||||
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;
|
|
||||||
|
|
||||||
&.open {
|
.styled-dialog-content {
|
||||||
opacity: 1;
|
align-items: center;
|
||||||
visibility: visible;
|
|
||||||
|
.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 {
|
.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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-dialog-fab {
|
.profile-dialog-fab {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
@@ -227,4 +181,28 @@
|
|||||||
transform: translateY(0);
|
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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,3 +11,4 @@
|
|||||||
@use "callWindow";
|
@use "callWindow";
|
||||||
@use "profile-dialog";
|
@use "profile-dialog";
|
||||||
@use "typing-indicators";
|
@use "typing-indicators";
|
||||||
|
@use "suspension-dialog";
|
||||||
@@ -26,6 +26,9 @@ export interface ProfileDialogData {
|
|||||||
online?: boolean;
|
online?: boolean;
|
||||||
isOwnProfile: boolean;
|
isOwnProfile: boolean;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
|
suspended?: boolean;
|
||||||
|
suspension_reason?: string | null;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveDM {
|
interface ActiveDM {
|
||||||
@@ -73,6 +76,8 @@ interface ChatState {
|
|||||||
export interface UserState {
|
export interface UserState {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
|
isSuspended: boolean;
|
||||||
|
suspensionReason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
@@ -112,6 +117,7 @@ interface AppState {
|
|||||||
setUser: (token: string, user: User) => void;
|
setUser: (token: string, user: User) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
restoreUserFromStorage: () => Promise<void>;
|
restoreUserFromStorage: () => Promise<void>;
|
||||||
|
setSuspended: (reason: string) => void;
|
||||||
|
|
||||||
// Profile dialog state
|
// Profile dialog state
|
||||||
setProfileDialog: (data: ProfileDialogData | null) => void;
|
setProfileDialog: (data: ProfileDialogData | null) => void;
|
||||||
@@ -226,13 +232,17 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
// User state
|
// User state
|
||||||
user: {
|
user: {
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
authToken: null
|
authToken: null,
|
||||||
|
isSuspended: false,
|
||||||
|
suspensionReason: null
|
||||||
},
|
},
|
||||||
setUser: (token: string, user: User) => {
|
setUser: (token: string, user: User) => {
|
||||||
set(() => ({
|
set(() => ({
|
||||||
user: {
|
user: {
|
||||||
currentUser: user,
|
currentUser: user,
|
||||||
authToken: token
|
authToken: token,
|
||||||
|
isSuspended: user.suspended || false,
|
||||||
|
suspensionReason: user.suspension_reason || null
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -279,7 +289,9 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
set(() => ({
|
set(() => ({
|
||||||
user: {
|
user: {
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
authToken: null
|
authToken: null,
|
||||||
|
isSuspended: false,
|
||||||
|
suspensionReason: null
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -296,10 +308,25 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
const user: User = await response.json();
|
const user: User = await response.json();
|
||||||
restoreKeys();
|
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(() => ({
|
set(() => ({
|
||||||
user: {
|
user: {
|
||||||
currentUser: user,
|
currentUser: user,
|
||||||
authToken: token
|
authToken: token,
|
||||||
|
isSuspended: false,
|
||||||
|
suspensionReason: null
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -679,5 +706,13 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
dmTypingUsers: newDmTypingUsers
|
dmTypingUsers: newDmTypingUsers
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
setSuspended: (reason: string) => set((state) => ({
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
isSuspended: true,
|
||||||
|
suspensionReason: reason
|
||||||
|
}
|
||||||
|
}))
|
||||||
}));
|
}));
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import type { ProfileDialogData } from "@/pages/chat/state";
|
import type { ProfileDialogData } from "@/pages/chat/state";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import { confirm } from "mdui/functions/confirm";
|
import { confirm } from "mdui/functions/confirm";
|
||||||
|
import { prompt } from "mdui/functions/prompt";
|
||||||
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
|
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
|
||||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||||
import { StatusBadge } from "@/core/components/StatusBadge";
|
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||||
@@ -11,6 +11,7 @@ import { VerifyButton } from "@/core/components/VerifyButton";
|
|||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { OnlineStatus } from "./right/OnlineStatus";
|
import { OnlineStatus } from "./right/OnlineStatus";
|
||||||
import { Input } from "@/core/components/Input";
|
import { Input } from "@/core/components/Input";
|
||||||
|
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -74,7 +75,6 @@ export function ProfileDialog() {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [openClass, setOpenClass] = useState(false);
|
|
||||||
|
|
||||||
// Handle dialog open/close based on state
|
// Handle dialog open/close based on state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,13 +82,7 @@ export function ProfileDialog() {
|
|||||||
// Fetch fresh data when opening dialog
|
// Fetch fresh data when opening dialog
|
||||||
fetchFreshProfileData(chat.profileDialog);
|
fetchFreshProfileData(chat.profileDialog);
|
||||||
} else if (!chat.profileDialog && isOpen) {
|
} else if (!chat.profileDialog && isOpen) {
|
||||||
// Start close animation
|
setIsOpen(false);
|
||||||
setOpenClass(false);
|
|
||||||
|
|
||||||
// Wait for animation to complete before closing
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
}, [chat.profileDialog, isOpen]);
|
}, [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
|
// Subscribe to user's online status when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -197,30 +168,16 @@ export function ProfileDialog() {
|
|||||||
confirmText: "Закрыть",
|
confirmText: "Закрыть",
|
||||||
cancelText: "Отмена"
|
cancelText: "Отмена"
|
||||||
});
|
});
|
||||||
triggerCloseAnimation();
|
closeProfileDialog();
|
||||||
} catch {
|
} catch {
|
||||||
// User cancelled, do nothing
|
// User cancelled, do nothing
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
triggerCloseAnimation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function triggerCloseAnimation() {
|
|
||||||
setOpenClass(false);
|
|
||||||
|
|
||||||
// Wait for animation to complete before closing
|
|
||||||
setTimeout(() => {
|
|
||||||
closeProfileDialog();
|
closeProfileDialog();
|
||||||
}, 300); // Match CSS transition duration
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleBackdropClick(e: React.MouseEvent) {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
handleClose();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
if (!currentData) return;
|
if (!currentData) return;
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
@@ -352,8 +309,8 @@ export function ProfileDialog() {
|
|||||||
setUser(user.authToken, updatedUser);
|
setUser(user.authToken, updatedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close dialog with animation after successful save
|
// Close dialog after successful save
|
||||||
triggerCloseAnimation();
|
closeProfileDialog();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save profile:", error);
|
console.error("Failed to save profile:", error);
|
||||||
// Handle API errors
|
// 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(() => {
|
const fabVisible = useMemo(() => {
|
||||||
let hasErrors = false;
|
let hasErrors = false;
|
||||||
@@ -387,15 +428,19 @@ export function ProfileDialog() {
|
|||||||
return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors;
|
return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors;
|
||||||
}, [hasChanges, currentData?.isOwnProfile, isSaving, errors]);
|
}, [hasChanges, currentData?.isOwnProfile, isSaving, errors]);
|
||||||
|
|
||||||
if (!isOpen || !currentData) return null;
|
if (!currentData) return null;
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<div
|
<StyledDialog
|
||||||
className={`profile-dialog-backdrop ${openClass ? "open" : ""}`}
|
open={isOpen}
|
||||||
onClick={handleBackdropClick}>
|
onOpenChange={(open) => {
|
||||||
<div className={`profile-dialog ${openClass ? "open" : ""}`}>
|
if (!open) {
|
||||||
<div className="profile-dialog-content">
|
handleClose();
|
||||||
<div className="profile-picture-section">
|
}
|
||||||
|
}}
|
||||||
|
onBackdropClick={handleClose}
|
||||||
|
>
|
||||||
|
<div className="profile-picture-section">
|
||||||
<img
|
<img
|
||||||
className="profile-picture"
|
className="profile-picture"
|
||||||
src={currentData.profilePicture || defaultAvatar}
|
src={currentData.profilePicture || defaultAvatar}
|
||||||
@@ -435,14 +480,46 @@ export function ProfileDialog() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(currentData?.userId || currentData?.isOwnProfile) && (
|
{(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && (
|
||||||
<div className="online-status-section">
|
<div className="online-status-section">
|
||||||
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
|
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Verify button for owner */}
|
{/* Admin Actions Section - Hide for deleted users */}
|
||||||
{!currentData.isOwnProfile && currentData.userId && (
|
{!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && (
|
||||||
|
<div className="admin-actions-section">
|
||||||
|
<h3 className="admin-actions-header">Admin Actions</h3>
|
||||||
|
<div className="admin-buttons">
|
||||||
|
<mdui-button
|
||||||
|
variant="filled"
|
||||||
|
color="error"
|
||||||
|
icon={currentData.suspended ? "check_circle--filled" : "block--filled"}
|
||||||
|
onClick={handleSuspend}
|
||||||
|
>
|
||||||
|
{currentData.suspended ? "Unsuspend Account" : "Suspend Account"}
|
||||||
|
</mdui-button>
|
||||||
|
<mdui-button
|
||||||
|
variant="filled"
|
||||||
|
color="error"
|
||||||
|
icon="delete_forever--filled"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</mdui-button>
|
||||||
|
<VerifyButton
|
||||||
|
userId={currentData.userId!}
|
||||||
|
verified={currentData.verified || false}
|
||||||
|
onVerificationChange={(verified) => {
|
||||||
|
setCurrentData({ ...currentData, verified });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verify button for non-admin owner */}
|
||||||
|
{!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && (
|
||||||
<div className="verify-section">
|
<div className="verify-section">
|
||||||
<VerifyButton
|
<VerifyButton
|
||||||
userId={currentData.userId}
|
userId={currentData.userId}
|
||||||
@@ -454,49 +531,51 @@ export function ProfileDialog() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="profile-sections">
|
{/* Hide profile sections for deleted users */}
|
||||||
<Section
|
{!currentData.deleted && (
|
||||||
type="username"
|
<div className="profile-sections">
|
||||||
error={errors.username}
|
|
||||||
icon="alternate_email--filled"
|
|
||||||
label="Имя пользователя:"
|
|
||||||
value={currentData.username}
|
|
||||||
onChange={handleUsernameChange}
|
|
||||||
readOnly={!currentData.isOwnProfile}
|
|
||||||
placeholder="username" />
|
|
||||||
|
|
||||||
{currentData.bio !== undefined && (
|
|
||||||
<Section
|
<Section
|
||||||
type="bio"
|
type="username"
|
||||||
icon="info--filled"
|
error={errors.username}
|
||||||
label="О себе:"
|
icon="alternate_email--filled"
|
||||||
value={currentData.bio}
|
label="Имя пользователя:"
|
||||||
onChange={handleBioChange}
|
value={currentData.username}
|
||||||
|
onChange={handleUsernameChange}
|
||||||
readOnly={!currentData.isOwnProfile}
|
readOnly={!currentData.isOwnProfile}
|
||||||
placeholder="Нет информации о себе"
|
placeholder="username" />
|
||||||
textArea />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentData.memberSince && (
|
{currentData.bio !== undefined && (
|
||||||
<Section
|
<Section
|
||||||
type="member-since"
|
type="bio"
|
||||||
icon="calendar_month--filled"
|
icon="info--filled"
|
||||||
label="Участник с:"
|
label="О себе:"
|
||||||
value={formatDate(currentData.memberSince)}
|
value={currentData.bio}
|
||||||
readOnly={true} />
|
onChange={handleBioChange}
|
||||||
)}
|
readOnly={!currentData.isOwnProfile}
|
||||||
|
placeholder="Нет информации о себе"
|
||||||
|
textArea />
|
||||||
|
)}
|
||||||
|
|
||||||
{currentData.verified && (
|
{currentData.memberSince && (
|
||||||
<Section
|
<Section
|
||||||
type="verified"
|
type="member-since"
|
||||||
icon="verified--filled"
|
icon="calendar_month--filled"
|
||||||
label="Верификация:"
|
label="Участник с:"
|
||||||
value="Этот аккаунт - официальное лицо FromChat."
|
value={formatDate(currentData.memberSince)}
|
||||||
readOnly={true}
|
readOnly={true} />
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{currentData.verified && (
|
||||||
</div>
|
<Section
|
||||||
|
type="verified"
|
||||||
|
icon="verified--filled"
|
||||||
|
label="Верификация:"
|
||||||
|
value="Этот аккаунт - официальное лицо FromChat."
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentData.isOwnProfile && (
|
{currentData.isOwnProfile && (
|
||||||
<mdui-fab
|
<mdui-fab
|
||||||
@@ -514,8 +593,6 @@ export function ProfileDialog() {
|
|||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StyledDialog>
|
||||||
</div>,
|
|
||||||
document.getElementById("root")!
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<StyledDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
>
|
||||||
|
<div className="suspension-dialog-content">
|
||||||
|
<div className="suspension-icon-section">
|
||||||
|
<mdui-icon name="block--filled" className="suspension-icon" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="suspension-text">
|
||||||
|
<h2 className="suspension-headline">Аккаунт заблокирован</h2>
|
||||||
|
<p className="suspension-body">
|
||||||
|
Ваш аккаунт был заблокирован за нарушение правил сообщества.
|
||||||
|
Вы не можете отправлять сообщения или взаимодействовать с другими пользователями.
|
||||||
|
</p>
|
||||||
|
{reason && reason !== "No reason provided" && (
|
||||||
|
<div className="suspension-reason">
|
||||||
|
<strong>Причина блокировки:</strong>
|
||||||
|
<div className="suspension-reason-text">
|
||||||
|
{reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="suspension-secondary">
|
||||||
|
Если вы считаете, что блокировка была применена по ошибке,
|
||||||
|
<a href="https://t.me/denis0001_dev" target="_blank" rel="noopener noreferrer">обратитесь к администратору</a> для рассмотрения вашего случая.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StyledDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -487,7 +487,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
{!isAuthor && !isDm && (
|
{!isAuthor && !isDm && (
|
||||||
<div className="message-profile-pic" onClick={handleProfileClick}>
|
<div className="message-profile-pic" onClick={handleProfileClick}>
|
||||||
<img
|
<img
|
||||||
src={message.profile_picture || defaultAvatar}
|
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
|
||||||
alt={message.username}
|
alt={message.username}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import type { Message, Size2D } from "@/core/types";
|
import type { Message, Size2D } from "@/core/types";
|
||||||
import { EmojiMenu } from "./EmojiMenu";
|
import { EmojiMenu } from "./EmojiMenu";
|
||||||
|
import { useAppState } from "@/pages/chat/state";
|
||||||
|
|
||||||
interface MessageContextMenuProps {
|
interface MessageContextMenuProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -33,6 +34,7 @@ export function MessageContextMenu({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: MessageContextMenuProps) {
|
}: MessageContextMenuProps) {
|
||||||
|
const { user } = useAppState();
|
||||||
// Internal state for closing animation
|
// Internal state for closing animation
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||||
@@ -209,7 +211,7 @@ export function MessageContextMenu({
|
|||||||
onDelete(message);
|
onDelete(message);
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
show: isAuthor
|
show: isAuthor || user.currentUser?.id === 1
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"mdui": "^2.1.4",
|
"mdui": "^2.1.4",
|
||||||
|
"motion": "^12.23.24",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.3",
|
"react-router-dom": "^7.9.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user