SHA256
UI: реальная смена серверов доступа
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.357
|
client.version=1.2.358
|
||||||
server.version=1.2.341
|
server.version=1.2.341
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, state } from '../state.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 { 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: 'Серверы доступа' };
|
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
||||||
|
|
||||||
@@ -18,6 +20,34 @@ function normalizeLogin(value) {
|
|||||||
return String(value || '').trim().toLowerCase();
|
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() {
|
function createConfirmModal() {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (!(root instanceof HTMLElement)) return null;
|
if (!(root instanceof HTMLElement)) return null;
|
||||||
@@ -45,8 +75,8 @@ function createConfirmModal() {
|
|||||||
<div class="modal" id="access-servers-confirm-modal">
|
<div class="modal" id="access-servers-confirm-modal">
|
||||||
<div class="modal-card stack" style="max-width:min(94vw,34rem);">
|
<div class="modal-card stack" style="max-width:min(94vw,34rem);">
|
||||||
<h3 class="modal-title" id="access-servers-dialog-title"></h3>
|
<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-text" style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||||
<p class="meta-muted" id="access-servers-dialog-note"${note ? '' : ' hidden'}></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">
|
<div class="auth-footer-actions">
|
||||||
<button class="ghost-btn" type="button" id="access-servers-dialog-cancel"></button>
|
<button class="ghost-btn" type="button" id="access-servers-dialog-cancel"></button>
|
||||||
<button class="primary-btn" type="button" id="access-servers-dialog-confirm"></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 noteEl = root.querySelector('#access-servers-dialog-note');
|
||||||
const cancelBtn = root.querySelector('#access-servers-dialog-cancel');
|
const cancelBtn = root.querySelector('#access-servers-dialog-cancel');
|
||||||
const confirmBtn = root.querySelector('#access-servers-dialog-confirm');
|
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();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,8 +109,8 @@ function createConfirmModal() {
|
|||||||
noteEl.hidden = true;
|
noteEl.hidden = true;
|
||||||
noteEl.textContent = '';
|
noteEl.textContent = '';
|
||||||
}
|
}
|
||||||
confirmBtn.textContent = String(confirmLabel || 'Да');
|
|
||||||
cancelBtn.textContent = String(cancelLabel || 'Нет');
|
cancelBtn.textContent = String(cancelLabel || 'Нет');
|
||||||
|
confirmBtn.textContent = String(confirmLabel || 'Да');
|
||||||
onConfirm = confirmHandler || null;
|
onConfirm = confirmHandler || null;
|
||||||
onCancel = cancelHandler || 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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
|
|
||||||
const sessionLogin = normalizeLogin(state.session.login);
|
const sessionLogin = normalizeLogin(state.session.login);
|
||||||
const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim();
|
const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim();
|
||||||
|
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let operationBusy = false;
|
||||||
let currentAccessServers = [];
|
let currentAccessServers = [];
|
||||||
let selectedCandidate = null;
|
let selectedCandidate = null;
|
||||||
let suggestionsLoading = false;
|
let suggestionsLoading = false;
|
||||||
|
|
||||||
const confirmModal = createConfirmModal();
|
const confirmModal = createConfirmModal();
|
||||||
|
const passwordModal = createPasswordModal();
|
||||||
|
|
||||||
const introCard = document.createElement('div');
|
const introCard = document.createElement('div');
|
||||||
introCard.className = 'card stack';
|
introCard.className = 'card stack';
|
||||||
@@ -157,7 +298,7 @@ export function render({ navigate }) {
|
|||||||
suggestEl.hidden = true;
|
suggestEl.hidden = true;
|
||||||
const addStatus = document.createElement('p');
|
const addStatus = document.createElement('p');
|
||||||
addStatus.className = 'meta-muted';
|
addStatus.className = 'meta-muted';
|
||||||
addStatus.textContent = 'Добавление сервера пока работает как подтверждающая заглушка.';
|
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||||
const addButton = document.createElement('button');
|
const addButton = document.createElement('button');
|
||||||
addButton.className = 'primary-btn';
|
addButton.className = 'primary-btn';
|
||||||
addButton.type = 'button';
|
addButton.type = 'button';
|
||||||
@@ -165,14 +306,27 @@ export function render({ navigate }) {
|
|||||||
addButton.disabled = true;
|
addButton.disabled = true;
|
||||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
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) => {
|
const setSelectedCandidate = (candidate) => {
|
||||||
selectedCandidate = candidate;
|
selectedCandidate = candidate;
|
||||||
addButton.disabled = !candidate;
|
refreshAddButton();
|
||||||
if (candidate) {
|
if (candidate) {
|
||||||
addInput.value = candidate.login;
|
addInput.value = candidate.login;
|
||||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||||
} else {
|
} else {
|
||||||
addStatus.textContent = 'Добавление сервера пока работает как подтверждающая заглушка.';
|
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,6 +344,7 @@ export function render({ navigate }) {
|
|||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.className = 'text-btn';
|
button.className = 'text-btn';
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
|
button.disabled = operationBusy;
|
||||||
button.innerHTML = `
|
button.innerHTML = `
|
||||||
<span style="display:block; text-align:left;">
|
<span style="display:block; text-align:left;">
|
||||||
<strong>@${escapeHtml(server.login)}</strong>
|
<strong>@${escapeHtml(server.login)}</strong>
|
||||||
@@ -197,8 +352,24 @@ export function render({ navigate }) {
|
|||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
|
if (operationBusy) return;
|
||||||
const isLast = currentAccessServers.length <= 1;
|
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({
|
confirmModal?.open({
|
||||||
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
|
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
|
||||||
text: isLast
|
text: isLast
|
||||||
@@ -206,8 +377,8 @@ export function render({ navigate }) {
|
|||||||
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
|
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
|
||||||
note: noteText,
|
note: noteText,
|
||||||
confirmLabel: isLast ? 'Понимаю' : 'Да',
|
confirmLabel: isLast ? 'Понимаю' : 'Да',
|
||||||
cancelLabel: 'Нет',
|
cancelLabel: isLast ? 'Отмена' : 'Нет',
|
||||||
onConfirm: () => window.alert('Отключение сервера доступа пока не реализовано.'),
|
onConfirm: performDisable,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,12 +389,12 @@ export function render({ navigate }) {
|
|||||||
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
|
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
|
||||||
confirmLabel: 'Продолжить',
|
confirmLabel: 'Продолжить',
|
||||||
cancelLabel: 'Отмена',
|
cancelLabel: 'Отмена',
|
||||||
onConfirm: () => openStub('Это повторное предупреждение. Сейчас действие остаётся заглушкой.'),
|
onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openStub();
|
askFinal();
|
||||||
});
|
});
|
||||||
listBody.append(button);
|
listBody.append(button);
|
||||||
if (index < currentAccessServers.length - 1) {
|
if (index < currentAccessServers.length - 1) {
|
||||||
@@ -252,9 +423,7 @@ export function render({ navigate }) {
|
|||||||
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
|
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
|
||||||
try {
|
try {
|
||||||
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||||
const logins = Array.isArray(parsed?.accessServers)
|
const logins = uniqueLogins(parsed?.accessServers);
|
||||||
? [...new Set(parsed.accessServers.map(normalizeLogin).filter(Boolean))]
|
|
||||||
: [];
|
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const login of logins) {
|
for (const login of logins) {
|
||||||
try {
|
try {
|
||||||
@@ -293,7 +462,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const loadSuggestions = async () => {
|
const loadSuggestions = async () => {
|
||||||
const prefix = normalizeLogin(addInput.value);
|
const prefix = normalizeLogin(addInput.value);
|
||||||
if (suggestionsLoading || prefix.length < 2) {
|
if (suggestionsLoading || prefix.length < 2 || operationBusy) {
|
||||||
suggestEl.hidden = true;
|
suggestEl.hidden = true;
|
||||||
suggestEl.innerHTML = '';
|
suggestEl.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
@@ -307,7 +476,7 @@ export function render({ navigate }) {
|
|||||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||||
items.push({ login: resolved.serverLogin, url: resolved.httpBase });
|
items.push({ login: resolved.serverLogin, url: resolved.httpBase });
|
||||||
} catch {
|
} catch {
|
||||||
// если server PDA битая, пропускаем подсказку
|
// Если server PDA повреждена, не показываем подсказку.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!disposed) renderSuggestions(items);
|
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', () => {
|
addInput.addEventListener('input', () => {
|
||||||
setSelectedCandidate(null);
|
setSelectedCandidate(null);
|
||||||
|
refreshAddButton();
|
||||||
void loadSuggestions();
|
void loadSuggestions();
|
||||||
});
|
});
|
||||||
addInput.addEventListener('focus', () => {
|
addInput.addEventListener('focus', () => {
|
||||||
@@ -341,19 +602,38 @@ export function render({ navigate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
addButton.addEventListener('click', async () => {
|
addButton.addEventListener('click', async () => {
|
||||||
|
if (operationBusy) return;
|
||||||
const login = normalizeLogin(selectedCandidate?.login || addInput.value);
|
const login = normalizeLogin(selectedCandidate?.login || addInput.value);
|
||||||
if (!login) {
|
if (!login) {
|
||||||
setSelectedCandidate(null);
|
setSelectedCandidate(null);
|
||||||
addStatus.textContent = 'Сначала укажите логин сервера доступа.';
|
addStatus.textContent = 'Сначала укажите логин сервера доступа.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (currentAccessServers.some((item) => item.login === login)) {
|
||||||
|
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||||
confirmModal?.open({
|
confirmModal?.open({
|
||||||
title: 'Добавить сервер доступа?',
|
title: 'Добавить сервер доступа?',
|
||||||
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
|
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
|
||||||
note: resolved.httpBase ? `Адрес сервера: ${resolved.httpBase}` : '',
|
note: resolved.httpBase
|
||||||
onConfirm: () => window.alert('Добавление сервера доступа пока не реализовано.'),
|
? `Адрес сервера: ${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) {
|
} catch (error) {
|
||||||
addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.';
|
addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.';
|
||||||
@@ -375,6 +655,7 @@ export function render({ navigate }) {
|
|||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
confirmModal?.destroy();
|
confirmModal?.destroy();
|
||||||
|
passwordModal?.destroy();
|
||||||
};
|
};
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
Reference in New Issue
Block a user