Files
SHiNE-server/shine-UI/js/pages/access-servers-view.js

656 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } 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';
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
export const pageMeta = { id: 'access-servers-view', title: 'Сервер доступа' };
const MAX_ACCESS_SERVERS = 1;
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);
if (result.length >= MAX_ACCESS_SERVERS) return;
});
return result.slice(0, MAX_ACCESS_SERVERS);
}
function shortenSignature(value) {
const text = String(value || '').trim();
if (text.length <= 16) return text;
return `${text.slice(0, 8)}...${text.slice(-8)}`;
}
function isInsufficientFundsForRentError(error) {
const text = [
error?.message,
error?.transactionMessage,
Array.isArray(error?.logs) ? error.logs.join('\n') : '',
Array.isArray(error?.transactionLogs) ? error.transactionLogs.join('\n') : '',
Array.isArray(error?.simulationLogs) ? error.simulationLogs.join('\n') : '',
].filter(Boolean).join('\n').toLowerCase();
return text.includes('insufficient funds') && text.includes('rent');
}
function clientAddressFromPublicB64(publicKeyB64) {
const bytes = base64ToBytes(publicKeyB64);
if (bytes.length !== 32) throw new Error('client public key должен быть 32 байта');
return bytesToBase58(bytes);
}
async function clientAddressFromPrivatePkcs8(privatePkcs8B64) {
return clientAddressFromPublicB64(await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64));
}
function createConfirmModal() {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return null;
let onConfirm = null;
let onCancel = null;
const close = () => {
root.innerHTML = '';
onConfirm = null;
onCancel = null;
};
return {
open({
title,
text,
note = '',
confirmLabel = 'Да',
cancelLabel = 'Нет',
onConfirm: confirmHandler,
onCancel: cancelHandler,
}) {
root.innerHTML = `
<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" 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>
</div>
</div>
</div>
`;
const modal = root.querySelector('#access-servers-confirm-modal');
const titleEl = root.querySelector('#access-servers-dialog-title');
const textEl = root.querySelector('#access-servers-dialog-text');
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)) {
close();
return;
}
titleEl.textContent = String(title || 'Подтверждение');
textEl.textContent = String(text || '');
if (note) {
noteEl.hidden = false;
noteEl.textContent = String(note);
} else {
noteEl.hidden = true;
noteEl.textContent = '';
}
cancelBtn.textContent = String(cancelLabel || 'Нет');
confirmBtn.textContent = String(confirmLabel || 'Да');
onConfirm = confirmHandler || null;
onCancel = cancelHandler || null;
modal.addEventListener('click', (event) => {
if (event.target === modal) close();
});
cancelBtn.addEventListener('click', async () => {
const handler = onCancel;
close();
if (typeof handler === 'function') await handler();
});
confirmBtn.addEventListener('click', async () => {
const handler = onConfirm;
close();
if (typeof handler === 'function') await handler();
});
},
destroy() {
close();
},
};
}
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, chrome}) {
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';
introCard.innerHTML = `
<p class="field-label">Где хранятся личные данные</p>
<p class="meta-muted">
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
а настройки и приватная переписка хранятся только на выбранном сервере.
При смене сервера прежняя переписка и настройки автоматически не переносятся.
</p>
`;
const listCard = document.createElement('div');
listCard.className = 'card stack';
const listTitle = document.createElement('p');
listTitle.className = 'field-label';
listTitle.textContent = 'Текущий сервер доступа';
const listHint = document.createElement('p');
listHint.className = 'meta-muted';
listHint.textContent = sessionLogin
? `Пользователь: @${sessionLogin}`
: 'В текущей сессии не найден логин пользователя.';
const listBody = document.createElement('div');
listBody.className = 'stack';
const listStatus = document.createElement('p');
listStatus.className = 'meta-muted';
listStatus.textContent = 'Загрузка данных из PDA...';
listCard.append(listTitle, listHint, listBody, listStatus);
const addCard = document.createElement('div');
addCard.className = 'card stack';
const addTitle = document.createElement('p');
addTitle.className = 'field-label';
addTitle.textContent = 'Сменить сервер доступа';
const addHint = document.createElement('p');
addHint.className = 'meta-muted';
addHint.textContent = 'Введите логин нового сервера или несколько первых букв, затем выберите сервер из подсказок. Новый сервер заменит текущий.';
const addInput = document.createElement('input');
addInput.className = 'input';
addInput.type = 'text';
addInput.autocomplete = 'off';
addInput.placeholder = 'Например: shineup';
const suggestEl = document.createElement('div');
suggestEl.className = 'profile-relative-search-suggest';
suggestEl.hidden = true;
const addStatus = document.createElement('p');
addStatus.className = 'meta-muted';
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
const addButton = document.createElement('button');
addButton.className = 'primary-btn';
addButton.type = 'button';
addButton.textContent = 'Сменить сервер';
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;
refreshAddButton();
if (candidate) {
addInput.value = candidate.login;
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
} else {
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
}
};
const renderServerList = () => {
listBody.innerHTML = '';
if (!currentAccessServers.length) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = 'Список серверов доступа пока пуст.';
listBody.append(empty);
return;
}
currentAccessServers.forEach((server) => {
const row = document.createElement('div');
row.innerHTML = `
<span style="display:block; text-align:left;">
<strong>@${escapeHtml(server.login)}</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
</span>
`;
listBody.append(row);
});
};
const loadCurrentServers = async () => {
if (!sessionLogin) {
currentAccessServers = [];
renderServerList();
listStatus.textContent = 'Нет активной пользовательской сессии.';
return;
}
if (!solanaEndpoint) {
currentAccessServers = [];
renderServerList();
listStatus.textContent = 'Не задан Solana RPC endpoint.';
return;
}
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
try {
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
const logins = uniqueLogins(parsed?.accessServers);
const rows = [];
for (const login of logins) {
try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
rows.push({ login: resolved.serverLogin, url: resolved.httpBase });
} catch {
rows.push({ login, url: '' });
}
}
currentAccessServers = rows;
renderServerList();
refreshAddButton();
listStatus.textContent = rows.length
? 'Сервер доступа загружен из PDA.'
: 'В PDA пользователя пока нет серверов доступа.';
} catch (error) {
currentAccessServers = [];
renderServerList();
refreshAddButton();
listStatus.textContent = error?.message || 'Не удалось прочитать сервер доступа.';
}
};
const renderSuggestions = (items) => {
suggestEl.innerHTML = '';
if (!items.length) {
suggestEl.hidden = true;
return;
}
suggestEl.hidden = false;
suggestEl.innerHTML = items.map((item) => (
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
@${escapeHtml(item.login)}
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
</button>`
)).join('');
};
const showTopupRequiredStatus = (target, clientAddress) => {
const address = String(clientAddress || '').trim();
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
target.innerHTML = `
<span style="display:block; margin-bottom:0.55rem;">
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
</span>
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
`;
};
const loadSuggestions = async () => {
const prefix = normalizeLogin(addInput.value);
if (suggestionsLoading || prefix.length < 2 || operationBusy) {
suggestEl.hidden = true;
suggestEl.innerHTML = '';
return;
}
suggestionsLoading = true;
try {
const logins = await authService.searchUsers(prefix, { isServer: true });
const items = [];
for (const login of logins.slice(0, 6)) {
try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
items.push({ login: resolved.serverLogin, url: resolved.httpBase });
} catch {
// Если server PDA повреждена, не показываем подсказку.
}
}
if (!disposed) renderSuggestions(items);
} catch (error) {
if (!disposed) {
suggestEl.hidden = true;
suggestEl.innerHTML = '';
addStatus.textContent = error?.message || 'Не удалось получить список серверов.';
}
} finally {
suggestionsLoading = false;
}
};
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,
clientAddress: await clientAddressFromPrivatePkcs8(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,
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
};
};
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 || 'Обновляем сервер доступа...');
let signingMaterial = null;
try {
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
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) {
if (isInsufficientFundsForRentError(error)) {
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
} else {
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
}
throw error;
} finally {
setOperationBusy(false);
}
};
addInput.addEventListener('input', () => {
setSelectedCandidate(null);
refreshAddButton();
void loadSuggestions();
});
addInput.addEventListener('focus', () => {
void loadSuggestions();
});
suggestEl.addEventListener('click', (event) => {
const target = event.target instanceof HTMLElement ? event.target.closest('[data-login]') : null;
if (!(target instanceof HTMLElement)) return;
setSelectedCandidate({
login: String(target.dataset.login || ''),
url: String(target.dataset.url || ''),
});
suggestEl.hidden = true;
suggestEl.innerHTML = '';
});
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}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
onConfirm: async () => {
const nextList = [resolved.serverLogin];
try {
await updateAccessServers(nextList, {
statusTarget: addStatus,
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
});
} catch {
// Сообщение уже показано в статусе.
}
},
});
} catch (error) {
addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.';
}
});
chrome?.setTopbar(createTopBar({
title: 'Сервер доступа',
back: { label: '←', onClick: () => navigate('settings-view') },
}));
screen.append(
introCard,
listCard,
addCard,
);
void loadCurrentServers();
screen.cleanup = () => {
disposed = true;
confirmModal?.destroy();
passwordModal?.destroy();
};
return screen;
}