<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crypto Storage</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 20px auto; padding: 0 10px; background: #f9f9f9; color: #333; }
.box { background: white; padding: 15px; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); margin-bottom: 20px; }
input, textarea, button { width: 100%; padding: 8px; margin: 5px 0 15px 0; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; }
input.masked-password {
-webkit-text-security: disk !important;
text-security: disk !important;
font-family: monospace;
}
button { background: #0066cc; color: white; border: none; cursor: pointer; font-weight: bold; }
button:hover { background: #0052a3; }
button.delete { background: #cc3333; }
button.delete:hover { background: #992222; }
button.secondary { background: #666; }
button.secondary:hover { background: #444; }
.hidden { display: none !important; }
.entry { border-bottom: 1px solid #eee; padding: 15px 0; }
.entry:last-child { border: none; }
.row { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.row button { width: auto; flex-grow: 1; margin: 0; }
.inline-btn { width: auto; padding: 4px 10px; margin: 5px 5px 0 0; }
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; }
.modal-content { background: white; padding: 20px; border-radius: 5px; max-width: 400px; width: 100%; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
.modal-buttons { display: flex; gap: 10px; margin-top: 10px; }
.modal-buttons button { margin: 0; }
</style>
</head>
<body>
<h2>Локальное хранилище паролей</h2>
<div id="auth-panel" class="box">
<label for="master-password" id="auth-label">Введите мастер-пароль:</label>
<input type="text" id="master-password" class="masked-password" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus>
<button id="auth-submit-btn" onclick="handleAuthSubmit()">Открыть хранилище</button>
<div id="auth-error" style="color: red; margin-top: 5px;"></div>
</div>
<div id="main-panel" class="box hidden">
<div class="row">
<button onclick="lock()" class="secondary">Скрыть</button>
<button onclick="openChangePasswordModal()">Сменить мастер-пароль</button>
</div>
<div class="row" style="align-items: center; margin-top: 20px;">
<h3 style="margin: 0;">Записи</h3>
<button class="inline-btn actionable-btn" id="add-btn-top" onclick="showFormAt(0, 'add')">Добавить</button>
</div>
<div id="editor-block" class="box hidden" style="border: 1px dashed #0066cc; margin: 10px 0;">
<h4 id="editor-form-title">Обработка записи</h4>
<form id="editor-form" onsubmit="handleFormSubmit(event)">
<label>Название ресурса:</label>
<input type="text" id="editor-title" placeholder="Например: Почта, Банк" autocomplete="off" required>
<label>Гиперссылка (необязательно):</label>
<input type="url" id="editor-url" placeholder="https://example.com" autocomplete="off">
<label>Логин или Email:</label>
<input type="text" id="editor-login" placeholder="username" autocomplete="off" required>
<label>Пароль:</label>
<input type="text" id="editor-pass" placeholder="password" autocomplete="off" required>
<label>Комментарий:</label>
<textarea id="editor-comment" placeholder="Дополнительные сведения" rows="2" spellcheck="false"></textarea>
<div class="row" style="margin: 10px 0 0 0;">
<div style="display: flex; gap: 10px; flex-grow: 1;">
<button type="submit" style="margin:0;">Сохранить</button>
<button type="button" class="secondary" onclick="closeEditorForm()" style="margin:0;">Отменить</button>
</div>
<div style="display: flex; gap: 5px; width: auto;">
<button type="button" id="move-up-btn" class="secondary inline-btn" onclick="moveFormPosition(-1)" style="margin:0; padding: 8px 15px;">↑</button>
<button type="button" id="move-down-btn" class="secondary inline-btn" onclick="moveFormPosition(1)" style="margin:0; padding: 8px 15px;">↓</button>
</div>
</div>
</form>
</div>
<div id="entries-container"></div>
</div>
<div id="custom-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3 id="modal-title">Контекстное окно</h3>
<div id="modal-body"></div>
<div class="modal-buttons" id="modal-actions"></div>
</div>
</div>
<script id="encrypted-data" type="application/json">""</script>
<script>
let decryptedEntries = [];
let backupEntries = []; // Для полной отмены перемещений при редактировании
let currentMasterPassword = '';
let currentTargetIndex = 0;
let currentFormMode = 'add';
let activeTriggerButtonId = null;
let isInitialized = false;
const enc = new TextEncoder();
const dec = new TextDecoder();
const buf2hex = buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
const hex2buf = hex => new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
window.addEventListener('DOMContentLoaded', () => {
checkInitializationState();
});
function checkInitializationState() {
const rawData = document.getElementById('encrypted-data').textContent.trim();
if (rawData === '""' || rawData === '' || rawData === '{"ciphertext":"","salt":"","iv":""}') {
isInitialized = false;
document.getElementById('auth-label').textContent = 'Задайте мастер-пароль:';
document.getElementById('auth-submit-btn').textContent = 'Сохранить мастер-пароль';
} else {
isInitialized = true;
document.getElementById('auth-label').textContent = 'Введите мастер-пароль:';
document.getElementById('auth-submit-btn').textContent = 'Открыть хранилище';
}
}
async function deriveKey(password, salt) {
const baseKey = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: salt, iterations: 100000, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 128 },
false,
["encrypt", "decrypt"]
);
}
async function decryptData(ciphertextHex, password, saltHex, ivHex) {
try {
const salt = hex2buf(saltHex);
const iv = hex2buf(ivHex);
const key = await deriveKey(password, salt);
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, key, hex2buf(ciphertextHex));
return JSON.parse(dec.decode(decrypted));
} catch (e) {
return null;
}
}
async function encryptData(dataObject, password) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const plaintext = enc.encode(JSON.stringify(dataObject));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plaintext);
return {
ciphertext: buf2hex(ciphertext),
salt: buf2hex(salt),
iv: buf2hex(iv)
};
}
function showModal(title, bodyHtml, actionsArray) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').innerHTML = bodyHtml;
const actionsContainer = document.getElementById('modal-actions');
actionsContainer.innerHTML = '';
actionsArray.forEach(action => {
const btn = document.createElement('button');
btn.textContent = action.label;
if (action.className) btn.className = action.className;
btn.onclick = () => {
closeModal();
if (action.callback) action.callback();
};
actionsContainer.appendChild(btn);
});
document.getElementById('custom-modal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('custom-modal').classList.add('hidden');
}
// Авторизация
function handleAuthSubmit() {
if (!isInitialized) {
openInitializationModal();
} else {
unlock();
}
}
function openInitializationModal() {
const passInput = document.getElementById('master-password');
const tmpPassword = passInput.value;
if (!tmpPassword) return;
const bodyHtml = `
<p>Повторите ввод для подтверждения мастер-пароля:</p>
<input type="text" id="init-confirm-pass" class="masked-password" autocomplete="off" autocapitalize="none" spellcheck="false" style="width:100%; padding:8px;">
`;
showModal('Инициализация базы', bodyHtml, [
{
label: 'Сохранить',
callback: async () => {
const confirmPass = document.getElementById('init-confirm-pass').value;
if (tmpPassword !== confirmPass) {
document.getElementById('master-password').value = '';
showModal('Ошибка', '<p>Пароли не совпадают. Инициализация прервана.</p>', [{ label: 'ОК' }]);
return;
}
currentMasterPassword = tmpPassword;
decryptedEntries = [];
isInitialized = true;
const encryptedPackage = await encryptData(decryptedEntries, currentMasterPassword);
document.getElementById('encrypted-data').textContent = JSON.stringify(encryptedPackage);
showMainPanel();
renderEntries();
}
},
{
label: 'Отменить',
className: 'secondary',
callback: () => { document.getElementById('master-password').value = ''; }
}
]);
setTimeout(() => {
const initInput = document.getElementById('init-confirm-pass');
if (initInput) initInput.focus();
}, 50);
}
async function unlock() {
const passInput = document.getElementById('master-password');
const errorDiv = document.getElementById('auth-error');
const rawData = document.getElementById('encrypted-data').textContent.trim();
const tmpPassword = passInput.value;
if (!tmpPassword) return;
try {
const encryptedObj = JSON.parse(rawData);
const result = await decryptData(encryptedObj.ciphertext, tmpPassword, encryptedObj.salt, encryptedObj.iv);
if (result !== null) {
currentMasterPassword = tmpPassword;
decryptedEntries = result;
errorDiv.textContent = '';
showMainPanel();
renderEntries();
} else {
errorDiv.textContent = 'Неверный мастер-пароль.';
}
} catch(e) {
errorDiv.textContent = 'Ошибка структуры данных.';
}
}
function showMainPanel() {
document.getElementById('auth-panel').classList.add('hidden');
document.getElementById('main-panel').classList.remove('hidden');
}
function lock() {
decryptedEntries = [];
backupEntries = [];
currentMasterPassword = '';
document.getElementById('master-password').value = '';
document.getElementById('entries-container').innerHTML = '';
closeEditorForm();
document.getElementById('main-panel').classList.add('hidden');
document.getElementById('auth-panel').classList.remove('hidden');
document.getElementById('auth-error').textContent = '';
if (isInitialized) {
document.getElementById('auth-label').textContent = 'Введите мастер-пароль:';
document.getElementById('auth-submit-btn').textContent = 'Открыть хранилище';
}
}
// Рендеринг списка БЕЗ дублирующих кнопок "Добавить"
function renderEntries() {
const container = document.getElementById('entries-container');
container.innerHTML = '';
decryptedEntries.forEach((entry, index) => {
const div = document.createElement('div');
div.className = 'entry';
div.id = `entry-${index}`;
const titleRender = entry.url && entry.url.trim() !== ''
? `<a href="${escapeHtml(entry.url)}" target="_blank" rel="noopener">${escapeHtml(entry.title)}</a>`
: `<strong>${escapeHtml(entry.title)}</strong>`;
div.innerHTML = `
<div class="view-mode" id="view-mode-${index}">
${titleRender}
<div style="margin-top: 5px;">
<small>Логин:</small> <input type="text" value="${escapeHtml(entry.login)}" readonly onclick="this.select()" autocomplete="off" spellcheck="false" style="width:auto; margin:0 5px;">
<small>Пароль:</small> <input type="text" value="${escapeHtml(entry.pass)}" readonly onclick="this.select()" autocomplete="off" spellcheck="false" style="width:auto; margin:0 5px;">
</div>
<div style="font-size: 0.9em; color: #666; margin: 5px 0; white-space: pre-wrap;">${escapeHtml(entry.comment || '')}</div>
<button class="inline-btn actionable-btn" id="edit-btn-${index}" onclick="showFormAt(${index}, 'edit')">Редактировать</button>
<button class="delete inline-btn actionable-btn" id="del-btn-${index}" onclick="openDeleteModal(${index})">Удалить</button>
</div>
`;
container.appendChild(div);
});
}
function showFormAt(index, mode) {
if (document.getElementById('editor-block').classList.contains('hidden') === false) {
closeEditorForm();
}
currentFormMode = mode;
currentTargetIndex = index;
const editorBlock = document.getElementById('editor-block');
const titleElement = document.getElementById('editor-form-title');
// ПРИНУДИТЕЛЬНО РАЗБЛОКИРУЕМ кнопки самой формы перед показом
const formButtons = editorBlock.querySelectorAll('button');
formButtons.forEach(btn => {
btn.disabled = false;
btn.style.opacity = "1";
btn.style.pointerEvents = "auto";
});
if (mode === 'add') {
titleElement.textContent = 'Добавление записи';
activeTriggerButtonId = 'add-btn-top';
if (index === 0) {
const btnTop = document.getElementById('add-btn-top');
btnTop.parentNode.insertAdjacentElement('afterend', editorBlock);
} else {
const targetEntry = document.getElementById(`entry-${index - 1}`);
targetEntry.insertAdjacentElement('afterend', editorBlock);
}
} else if (mode === 'edit') {
titleElement.textContent = 'Редактирование записи';
activeTriggerButtonId = `edit-btn-${index}`;
backupEntries = JSON.parse(JSON.stringify(decryptedEntries));
const data = decryptedEntries[index];
document.getElementById('editor-title').value = data.title;
document.getElementById('editor-url').value = data.url || '';
document.getElementById('editor-login').value = data.login;
document.getElementById('editor-pass').value = data.pass;
document.getElementById('editor-comment').value = data.comment || '';
const targetEntryView = document.getElementById(`view-mode-${index}`);
targetEntryView.insertAdjacentElement('afterend', editorBlock);
}
if (activeTriggerButtonId) {
const btn = document.getElementById(activeTriggerButtonId);
if (btn) btn.classList.add('hidden');
}
editorBlock.classList.remove('hidden');
document.getElementById('editor-title').focus();
updateMoveButtonsState();
// Включаем безопасную блокировку остальных элементов
toggleAllInterfaceButtons(true);
}
// Транзакционная отмена (Пункт 1)
function closeEditorForm() {
const editorBlock = document.getElementById('editor-block');
editorBlock.classList.add('hidden');
document.getElementById('editor-form').reset();
// Если мы редактировали и двигали запись — откатываем массив назад
if (currentFormMode === 'edit' && backupEntries.length > 0) {
decryptedEntries = JSON.parse(JSON.stringify(backupEntries));
backupEntries = [];
}
// Возвращаем все скрытые кнопки на место
if (activeTriggerButtonId) {
const btn = document.getElementById(activeTriggerButtonId);
if (btn) btn.classList.remove('hidden');
activeTriggerButtonId = null;
}
const topBtn = document.getElementById('add-btn-top');
if (topBtn) topBtn.classList.remove('hidden');
// Перерисовываем DOM в исходное состояние
renderEntries();
document.getElementById('main-panel').insertBefore(editorBlock, document.getElementById('entries-container'));
document.getElementById('move-up-btn').disabled = false;
document.getElementById('move-down-btn').disabled = false;
document.getElementById('move-up-btn').style.opacity = "1";
document.getElementById('move-down-btn').style.opacity = "1";
// РАЗБЛОКИРУЕМ интерфейс обратно
toggleAllInterfaceButtons(false);
}
// Логика перемещения с защитой DOM
function moveFormPosition(direction) {
const totalEntries = decryptedEntries.length;
const editorBlock = document.getElementById('editor-block');
document.getElementById('main-panel').insertBefore(editorBlock, document.getElementById('entries-container'));
if (currentFormMode === 'edit') {
const oldIndex = currentTargetIndex;
const newIndex = oldIndex + direction;
if (newIndex < 0 || newIndex >= totalEntries) {
restoreEditorPosition();
return;
}
const temp = decryptedEntries[oldIndex];
decryptedEntries[oldIndex] = decryptedEntries[newIndex];
decryptedEntries[newIndex] = temp;
currentTargetIndex = newIndex;
renderEntries();
restoreEditorPosition();
} else if (currentFormMode === 'add') {
const oldIndex = currentTargetIndex;
const newIndex = oldIndex + direction;
if (newIndex < 0 || newIndex > totalEntries) {
restoreEditorPosition();
return;
}
currentTargetIndex = newIndex;
renderEntries();
restoreEditorPosition();
}
}
function restoreEditorPosition() {
const editorBlock = document.getElementById('editor-block');
const topBtn = document.getElementById('add-btn-top');
if (topBtn) topBtn.classList.remove('hidden');
if (currentFormMode === 'edit') {
activeTriggerButtonId = `edit-btn-${currentTargetIndex}`;
const targetEntryView = document.getElementById(`view-mode-${currentTargetIndex}`);
if (targetEntryView) targetEntryView.insertAdjacentElement('afterend', editorBlock);
} else if (currentFormMode === 'add') {
if (currentTargetIndex === 0) {
activeTriggerButtonId = 'add-btn-top';
if (topBtn) topBtn.parentNode.insertAdjacentElement('afterend', editorBlock);
} else {
activeTriggerButtonId = null;
const targetEntry = document.getElementById(`entry-${currentTargetIndex - 1}`);
if (targetEntry) targetEntry.insertAdjacentElement('afterend', editorBlock);
}
}
if (activeTriggerButtonId) {
const btn = document.getElementById(activeTriggerButtonId);
if (btn) btn.classList.add('hidden');
}
// КРИТИЧНО: Принудительно закрепляем блокировку интерфейса
// поверх только что перерисованного через renderEntries() DOM-дерева
toggleAllInterfaceButtons(true);
// И обновляем состояние стрелок (чтобы заблокировать их на границах)
updateMoveButtonsState();
}
function updateMoveButtonsState() {
const upBtn = document.getElementById('move-up-btn');
const downBtn = document.getElementById('move-down-btn');
const totalEntries = decryptedEntries.length;
if (currentFormMode === 'edit') {
upBtn.disabled = (currentTargetIndex === 0);
downBtn.disabled = (currentTargetIndex === totalEntries - 1);
} else {
upBtn.disabled = (currentTargetIndex === 0);
downBtn.disabled = (currentTargetIndex === totalEntries);
}
upBtn.style.opacity = upBtn.disabled ? "0.4" : "1";
downBtn.style.opacity = downBtn.disabled ? "0.4" : "1";
}
async function handleFormSubmit(e) {
e.preventDefault();
const record = {
title: document.getElementById('editor-title').value,
url: document.getElementById('editor-url').value,
login: document.getElementById('editor-login').value,
pass: document.getElementById('editor-pass').value,
comment: document.getElementById('editor-comment').value
};
if (currentFormMode === 'add') {
decryptedEntries.splice(currentTargetIndex, 0, record);
} else if (currentFormMode === 'edit') {
decryptedEntries[currentTargetIndex] = record;
}
// Успешно сохранено — очищаем бэкап отмены
backupEntries = [];
await saveAndClose();
}
function openDeleteModal(index) {
showModal('Подтверждение удаления', `<p>Удалить запись "${escapeHtml(decryptedEntries[index].title)}"?</p>`, [
{ label: 'Удалить', className: 'delete', callback: async () => { decryptedEntries.splice(index, 1); await saveAndClose(); } },
{ label: 'Отменить', className: 'secondary' }
]);
}
function openChangePasswordModal() {
const bodyHtml = `
<input type="text" id="new-master-pass" class="masked-password" placeholder="Новый мастер-пароль" autocomplete="off" autocapitalize="none" spellcheck="false" style="width:100%; padding:8px; margin-bottom:10px;">
<input type="text" id="confirm-master-pass" class="masked-password" placeholder="Повторите новый мастер-пароль" autocomplete="off" autocapitalize="none" spellcheck="false" style="width:100%; padding:8px;">
`;
showModal('Смена мастер-пароля', bodyHtml, [
{
label: 'Сохранить',
callback: async () => {
const newPass = document.getElementById('new-master-pass').value;
const confirmPass = document.getElementById('confirm-master-pass').value;
if (!newPass) return;
if (newPass !== confirmPass) {
showModal('Ошибка', '<p>Пароли не совпадают!</p>', [{ label: 'ОК' }]);
return;
}
currentMasterPassword = newPass;
await saveAndClose();
}
},
{ label: 'Отменить', className: 'secondary' }
]);
}
async function saveAndClose() {
if (!currentMasterPassword) return;
const encryptedPackage = await encryptData(decryptedEntries, currentMasterPassword);
const jsonString = JSON.stringify(encryptedPackage, null, 2);
const editorBlock = document.getElementById('editor-block');
document.getElementById('main-panel').insertBefore(editorBlock, document.getElementById('entries-container'));
const topBtn = document.getElementById('add-btn-top');
if (topBtn) topBtn.classList.remove('hidden');
// КРИТИЧНО: Снимаем любые блокировки в текущем DOM перед клонированием
toggleAllInterfaceButtons(false);
const cloneDoc = document.documentElement.cloneNode(true);
cloneDoc.querySelector('#main-panel').classList.add('hidden');
cloneDoc.querySelector('#auth-panel').classList.remove('hidden');
cloneDoc.querySelector('#custom-modal').classList.add('hidden');
cloneDoc.querySelector('#master-password').value = '';
const cloneEditor = cloneDoc.querySelector('#editor-block');
cloneEditor.classList.add('hidden');
cloneDoc.querySelector('#editor-form').reset();
cloneDoc.querySelector('#entries-container').innerHTML = '';
cloneDoc.querySelector('#encrypted-data').textContent = jsonString;
const finalHtml = '<!DOCTYPE html>\n' + cloneDoc.outerHTML;
lock();
const blob = new Blob([finalHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'passwords.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setTimeout(() => {
window.close();
document.open();
document.write('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:sans-serif; text-align:center; margin-top:50px; color:#666;"><h3>Сессия завершена. Изменения сохранены в файл passwords.html.</h3></body></html>');
document.close();
}, 300);
}
function escapeHtml(str) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
function toggleAllInterfaceButtons(disable) {
// 1. Блокируем/разблокируем только верхние кнопки управления (Скрыть, Сменить пароль, Добавить)
const topRow = document.querySelector('#main-panel > .row');
if (topRow) {
topRow.querySelectorAll('button').forEach(btn => {
btn.disabled = disable;
btn.style.opacity = disable ? "0.5" : "1";
btn.style.pointerEvents = disable ? "none" : "auto";
});
}
const addBtnTop = document.getElementById('add-btn-top');
if (addBtnTop) {
addBtnTop.disabled = disable;
addBtnTop.style.opacity = disable ? "0.5" : "1";
addBtnTop.style.pointerEvents = disable ? "none" : "auto";
}
// 2. Блокируем/разблокируем кнопки "Редактировать" и "Удалить" внутри всех записей
const entries = document.querySelectorAll('.entry');
entries.forEach(entry => {
// Ищем кнопки только в режиме просмотра (view-mode), не трогая форму, если она внутри записи
const viewMode = entry.querySelector('.view-mode');
if (viewMode) {
viewMode.querySelectorAll('button').forEach(btn => {
btn.disabled = disable;
btn.style.opacity = disable ? "0.5" : "1";
btn.style.pointerEvents = disable ? "none" : "auto";
});
}
});
}
</script>
</body>
</html>