diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 9d6458e..f5aa817 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -8,10 +8,11 @@ from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent from app.schemas import ( UserPublic, MessageResponse, - AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse, + AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse, BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate, StaticContentCreate, DashboardStats ) +from app.core.security import get_password_hash from app.services.telegram_notifier import telegram_notifier from app.core.rate_limit import limiter @@ -431,6 +432,66 @@ async def unban_user( ) +# ============ Reset Password ============ +@router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse) +async def reset_user_password( + request: Request, + user_id: int, + data: AdminResetPasswordRequest, + current_user: CurrentUser, + db: DbSession, +): + """Reset user password. Admin only.""" + require_admin_with_2fa(current_user) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Hash and save new password + user.password_hash = get_password_hash(data.new_password) + await db.commit() + await db.refresh(user) + + # Log action + await log_admin_action( + db, current_user.id, AdminActionType.USER_PASSWORD_RESET.value, + "user", user_id, + {"nickname": user.nickname}, + request.client.host if request.client else None + ) + + # Notify user via Telegram if linked + if user.telegram_id: + await telegram_notifier.send_message( + user.telegram_id, + "🔐 Ваш пароль был сброшен\n\n" + "Администратор установил вам новый пароль. " + "Если это были не вы, свяжитесь с поддержкой." + ) + + marathons_count = await db.scalar( + select(func.count()).select_from(Participant).where(Participant.user_id == user.id) + ) + + return AdminUserResponse( + id=user.id, + login=user.login, + nickname=user.nickname, + role=user.role, + avatar_url=user.avatar_url, + telegram_id=user.telegram_id, + telegram_username=user.telegram_username, + marathons_count=marathons_count, + created_at=user.created_at.isoformat(), + is_banned=user.is_banned, + banned_at=user.banned_at.isoformat() if user.banned_at else None, + banned_until=user.banned_until.isoformat() if user.banned_until else None, + ban_reason=user.ban_reason, + ) + + # ============ Force Finish Marathon ============ @router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse) async def force_finish_marathon( diff --git a/backend/app/models/admin_log.py b/backend/app/models/admin_log.py index afa4685..b7fae4e 100644 --- a/backend/app/models/admin_log.py +++ b/backend/app/models/admin_log.py @@ -12,6 +12,7 @@ class AdminActionType(str, Enum): USER_UNBAN = "user_unban" USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban USER_ROLE_CHANGE = "user_role_change" + USER_PASSWORD_RESET = "user_password_reset" # Marathon actions MARATHON_FORCE_FINISH = "marathon_force_finish" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 34d3568..e49064c 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -83,6 +83,7 @@ from app.schemas.dispute import ( ) from app.schemas.admin import ( BanUserRequest, + AdminResetPasswordRequest, AdminUserResponse, AdminLogResponse, AdminLogsListResponse, @@ -175,6 +176,7 @@ __all__ = [ "ReturnedAssignmentResponse", # Admin "BanUserRequest", + "AdminResetPasswordRequest", "AdminUserResponse", "AdminLogResponse", "AdminLogsListResponse", diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 5323e26..cadf302 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -9,6 +9,10 @@ class BanUserRequest(BaseModel): banned_until: datetime | None = None # None = permanent ban +class AdminResetPasswordRequest(BaseModel): + new_password: str = Field(..., min_length=6, max_length=100) + + class AdminUserResponse(BaseModel): id: int login: str diff --git a/frontend/public/telegram_bot_banner.png b/frontend/public/telegram_bot_banner.png new file mode 100644 index 0000000..67d87dc Binary files /dev/null and b/frontend/public/telegram_bot_banner.png differ diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 2b3c500..aba7708 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -52,6 +52,13 @@ export const adminApi = { return response.data }, + resetUserPassword: async (id: number, newPassword: string): Promise => { + const response = await client.post(`/admin/users/${id}/reset-password`, { + new_password: newPassword, + }) + return response.data + }, + // Marathons listMarathons: async (skip = 0, limit = 50, search?: string): Promise => { const params: Record = { skip, limit } diff --git a/frontend/src/pages/admin/AdminUsersPage.tsx b/frontend/src/pages/admin/AdminUsersPage.tsx index 493e8ad..5cda106 100644 --- a/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/frontend/src/pages/admin/AdminUsersPage.tsx @@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types' import { useToast } from '@/store/toast' import { useConfirm } from '@/store/confirm' import { NeonButton } from '@/components/ui' -import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react' +import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound } from 'lucide-react' export function AdminUsersPage() { const [users, setUsers] = useState([]) @@ -17,6 +17,9 @@ export function AdminUsersPage() { const [banDuration, setBanDuration] = useState('permanent') const [banCustomDate, setBanCustomDate] = useState('') const [banning, setBanning] = useState(false) + const [resetPasswordUser, setResetPasswordUser] = useState(null) + const [newPassword, setNewPassword] = useState('') + const [resettingPassword, setResettingPassword] = useState(false) const toast = useToast() const confirm = useConfirm() @@ -120,6 +123,24 @@ export function AdminUsersPage() { } } + const handleResetPassword = async () => { + if (!resetPasswordUser || !newPassword.trim() || newPassword.length < 6) return + + setResettingPassword(true) + try { + const updated = await adminApi.resetUserPassword(resetPasswordUser.id, newPassword) + setUsers(users.map(u => u.id === updated.id ? updated : u)) + toast.success(`Пароль ${updated.nickname} сброшен`) + setResetPasswordUser(null) + setNewPassword('') + } catch (err) { + console.error('Failed to reset password:', err) + toast.error('Ошибка сброса пароля') + } finally { + setResettingPassword(false) + } + } + return (
{/* Header */} @@ -265,6 +286,14 @@ export function AdminUsersPage() { )} + +
@@ -393,6 +422,71 @@ export function AdminUsersPage() { )} + + {/* Reset Password Modal */} + {resetPasswordUser && ( +
+
+
+

+ + Сбросить пароль {resetPasswordUser.nickname} +

+ +
+ +
+ + setNewPassword(e.target.value)} + placeholder="Минимум 6 символов" + className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors" + /> + {newPassword && newPassword.length < 6 && ( +

Пароль должен быть минимум 6 символов

+ )} + {resetPasswordUser.telegram_id && ( +

+ Пользователь получит уведомление в Telegram о смене пароля +

+ )} +
+ +
+ { + setResetPasswordUser(null) + setNewPassword('') + }} + > + Отмена + + } + > + Сбросить + +
+
+
+ )} ) }