UI: реальная смена серверов доступа

This commit is contained in:
AidarKC
2026-07-29 12:08:02 +04:00
parent d1e06d9ef1
commit 2f73cdb0f9
2 changed files with 302 additions and 21 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.2.357
client.version=1.2.358
server.version=1.2.341
+301 -20
View File
@@ -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;