SHA256
UI: реальная смена серверов доступа
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { readShineUserPda } from '../services/shine-user-pda-service.js';
|
||||
import { base64ToBytes } from '../services/crypto-utils.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
import { resolveShineServerByServerLogin } from '../services/shine-server-resolver.js';
|
||||
import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js';
|
||||
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
||||
|
||||
@@ -18,6 +20,34 @@ function normalizeLogin(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function equalBytes(a, b) {
|
||||
const left = a instanceof Uint8Array ? a : new Uint8Array(a || []);
|
||||
const right = b instanceof Uint8Array ? b : new Uint8Array(b || []);
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function uniqueLogins(values) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
(Array.isArray(values) ? values : []).forEach((value) => {
|
||||
const login = normalizeLogin(value);
|
||||
if (!login || seen.has(login)) return;
|
||||
seen.add(login);
|
||||
result.push(login);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function shortenSignature(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (text.length <= 16) return text;
|
||||
return `${text.slice(0, 8)}...${text.slice(-8)}`;
|
||||
}
|
||||
|
||||
function createConfirmModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!(root instanceof HTMLElement)) return null;
|
||||
@@ -45,8 +75,8 @@ function createConfirmModal() {
|
||||
<div class="modal" id="access-servers-confirm-modal">
|
||||
<div class="modal-card stack" style="max-width:min(94vw,34rem);">
|
||||
<h3 class="modal-title" id="access-servers-dialog-title"></h3>
|
||||
<p class="meta-muted" id="access-servers-dialog-text"></p>
|
||||
<p class="meta-muted" id="access-servers-dialog-note"${note ? '' : ' hidden'}></p>
|
||||
<p class="meta-muted" id="access-servers-dialog-text" style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<p class="meta-muted" id="access-servers-dialog-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<div class="auth-footer-actions">
|
||||
<button class="ghost-btn" type="button" id="access-servers-dialog-cancel"></button>
|
||||
<button class="primary-btn" type="button" id="access-servers-dialog-confirm"></button>
|
||||
@@ -60,7 +90,12 @@ function createConfirmModal() {
|
||||
const noteEl = root.querySelector('#access-servers-dialog-note');
|
||||
const cancelBtn = root.querySelector('#access-servers-dialog-cancel');
|
||||
const confirmBtn = root.querySelector('#access-servers-dialog-confirm');
|
||||
if (!(modal instanceof HTMLElement) || !(titleEl instanceof HTMLElement) || !(textEl instanceof HTMLElement) || !(noteEl instanceof HTMLElement) || !(cancelBtn instanceof HTMLButtonElement) || !(confirmBtn instanceof HTMLButtonElement)) {
|
||||
if (!(modal instanceof HTMLElement)
|
||||
|| !(titleEl instanceof HTMLElement)
|
||||
|| !(textEl instanceof HTMLElement)
|
||||
|| !(noteEl instanceof HTMLElement)
|
||||
|| !(cancelBtn instanceof HTMLButtonElement)
|
||||
|| !(confirmBtn instanceof HTMLButtonElement)) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
@@ -74,8 +109,8 @@ function createConfirmModal() {
|
||||
noteEl.hidden = true;
|
||||
noteEl.textContent = '';
|
||||
}
|
||||
confirmBtn.textContent = String(confirmLabel || 'Да');
|
||||
cancelBtn.textContent = String(cancelLabel || 'Нет');
|
||||
confirmBtn.textContent = String(confirmLabel || 'Да');
|
||||
onConfirm = confirmHandler || null;
|
||||
onCancel = cancelHandler || null;
|
||||
|
||||
@@ -99,17 +134,123 @@ function createConfirmModal() {
|
||||
};
|
||||
}
|
||||
|
||||
function createPasswordModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!(root instanceof HTMLElement)) return null;
|
||||
|
||||
return {
|
||||
open({ title, text, note = '' }) {
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="access-servers-password-modal">
|
||||
<div class="modal-card stack" style="max-width:min(94vw,36rem);">
|
||||
<h3 class="modal-title" id="access-servers-password-title"></h3>
|
||||
<p class="meta-muted" id="access-servers-password-text" style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<p class="meta-muted" id="access-servers-password-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<label class="stack" style="gap:0.35rem;">
|
||||
<span class="field-label">Пароль аккаунта</span>
|
||||
<input class="input" id="access-servers-password-input" type="password" autocomplete="current-password" placeholder="Введите пароль" />
|
||||
</label>
|
||||
<div class="stack" style="gap:0.45rem;">
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||
<span>Использовать root key только сейчас</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||
<span>Сохранить root key на этом устройстве</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
|
||||
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const modal = root.querySelector('#access-servers-password-modal');
|
||||
const titleEl = root.querySelector('#access-servers-password-title');
|
||||
const textEl = root.querySelector('#access-servers-password-text');
|
||||
const noteEl = root.querySelector('#access-servers-password-note');
|
||||
const inputEl = root.querySelector('#access-servers-password-input');
|
||||
const cancelBtn = root.querySelector('#access-servers-password-cancel');
|
||||
const confirmBtn = root.querySelector('#access-servers-password-confirm');
|
||||
if (!(modal instanceof HTMLElement)
|
||||
|| !(titleEl instanceof HTMLElement)
|
||||
|| !(textEl instanceof HTMLElement)
|
||||
|| !(noteEl instanceof HTMLElement)
|
||||
|| !(inputEl instanceof HTMLInputElement)
|
||||
|| !(cancelBtn instanceof HTMLButtonElement)
|
||||
|| !(confirmBtn instanceof HTMLButtonElement)) {
|
||||
root.innerHTML = '';
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
titleEl.textContent = String(title || 'Введите пароль');
|
||||
textEl.textContent = String(text || '');
|
||||
if (note) {
|
||||
noteEl.hidden = false;
|
||||
noteEl.textContent = String(note);
|
||||
} else {
|
||||
noteEl.hidden = true;
|
||||
noteEl.textContent = '';
|
||||
}
|
||||
|
||||
const close = (result = null) => {
|
||||
root.innerHTML = '';
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(null);
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => close(null));
|
||||
inputEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
confirmBtn.click();
|
||||
}
|
||||
});
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
const password = String(inputEl.value || '');
|
||||
if (!password.trim()) {
|
||||
inputEl.focus();
|
||||
return;
|
||||
}
|
||||
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
|
||||
close({
|
||||
password,
|
||||
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
});
|
||||
});
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const sessionLogin = normalizeLogin(state.session.login);
|
||||
const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
|
||||
let disposed = false;
|
||||
let operationBusy = false;
|
||||
let currentAccessServers = [];
|
||||
let selectedCandidate = null;
|
||||
let suggestionsLoading = false;
|
||||
|
||||
const confirmModal = createConfirmModal();
|
||||
const passwordModal = createPasswordModal();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
@@ -157,7 +298,7 @@ export function render({ navigate }) {
|
||||
suggestEl.hidden = true;
|
||||
const addStatus = document.createElement('p');
|
||||
addStatus.className = 'meta-muted';
|
||||
addStatus.textContent = 'Добавление сервера пока работает как подтверждающая заглушка.';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'primary-btn';
|
||||
addButton.type = 'button';
|
||||
@@ -165,14 +306,27 @@ export function render({ navigate }) {
|
||||
addButton.disabled = true;
|
||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
||||
|
||||
const refreshAddButton = () => {
|
||||
addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
};
|
||||
|
||||
const setOperationBusy = (busy) => {
|
||||
operationBusy = busy;
|
||||
addInput.disabled = busy;
|
||||
refreshAddButton();
|
||||
listBody.querySelectorAll('button').forEach((button) => {
|
||||
button.disabled = busy;
|
||||
});
|
||||
};
|
||||
|
||||
const setSelectedCandidate = (candidate) => {
|
||||
selectedCandidate = candidate;
|
||||
addButton.disabled = !candidate;
|
||||
refreshAddButton();
|
||||
if (candidate) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = 'Добавление сервера пока работает как подтверждающая заглушка.';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -190,6 +344,7 @@ export function render({ navigate }) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'text-btn';
|
||||
button.type = 'button';
|
||||
button.disabled = operationBusy;
|
||||
button.innerHTML = `
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>@${escapeHtml(server.login)}</strong>
|
||||
@@ -197,8 +352,24 @@ export function render({ navigate }) {
|
||||
</span>
|
||||
`;
|
||||
button.addEventListener('click', () => {
|
||||
if (operationBusy) return;
|
||||
const isLast = currentAccessServers.length <= 1;
|
||||
const openStub = (noteText = '') => {
|
||||
const performDisable = async () => {
|
||||
const nextList = currentAccessServers
|
||||
.map((item) => item.login)
|
||||
.filter((login) => login !== server.login);
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: listStatus,
|
||||
successText: `Сервер доступа @${server.login} отключён.`,
|
||||
inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
}
|
||||
};
|
||||
|
||||
const askFinal = (noteText = '') => {
|
||||
confirmModal?.open({
|
||||
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
|
||||
text: isLast
|
||||
@@ -206,8 +377,8 @@ export function render({ navigate }) {
|
||||
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
|
||||
note: noteText,
|
||||
confirmLabel: isLast ? 'Понимаю' : 'Да',
|
||||
cancelLabel: 'Нет',
|
||||
onConfirm: () => window.alert('Отключение сервера доступа пока не реализовано.'),
|
||||
cancelLabel: isLast ? 'Отмена' : 'Нет',
|
||||
onConfirm: performDisable,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -218,12 +389,12 @@ export function render({ navigate }) {
|
||||
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
|
||||
confirmLabel: 'Продолжить',
|
||||
cancelLabel: 'Отмена',
|
||||
onConfirm: () => openStub('Это повторное предупреждение. Сейчас действие остаётся заглушкой.'),
|
||||
onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
openStub();
|
||||
askFinal();
|
||||
});
|
||||
listBody.append(button);
|
||||
if (index < currentAccessServers.length - 1) {
|
||||
@@ -252,9 +423,7 @@ export function render({ navigate }) {
|
||||
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
|
||||
try {
|
||||
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
const logins = Array.isArray(parsed?.accessServers)
|
||||
? [...new Set(parsed.accessServers.map(normalizeLogin).filter(Boolean))]
|
||||
: [];
|
||||
const logins = uniqueLogins(parsed?.accessServers);
|
||||
const rows = [];
|
||||
for (const login of logins) {
|
||||
try {
|
||||
@@ -293,7 +462,7 @@ export function render({ navigate }) {
|
||||
|
||||
const loadSuggestions = async () => {
|
||||
const prefix = normalizeLogin(addInput.value);
|
||||
if (suggestionsLoading || prefix.length < 2) {
|
||||
if (suggestionsLoading || prefix.length < 2 || operationBusy) {
|
||||
suggestEl.hidden = true;
|
||||
suggestEl.innerHTML = '';
|
||||
return;
|
||||
@@ -307,7 +476,7 @@ export function render({ navigate }) {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
items.push({ login: resolved.serverLogin, url: resolved.httpBase });
|
||||
} catch {
|
||||
// если server PDA битая, пропускаем подсказку
|
||||
// Если server PDA повреждена, не показываем подсказку.
|
||||
}
|
||||
}
|
||||
if (!disposed) renderSuggestions(items);
|
||||
@@ -322,8 +491,100 @@ export function render({ navigate }) {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveAccessServerSigningMaterial = async (currentPda) => {
|
||||
if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.');
|
||||
if (!storagePwd) throw new Error('В памяти сессии нет storagePwd. Выполните вход заново.');
|
||||
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await loadEncryptedUserSecrets(sessionLogin, storagePwd);
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
const savedRoot = String(saved?.rootKey || '').trim();
|
||||
const savedClient = String(saved?.clientKey || '').trim();
|
||||
if (savedRoot && savedClient) {
|
||||
return {
|
||||
rootPrivatePkcs8B64: savedRoot,
|
||||
clientPrivatePkcs8B64: savedClient,
|
||||
};
|
||||
}
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||
});
|
||||
if (!passwordResult) {
|
||||
throw new Error('Операция отменена пользователем.');
|
||||
}
|
||||
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
|
||||
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||||
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
||||
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
|
||||
}
|
||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
|
||||
}
|
||||
|
||||
if (passwordResult.saveRoot) {
|
||||
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
|
||||
saveRoot: true,
|
||||
saveBlockchain: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
};
|
||||
};
|
||||
|
||||
const updateAccessServers = async (nextLogins, {
|
||||
statusTarget,
|
||||
successText,
|
||||
inFlightText,
|
||||
} = {}) => {
|
||||
const target = statusTarget instanceof HTMLElement ? statusTarget : listStatus;
|
||||
if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.');
|
||||
if (!solanaEndpoint) throw new Error('Не задан Solana RPC endpoint.');
|
||||
if (operationBusy) return;
|
||||
|
||||
const normalizedList = uniqueLogins(nextLogins);
|
||||
setOperationBusy(true);
|
||||
target.textContent = String(inFlightText || 'Обновляем список серверов доступа...');
|
||||
try {
|
||||
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
const signingMaterial = await resolveAccessServerSigningMaterial(currentPda);
|
||||
const tx = await updateShineUserPdaOnSolana({
|
||||
login: sessionLogin,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
|
||||
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
|
||||
accessServers: normalizedList,
|
||||
});
|
||||
await loadCurrentServers();
|
||||
target.textContent = `${String(successText || 'Список серверов доступа обновлён.')} Tx: ${shortenSignature(tx?.signature)}`;
|
||||
setSelectedCandidate(null);
|
||||
suggestEl.hidden = true;
|
||||
suggestEl.innerHTML = '';
|
||||
refreshAddButton();
|
||||
} catch (error) {
|
||||
target.textContent = error?.message || 'Не удалось обновить список серверов доступа.';
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
addInput.addEventListener('input', () => {
|
||||
setSelectedCandidate(null);
|
||||
refreshAddButton();
|
||||
void loadSuggestions();
|
||||
});
|
||||
addInput.addEventListener('focus', () => {
|
||||
@@ -341,19 +602,38 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
addButton.addEventListener('click', async () => {
|
||||
if (operationBusy) return;
|
||||
const login = normalizeLogin(selectedCandidate?.login || addInput.value);
|
||||
if (!login) {
|
||||
setSelectedCandidate(null);
|
||||
addStatus.textContent = 'Сначала укажите логин сервера доступа.';
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.some((item) => item.login === login)) {
|
||||
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
confirmModal?.open({
|
||||
title: 'Добавить сервер доступа?',
|
||||
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
|
||||
note: resolved.httpBase ? `Адрес сервера: ${resolved.httpBase}` : '',
|
||||
onConfirm: () => window.alert('Добавление сервера доступа пока не реализовано.'),
|
||||
note: resolved.httpBase
|
||||
? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.`
|
||||
: 'Изменение будет записано в Solana user PDA.',
|
||||
onConfirm: async () => {
|
||||
const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]);
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: addStatus,
|
||||
successText: `Сервер доступа @${resolved.serverLogin} добавлен.`,
|
||||
inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.';
|
||||
@@ -375,6 +655,7 @@ export function render({ navigate }) {
|
||||
screen.cleanup = () => {
|
||||
disposed = true;
|
||||
confirmModal?.destroy();
|
||||
passwordModal?.destroy();
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
Reference in New Issue
Block a user