1503 lines
61 KiB
TypeScript
1503 lines
61 KiB
TypeScript
import { useState, useEffect, createContext, useContext } from 'react';
|
||
import axios from 'axios';
|
||
import { API_URL } from '../../config/api';
|
||
import apiClient from '../../utils/apiClient';
|
||
import { useTranslation } from '../../i18n';
|
||
import {
|
||
getProfile,
|
||
updateProfile,
|
||
changePassword,
|
||
uploadAvatar,
|
||
deleteAvatar,
|
||
getSessions,
|
||
terminateSession,
|
||
getLoginHistory,
|
||
getSSHKeys,
|
||
addSSHKey,
|
||
deleteSSHKey,
|
||
getAPIKeys,
|
||
createAPIKey,
|
||
deleteAPIKey,
|
||
getNotificationSettings,
|
||
updateNotificationSettings,
|
||
exportUserData,
|
||
type UserProfile,
|
||
type Session,
|
||
type LoginHistoryEntry,
|
||
type SSHKey,
|
||
type APIKey,
|
||
type NotificationSettings
|
||
} from '../../services/userService';
|
||
|
||
type TabType = 'profile' | 'security' | 'notifications' | 'api' | 'ssh' | 'delete';
|
||
|
||
// Context for sharing isEn across settings tabs
|
||
const SettingsLangContext = createContext<boolean>(false);
|
||
const useSettingsLang = () => useContext(SettingsLangContext);
|
||
|
||
const SettingsPage = () => {
|
||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const { locale } = useTranslation();
|
||
const isEn = locale === 'en';
|
||
|
||
useEffect(() => {
|
||
loadProfile();
|
||
}, []);
|
||
|
||
const loadProfile = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await getProfile();
|
||
setProfile(data);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки профиля:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex justify-center items-center min-h-screen">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const tabs = [
|
||
{ id: 'profile' as TabType, label: isEn ? 'Profile' : 'Профиль' },
|
||
{ id: 'security' as TabType, label: isEn ? 'Security' : 'Безопасность' },
|
||
{ id: 'notifications' as TabType, label: isEn ? 'Notifications' : 'Уведомления' },
|
||
{ id: 'api' as TabType, label: isEn ? 'API Keys' : 'API ключи' },
|
||
{ id: 'ssh' as TabType, label: isEn ? 'SSH Keys' : 'SSH ключи' },
|
||
{ id: 'delete' as TabType, label: isEn ? 'Delete' : 'Удаление' },
|
||
];
|
||
|
||
return (
|
||
<SettingsLangContext.Provider value={isEn}>
|
||
<div className="p-4 lg:p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
{/* Заголовок */}
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Account Settings' : 'Настройки аккаунта'}</h1>
|
||
<p className="text-gray-600 mt-2">{isEn ? 'Manage profile, security and integrations' : 'Управление профилем, безопасностью и интеграциями'}</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||
{/* Sidebar с вкладками */}
|
||
<div className="lg:col-span-1">
|
||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden sticky top-4">
|
||
<nav className="flex flex-col">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`px-4 py-3 text-left transition-colors ${
|
||
activeTab === tab.id
|
||
? 'bg-ospab-primary text-white font-medium'
|
||
: 'text-gray-700 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<span>{tab.label}</span>
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Контент вкладки */}
|
||
<div className="lg:col-span-3">
|
||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||
{activeTab === 'profile' && <ProfileTab profile={profile} onUpdate={loadProfile} />}
|
||
{activeTab === 'security' && <SecurityTab />}
|
||
{activeTab === 'notifications' && <NotificationsTab />}
|
||
{activeTab === 'api' && <APIKeysTab />}
|
||
{activeTab === 'ssh' && <SSHKeysTab />}
|
||
{activeTab === 'delete' && <DeleteAccountTab />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SettingsLangContext.Provider>
|
||
);
|
||
};
|
||
|
||
// ============ ПРОФИЛЬ ============
|
||
const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpdate: () => void }) => {
|
||
const isEn = useSettingsLang();
|
||
const [username, setUsername] = useState(profile?.username || '');
|
||
const [email, setEmail] = useState(profile?.email || '');
|
||
const [phoneNumber, setPhoneNumber] = useState(profile?.profile?.phoneNumber || '');
|
||
const [timezone, setTimezone] = useState(profile?.profile?.timezone || 'Europe/Moscow');
|
||
const [language, setLanguage] = useState(profile?.profile?.language || 'ru');
|
||
const [saving, setSaving] = useState(false);
|
||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||
|
||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
setAvatarFile(file);
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setAvatarPreview(reader.result as string);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
const handleAvatarUpload = async () => {
|
||
if (!avatarFile) return;
|
||
|
||
try {
|
||
setSaving(true);
|
||
await uploadAvatar(avatarFile);
|
||
alert(isEn ? 'Avatar uploaded!' : 'Аватар загружен!');
|
||
onUpdate();
|
||
setAvatarFile(null);
|
||
setAvatarPreview(null);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки аватара:', error);
|
||
alert(isEn ? 'Error uploading avatar' : 'Ошибка загрузки аватара');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteAvatar = async () => {
|
||
if (!confirm(isEn ? 'Delete avatar?' : 'Удалить аватар?')) return;
|
||
|
||
try {
|
||
setSaving(true);
|
||
await deleteAvatar();
|
||
alert(isEn ? 'Avatar deleted' : 'Аватар удалён');
|
||
onUpdate();
|
||
} catch (error) {
|
||
console.error('Ошибка удаления аватара:', error);
|
||
alert(isEn ? 'Error deleting avatar' : 'Ошибка удаления аватара');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
setSaving(true);
|
||
await updateProfile({ username, email, phoneNumber, timezone, language });
|
||
alert(isEn ? 'Profile updated!' : 'Профиль обновлён!');
|
||
onUpdate();
|
||
} catch (error) {
|
||
console.error('Ошибка обновления профиля:', error);
|
||
alert(isEn ? 'Error updating profile' : 'Ошибка обновления профиля');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Profile' : 'Профиль'}</h2>
|
||
<p className="text-gray-600">{isEn ? 'Update your profile information' : 'Обновите информацию о своём профиле'}</p>
|
||
</div>
|
||
|
||
{/* Аватар */}
|
||
<div className="border-t border-gray-200 pt-6">
|
||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Avatar' : 'Аватар'}</h3>
|
||
<div className="flex items-center gap-6">
|
||
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
||
{avatarPreview || profile?.profile?.avatarUrl ? (
|
||
<img
|
||
src={avatarPreview || (profile?.profile?.avatarUrl ? `${API_URL}${profile.profile.avatarUrl}` : undefined)}
|
||
alt="Avatar"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<svg className="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<label className="px-4 py-2 bg-ospab-primary text-white rounded-lg cursor-pointer hover:bg-ospab-accent transition">
|
||
{isEn ? 'Choose file' : 'Выбрать файл'}
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleAvatarChange}
|
||
/>
|
||
</label>
|
||
{avatarFile && (
|
||
<button
|
||
onClick={handleAvatarUpload}
|
||
disabled={saving}
|
||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||
>
|
||
{isEn ? 'Upload' : 'Загрузить'}
|
||
</button>
|
||
)}
|
||
{profile?.profile?.avatarUrl && (
|
||
<button
|
||
onClick={handleDeleteAvatar}
|
||
disabled={saving}
|
||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||
>
|
||
{isEn ? 'Delete' : 'Удалить'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Основная информация */}
|
||
<div className="border-t border-gray-200 pt-6 space-y-4">
|
||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Basic Information' : 'Основная информация'}</h3>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Username' : 'Имя пользователя'}</label>
|
||
<input
|
||
type="text"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Phone (optional)' : 'Телефон (опционально)'}</label>
|
||
<input
|
||
type="tel"
|
||
value={phoneNumber}
|
||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||
placeholder="+7 (999) 999-99-99"
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Timezone' : 'Часовой пояс'}</label>
|
||
<select
|
||
value={timezone}
|
||
onChange={(e) => setTimezone(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
>
|
||
<option value="Europe/Moscow">Москва (UTC+3)</option>
|
||
<option value="Europe/Kaliningrad">Калининград (UTC+2)</option>
|
||
<option value="Asia/Yekaterinburg">Екатеринбург (UTC+5)</option>
|
||
<option value="Asia/Novosibirsk">Новосибирск (UTC+7)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Language' : 'Язык'}</label>
|
||
<select
|
||
value={language}
|
||
onChange={(e) => setLanguage(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
>
|
||
<option value="ru">Русский</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
||
>
|
||
{saving ? (isEn ? 'Saving...' : 'Сохранение...') : (isEn ? 'Save changes' : 'Сохранить изменения')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ============ БЕЗОПАСНОСТЬ ============
|
||
const SecurityTab = () => {
|
||
const isEn = useSettingsLang();
|
||
const [view, setView] = useState<'password' | 'sessions'>('password');
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Security' : 'Безопасность'}</h2>
|
||
<p className="text-gray-600">{isEn ? 'Manage password and active sessions' : 'Управление паролем и активными сеансами'}</p>
|
||
</div>
|
||
|
||
{/* Sub-tabs */}
|
||
<div className="flex gap-2 border-b border-gray-200">
|
||
<button
|
||
onClick={() => setView('password')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
view === 'password'
|
||
? 'text-ospab-primary border-b-2 border-ospab-primary'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
{isEn ? 'Change Password' : 'Смена пароля'}
|
||
</button>
|
||
<button
|
||
onClick={() => setView('sessions')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
view === 'sessions'
|
||
? 'text-ospab-primary border-b-2 border-ospab-primary'
|
||
: 'text-gray-600 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
{isEn ? 'Active Sessions' : 'Активные сеансы'}
|
||
</button>
|
||
</div>
|
||
|
||
{view === 'password' && <PasswordChange />}
|
||
{view === 'sessions' && <ActiveSessions />}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const PasswordChange = () => {
|
||
const isEn = useSettingsLang();
|
||
const [currentPassword, setCurrentPassword] = useState('');
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [confirmPassword, setConfirmPassword] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const getPasswordStrength = (password: string) => {
|
||
if (password.length === 0) return { strength: 0, label: '' };
|
||
if (password.length < 6) return { strength: 1, label: isEn ? 'Weak' : 'Слабый', color: 'bg-red-500' };
|
||
if (password.length < 10) return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password))
|
||
return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||
return { strength: 3, label: isEn ? 'Strong' : 'Сильный', color: 'bg-green-500' };
|
||
};
|
||
|
||
const strength = getPasswordStrength(newPassword);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
alert(isEn ? 'Passwords do not match' : 'Пароли не совпадают');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoading(true);
|
||
await changePassword({ currentPassword, newPassword });
|
||
alert(isEn ? 'Password changed successfully!' : 'Пароль успешно изменён!');
|
||
setCurrentPassword('');
|
||
setNewPassword('');
|
||
setConfirmPassword('');
|
||
} catch (error) {
|
||
console.error('Ошибка смены пароля:', error);
|
||
alert(isEn ? 'Password change error. Check current password.' : 'Ошибка смены пароля. Проверьте текущий пароль.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Current password' : 'Текущий пароль'}</label>
|
||
<input
|
||
type="password"
|
||
value={currentPassword}
|
||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||
required
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'New password' : 'Новый пароль'}</label>
|
||
<input
|
||
type="password"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
required
|
||
minLength={6}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
{newPassword && (
|
||
<div className="mt-2">
|
||
<div className="flex gap-1">
|
||
{[1, 2, 3].map((level) => (
|
||
<div
|
||
key={level}
|
||
className={`h-2 flex-1 rounded ${
|
||
level <= strength.strength ? strength.color : 'bg-gray-200'
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
<p className="text-sm text-gray-600 mt-1">{isEn ? 'Password strength:' : 'Сила пароля:'} {strength.label}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Confirm new password' : 'Подтвердите новый пароль'}</label>
|
||
<input
|
||
type="password"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
required
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
||
>
|
||
{loading ? (isEn ? 'Changing...' : 'Изменение...') : (isEn ? 'Change password' : 'Изменить пароль')}
|
||
</button>
|
||
</form>
|
||
);
|
||
};
|
||
|
||
const ActiveSessions = () => {
|
||
const isEn = useSettingsLang();
|
||
const [sessions, setSessions] = useState<Session[]>([]);
|
||
const [loginHistory, setLoginHistory] = useState<LoginHistoryEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showHistory, setShowHistory] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadSessions();
|
||
loadHistory();
|
||
}, []);
|
||
|
||
const loadSessions = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await getSessions();
|
||
setSessions(data);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки сеансов:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadHistory = async () => {
|
||
try {
|
||
const data = await getLoginHistory(20);
|
||
setLoginHistory(data);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки истории:', error);
|
||
}
|
||
};
|
||
|
||
const handleTerminate = async (id: number) => {
|
||
if (!confirm(isEn ? 'Are you sure you want to terminate this session?' : 'Вы уверены, что хотите завершить эту сессию?')) return;
|
||
|
||
try {
|
||
await terminateSession(id);
|
||
alert(isEn ? 'Session terminated' : 'Сеанс завершён');
|
||
loadSessions();
|
||
} catch (error) {
|
||
console.error('Ошибка завершения сеанса:', error);
|
||
alert(isEn ? 'Failed to terminate session' : 'Не удалось завершить сессию');
|
||
}
|
||
};
|
||
|
||
const handleTerminateAllOthers = async () => {
|
||
if (!confirm(isEn ? 'Are you sure you want to terminate all other sessions?' : 'Вы уверены, что хотите завершить все остальные сессии?')) return;
|
||
|
||
try {
|
||
// Используем API для завершения всех остальных сессий
|
||
await apiClient.delete('/api/sessions/others/all');
|
||
alert(isEn ? 'All other sessions terminated' : 'Все остальные сессии завершены');
|
||
loadSessions();
|
||
} catch (error) {
|
||
console.error('Ошибка завершения сессий:', error);
|
||
alert(isEn ? 'Failed to terminate sessions' : 'Не удалось завершить сессии');
|
||
}
|
||
};
|
||
|
||
const getDeviceIcon = (device: string) => {
|
||
const deviceLower = (device || 'desktop').toLowerCase();
|
||
if (deviceLower.includes('mobile') || deviceLower.includes('phone')) return '📱';
|
||
if (deviceLower.includes('tablet')) return '📱';
|
||
return '💻';
|
||
};
|
||
|
||
const formatRelativeTime = (dateString: string) => {
|
||
const date = new Date(dateString);
|
||
const now = new Date();
|
||
const diffMs = now.getTime() - date.getTime();
|
||
const diffMins = Math.floor(diffMs / 60000);
|
||
const diffHours = Math.floor(diffMs / 3600000);
|
||
const diffDays = Math.floor(diffMs / 86400000);
|
||
|
||
if (diffMins < 1) return isEn ? 'just now' : 'только что';
|
||
if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`;
|
||
if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`;
|
||
if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`;
|
||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||
};
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-8">Загрузка...</div>;
|
||
}
|
||
|
||
// Определяем количество других сессий для кнопки
|
||
const otherSessions = sessions.filter(s => !s.isCurrent && !s.device?.includes('Current'));
|
||
|
||
return (
|
||
<div className="pt-4 space-y-6">
|
||
{/* Кнопка завершения всех остальных сессий */}
|
||
{otherSessions.length > 0 && (
|
||
<div>
|
||
<button
|
||
onClick={handleTerminateAllOthers}
|
||
className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
|
||
>
|
||
<span>🚫</span>
|
||
{isEn ? 'Terminate all other sessions' : 'Завершить все остальные сессии'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Сессии в виде карточек */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{sessions.length === 0 ? (
|
||
<p className="text-gray-600 text-center py-8 col-span-2">{isEn ? 'No active sessions' : 'Нет активных сеансов'}</p>
|
||
) : (
|
||
sessions.map((session) => {
|
||
const isCurrent = session.isCurrent || session.device?.includes('Current');
|
||
return (
|
||
<div
|
||
key={session.id}
|
||
className={`bg-white rounded-xl shadow-md overflow-hidden transition-all duration-200 hover:shadow-lg ${
|
||
isCurrent ? 'ring-2 ring-green-500' : ''
|
||
}`}
|
||
>
|
||
<div className="p-6">
|
||
{/* Бейдж текущей сессии */}
|
||
{isCurrent && (
|
||
<div className="mb-3">
|
||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||
✓ {isEn ? 'Current Session' : 'Текущая сессия'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Информация об устройстве */}
|
||
<div className="flex items-start gap-4">
|
||
<div className="text-4xl">{getDeviceIcon(session.device || 'desktop')}</div>
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||
{session.browser || (isEn ? 'Unknown browser' : 'Неизвестный браузер')} · {session.device || 'Desktop'}
|
||
</h3>
|
||
<div className="space-y-1 text-sm text-gray-600">
|
||
<p className="flex items-center gap-2">
|
||
<span>🌐</span>
|
||
<span>{session.ipAddress || (isEn ? 'Unknown' : 'Неизвестно')}</span>
|
||
</p>
|
||
{session.location && (
|
||
<p className="flex items-center gap-2">
|
||
<span>📍</span>
|
||
<span>{session.location}</span>
|
||
</p>
|
||
)}
|
||
<p className="flex items-center gap-2">
|
||
<span>⏱️</span>
|
||
<span>{isEn ? 'Activity' : 'Активность'}: {formatRelativeTime(session.lastActivity)}</span>
|
||
</p>
|
||
<p className="flex items-center gap-2 text-gray-500">
|
||
<span>🔐</span>
|
||
<span>{isEn ? 'Login' : 'Вход'}: {new Date(session.createdAt || session.lastActivity).toLocaleString(isEn ? 'en-US' : 'ru-RU')}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Кнопка завершения */}
|
||
{!isCurrent && (
|
||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||
<button
|
||
onClick={() => handleTerminate(session.id)}
|
||
className="w-full bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg font-medium transition-colors duration-200"
|
||
>
|
||
{isEn ? 'Terminate Session' : 'Завершить сессию'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* История входов */}
|
||
<div className="bg-white rounded-xl shadow-md overflow-hidden">
|
||
<div className="p-6 border-b border-gray-200">
|
||
<button
|
||
onClick={() => setShowHistory(!showHistory)}
|
||
className="w-full flex items-center justify-between text-left"
|
||
>
|
||
<div>
|
||
<h2 className="text-xl font-bold text-gray-900">{isEn ? 'Login History' : 'История входов'}</h2>
|
||
<p className="text-sm text-gray-600 mt-1">{isEn ? 'Last 20 login attempts' : 'Последние 20 попыток входа в аккаунт'}</p>
|
||
</div>
|
||
<span className="text-2xl">{showHistory ? '▼' : '▶'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{showHistory && (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
{isEn ? 'Status' : 'Статус'}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
{isEn ? 'IP Address' : 'IP адрес'}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
{isEn ? 'Device' : 'Устройство'}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
{isEn ? 'Date and Time' : 'Дата и время'}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{loginHistory.map((entry) => (
|
||
<tr key={entry.id} className={entry.success ? '' : 'bg-red-50'}>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||
entry.success
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-red-100 text-red-800'
|
||
}`}
|
||
>
|
||
{entry.success ? (isEn ? '✓ Success' : '✓ Успешно') : (isEn ? '✗ Error' : '✗ Ошибка')}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||
{entry.ipAddress}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{entry.userAgent ? entry.userAgent.substring(0, 60) + '...' : entry.device || (isEn ? 'Unknown' : 'Неизвестно')}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||
{new Date(entry.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Советы по безопасности */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||
<h3 className="text-lg font-semibold text-blue-900 mb-3">💡 {isEn ? 'Security Tips' : 'Советы по безопасности'}</h3>
|
||
<ul className="space-y-2 text-sm text-blue-800">
|
||
<li>• {isEn ? 'Regularly check the list of active sessions' : 'Регулярно проверяйте список активных сессий'}</li>
|
||
<li>• {isEn ? 'Terminate sessions on devices you no longer use' : 'Завершайте сессии на устройствах, которыми больше не пользуетесь'}</li>
|
||
<li>• {isEn ? 'If you see suspicious activity, immediately terminate all sessions and change password' : 'Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль'}</li>
|
||
<li>• {isEn ? 'Use strong passwords and two-factor authentication' : 'Используйте надёжные пароли и двухфакторную аутентификацию'}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ============ УВЕДОМЛЕНИЯ ============
|
||
const NotificationsTab = () => {
|
||
const isEn = useSettingsLang();
|
||
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadSettings();
|
||
}, []);
|
||
|
||
const loadSettings = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await getNotificationSettings();
|
||
setSettings(data);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки настроек:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleToggle = async (field: keyof NotificationSettings, value: boolean) => {
|
||
if (!settings) return;
|
||
|
||
const updated = { ...settings, [field]: value };
|
||
setSettings(updated);
|
||
|
||
try {
|
||
setSaving(true);
|
||
await updateNotificationSettings({ [field]: value });
|
||
} catch (error) {
|
||
console.error('Ошибка обновления настроек:', error);
|
||
alert(isEn ? 'Error saving settings' : 'Ошибка сохранения настроек');
|
||
loadSettings(); // Revert
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-8">Загрузка...</div>;
|
||
}
|
||
|
||
if (!settings) {
|
||
return <div className="text-center py-8 text-gray-600">{isEn ? 'Error loading settings' : 'Ошибка загрузки настроек'}</div>;
|
||
}
|
||
|
||
const emailSettings = [
|
||
{ key: 'emailBalanceLow' as keyof NotificationSettings, label: 'Низкий баланс' },
|
||
{ key: 'emailPaymentCharged' as keyof NotificationSettings, label: 'Списание оплаты' },
|
||
{ key: 'emailTicketReply' as keyof NotificationSettings, label: 'Ответ на тикет' },
|
||
{ key: 'emailNewsletter' as keyof NotificationSettings, label: 'Рассылка новостей' },
|
||
];
|
||
|
||
const pushSettings = [
|
||
{ key: 'pushBalanceLow' as keyof NotificationSettings, label: 'Низкий баланс' },
|
||
{ key: 'pushPaymentCharged' as keyof NotificationSettings, label: 'Списание оплаты' },
|
||
{ key: 'pushTicketReply' as keyof NotificationSettings, label: 'Ответ на тикет' },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Notifications' : 'Уведомления'}</h2>
|
||
<p className="text-gray-600">{isEn ? 'Configure notification methods' : 'Настройте способы получения уведомлений'}</p>
|
||
</div>
|
||
|
||
{/* Email уведомления */}
|
||
<div className="border-t border-gray-200 pt-6">
|
||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Email Notifications' : 'Email уведомления'}</h3>
|
||
<div className="space-y-3">
|
||
{emailSettings.map((setting) => (
|
||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
||
<span className="text-gray-700">{setting.label}</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings[setting.key] as boolean}
|
||
onChange={(e) => handleToggle(setting.key, e.target.checked)}
|
||
disabled={saving}
|
||
className="w-5 h-5 text-ospab-primary focus:ring-ospab-primary border-gray-300 rounded"
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Push уведомления */}
|
||
<div className="border-t border-gray-200 pt-6">
|
||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Push Notifications' : 'Push уведомления'}</h3>
|
||
<div className="space-y-3">
|
||
{pushSettings.map((setting) => (
|
||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
||
<span className="text-gray-700">{setting.label}</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings[setting.key] as boolean}
|
||
onChange={(e) => handleToggle(setting.key, e.target.checked)}
|
||
disabled={saving}
|
||
className="w-5 h-5 text-ospab-primary focus:ring-ospab-primary border-gray-300 rounded"
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{saving && (
|
||
<div className="text-sm text-gray-600 flex items-center gap-2">
|
||
<div className="w-4 h-4 border-2 border-ospab-primary border-t-transparent rounded-full animate-spin"></div>
|
||
{isEn ? 'Saving...' : 'Сохранение...'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ============ API КЛЮЧИ ============
|
||
const APIKeysTab = () => {
|
||
const isEn = useSettingsLang();
|
||
const [keys, setKeys] = useState<APIKey[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [newKey, setNewKey] = useState<{ fullKey: string } | null>(null);
|
||
|
||
useEffect(() => {
|
||
loadKeys();
|
||
}, []);
|
||
|
||
const loadKeys = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await getAPIKeys();
|
||
setKeys(data);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки ключей:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCreate = async (name: string) => {
|
||
try {
|
||
const key = await createAPIKey({ name });
|
||
setNewKey(key);
|
||
loadKeys();
|
||
} catch (error) {
|
||
console.error('Ошибка создания ключа:', error);
|
||
alert(isEn ? 'Error creating key' : 'Ошибка создания ключа');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
if (!confirm(isEn ? 'Delete this API key? Applications using it will stop working.' : 'Удалить этот API ключ? Приложения, использующие его, перестанут работать.')) return;
|
||
|
||
try {
|
||
await deleteAPIKey(id);
|
||
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||
loadKeys();
|
||
} catch (error) {
|
||
console.error('Ошибка удаления ключа:', error);
|
||
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-8">Загрузка...</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'API Keys' : 'API ключи'}</h2>
|
||
<p className="text-gray-600">{isEn ? 'Manage integration keys' : 'Управление ключами для интеграций'}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowModal(true)}
|
||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||
>
|
||
{isEn ? 'Create Key' : 'Создать ключ'}
|
||
</button>
|
||
</div>
|
||
|
||
{keys.length === 0 ? (
|
||
<p className="text-gray-600 text-center py-8">{isEn ? 'No keys created' : 'Нет созданных ключей'}</p>
|
||
) : (
|
||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Name' : 'Название'}</th>
|
||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Prefix' : 'Префикс'}</th>
|
||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Created' : 'Создан'}</th>
|
||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Last Used' : 'Последнее использование'}</th>
|
||
<th className="text-right py-3 px-4 font-medium text-gray-700">{isEn ? 'Actions' : 'Действия'}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{keys.map((key) => (
|
||
<tr key={key.id} className="border-t border-gray-100">
|
||
<td className="py-3 px-4">{key.name}</td>
|
||
<td className="py-3 px-4 font-mono text-sm">{key.prefix}...</td>
|
||
<td className="py-3 px-4 text-sm text-gray-600">
|
||
{new Date(key.createdAt).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}
|
||
</td>
|
||
<td className="py-3 px-4 text-sm text-gray-600">
|
||
{key.lastUsed ? new Date(key.lastUsed).toLocaleDateString(isEn ? 'en-US' : 'ru-RU') : (isEn ? 'Never' : 'Никогда')}
|
||
</td>
|
||
<td className="py-3 px-4 text-right">
|
||
<button
|
||
onClick={() => handleDelete(key.id)}
|
||
className="text-red-600 hover:text-red-700 text-sm"
|
||
>
|
||
{isEn ? 'Delete' : 'Удалить'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Модалка создания ключа */}
|
||
{showModal && (
|
||
<CreateAPIKeyModal
|
||
onClose={() => setShowModal(false)}
|
||
onCreate={handleCreate}
|
||
/>
|
||
)}
|
||
|
||
{/* Модалка показа нового ключа */}
|
||
{newKey && (
|
||
<ShowNewKeyModal
|
||
keyData={newKey}
|
||
onClose={() => setNewKey(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const CreateAPIKeyModal = ({ onClose, onCreate }: { onClose: () => void; onCreate: (name: string) => void }) => {
|
||
const [name, setName] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
await onCreate(name);
|
||
setLoading(false);
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||
<h3 className="text-xl font-bold mb-4">Create API Key</h3>
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
placeholder="My project"
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
||
>
|
||
{loading ? 'Creating...' : 'Create'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; onClose: () => void }) => {
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(keyData.fullKey);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||
<h3 className="text-xl font-bold mb-4 text-green-600">Key created successfully!</h3>
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||
<p className="text-sm text-yellow-800 font-medium">
|
||
Save this key now! It will not be shown again.
|
||
</p>
|
||
</div>
|
||
<div className="bg-gray-100 rounded-lg p-4 mb-4 font-mono text-sm break-all">
|
||
{keyData.fullKey}
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handleCopy}
|
||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent"
|
||
>
|
||
{copied ? 'Copied!' : 'Copy'}
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ============ SSH КЛЮЧИ ============
|
||
const SSHKeysTab = () => {
|
||
const isEn = useSettingsLang();
|
||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showModal, setShowModal] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadKeys();
|
||
}, []);
|
||
|
||
const loadKeys = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await getSSHKeys();
|
||
setKeys(data);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки ключей:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleAdd = async (name: string, publicKey: string) => {
|
||
try {
|
||
await addSSHKey({ name, publicKey });
|
||
alert(isEn ? 'SSH key added' : 'SSH ключ добавлен');
|
||
loadKeys();
|
||
setShowModal(false);
|
||
} catch (error) {
|
||
console.error('Ошибка добавления ключа:', error);
|
||
alert(isEn ? 'Error adding key. Check the format.' : 'Ошибка добавления ключа. Проверьте формат.');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
if (!confirm(isEn ? 'Delete this SSH key?' : 'Удалить этот SSH ключ?')) return;
|
||
|
||
try {
|
||
await deleteSSHKey(id);
|
||
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||
loadKeys();
|
||
} catch (error) {
|
||
console.error('Ошибка удаления ключа:', error);
|
||
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-8">Загрузка...</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'SSH Keys' : 'SSH ключи'}</h2>
|
||
<p className="text-gray-600">{isEn ? 'Manage SSH keys for server access' : 'Управление SSH ключами для доступа к серверам'}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowModal(true)}
|
||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||
>
|
||
{isEn ? 'Add Key' : 'Добавить ключ'}
|
||
</button>
|
||
</div>
|
||
|
||
{keys.length === 0 ? (
|
||
<p className="text-gray-600 text-center py-8">{isEn ? 'No keys added' : 'Нет добавленных ключей'}</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{keys.map((key) => (
|
||
<div key={key.id} className="border border-gray-200 rounded-lg p-4">
|
||
<div className="flex justify-between items-start mb-2">
|
||
<h4 className="font-medium text-gray-900">{key.name}</h4>
|
||
<button
|
||
onClick={() => handleDelete(key.id)}
|
||
className="text-red-600 hover:text-red-700 text-sm"
|
||
>
|
||
{isEn ? 'Delete' : 'Удалить'}
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mb-1">
|
||
{isEn ? 'Fingerprint' : 'Отпечаток'}: <span className="font-mono">{key.fingerprint}</span>
|
||
</p>
|
||
<p className="text-sm text-gray-500">
|
||
{isEn ? 'Added' : 'Добавлен'}: {new Date(key.createdAt).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}
|
||
{key.lastUsed && ` • ${isEn ? 'Used' : 'Использован'}: ${new Date(key.lastUsed).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}`}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{showModal && (
|
||
<AddSSHKeyModal
|
||
onClose={() => setShowModal(false)}
|
||
onAdd={handleAdd}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name: string, publicKey: string) => void }) => {
|
||
const [name, setName] = useState('');
|
||
const [publicKey, setPublicKey] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
await onAdd(name, publicKey);
|
||
setLoading(false);
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||
<h3 className="text-xl font-bold mb-4">Add SSH Key</h3>
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
placeholder="My laptop"
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
</div>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Public Key</label>
|
||
<textarea
|
||
value={publicKey}
|
||
onChange={(e) => setPublicKey(e.target.value)}
|
||
required
|
||
rows={6}
|
||
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg font-mono text-sm focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Copy the contents of ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
||
>
|
||
{loading ? 'Adding...' : 'Add'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ============ УДАЛЕНИЕ АККАУНТА ============
|
||
const DeleteAccountTab = () => {
|
||
const isEn = useSettingsLang();
|
||
const [showConfirm, setShowConfirm] = useState(false);
|
||
|
||
const handleExport = async () => {
|
||
try {
|
||
const data = await exportUserData();
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `ospab-data-${new Date().toISOString().split('T')[0]}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
console.error('Ошибка экспорта данных:', error);
|
||
alert(isEn ? 'Error exporting data' : 'Ошибка экспорта данных');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Delete Account' : 'Удаление аккаунта'}</h2>
|
||
<p className="text-gray-600">{isEn ? 'Export data and permanently delete account' : 'Экспорт данных и безвозвратное удаление аккаунта'}</p>
|
||
</div>
|
||
|
||
{/* Экспорт данных */}
|
||
<div className="border-t border-gray-200 pt-6">
|
||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Export Data' : 'Экспорт данных'}</h3>
|
||
<p className="text-gray-600 mb-4">
|
||
{isEn ? 'Download a copy of all your data including profile, servers, tickets and transactions in JSON format.' : 'Скачайте копию всех ваших данных включая профиль, серверы, тикеты и транзакции в формате JSON.'}
|
||
</p>
|
||
<button
|
||
onClick={handleExport}
|
||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent transition"
|
||
>
|
||
{isEn ? 'Download My Data' : 'Скачать мои данные'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Удаление аккаунта */}
|
||
<div className="border-t border-gray-200 pt-6">
|
||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6">
|
||
<h3 className="text-lg font-semibold text-red-900 mb-4">{isEn ? 'Danger Zone' : 'Опасная зона'}</h3>
|
||
<div className="space-y-4">
|
||
<div className="flex items-start gap-3">
|
||
<svg className="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
|
||
</svg>
|
||
<div>
|
||
<p className="text-red-900 font-medium">{isEn ? 'This action is irreversible' : 'Это действие необратимо'}</p>
|
||
<p className="text-red-700 text-sm mt-1">
|
||
{isEn ? 'All your servers will be stopped and deleted. Payment history, tickets and other data will be permanently deleted.' : 'Все ваши серверы будут остановлены и удалены. История платежей, тикеты и другие данные будут безвозвратно удалены.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowConfirm(true)}
|
||
className="px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition"
|
||
>
|
||
{isEn ? 'Delete My Account' : 'Удалить мой аккаунт'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{showConfirm && <DeleteConfirmModal onClose={() => setShowConfirm(false)} />}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DeleteConfirmModal = ({ onClose }: { onClose: () => void }) => {
|
||
const [step, setStep] = useState<'confirm' | 'code'>('confirm');
|
||
const [confirmed, setConfirmed] = useState(false);
|
||
const [typedDelete, setTypedDelete] = useState('');
|
||
const [code, setCode] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const handleRequestDeletion = async () => {
|
||
if (!confirmed || typedDelete !== 'DELETE') {
|
||
alert('Пожалуйста, подтвердите удаление правильно');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoading(true);
|
||
const token = localStorage.getItem('access_token');
|
||
const response = await axios.post(
|
||
`${API_URL}/api/account/delete/request`,
|
||
{},
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
|
||
if (response.data.success) {
|
||
alert('Код подтверждения отправлен на вашу почту');
|
||
setStep('code');
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка запроса удаления:', error);
|
||
alert('Ошибка запроса удаления аккаунта');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleConfirmDeletion = async () => {
|
||
if (!code || code.length !== 6) {
|
||
alert('Введите 6-значный код подтверждения');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoading(true);
|
||
const token = localStorage.getItem('access_token');
|
||
|
||
console.log('[DELETE] Подтверждаем удаление аккаунта...');
|
||
|
||
const response = await axios.post(
|
||
`${API_URL}/api/account/delete/confirm`,
|
||
{ code },
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
|
||
console.log('[DELETE] Аккаунт удалён, очищаем данные...');
|
||
|
||
if (response.data.success) {
|
||
// Полная очистка всех данных из localStorage
|
||
console.log(' 🧹 Очищаем localStorage...');
|
||
localStorage.removeItem('access_token');
|
||
localStorage.removeItem('token'); // на случай если старый ключ остался
|
||
localStorage.removeItem('userData');
|
||
localStorage.removeItem('user');
|
||
localStorage.clear(); // полная очистка
|
||
|
||
console.log('Очищаем sessionStorage...');
|
||
sessionStorage.clear();
|
||
|
||
console.log(' ✅ Все данные очищены');
|
||
|
||
alert('Аккаунт успешно удалён. До свидания!');
|
||
|
||
// Принудительный редирект с перезагрузкой
|
||
console.log(' 🔄 Редирект на страницу входа...');
|
||
window.location.replace('/login');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ [DELETE] Ошибка подтверждения удаления:', error);
|
||
if (axios.isAxiosError(error)) {
|
||
const errorMessage = error.response?.data?.error || 'Ошибка подтверждения удаления';
|
||
alert(errorMessage);
|
||
} else {
|
||
alert('Ошибка подтверждения удаления аккаунта');
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||
<h3 className="text-xl font-bold text-red-600 mb-4">
|
||
{step === 'confirm' ? 'Подтверждение удаления' : 'Введите код подтверждения'}
|
||
</h3>
|
||
|
||
{step === 'confirm' ? (
|
||
// Шаг 1: Подтверждение намерения
|
||
<>
|
||
<div className="space-y-4 mb-6">
|
||
<label className="flex items-start gap-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={confirmed}
|
||
onChange={(e) => setConfirmed(e.target.checked)}
|
||
className="w-5 h-5 text-red-600 focus:ring-red-500 border-gray-300 rounded mt-0.5"
|
||
/>
|
||
<span className="text-sm text-gray-700">
|
||
Я понимаю, что это действие необратимо и все мои данные будут удалены безвозвратно
|
||
</span>
|
||
</label>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Введите <strong className="text-red-600">DELETE</strong> для подтверждения
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={typedDelete}
|
||
onChange={(e) => setTypedDelete(e.target.value)}
|
||
placeholder="DELETE"
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent uppercase"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||
disabled={loading}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={handleRequestDeletion}
|
||
disabled={loading || !confirmed || typedDelete !== 'DELETE'}
|
||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{loading ? 'Отправка...' : 'Отправить код'}
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
// Шаг 2: Ввод кода из email
|
||
<>
|
||
<div className="space-y-4 mb-6">
|
||
<p className="text-sm text-gray-600">
|
||
Мы отправили 6-значный код подтверждения на вашу почту. Введите его ниже для завершения удаления аккаунта.
|
||
</p>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Код подтверждения
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||
placeholder="000000"
|
||
maxLength={6}
|
||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent text-center text-2xl tracking-widest font-mono"
|
||
/>
|
||
</div>
|
||
|
||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||
<p className="text-sm text-red-800">
|
||
⚠️ После подтверждения ваш аккаунт будет удалён немедленно и безвозвратно
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||
disabled={loading}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={handleConfirmDeletion}
|
||
disabled={loading || code.length !== 6}
|
||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{loading ? 'Удаление...' : 'Удалить навсегда'}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage;
|