Add reset password to admin panel
This commit is contained in:
@@ -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,
|
||||
"🔐 <b>Ваш пароль был сброшен</b>\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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
frontend/public/telegram_bot_banner.png
Normal file
BIN
frontend/public/telegram_bot_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -52,6 +52,13 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
resetUserPassword: async (id: number, newPassword: string): Promise<AdminUser> => {
|
||||
const response = await client.post<AdminUser>(`/admin/users/${id}/reset-password`, {
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Marathons
|
||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
|
||||
@@ -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<AdminUser[]>([])
|
||||
@@ -17,6 +17,9 @@ export function AdminUsersPage() {
|
||||
const [banDuration, setBanDuration] = useState<string>('permanent')
|
||||
const [banCustomDate, setBanCustomDate] = useState('')
|
||||
const [banning, setBanning] = useState(false)
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState<AdminUser | null>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -265,6 +286,14 @@ export function AdminUsersPage() {
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setResetPasswordUser(user)}
|
||||
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
|
||||
title="Сбросить пароль"
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -393,6 +422,71 @@ export function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{resetPasswordUser && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-yellow-400" />
|
||||
Сбросить пароль {resetPasswordUser.nickname}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setResetPasswordUser(null)
|
||||
setNewPassword('')
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Новый пароль
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPassword}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="mt-2 text-sm text-red-400">Пароль должен быть минимум 6 символов</p>
|
||||
)}
|
||||
{resetPasswordUser.telegram_id && (
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
Пользователь получит уведомление в Telegram о смене пароля
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<NeonButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setResetPasswordUser(null)
|
||||
setNewPassword('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
color="neon"
|
||||
onClick={handleResetPassword}
|
||||
disabled={!newPassword.trim() || newPassword.length < 6 || resettingPassword}
|
||||
isLoading={resettingPassword}
|
||||
icon={<KeyRound className="w-4 h-4" />}
|
||||
>
|
||||
Сбросить
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user