Files
SHiNE-server/shine-UI/js/pages/access-servers-view.js
T
qandClaude Opus 5.5 f8900e531a UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра
- 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>
2026-09-26 10:05:26 +03:00

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, '&amp;')
.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>Использовать главный ключ только сейчас</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;
}