SHA256
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов, цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли. - Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие — редактор цветов с экспортом/импортом. - Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале», создание канала с адресом из названия; лента открывается на свежих постах. - Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке. - Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка; настройки, кошелёк, сеансы — меню-списки. - Нижняя панель: иконки без подписей, бейджи на иконках. - confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу. - docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
650 lines
27 KiB
JavaScript
650 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>Использовать главный ключ только сейчас</span>
|
|
</label>
|
|
<label class="checkbox-row">
|
|
<input type="radio" name="access-servers-key-mode" value="save" />
|
|
<span>Сохранить главный ключ на этом устройстве</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 = 'settings-intro';
|
|
introCard.innerHTML = `
|
|
<p class="meta-muted">Сервер доступа хранит вашу зашифрованную переписку и настройки и помогает со звонками. Публичные записи доступны через любой сервер. При смене сервера переписка сама не переносится.</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 = 'Загрузка данных аккаунта...';
|
|
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 = 'Для изменения списка понадобится подтверждение главным ключом.';
|
|
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 = 'Для смены сервера понадобится подтверждение главным ключом.';
|
|
}
|
|
};
|
|
|
|
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...';
|
|
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
|
|
? 'Сервер доступа загружен из записи аккаунта.'
|
|
: 'В записи аккаунта пока нет серверов доступа.';
|
|
} 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 на ключе устройства для оплаты комиссии Solana при обновлении записи аккаунта в Solana.
|
|
</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: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
|
note: savedClient
|
|
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
|
|
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
|
});
|
|
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('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
|
|
}
|
|
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
|
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
|
}
|
|
|
|
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.`
|
|
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.',
|
|
onConfirm: async () => {
|
|
const nextList = [resolved.serverLogin];
|
|
try {
|
|
await updateAccessServers(nextList, {
|
|
statusTarget: addStatus,
|
|
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
|
|
inFlightText: `Обновляем запись аккаунта и меняем сервер на @${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;
|
|
}
|