UI и API: экран серверов доступа и фильтр SearchUsers

This commit is contained in:
AidarKC
2026-07-28 16:20:29 +04:00
parent 64818c586b
commit 0ed78e2202
12 changed files with 457 additions and 16 deletions
+2
View File
@@ -56,6 +56,7 @@ import * as profileView from './pages/profile-view.js?v=202607150910';
import * as profileEditView from './pages/profile-edit-view.js';
import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
@@ -116,6 +117,7 @@ const routes = {
'profile-edit-view': profileEditView,
'wallet-view': walletView,
'settings-view': settingsView,
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
+380
View File
@@ -0,0 +1,380 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { readShineUserPda } from '../services/shine-user-pda-service.js';
import { resolveShineServerByServerLogin } from '../services/shine-server-resolver.js';
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
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 createConfirmModal() {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return null;
const modal = document.createElement('div');
modal.className = 'modal';
modal.hidden = true;
modal.innerHTML = `
<div class="modal-card stack" style="max-width:min(94vw,34rem);">
<h3 class="modal-title" id="access-servers-dialog-title">Подтверждение</h3>
<p class="meta-muted" id="access-servers-dialog-text"></p>
<p class="meta-muted" id="access-servers-dialog-note" hidden></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>
`;
root.append(modal);
const titleEl = modal.querySelector('#access-servers-dialog-title');
const textEl = modal.querySelector('#access-servers-dialog-text');
const noteEl = modal.querySelector('#access-servers-dialog-note');
const cancelBtn = modal.querySelector('#access-servers-dialog-cancel');
const confirmBtn = modal.querySelector('#access-servers-dialog-confirm');
let onConfirm = null;
let onCancel = null;
const close = () => {
modal.hidden = true;
onConfirm = null;
onCancel = 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();
});
return {
open({
title,
text,
note = '',
confirmLabel = 'Да',
cancelLabel = 'Нет',
onConfirm: confirmHandler,
onCancel: cancelHandler,
}) {
titleEl.textContent = String(title || 'Подтверждение');
textEl.textContent = String(text || '');
if (note) {
noteEl.hidden = false;
noteEl.textContent = String(note);
} else {
noteEl.hidden = true;
noteEl.textContent = '';
}
confirmBtn.textContent = String(confirmLabel || 'Да');
cancelBtn.textContent = String(cancelLabel || 'Нет');
onConfirm = confirmHandler || null;
onCancel = cancelHandler || null;
modal.hidden = false;
},
destroy() {
modal.remove();
},
};
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const sessionLogin = normalizeLogin(state.session.login);
const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim();
let disposed = false;
let currentAccessServers = [];
let selectedCandidate = null;
let suggestionsLoading = false;
const confirmModal = createConfirmModal();
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 = 'Добавление сервера пока работает как подтверждающая заглушка.';
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 setSelectedCandidate = (candidate) => {
selectedCandidate = candidate;
addButton.disabled = !candidate;
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, index) => {
const button = document.createElement('button');
button.className = 'text-btn';
button.type = 'button';
button.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>
`;
button.addEventListener('click', () => {
const isLast = currentAccessServers.length <= 1;
const openStub = (noteText = '') => {
confirmModal?.open({
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
text: isLast
? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.'
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
note: noteText,
confirmLabel: isLast ? 'Понимаю' : 'Да',
cancelLabel: 'Нет',
onConfirm: () => window.alert('Отключение сервера доступа пока не реализовано.'),
});
};
if (isLast) {
confirmModal?.open({
title: 'Отключить последний сервер?',
text: `У пользователя остался только один сервер доступа: @${server.login}.`,
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
confirmLabel: 'Продолжить',
cancelLabel: 'Отмена',
onConfirm: () => openStub('Это повторное предупреждение. Сейчас действие остаётся заглушкой.'),
});
return;
}
openStub();
});
listBody.append(button);
if (index < currentAccessServers.length - 1) {
const divider = document.createElement('div');
divider.style.height = '1px';
divider.style.background = 'rgba(255,255,255,0.08)';
listBody.append(divider);
}
});
};
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 = Array.isArray(parsed?.accessServers)
? [...new Set(parsed.accessServers.map(normalizeLogin).filter(Boolean))]
: [];
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();
listStatus.textContent = rows.length
? `Найдено серверов доступа: ${rows.length}`
: 'В PDA пользователя пока нет серверов доступа.';
} catch (error) {
currentAccessServers = [];
renderServerList();
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="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 loadSuggestions = async () => {
const prefix = normalizeLogin(addInput.value);
if (suggestionsLoading || prefix.length < 2) {
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;
}
};
addInput.addEventListener('input', () => {
setSelectedCandidate(null);
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 () => {
const login = normalizeLogin(selectedCandidate?.login || addInput.value);
if (!login) {
setSelectedCandidate(null);
addStatus.textContent = 'Сначала укажите логин сервера доступа.';
return;
}
try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
confirmModal?.open({
title: 'Добавить сервер доступа?',
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
note: resolved.httpBase ? `Адрес сервера: ${resolved.httpBase}` : '',
onConfirm: () => window.alert('Добавление сервера доступа пока не реализовано.'),
});
} catch (error) {
addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.';
}
});
screen.append(
renderHeader({
title: 'Серверы доступа',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
introCard,
listCard,
addCard,
);
void loadCurrentServers();
screen.cleanup = () => {
disposed = true;
confirmModal?.destroy();
};
return screen;
}
+14 -3
View File
@@ -2,7 +2,7 @@ import { renderHeader } from '../components/header.js';
import { saveEntrySettings, state } from '../state.js';
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
export const pageMeta = { id: 'server-settings-view', title: 'Настройки серверов' };
export const pageMeta = { id: 'server-settings-view', title: 'Серверы блокчейнов' };
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
@@ -26,6 +26,16 @@ export function render({ navigate }) {
const timers = new Map();
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.innerHTML = `
<p class="field-label">Серверы блокчейнов и публичных данных</p>
<p class="meta-muted">
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
Эти настройки не меняют список личных серверов доступа пользователя.
</p>
`;
const body = document.createElement('div');
body.className = 'card stack';
@@ -168,7 +178,7 @@ export function render({ navigate }) {
await saveEntrySettings(draft);
navigate('settings-view');
} catch (error) {
window.alert(error?.message || 'Не удалось сохранить настройки серверов.');
window.alert(error?.message || 'Не удалось сохранить настройки серверов блокчейнов.');
}
});
@@ -186,9 +196,10 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Настройки серверов',
title: 'Серверы блокчейнов',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
introCard,
body,
actions,
help,
+14 -2
View File
@@ -42,14 +42,26 @@ export function render({ navigate }) {
card.innerHTML = `
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-access-servers">
<span style="display:block; text-align:left;">
<strong>Серверы доступа</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-blockchain-servers">
<span style="display:block; text-align:left;">
<strong>Серверы блокчейнов</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout');
+3
View File
@@ -29,6 +29,7 @@ const PRETTY_PATHS = new Map([
['network-view', 'network'],
['notifications-view', 'notifications'],
['settings-view', 'settings'],
['access-servers-view', 'settings/access-servers'],
['server-settings-view', 'settings/servers'],
['developer-settings-view', 'settings/developer'],
['trusted-device-login-settings-view', 'settings/device-login'],
@@ -258,6 +259,7 @@ export function getRoute() {
if (pageId === 'settings') {
const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
@@ -368,6 +370,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'profile-edit-view' ||
pageId === 'wallet-view' ||
pageId === 'settings-view' ||
pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'remote-addblock-session-view' ||
+6 -2
View File
@@ -2603,8 +2603,12 @@ export class AuthService {
return response.payload || {};
}
async searchUsers(prefix) {
const response = await this.ws.request('SearchUsers', { prefix });
async searchUsers(prefix, options = {}) {
const payload = { prefix };
if (typeof options?.isServer === 'boolean') {
payload.isServer = options.isServer;
}
const response = await this.ws.request('SearchUsers', payload);
if (response.status !== 200) throw opError('SearchUsers', response);
return response.payload?.logins || [];
}