This commit is contained in:
Professional 2025-05-26 18:42:01 +07:00
parent 987ab236f8
commit 96cf4cb0db
5 changed files with 507 additions and 18 deletions

View File

@ -317,6 +317,99 @@ const getBlockedDeviceDetails = async (req, res) => {
} }
}; };
// @desc Получение устройств пользователя
// @route GET /api/admin/user/:userId/devices
// @access Private/Admin
const getUserDevices = async (req, res) => {
try {
const { userId } = req.params;
// Проверяем, существует ли пользователь
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({
message: 'Пользователь не найден'
});
}
// Получаем список устройств пользователя по его ID
const devices = await User.findUserDevices(userId);
console.log(`[ADMIN] Запрос устройств пользователя ${userId}, найдено: ${devices.length}`);
res.json({
success: true,
data: devices
});
} catch (error) {
console.error('[ADMIN] Ошибка при получении устройств пользователя:', error);
res.status(500).json({
message: 'Ошибка при получении устройств пользователя',
error: error.message
});
}
};
// @desc Блокировка всех устройств пользователя
// @route POST /api/admin/user/:userId/block-devices
// @access Private/Admin
const blockUserDevices = async (req, res) => {
try {
const { userId } = req.params;
const { reason = 'Блокировка администратором', duration = 'permanent' } = req.body;
// Проверяем, существует ли пользователь
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({
message: 'Пользователь не найден'
});
}
// Получаем все устройства пользователя
const devices = await User.findUserDevices(userId);
if (!devices.length) {
return res.status(404).json({
message: 'У пользователя не найдено устройств для блокировки'
});
}
// Блокируем каждое устройство
const blockedDevices = [];
for (const device of devices) {
try {
const blockedDevice = await blockUserDevice(
userId,
req.user._id,
reason,
device.fingerprint,
duration
);
blockedDevices.push(blockedDevice);
} catch (err) {
console.error(`[ADMIN] Ошибка при блокировке устройства ${device.fingerprint}:`, err);
}
}
console.log(`[ADMIN] Заблокированы устройства пользователя ${userId}, количество: ${blockedDevices.length}`);
res.json({
success: true,
message: `Заблокировано ${blockedDevices.length} из ${devices.length} устройств пользователя`,
blockedDevices
});
} catch (error) {
console.error('[ADMIN] Ошибка при блокировке устройств пользователя:', error);
res.status(500).json({
message: 'Ошибка при блокировке устройств пользователя',
error: error.message
});
}
};
/** /**
* Вычисляет уровень риска на основе подозрительной активности * Вычисляет уровень риска на основе подозрительной активности
*/ */
@ -357,5 +450,7 @@ module.exports = {
blockDevice, blockDevice,
unblockDeviceAdmin, unblockDeviceAdmin,
getBlockedDevicesList, getBlockedDevicesList,
getBlockedDeviceDetails getBlockedDeviceDetails,
getUserDevices,
blockUserDevices
}; };

View File

