фикс
This commit is contained in:
parent
2317ecafca
commit
e8c839d493
@ -18,10 +18,14 @@ app.use(router);
|
|||||||
// Получаем экземпляр нашего auth "стора"
|
// Получаем экземпляр нашего auth "стора"
|
||||||
const { fetchUser, handleDeviceBlocked } = useAuth();
|
const { fetchUser, handleDeviceBlocked } = useAuth();
|
||||||
|
|
||||||
|
// Проверка на наличие браузерного окружения
|
||||||
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||||
|
|
||||||
// Асинхронная самовызывающаяся функция для инициализации
|
// Асинхронная самовызывающаяся функция для инициализации
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// Инициализируем систему безопасности устройств
|
// Инициализируем систему безопасности устройств только в браузере
|
||||||
|
if (isBrowser) {
|
||||||
console.log('Инициализация системы безопасности устройств...');
|
console.log('Инициализация системы безопасности устройств...');
|
||||||
await deviceSecurityService.initialize();
|
await deviceSecurityService.initialize();
|
||||||
|
|
||||||
@ -36,6 +40,7 @@ const { fetchUser, handleDeviceBlocked } = useAuth();
|
|||||||
window.location.href = '/login?blocked=true&type=device';
|
window.location.href = '/login?blocked=true&type=device';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await fetchUser(); // Пытаемся загрузить пользователя по токену из localStorage
|
await fetchUser(); // Пытаемся загрузить пользователя по токену из localStorage
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
import DeviceFingerprint from '../utils/deviceFingerprint.js';
|
import DeviceFingerprint from '../utils/deviceFingerprint.js';
|
||||||
import { api } from './api.js';
|
import api from './api.js';
|
||||||
|
|
||||||
|
// Проверка на наличие браузерного окружения
|
||||||
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис для работы с системой блокировки устройств
|
* Сервис для работы с системой блокировки устройств
|
||||||
*/
|
*/
|
||||||
class DeviceSecurityService {
|
class DeviceSecurityService {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Инициализируем только в браузерном окружении
|
||||||
|
if (isBrowser) {
|
||||||
this.deviceFingerprint = new DeviceFingerprint();
|
this.deviceFingerprint = new DeviceFingerprint();
|
||||||
this.currentFingerprint = null;
|
this.currentFingerprint = null;
|
||||||
this.isBlocked = false;
|
this.isBlocked = false;
|
||||||
@ -22,11 +27,22 @@ class DeviceSecurityService {
|
|||||||
fingerprintChangeThreshold: 0.1 // Порог изменения отпечатка (10%)
|
fingerprintChangeThreshold: 0.1 // Порог изменения отпечатка (10%)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инициализация сервиса безопасности
|
* Инициализация сервиса безопасности
|
||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
// Проверяем, находимся ли мы в браузерном окружении
|
||||||
|
if (!isBrowser) {
|
||||||
|
console.log('[DeviceSecurity] Инициализация пропущена - не браузерное окружение');
|
||||||
|
return {
|
||||||
|
fingerprint: null,
|
||||||
|
isBlocked: false,
|
||||||
|
initialized: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[DeviceSecurity] Инициализация сервиса безопасности устройств...');
|
console.log('[DeviceSecurity] Инициализация сервиса безопасности устройств...');
|
||||||
|
|
||||||
@ -58,6 +74,8 @@ class DeviceSecurityService {
|
|||||||
* Проверка статуса блокировки устройства
|
* Проверка статуса блокировки устройства
|
||||||
*/
|
*/
|
||||||
async checkDeviceBlockStatus() {
|
async checkDeviceBlockStatus() {
|
||||||
|
if (!isBrowser) return true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/security/check-device', {
|
const response = await api.post('/security/check-device', {
|
||||||
fingerprint: this.currentFingerprint,
|
fingerprint: this.currentFingerprint,
|
||||||
@ -94,6 +112,8 @@ class DeviceSecurityService {
|
|||||||
* Получение информации об устройстве
|
* Получение информации об устройстве
|
||||||
*/
|
*/
|
||||||
getDeviceInfo() {
|
getDeviceInfo() {
|
||||||
|
if (!isBrowser) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
platform: navigator.platform,
|
platform: navigator.platform,
|
||||||
@ -109,6 +129,8 @@ class DeviceSecurityService {
|
|||||||
* Получение количества посещений (из localStorage)
|
* Получение количества посещений (из localStorage)
|
||||||
*/
|
*/
|
||||||
getVisitCount() {
|
getVisitCount() {
|
||||||
|
if (!isBrowser) return 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const visits = localStorage.getItem('device_visits');
|
const visits = localStorage.getItem('device_visits');
|
||||||
const count = visits ? parseInt(visits) + 1 : 1;
|
const count = visits ? parseInt(visits) + 1 : 1;
|
||||||
@ -123,6 +145,8 @@ class DeviceSecurityService {
|
|||||||
* Запуск периодического мониторинга
|
* Запуск периодического мониторинга
|
||||||
*/
|
*/
|
||||||
startMonitoring() {
|
startMonitoring() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
if (this.checkInterval) {
|
if (this.checkInterval) {
|
||||||
clearInterval(this.checkInterval);
|
clearInterval(this.checkInterval);
|
||||||
}
|
}
|
||||||
@ -142,6 +166,8 @@ class DeviceSecurityService {
|
|||||||
* Остановка мониторинга
|
* Остановка мониторинга
|
||||||
*/
|
*/
|
||||||
stopMonitoring() {
|
stopMonitoring() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
if (this.checkInterval) {
|
if (this.checkInterval) {
|
||||||
clearInterval(this.checkInterval);
|
clearInterval(this.checkInterval);
|
||||||
this.checkInterval = null;
|
this.checkInterval = null;
|
||||||
@ -153,6 +179,8 @@ class DeviceSecurityService {
|
|||||||
* Выполнение периодической проверки безопасности
|
* Выполнение периодической проверки безопасности
|
||||||
*/
|
*/
|
||||||
async performSecurityCheck() {
|
async performSecurityCheck() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Проверяем, не изменился ли отпечаток устройства
|
// Проверяем, не изменился ли отпечаток устройства
|
||||||
await this.checkFingerprintChanges();
|
await this.checkFingerprintChanges();
|
||||||
@ -382,45 +410,9 @@ class DeviceSecurityService {
|
|||||||
* Проверка блокировки
|
* Проверка блокировки
|
||||||
*/
|
*/
|
||||||
isDeviceBlocked() {
|
isDeviceBlocked() {
|
||||||
|
if (!isBrowser) return false;
|
||||||
return this.isBlocked;
|
return this.isBlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получение компонентов отпечатка (для отладки)
|
|
||||||
*/
|
|
||||||
getFingerprintComponents() {
|
|
||||||
return this.deviceFingerprint.getComponents();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получение статистики подозрительных активностей
|
|
||||||
*/
|
|
||||||
getSuspiciousActivitiesStats() {
|
|
||||||
return {
|
|
||||||
total: this.suspiciousActivities.length,
|
|
||||||
activities: this.suspiciousActivities.slice(),
|
|
||||||
lastCheck: this.lastFingerprintCheck
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Очистка истории (для отладки)
|
|
||||||
*/
|
|
||||||
clearHistory() {
|
|
||||||
this.suspiciousActivities = [];
|
|
||||||
localStorage.removeItem('login_attempts');
|
|
||||||
console.log('[DeviceSecurity] История очищена');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Деструктор
|
|
||||||
*/
|
|
||||||
destroy() {
|
|
||||||
this.stopMonitoring();
|
|
||||||
this.suspiciousActivities = [];
|
|
||||||
this.initialized = false;
|
|
||||||
console.log('[DeviceSecurity] Сервис уничтожен');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем единственный экземпляр сервиса
|
// Создаем единственный экземпляр сервиса
|
||||||
|
@ -3,6 +3,9 @@
|
|||||||
* Используется для блокировки на уровне железа
|
* Используется для блокировки на уровне железа
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Проверка на наличие браузерного окружения
|
||||||
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||||
|
|
||||||
class DeviceFingerprint {
|
class DeviceFingerprint {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.components = new Map();
|
this.components = new Map();
|
||||||
@ -14,6 +17,14 @@ class DeviceFingerprint {
|
|||||||
* Инициализация и создание отпечатка устройства
|
* Инициализация и создание отпечатка устройства
|
||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
// Если мы не в браузере - возвращаем фиктивный отпечаток
|
||||||
|
if (!isBrowser) {
|
||||||
|
console.log('[DeviceFingerprint] Не браузерное окружение, отпечаток не создаётся');
|
||||||
|
this.fingerprint = 'server-environment-no-fingerprint';
|
||||||
|
this.initialized = true;
|
||||||
|
return this.fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[DeviceFingerprint] Начинаем инициализацию отпечатка устройства...');
|
console.log('[DeviceFingerprint] Начинаем инициализацию отпечатка устройства...');
|
||||||
|
|
||||||
@ -53,6 +64,8 @@ class DeviceFingerprint {
|
|||||||
* Информация об экране
|
* Информация об экране
|
||||||
*/
|
*/
|
||||||
async collectScreenInfo() {
|
async collectScreenInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.components.set('screen', {
|
this.components.set('screen', {
|
||||||
width: screen.width,
|
width: screen.width,
|
||||||
@ -72,6 +85,8 @@ class DeviceFingerprint {
|
|||||||
* Информация о навигаторе
|
* Информация о навигаторе
|
||||||
*/
|
*/
|
||||||
async collectNavigatorInfo() {
|
async collectNavigatorInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.components.set('navigator', {
|
this.components.set('navigator', {
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
@ -93,6 +108,8 @@ class DeviceFingerprint {
|
|||||||
* Canvas fingerprinting
|
* Canvas fingerprinting
|
||||||
*/
|
*/
|
||||||
async collectCanvasFingerprint() {
|
async collectCanvasFingerprint() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@ -116,7 +133,7 @@ class DeviceFingerprint {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
const canvasData = canvas.toDataURL();
|
const canvasData = canvas.toDataURL();
|
||||||
this.components.set('canvas', this.hashString(canvasData));
|
this.components.set('canvas', await this.hashString(canvasData));
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[DeviceFingerprint] Не удалось создать canvas fingerprint:', e);
|
console.warn('[DeviceFingerprint] Не удалось создать canvas fingerprint:', e);
|
||||||
@ -127,6 +144,8 @@ class DeviceFingerprint {
|
|||||||
* WebGL fingerprinting
|
* WebGL fingerprinting
|
||||||
*/
|
*/
|
||||||
async collectWebGLFingerprint() {
|
async collectWebGLFingerprint() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||||
@ -152,6 +171,8 @@ class DeviceFingerprint {
|
|||||||
* Audio fingerprinting
|
* Audio fingerprinting
|
||||||
*/
|
*/
|
||||||
async collectAudioFingerprint() {
|
async collectAudioFingerprint() {
|
||||||
|
if (!isBrowser) return Promise.resolve();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
if (!window.AudioContext && !window.webkitAudioContext) {
|
if (!window.AudioContext && !window.webkitAudioContext) {
|
||||||
@ -177,11 +198,11 @@ class DeviceFingerprint {
|
|||||||
scriptProcessor.connect(gainNode);
|
scriptProcessor.connect(gainNode);
|
||||||
gainNode.connect(context.destination);
|
gainNode.connect(context.destination);
|
||||||
|
|
||||||
scriptProcessor.onaudioprocess = (event) => {
|
scriptProcessor.onaudioprocess = async (event) => {
|
||||||
const bins = new Float32Array(analyser.frequencyBinCount);
|
const bins = new Float32Array(analyser.frequencyBinCount);
|
||||||
analyser.getFloatFrequencyData(bins);
|
analyser.getFloatFrequencyData(bins);
|
||||||
|
|
||||||
const audioHash = this.hashString(bins.slice(0, 50).join(','));
|
const audioHash = await this.hashString(bins.slice(0, 50).join(','));
|
||||||
this.components.set('audio', audioHash);
|
this.components.set('audio', audioHash);
|
||||||
|
|
||||||
oscillator.disconnect();
|
oscillator.disconnect();
|
||||||
@ -211,6 +232,8 @@ class DeviceFingerprint {
|
|||||||
* Информация о временной зоне
|
* Информация о временной зоне
|
||||||
*/
|
*/
|
||||||
async collectTimezoneInfo() {
|
async collectTimezoneInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.components.set('timezone', {
|
this.components.set('timezone', {
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
@ -226,6 +249,8 @@ class DeviceFingerprint {
|
|||||||
* Языковые настройки
|
* Языковые настройки
|
||||||
*/
|
*/
|
||||||
async collectLanguageInfo() {
|
async collectLanguageInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.components.set('language', {
|
this.components.set('language', {
|
||||||
language: navigator.language,
|
language: navigator.language,
|
||||||
@ -240,6 +265,8 @@ class DeviceFingerprint {
|
|||||||
* Платформа и ОС
|
* Платформа и ОС
|
||||||
*/
|
*/
|
||||||
async collectPlatformInfo() {
|
async collectPlatformInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.components.set('platform', {
|
this.components.set('platform', {
|
||||||
platform: navigator.platform,
|
platform: navigator.platform,
|
||||||
@ -255,6 +282,8 @@ class DeviceFingerprint {
|
|||||||
* Аппаратная информация
|
* Аппаратная информация
|
||||||
*/
|
*/
|
||||||
async collectHardwareInfo() {
|
async collectHardwareInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.components.set('hardware', {
|
this.components.set('hardware', {
|
||||||
hardwareConcurrency: navigator.hardwareConcurrency || 0,
|
hardwareConcurrency: navigator.hardwareConcurrency || 0,
|
||||||
@ -270,6 +299,8 @@ class DeviceFingerprint {
|
|||||||
* Информация о хранилище
|
* Информация о хранилище
|
||||||
*/
|
*/
|
||||||
async collectStorageInfo() {
|
async collectStorageInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let storageQuota = 0;
|
let storageQuota = 0;
|
||||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||||
@ -292,6 +323,8 @@ class DeviceFingerprint {
|
|||||||
* Сетевая информация
|
* Сетевая информация
|
||||||
*/
|
*/
|
||||||
async collectNetworkInfo() {
|
async collectNetworkInfo() {
|
||||||
|
if (!isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const networkInfo = {};
|
const networkInfo = {};
|
||||||
|
|
||||||
@ -312,6 +345,8 @@ class DeviceFingerprint {
|
|||||||
* Генерация финального отпечатка
|
* Генерация финального отпечатка
|
||||||
*/
|
*/
|
||||||
async generateFingerprint() {
|
async generateFingerprint() {
|
||||||
|
if (!isBrowser) return 'server-environment-no-fingerprint';
|
||||||
|
|
||||||
const componentsString = JSON.stringify(Array.from(this.components.entries()).sort());
|
const componentsString = JSON.stringify(Array.from(this.components.entries()).sort());
|
||||||
return await this.hashString(componentsString);
|
return await this.hashString(componentsString);
|
||||||
}
|
}
|
||||||
@ -320,6 +355,8 @@ class DeviceFingerprint {
|
|||||||
* Создание резервного отпечатка при ошибках
|
* Создание резервного отпечатка при ошибках
|
||||||
*/
|
*/
|
||||||
async createFallbackFingerprint() {
|
async createFallbackFingerprint() {
|
||||||
|
if (!isBrowser) return 'server-environment-no-fingerprint';
|
||||||
|
|
||||||
const fallbackData = {
|
const fallbackData = {
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
screen: `${screen.width}x${screen.height}`,
|
screen: `${screen.width}x${screen.height}`,
|
||||||
@ -335,12 +372,19 @@ class DeviceFingerprint {
|
|||||||
* Хеширование строки
|
* Хеширование строки
|
||||||
*/
|
*/
|
||||||
async hashString(str) {
|
async hashString(str) {
|
||||||
if (crypto && crypto.subtle) {
|
if (!isBrowser) return 'server-environment-hash';
|
||||||
|
|
||||||
|
if (window.crypto && window.crypto.subtle) {
|
||||||
|
try {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(str);
|
const data = encoder.encode(str);
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[DeviceFingerprint] Ошибка при использовании Crypto API:', e);
|
||||||
|
return this.simpleHash(str);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback для старых браузеров
|
// Fallback для старых браузеров
|
||||||
return this.simpleHash(str);
|
return this.simpleHash(str);
|
||||||
|
95
src/views/DeviceBlockedView.vue
Normal file
95
src/views/DeviceBlockedView.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="device-blocked-container">
|
||||||
|
<div class="blocked-card">
|
||||||
|
<div class="blocked-icon">
|
||||||
|
<i class="fas fa-ban"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Устройство заблокировано</h1>
|
||||||
|
<p>
|
||||||
|
Для безопасности вашей учетной записи, это устройство было заблокировано администратором.
|
||||||
|
</p>
|
||||||
|
<p class="device-info" v-if="fingerprint">
|
||||||
|
ID устройства: <code>{{ shortenFingerprint(fingerprint) }}</code>
|
||||||
|
</p>
|
||||||
|
<p class="contact-info">
|
||||||
|
Если вы считаете, что это ошибка, пожалуйста, свяжитесь с администратором сервиса.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useAuth } from '../auth';
|
||||||
|
|
||||||
|
const { deviceFingerprint } = useAuth();
|
||||||
|
const fingerprint = ref('');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fingerprint.value = deviceFingerprint.value || localStorage.getItem('deviceFingerprint') || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const shortenFingerprint = (fp) => {
|
||||||
|
if (!fp) return '';
|
||||||
|
if (fp.length <= 16) return fp;
|
||||||
|
return fp.substring(0, 8) + '...' + fp.substring(fp.length - 8);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-blocked-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-card {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
color: #dc3545;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #dc3545;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: #eee;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
x
Reference in New Issue
Block a user