SHA256
656 lines
27 KiB
JavaScript
656 lines
27 KiB
JavaScript
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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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;
|
||
}
|