@ -113,6 +113,66 @@ userSchema.methods.matchPassword = async function (enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password); return await bcrypt.compare(enteredPassword, this.password);
}; };
// Статический метод для получения устройств пользователя
userSchema.statics.findUserDevices = async function(userId) {
try {
// Получаем сессии и авторизации пользователя
const user = await this.findById(userId).select('authHistory devices sessions');
if (!user) return [];
// Объединяем все возможные источники устройств
let devices = [];
// Из истории авторизаций
if (user.authHistory && user.authHistory.length > 0) {
devices = devices.concat(user.authHistory.map(auth => ({
fingerprint: auth.deviceFingerprint,
userAgent: auth.userAgent,
ipAddress: auth.ipAddress,
lastUsed: auth.timestamp,
type: 'auth'
})));
}
// Из списка устройств
if (user.devices && user.devices.length > 0) {
devices = devices.concat(user.devices.map(device => ({
fingerprint: device.fingerprint,
userAgent: device.userAgent,
platform: device.platform,
lastUsed: device.lastActive,
type: 'device'
})));
}
// Из активных сессий
if (user.sessions && user.sessions.length > 0) {
devices = devices.concat(user.sessions.map(session => ({
fingerprint: session.deviceFingerprint,
userAgent: session.userAgent,
ipAddress: session.ipAddress,
lastUsed: session.lastActive,
type: 'session'
})));
}
// Удаляем дубликаты по fingerprint
const uniqueDevices = Array.from(
devices.reduce((map, device) => {
if (device.fingerprint && !map.has(device.fingerprint)) {
map.set(device.fingerprint, device);
}
return map;
}, new Map()).values()
);
return uniqueDevices;
} catch (error) {
console.error('Ошибка при поиске устройств пользователя:', error);
return [];
}
};
const User = mongoose.model('User', userSchema); const User = mongoose.model('User', userSchema);

View File

@ -13,7 +13,9 @@ const {
blockDevice, blockDevice,
unblockDeviceAdmin, unblockDeviceAdmin,
getBlockedDevicesList, getBlockedDevicesList,
getBlockedDeviceDetails getBlockedDeviceDetails,
getUserDevices,
blockUserDevices
} = require('../controllers/deviceSecurityController'); } = require('../controllers/deviceSecurityController');
// Все маршруты защищены middleware для проверки авторизации и прав администратора // Все маршруты защищены middleware для проверки авторизации и прав администратора
@ -44,4 +46,8 @@ router.get('/blocked-devices/:id', getBlockedDeviceDetails);
router.post('/block-device', blockDevice); router.post('/block-device', blockDevice);
router.post('/unblock-device', unblockDeviceAdmin); router.post('/unblock-device', unblockDeviceAdmin);
// Новые маршруты для управления устройствами пользователя
router.get('/user/:userId/devices', getUserDevices);
router.post('/user/:userId/block-devices', blockUserDevices);
module.exports = router; module.exports = router;

View File

@ -318,5 +318,13 @@ export default {
}, },
unblockDevice(deviceFingerprint, reason) { unblockDevice(deviceFingerprint, reason) {
return apiClient.post('/admin/unblock-device', { deviceFingerprint, reason }); return apiClient.post('/admin/unblock-device', { deviceFingerprint, reason });
},
// Новые методы для управления устройствами пользователей
getUserDevices(userId) {
return apiClient.get(`/admin/user/${userId}/devices`);
},
blockUserDevices(userId, data = {}) {
return apiClient.post(`/admin/user/${userId}/block-devices`, data);
} }
}; };

View File

