Merge branch 'main' into hap_ac2_fix
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user