SHA256
UI и API: экран серверов доступа и фильтр SearchUsers
This commit is contained in:
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user