@ -5,14 +5,26 @@
« Назад к списку пользователей « Назад к списку пользователей
</button> </button>
<button <div class="action-buttons">
v-if="user" <button
@click="toggleUserStatus" v-if="user"
class="status-btn" @click="blockAllDevices"
:class="user.isActive ? 'block-btn' : 'unblock-btn'" class="action-btn device-block-btn"
> :disabled="blockingDevices"
{{ user.isActive ? 'Заблокировать' : 'Разблокировать' }} >
</button> <span v-if="!blockingDevices">Блокировать устройства</span>
<span v-else>Блокировка...</span>
</button>
<button
v-if="user"
@click="toggleUserStatus"
class="status-btn"
:class="user.isActive ? 'block-btn' : 'unblock-btn'"
>
{{ user.isActive ? 'Заблокировать' : 'Разблокировать' }}
</button>
</div>
</div> </div>
<div v-if="loading" class="loading-indicator"> <div v-if="loading" class="loading-indicator">
@ -135,14 +147,80 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Диалоговое окно для блокировки устройств -->
<div v-if="showBlockDialog" class="modal-overlay" @click="cancelBlockDialog">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>Подтверждение блокировки устройств</h3>
<button @click="cancelBlockDialog" class="close-btn">&times;</button>
</div>
<div class="modal-body">
<p>Вы собираетесь заблокировать все устройства пользователя <strong>{{ user?.name }}</strong>.</p>
<div class="form-group">
<label for="blockReason">Выберите причину блокировки:</label>
<select
id="blockReason"
v-model="blockDeviceData.reason"
class="form-control"
>
<option value="multiple_violations">Множественные нарушения</option>
<option value="spam">Спам</option>
<option value="harassment">Домогательства</option>
<option value="fake_profile">Фейковый профиль</option>
<option value="other">Другое</option>
</select>
</div>
<div v-if="blockDeviceData.reason === 'other'" class="form-group">
<label for="customReason">Укажите причину:</label>
<input
type="text"
id="customReason"
v-model="blockDeviceData.customReason"
placeholder="Опишите причину блокировки"
class="form-control"
/>
</div>
<div class="form-group">
<label for="blockDuration">Срок блокировки:</label>
<select
id="blockDuration"
v-model="blockDeviceData.duration"
class="form-control"
>
<option value="1d">1 день</option>
<option value="3d">3 дня</option>
<option value="7d">7 дней</option>
<option value="30d">30 дней</option>
<option value="permanent">Навсегда</option>
</select>
</div>
</div>
<div class="modal-footer">
<button @click="cancelBlockDialog" class="btn btn-outline">Отмена</button>
<button
@click="confirmBlockDevices"
class="btn btn-danger"
:disabled="!isBlockDeviceFormValid || blockingDevices"
>
<span v-if="!blockingDevices">Подтвердить блокировку</span>
<span v-else>Блокировка...</span>
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import toastService from '@/services/toastService'; import toastService from '@/services/toastService';
import api from '@/services/api';
export default { export default {
name: 'AdminUserDetail', name: 'AdminUserDetail',
@ -154,10 +232,69 @@ export default {
}, },
setup(props) { setup(props) {
const router = useRouter(); const router = useRouter();
const route = useRoute();
const user = ref(null); const user = ref(null);
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref(null);
const blockingDevices = ref(false);
const showBlockDialog = ref(false);
// Данные для блокировки устройств
const blockDeviceData = ref({
reason: 'multiple_violations',
customReason: '',
duration: 'permanent'
});
// Проверка валидности формы блокировки устройства
const isBlockDeviceFormValid = computed(() => {
return blockDeviceData.value.reason !== 'other' ||
(blockDeviceData.value.reason === 'other' && blockDeviceData.value.customReason.trim() !== '');
});
// Открыть диалог блокировки всех устройств
const blockAllDevices = () => {
showBlockDialog.value = true;
};
// Закрыть диалог блокировки
const cancelBlockDialog = () => {
showBlockDialog.value = false;
};
// Подтвердить и выполнить блокировку устройств
const confirmBlockDevices = async () => {
if (!isBlockDeviceFormValid.value) return;
try {
blockingDevices.value = true;
// Формируем причину блокировки
const reason = blockDeviceData.value.reason === 'other'
? blockDeviceData.value.customReason
: blockDeviceData.value.reason;
// Отправляем запрос на блокировку всех устройств
const response = await api.blockUserDevices(props.id, {
reason: reason,
duration: blockDeviceData.value.duration
});
// Обрабатываем успешный ответ
if (response.data.success) {
toastService.add(`Успешно заблокировано ${response.data.blockedDevices.length} устройств`, 'success');
} else {
toastService.add('Не удалось заблокировать устройства', 'error');
}
// Закрываем диалог
showBlockDialog.value = false;
} catch (err) {
console.error('Ошибка при блокировке устройств:', err);
toastService.add(err.response?.data?.message || 'Ошибка при блокировке устройств', 'error');
} finally {
blockingDevices.value = false;
}
};
// Получить основное фото пользователя // Получить основное фото пользователя
const userProfilePhoto = computed(() => { const userProfilePhoto = computed(() => {
@ -167,12 +304,13 @@ export default {
return profilePhoto ? profilePhoto.url : (user.value.photos.length > 0 ? user.value.photos[0].url : null); return profilePhoto ? profilePhoto.url : (user.value.photos.length > 0 ? user.value.photos[0].url : null);
}); });
// Загрузить данные пользователя
const loadUserDetails = async () => { const loadUserDetails = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const token = localStorage.getItem('userToken'); // Исправлено с 'token' на 'userToken' const token = localStorage.getItem('userToken');
const response = await axios.get( const response = await axios.get(
`/api/admin/users/${props.id}`, `/api/admin/users/${props.id}`,
{ {
@ -201,7 +339,7 @@ export default {
try { try {
loading.value = true; loading.value = true;
const token = localStorage.getItem('userToken'); // Исправлено с 'token' на 'userToken' const token = localStorage.getItem('userToken');
const response = await axios.put( const response = await axios.put(
`/api/admin/users/${props.id}/toggle-active`, `/api/admin/users/${props.id}/toggle-active`,
{}, {},
@ -215,7 +353,7 @@ export default {
// Обновляем статус пользователя // Обновляем статус пользователя
user.value.isActive = response.data.isActive; user.value.isActive = response.data.isActive;
// Заменяем alert на уведомление через сервис // Показываем уведомление
const actionType = user.value.isActive ? 'success' : 'warning'; const actionType = user.value.isActive ? 'success' : 'warning';
toastService.add(response.data.message, actionType, 3000); toastService.add(response.data.message, actionType, 3000);
} catch (err) { } catch (err) {
@ -292,7 +430,16 @@ export default {
getUserInitials, getUserInitials,
getGenderText, getGenderText,
calculateAge, calculateAge,
formatDate formatDate,
// Новые методы для блокировки устройств
blockingDevices,
showBlockDialog,
blockDeviceData,
isBlockDeviceFormValid,
blockAllDevices,
cancelBlockDialog,
confirmBlockDevices
}; };
} }
} }
@ -328,7 +475,13 @@ export default {
transform: translateX(-2px); transform: translateX(-2px);
} }
.status-btn { .action-buttons {
display: flex;
gap: 10px;
}
.status-btn,
.action-btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
@ -337,10 +490,25 @@ export default {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.status-btn:hover { .status-btn:hover,
.action-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
.device-block-btn {
background-color: rgba(33, 150, 243, 0.1);
color: #2196f3;
}
.device-block-btn:hover {
background-color: rgba(33, 150, 243, 0.2);
}
.device-block-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.block-btn { .block-btn {
background-color: rgba(211, 47, 47, 0.1); background-color: rgba(211, 47, 47, 0.1);
color: #d32f2f; color: #d32f2f;
@ -722,4 +890,156 @@ h3 {
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
} }
} }
/* Стили для модального окна */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 10px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6c757d;
line-height: 1;
}
.modal-body {
padding: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 15px 20px;
border-top: 1px solid #e9ecef;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #212529;
}
.form-control {
width: 100%;
padding: 8px 12px;
font-size: 1rem;
border: 1px solid #ced4da;
border-radius: 4px;
transition: border-color 0.15s ease-in-out;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
.form-text {
margin-top: 5px;
font-size: 0.875rem;
}
.text-muted {
color: #6c757d;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
border: none;
}
.btn-outline {
background-color: white;
border: 1px solid #ced4da;
color: #495057;
}
.btn-outline:hover {
background-color: #f8f9fa;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mb-3 {
margin-bottom: 15px;
}
@media (max-width: 767px) {
.header-controls {
flex-direction: column;
gap: 10px;
}
.back-btn {
width: 100%;
justify-content: center;
}
.action-buttons {
display: flex;
flex-direction: column;
width: 100%;
gap: 8px;
}
.modal {
width: 95%;
}
}
</style> </style>