фикс
This commit is contained in:
parent
987ab236f8
commit
96cf4cb0db
@ -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
|
||||||
};
|
};
|
@ -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);
|
||||||
|
|
||||||
|
@ -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;
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
@ -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">×</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>
|
Loading…
x
Reference in New Issue
Block a user