Implement account suspension, right to delete any message for owner, account deletion

This commit is contained in:
2025-10-22 19:50:45 +03:00
Unverified
parent 9e19342998
commit a95efcdcf8
21 changed files with 932 additions and 337 deletions
+16
View File
@@ -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
+8 -2
View File
@@ -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
+4 -1
View File
@@ -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")
+36 -3
View File
@@ -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,6 +108,11 @@ 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()
# Handle deleted users
if sender and sender.deleted:
sender_verified = False
else:
sender_verified = sender.verified if sender else False
return {
@@ -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 = {
+177 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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(() => {
@@ -85,6 +86,13 @@ export default function App() {
))}
</Routes>
</div>
{user.isSuspended && (
<SuspensionDialog
reason={user.suspensionReason || "No reason provided"}
open={true}
onOpenChange={() => {}} // Suspended users can't close the dialog
/>
)}
</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;
}
}
}
+3
View File
@@ -118,6 +118,9 @@ export interface User {
bio?: string;
profile_picture: string;
verified?: boolean;
suspended?: boolean;
suspension_reason?: string | null;
deleted?: boolean;
}
/**
+14
View File
@@ -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
+5
View File
@@ -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";
@@ -42,3 +43,7 @@ mdui-dialog {
margin-block-end: 0;
}
}
mdui-icon {
user-select: none;
}
+9
View File
@@ -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) {
@@ -2,57 +2,10 @@
@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;
}
}
.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;
.styled-dialog-content {
align-items: center;
.error-message {
@@ -215,6 +168,7 @@
}
}
.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%;
}
}
@@ -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;
}
}
}
}
+1
View File
@@ -11,3 +11,4 @@
@use "callWindow";
@use "profile-dialog";
@use "typing-indicators";
@use "suspension-dialog";
+40 -5
View File
@@ -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<void>;
setSuspended: (reason: string) => void;
// Profile dialog state
setProfileDialog: (data: ProfileDialogData | null) => void;
@@ -226,13 +232,17 @@ export const useAppState = create<AppState>((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<AppState>((set, get) => ({
set(() => ({
user: {
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();
restoreKeys();
// Check if user is suspended
if (user.suspended) {
set(() => ({
user: {
currentUser: user,
authToken: token
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,
isSuspended: false,
suspensionReason: null
}
}));
@@ -679,5 +706,13 @@ export const useAppState = create<AppState>((set, get) => ({
dmTypingUsers: newDmTypingUsers
}
};
})
}),
setSuspended: (reason: string) => set((state) => ({
user: {
...state.user,
isSuspended: true,
suspensionReason: reason
}
}))
}));
+140 -63
View File
@@ -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<HTMLInputElement>(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);
}
}, [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,30 +168,16 @@ 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<HTMLInputElement>) {
if (!currentData) return;
const newValue = e.target.value;
@@ -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,14 +428,18 @@ 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(
<div
className={`profile-dialog-backdrop ${openClass ? "open" : ""}`}
onClick={handleBackdropClick}>
<div className={`profile-dialog ${openClass ? "open" : ""}`}>
<div className="profile-dialog-content">
return (
<StyledDialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
onBackdropClick={handleClose}
>
<div className="profile-picture-section">
<img
className="profile-picture"
@@ -435,14 +480,46 @@ export function ProfileDialog() {
)}
</div>
{(currentData?.userId || currentData?.isOwnProfile) && (
{(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && (
<div className="online-status-section">
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
</div>
)}
{/* Verify button for owner */}
{!currentData.isOwnProfile && currentData.userId && (
{/* Admin Actions Section - Hide for deleted users */}
{!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">
<VerifyButton
userId={currentData.userId}
@@ -454,6 +531,8 @@ export function ProfileDialog() {
</div>
)}
{/* Hide profile sections for deleted users */}
{!currentData.deleted && (
<div className="profile-sections">
<Section
type="username"
@@ -496,7 +575,7 @@ export function ProfileDialog() {
/>
)}
</div>
</div>
)}
{currentData.isOwnProfile && (
<mdui-fab
@@ -514,8 +593,6 @@ export function ProfileDialog() {
style={{ display: "none" }}
onChange={handleFileSelect}
/>
</div>
</div>,
document.getElementById("root")!
</StyledDialog>
);
}
@@ -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>
);
}
+1 -1
View File
@@ -487,7 +487,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{!isAuthor && !isDm && (
<div className="message-profile-pic" onClick={handleProfileClick}>
<img
src={message.profile_picture || defaultAvatar}
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
alt={message.username}
onError={(e) => {
const target = e.target as HTMLImageElement;
@@ -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
},
];
+1
View File
@@ -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",