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.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
UserPublic, MessageResponse,
|
UserPublic, MessageResponse,
|
||||||
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
|
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||||
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
||||||
StaticContentCreate, DashboardStats
|
StaticContentCreate, DashboardStats
|
||||||
)
|
)
|
||||||
|
from app.core.security import get_password_hash
|
||||||
from app.services.telegram_notifier import telegram_notifier
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
from app.core.rate_limit import limiter
|
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 ============
|
# ============ Force Finish Marathon ============
|
||||||
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
||||||
async def force_finish_marathon(
|
async def force_finish_marathon(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class AdminActionType(str, Enum):
|
|||||||
USER_UNBAN = "user_unban"
|
USER_UNBAN = "user_unban"
|
||||||
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||||
USER_ROLE_CHANGE = "user_role_change"
|
USER_ROLE_CHANGE = "user_role_change"
|
||||||
|
USER_PASSWORD_RESET = "user_password_reset"
|
||||||
|
|
||||||
# Marathon actions
|
# Marathon actions
|
||||||
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ from app.schemas.dispute import (
|
|||||||
)
|
)
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
BanUserRequest,
|
BanUserRequest,
|
||||||
|
AdminResetPasswordRequest,
|
||||||
AdminUserResponse,
|
AdminUserResponse,
|
||||||
AdminLogResponse,
|
AdminLogResponse,
|
||||||
AdminLogsListResponse,
|
AdminLogsListResponse,
|
||||||
@@ -175,6 +176,7 @@ __all__ = [
|
|||||||
"ReturnedAssignmentResponse",
|
"ReturnedAssignmentResponse",
|
||||||
# Admin
|
# Admin
|
||||||
"BanUserRequest",
|
"BanUserRequest",
|
||||||
|
"AdminResetPasswordRequest",
|
||||||
"AdminUserResponse",
|
"AdminUserResponse",
|
||||||
"AdminLogResponse",
|
"AdminLogResponse",
|
||||||
"AdminLogsListResponse",
|
"AdminLogsListResponse",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ class BanUserRequest(BaseModel):
|
|||||||
banned_until: datetime | None = None # None = permanent ban
|
banned_until: datetime | None = None # None = permanent ban
|
||||||
|
|
||||||
|
|
||||||
|
class AdminResetPasswordRequest(BaseModel):
|
||||||
|
new_password: str = Field(..., min_length=6, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
class AdminUserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
login: str
|
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
|
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
|
// Marathons
|
||||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types'
|
|||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { NeonButton } from '@/components/ui'
|
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() {
|
export function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
@@ -17,6 +17,9 @@ export function AdminUsersPage() {
|
|||||||
const [banDuration, setBanDuration] = useState<string>('permanent')
|
const [banDuration, setBanDuration] = useState<string>('permanent')
|
||||||
const [banCustomDate, setBanCustomDate] = useState('')
|
const [banCustomDate, setBanCustomDate] = useState('')
|
||||||
const [banning, setBanning] = useState(false)
|
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 toast = useToast()
|
||||||
const confirm = useConfirm()
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -265,6 +286,14 @@ export function AdminUsersPage() {
|
|||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -393,6 +422,71 @@ export function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user