Merge branch 'main' into hap_ac2_fix

This commit is contained in:
2026-05-18 01:37:55 +05:00
11 changed files with 831 additions and 16 deletions
+41
View File
@@ -232,3 +232,44 @@ export interface HeartbeatOut {
hours: number;
devices: HeartbeatDevice[];
}
// --- Vault (мастер-пароль / шифрование секретов устройств) -------------------
export interface VaultStatus {
initialized: boolean;
unlocked: boolean;
}
export interface VaultMigration {
migrated: number;
failed: number;
skipped: number;
}
export interface VaultActionOut {
status: VaultStatus;
migration?: VaultMigration;
}
export const vaultApi = {
async status(): Promise<VaultStatus> {
const r = await api.get<VaultStatus>('/vault/status');
return r.data;
},
async init(master_password: string): Promise<VaultActionOut> {
const r = await api.post<VaultActionOut>('/vault/init', { master_password });
return r.data;
},
async unlock(master_password: string): Promise<VaultActionOut> {
const r = await api.post<VaultActionOut>('/vault/unlock', { master_password });
return r.data;
},
async lock(): Promise<{ unlocked: false }> {
const r = await api.post<{ unlocked: false }>('/vault/lock');
return r.data;
},
async rotate(old_password: string, new_password: string): Promise<VaultActionOut> {
const r = await api.post<VaultActionOut>('/vault/rotate', { old_password, new_password });
return r.data;
},
};
+48 -1
View File
@@ -5,9 +5,10 @@ import {
CheckCircle2, AlertTriangle, Bell, Terminal,
Menu, X, Settings as SettingsIcon,
ChevronDown, ChevronUp,
Lock, Unlock, ShieldAlert,
} from 'lucide-react';
import { useAuth } from '@/store/auth';
import { api, Device } from '@/api/client';
import { api, Device, vaultApi, VaultStatus } from '@/api/client';
import AboutModal from './AboutModal';
import { useSettings } from '@/store/settings';
import { pickOkMessage } from '@/utils/okMessages';
@@ -118,6 +119,51 @@ function GlobalHealth() {
);
}
function VaultBadge() {
const navigate = useNavigate();
const [s, setS] = useState<VaultStatus | null>(null);
useEffect(() => {
const load = () => vaultApi.status().then(setS).catch(() => {});
load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, []);
if (!s) return null;
const goto = () => navigate('/settings#security');
// Три состояния: ok (зелёный замок открыт), locked (жёлтый замок закрыт), uninit (красный щит)
if (!s.initialized) {
return (
<button
onClick={goto}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-err hover:bg-white/[0.04]"
title="Vault не инициализирован — задайте мастер-пароль"
>
<ShieldAlert size={14} /> <span className="hidden md:inline">vault</span>
</button>
);
}
if (!s.unlocked) {
return (
<button
onClick={goto}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-warn hover:bg-white/[0.04]"
title="Vault заблокирован — введите мастер-пароль, опрос устройств приостановлен"
>
<Lock size={14} /> <span className="hidden md:inline">locked</span>
</button>
);
}
return (
<button
onClick={goto}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-ok hover:bg-white/[0.04]"
title="Vault разблокирован — секреты устройств доступны"
>
<Unlock size={14} />
</button>
);
}
function AlertsBell() {
const navigate = useNavigate();
const [count, setCount] = useState(0);
@@ -431,6 +477,7 @@ export default function AppLayout() {
v{version}
</span>
)}
<VaultBadge />
<AlertsBell />
<button
onClick={() => setAboutOpen(true)}
+244 -9
View File
@@ -3,8 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
import {
Database, Settings as SettingsIcon, Download, Upload, RefreshCw, Eye, Save,
Globe, Palette, Tag, Activity, Radar, AlertTriangle, User as UserIcon, KeyRound,
Lock, Unlock, ShieldCheck, ShieldAlert,
} from 'lucide-react';
import { api, AppSettings } from '@/api/client';
import { api, AppSettings, vaultApi, VaultStatus, VaultMigration } from '@/api/client';
import { useAuth } from '@/store/auth';
import { useSettings } from '@/store/settings';
import { useT, LOCALES, THEMES, HEARTBEAT_RANGES, PROBE_INTERVALS } from '@/i18n';
@@ -19,11 +20,12 @@ const MENU_LABELS: Record<keyof AppSettings['menu'], string> = {
settings: 'Настройки',
};
type TabKey = 'general' | 'probe' | 'user' | 'menu' | 'backup';
type TabKey = 'general' | 'probe' | 'user' | 'security' | 'menu' | 'backup';
function parseHash(h: string): TabKey {
const v = h.replace(/^#/, '');
if (v === 'users' || v === 'password' || v === 'user') return 'user';
if (v === 'security' || v === 'vault' || v === 'master') return 'security';
if (v === 'menu') return 'menu';
if (v === 'backup') return 'backup';
if (v === 'probe') return 'probe';
@@ -32,11 +34,12 @@ function parseHash(h: string): TabKey {
}
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'general', label: 'Общие', icon: SettingsIcon },
{ key: 'probe', label: 'Опрос', icon: Radar },
{ key: 'user', label: 'Пользователь', icon: UserIcon },
{ key: 'menu', label: 'Меню', icon: Eye },
{ key: 'backup', label: 'Бэкап', icon: Database },
{ key: 'general', label: 'Общие', icon: SettingsIcon },
{ key: 'probe', label: 'Опрос', icon: Radar },
{ key: 'user', label: 'Пользователь', icon: UserIcon },
{ key: 'security', label: 'Безопасность', icon: ShieldCheck },
{ key: 'menu', label: 'Меню', icon: Eye },
{ key: 'backup', label: 'Бэкап', icon: Database },
];
export default function SettingsPage() {
@@ -131,8 +134,9 @@ export default function SettingsPage() {
const updUi = (k: keyof AppSettings['ui'], v: any) =>
setDraft({ ...draft, ui: { ...ui, [k]: v } });
// На вкладке "Пользователь" своя кнопка сохранения — основная "Сохранить" не нужна.
const showSaveBtn = tab !== 'user';
// На вкладках "Пользователь" и "Безопасность" — свои кнопки в формах,
// глобальный "Сохранить" не нужен.
const showSaveBtn = tab !== 'user' && tab !== 'security';
return (
<div className="space-y-4 max-w-3xl">
@@ -302,6 +306,8 @@ export default function SettingsPage() {
{tab === 'user' && <UserTab email={email} />}
{tab === 'security' && <SecurityTab />}
{tab === 'menu' && (
<div className="card space-y-3">
<div className="flex items-center gap-2">
@@ -481,3 +487,232 @@ function UserTab({ email }: { email: string | null }) {
</div>
);
}
// ---------- Вкладка «Безопасность» (мастер-пароль / vault) ----------
function SecurityTab() {
const [status, setStatus] = useState<VaultStatus | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [migration, setMigration] = useState<VaultMigration | null>(null);
// init / unlock form
const [pwd, setPwd] = useState('');
const [pwd2, setPwd2] = useState('');
// rotate form
const [oldPwd, setOldPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [newPwd2, setNewPwd2] = useState('');
const refresh = async () => {
try { setStatus(await vaultApi.status()); }
catch (ex: any) { setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? String(ex) }); }
};
useEffect(() => { refresh(); }, []);
const doInit = async (e: FormEvent) => {
e.preventDefault();
setMsg(null); setMigration(null);
if (pwd.length < 8) { setMsg({ kind: 'err', text: 'Мастер-пароль должен быть не короче 8 символов' }); return; }
if (pwd !== pwd2) { setMsg({ kind: 'err', text: 'Пароли не совпадают' }); return; }
setBusy(true);
try {
const r = await vaultApi.init(pwd);
setStatus(r.status);
setMigration(r.migration ?? null);
setPwd(''); setPwd2('');
setMsg({ kind: 'ok', text: 'Мастер-пароль установлен. Vault разблокирован.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка инициализации' });
} finally { setBusy(false); }
};
const doUnlock = async (e: FormEvent) => {
e.preventDefault();
setMsg(null); setMigration(null);
setBusy(true);
try {
const r = await vaultApi.unlock(pwd);
setStatus(r.status);
setMigration(r.migration ?? null);
setPwd('');
setMsg({ kind: 'ok', text: 'Vault разблокирован.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка разблокировки' });
} finally { setBusy(false); }
};
const doLock = async () => {
setBusy(true); setMsg(null);
try {
await vaultApi.lock();
await refresh();
setMsg({ kind: 'ok', text: 'Vault заблокирован. Фоновые задачи опроса остановлены до следующей разблокировки.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка блокировки' });
} finally { setBusy(false); }
};
const doRotate = async (e: FormEvent) => {
e.preventDefault();
setMsg(null);
if (newPwd.length < 8) { setMsg({ kind: 'err', text: 'Новый мастер-пароль не короче 8 символов' }); return; }
if (newPwd !== newPwd2) { setMsg({ kind: 'err', text: 'Новые пароли не совпадают' }); return; }
setBusy(true);
try {
const r = await vaultApi.rotate(oldPwd, newPwd);
setStatus(r.status);
setOldPwd(''); setNewPwd(''); setNewPwd2('');
setMsg({ kind: 'ok', text: 'Мастер-пароль изменён. Все секреты остались валидными.' });
} catch (ex: any) {
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка смены пароля' });
} finally { setBusy(false); }
};
const renderStatus = () => {
if (!status) return <span className="text-mk-mute">Загрузка</span>;
if (!status.initialized) {
return <span className="inline-flex items-center gap-1.5 text-mk-warn"><ShieldAlert size={14}/> не инициализирован</span>;
}
if (status.unlocked) {
return <span className="inline-flex items-center gap-1.5 text-mk-ok"><Unlock size={14}/> разблокирован</span>;
}
return <span className="inline-flex items-center gap-1.5 text-mk-warn"><Lock size={14}/> заблокирован</span>;
};
return (
<div className="space-y-4">
<div className="card space-y-2">
<div className="flex items-center gap-2">
<ShieldCheck size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Шифрование секретов устройств</h3>
</div>
<p className="text-xs text-mk-mute leading-relaxed">
Пароли подключения к RouterOS-устройствам хранятся в БД зашифрованными
<b> AES-256-GCM</b>. Ключ шифрования (DEK) защищён вашим мастер-паролем
(PBKDF2-HMAC-SHA256, 200 000 итераций). Мастер-пароль в БД <b>не сохраняется</b>
его помнит только администратор. После рестарта контейнера vault блокируется
автоматически, до повторного ввода пароля фоновый опрос устройств приостанавливается.
</p>
<div className="text-sm">
<span className="text-mk-mute">Статус: </span>{renderStatus()}
</div>
{migration && (
<div className="text-[11px] text-mk-mute">
Миграция legacy-секретов: перешифровано <b>{migration.migrated}</b>,
пропущено уже-v2 <b>{migration.skipped}</b>,
не удалось <b>{migration.failed}</b>.
</div>
)}
</div>
{/* --- INIT (vault ещё не создан) --- */}
{status && !status.initialized && (
<form onSubmit={doInit} className="card space-y-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Создать мастер-пароль</h3>
</div>
<p className="text-[11px] text-mk-warn flex items-start gap-1">
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
<span>
Запомните его надёжно. Если потеряете пароли устройств в БД восстановить
будет нельзя, придётся завести устройства заново.
</span>
</p>
<div>
<label className="text-xs text-mk-mute">Мастер-пароль (мин. 8 символов)</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={pwd} onChange={(e) => setPwd(e.target.value)} required />
</div>
<div>
<label className="text-xs text-mk-mute">Повторите</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={pwd2} onChange={(e) => setPwd2(e.target.value)} required />
</div>
{msg && <div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>}
<button className="btn-primary !text-xs" disabled={busy}>
{busy ? 'Создаём…' : 'Создать мастер-пароль'}
</button>
</form>
)}
{/* --- UNLOCK (создан, но заблокирован) --- */}
{status && status.initialized && !status.unlocked && (
<form onSubmit={doUnlock} className="card space-y-3">
<div className="flex items-center gap-2">
<Unlock size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Разблокировать vault</h3>
</div>
<p className="text-xs text-mk-mute">
Введите мастер-пароль, чтобы возобновить опрос устройств и операции с их секретами.
</p>
<div>
<label className="text-xs text-mk-mute">Мастер-пароль</label>
<input className="input" type="password" autoComplete="current-password"
value={pwd} onChange={(e) => setPwd(e.target.value)} required autoFocus />
</div>
{msg && <div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>}
<button className="btn-primary !text-xs" disabled={busy}>
{busy ? 'Разблокируем…' : 'Разблокировать'}
</button>
</form>
)}
{/* --- LOCK + ROTATE (разблокирован) --- */}
{status && status.initialized && status.unlocked && (
<>
<div className="card space-y-3">
<div className="flex items-center gap-2">
<Lock size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Заблокировать сейчас</h3>
</div>
<p className="text-xs text-mk-mute">
После блокировки DEK будет очищен из памяти, и до следующей разблокировки
автоопрос приостановится, а API устройств начнёт отвечать <b>423 Locked</b>.
</p>
<button type="button" className="btn-ghost !text-xs" disabled={busy} onClick={doLock}>
<Lock size={13} /> Заблокировать
</button>
{msg && msg.kind === 'ok' && (
<div className="text-sm text-mk-ok">{msg.text}</div>
)}
</div>
<form onSubmit={doRotate} className="card space-y-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-mk-accent2" />
<h3 className="text-sm font-semibold">Сменить мастер-пароль</h3>
</div>
<p className="text-xs text-mk-mute">
Сами зашифрованные секреты при смене мастера не трогаются перешифровывается
только защитная обёртка DEK. Это безопасно и быстро.
</p>
<div>
<label className="text-xs text-mk-mute">Текущий мастер-пароль</label>
<input className="input" type="password" autoComplete="current-password"
value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} required />
</div>
<div>
<label className="text-xs text-mk-mute">Новый мастер-пароль (мин. 8 символов)</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={newPwd} onChange={(e) => setNewPwd(e.target.value)} required />
</div>
<div>
<label className="text-xs text-mk-mute">Повторите новый</label>
<input className="input" type="password" autoComplete="new-password" minLength={8}
value={newPwd2} onChange={(e) => setNewPwd2(e.target.value)} required />
</div>
{msg && msg.kind === 'err' && (
<div className="text-sm text-mk-err">{msg.text}</div>
)}
<button className="btn-primary !text-xs" disabled={busy}>
{busy ? 'Меняем…' : 'Сменить мастер-пароль'}
</button>
</form>
</>
)}
</div>
);
}