SHA256
UI: обновлена шапка каналов, закрыты pending-задачи и обновлены версии
This commit is contained in:
@@ -1173,34 +1173,52 @@ export function render({ navigate, route }) {
|
||||
const topBarEl = document.createElement('div');
|
||||
topBarEl.className = 'channels-top-bar';
|
||||
|
||||
const tabsEl = document.createElement('div');
|
||||
tabsEl.className = 'channels-tabs';
|
||||
const tabLabels = {
|
||||
feed: 'Каналы',
|
||||
my: 'Мои каналы',
|
||||
};
|
||||
TAB_ORDER.forEach((tabKey) => {
|
||||
if (isGuest && tabKey === 'my') return;
|
||||
const tabBtn = document.createElement('button');
|
||||
tabBtn.type = 'button';
|
||||
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
||||
tabBtn.textContent = tabLabels[tabKey] || tabKey;
|
||||
tabBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === tabKey) return;
|
||||
listState.activeTab = tabKey;
|
||||
rerenderList();
|
||||
});
|
||||
tabsEl.append(tabBtn);
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const backToFeedBtn = document.createElement('button');
|
||||
backToFeedBtn.type = 'button';
|
||||
backToFeedBtn.className = 'icon-btn channels-top-back-btn';
|
||||
backToFeedBtn.textContent = '←';
|
||||
backToFeedBtn.setAttribute('aria-label', 'Назад');
|
||||
backToFeedBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === 'feed') return;
|
||||
listState.activeTab = 'feed';
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
const topActionBtn = document.createElement('button');
|
||||
topActionBtn.type = 'button';
|
||||
topActionBtn.className = 'secondary-btn channels-top-action-btn';
|
||||
topActionBtn.textContent = 'Создать канал';
|
||||
topActionBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||
if (isGuest) topActionBtn.style.display = 'none';
|
||||
const allChannelsBtn = document.createElement('button');
|
||||
allChannelsBtn.type = 'button';
|
||||
allChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
|
||||
allChannelsBtn.textContent = 'Все каналы';
|
||||
allChannelsBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === 'feed') return;
|
||||
listState.activeTab = 'feed';
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
topBarEl.append(tabsEl, topActionBtn);
|
||||
const topTitle = document.createElement('strong');
|
||||
topTitle.className = 'channels-top-title';
|
||||
|
||||
const myChannelsBtn = document.createElement('button');
|
||||
myChannelsBtn.type = 'button';
|
||||
myChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
|
||||
myChannelsBtn.textContent = 'Мои каналы';
|
||||
myChannelsBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === 'my') return;
|
||||
listState.activeTab = 'my';
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
const createInMyBtn = document.createElement('button');
|
||||
createInMyBtn.type = 'button';
|
||||
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
||||
createInMyBtn.textContent = '+';
|
||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||
|
||||
topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn);
|
||||
topBarEl.append(topBarLeft, createInMyBtn);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
@@ -1229,8 +1247,19 @@ export function render({ navigate, route }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
const showCreate = !isGuest && listState.activeTab === 'my';
|
||||
topActionBtn.style.display = showCreate ? '' : 'none';
|
||||
if (listState.activeTab === 'my' && !isGuest) {
|
||||
backToFeedBtn.style.display = '';
|
||||
allChannelsBtn.style.display = '';
|
||||
myChannelsBtn.style.display = 'none';
|
||||
topTitle.textContent = 'Мои каналы';
|
||||
createInMyBtn.style.display = '';
|
||||
} else {
|
||||
backToFeedBtn.style.display = 'none';
|
||||
allChannelsBtn.style.display = 'none';
|
||||
myChannelsBtn.style.display = isGuest ? 'none' : '';
|
||||
topTitle.textContent = 'Каналы';
|
||||
createInMyBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
@@ -1238,10 +1267,6 @@ export function render({ navigate, route }) {
|
||||
navigate,
|
||||
isTabEmpty,
|
||||
});
|
||||
tabsEl.querySelectorAll('.channels-tab-btn').forEach((btn, idx) => {
|
||||
const key = TAB_ORDER[idx];
|
||||
btn.classList.toggle('is-active', key === listState.activeTab);
|
||||
});
|
||||
};
|
||||
|
||||
let touchStartX = 0;
|
||||
@@ -1262,6 +1287,7 @@ export function render({ navigate, route }) {
|
||||
if (index < 0) return;
|
||||
if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1];
|
||||
if (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1];
|
||||
if (isGuest && listState.activeTab === 'my') listState.activeTab = 'feed';
|
||||
rerenderList();
|
||||
}, { passive: true });
|
||||
|
||||
|
||||
@@ -102,14 +102,46 @@ export function render({ navigate }) {
|
||||
nextButton.addEventListener('click', async () => {
|
||||
formError.style.display = 'none';
|
||||
const isFree = await runAvailabilityCheck();
|
||||
if (!isFree) {
|
||||
return;
|
||||
}
|
||||
if (!isFree) return;
|
||||
|
||||
state.registrationDraft.login = loginInput.value.trim();
|
||||
state.registrationDraft.password = passwordInput.value;
|
||||
state.registrationDraft.preGeneratedKeyBundle = null;
|
||||
|
||||
navigate('registration-payment-view');
|
||||
// Показываем информационный экран пока генерируются ключи
|
||||
form.innerHTML = '';
|
||||
const infoMsg = document.createElement('p');
|
||||
infoMsg.className = 'auth-copy';
|
||||
infoMsg.textContent =
|
||||
'Из вашего логина и пароля (надеемся, что вы выбрали достаточно длинный и надёжный пароль) ' +
|
||||
'генерируется секрет, из которого получаются root key, blockchain key и device key.';
|
||||
|
||||
const spinnerMsg = document.createElement('p');
|
||||
spinnerMsg.className = 'meta-muted';
|
||||
spinnerMsg.textContent = 'Генерация ключей...';
|
||||
|
||||
const genError = document.createElement('p');
|
||||
genError.className = 'status-line is-unavailable';
|
||||
genError.style.display = 'none';
|
||||
|
||||
form.append(infoMsg, spinnerMsg, genError);
|
||||
nextButton.disabled = true;
|
||||
backButton.disabled = true;
|
||||
|
||||
try {
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.password,
|
||||
);
|
||||
state.registrationDraft.preGeneratedKeyBundle = keyBundle;
|
||||
navigate('registration-payment-view');
|
||||
} catch (error) {
|
||||
genError.textContent = `Ошибка генерации ключей: ${error?.message || 'неизвестная ошибка'}`;
|
||||
genError.style.display = '';
|
||||
spinnerMsg.style.display = 'none';
|
||||
nextButton.disabled = false;
|
||||
backButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(backButton, nextButton);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { bytesToBase64 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false };
|
||||
|
||||
function makeSecretField({ label, value }) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'field-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'inline-input-row';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input';
|
||||
input.type = 'password';
|
||||
input.readOnly = true;
|
||||
input.value = value;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'ghost-btn';
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.textContent = 'Показать';
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
toggleBtn.textContent = 'Скрыть';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
toggleBtn.textContent = 'Показать';
|
||||
}
|
||||
});
|
||||
|
||||
row.append(input, toggleBtn);
|
||||
wrap.append(labelEl, row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function makePublicField({ label, value }) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'field-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input';
|
||||
input.type = 'text';
|
||||
input.readOnly = true;
|
||||
input.value = value;
|
||||
|
||||
wrap.append(labelEl, input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
if (!keyBundle) {
|
||||
const msg = document.createElement('p');
|
||||
msg.className = 'status-line is-unavailable';
|
||||
msg.textContent = 'Ключи ещё не сгенерированы. Вернитесь на экран регистрации и введите логин с паролем.';
|
||||
card.append(msg);
|
||||
} else {
|
||||
const warning = document.createElement('p');
|
||||
warning.className = 'meta-muted';
|
||||
warning.textContent =
|
||||
'Никому не сообщайте приватные ключи и секрет. Они не хранятся на сервере и существуют только на вашем устройстве.';
|
||||
|
||||
card.append(warning);
|
||||
|
||||
// Секрет (root key seed)
|
||||
let secretB64 = '';
|
||||
try {
|
||||
const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64);
|
||||
secretB64 = bytesToBase64(rootSeed32);
|
||||
} catch {
|
||||
secretB64 = '(не удалось извлечь)';
|
||||
}
|
||||
card.append(makeSecretField({ label: 'Секрет (root seed, 32 байта)', value: secretB64 }));
|
||||
|
||||
// Root key
|
||||
const rootSep = document.createElement('p');
|
||||
rootSep.className = 'field-label';
|
||||
rootSep.textContent = 'Root key';
|
||||
card.append(rootSep);
|
||||
card.append(makePublicField({ label: 'Root — публичный', value: keyBundle.rootPair.publicKeyB64 }));
|
||||
card.append(makeSecretField({ label: 'Root — приватный (PKCS8)', value: keyBundle.rootPair.privatePkcs8B64 }));
|
||||
|
||||
// Blockchain key
|
||||
const bchSep = document.createElement('p');
|
||||
bchSep.className = 'field-label';
|
||||
bchSep.textContent = 'Blockchain key';
|
||||
card.append(bchSep);
|
||||
card.append(makePublicField({ label: 'Blockchain — публичный', value: keyBundle.blockchainPair.publicKeyB64 }));
|
||||
card.append(makeSecretField({ label: 'Blockchain — приватный (PKCS8)', value: keyBundle.blockchainPair.privatePkcs8B64 }));
|
||||
|
||||
// Device key
|
||||
const devSep = document.createElement('p');
|
||||
devSep.className = 'field-label';
|
||||
devSep.textContent = 'Device key (= Solana wallet)';
|
||||
card.append(devSep);
|
||||
card.append(makePublicField({ label: 'Device — публичный', value: keyBundle.devicePair.publicKeyB64 }));
|
||||
card.append(makeSecretField({ label: 'Device — приватный (PKCS8)', value: keyBundle.devicePair.privatePkcs8B64 }));
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'ghost-btn';
|
||||
backButton.type = 'button';
|
||||
backButton.textContent = 'Назад';
|
||||
backButton.addEventListener('click', () => navigate('registration-payment-view'));
|
||||
|
||||
actions.append(backButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Сгенерированные ключи',
|
||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getBalanceSol,
|
||||
getTopupSiteUrl,
|
||||
} from '../services/solana-wallet-service.js';
|
||||
import { registerUserOnSolana } from '../services/solana-register-service.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||
const MIN_REQUIRED_SOL = 0.01;
|
||||
@@ -136,6 +137,12 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
const showKeysButton = document.createElement('button');
|
||||
showKeysButton.className = 'ghost-btn';
|
||||
showKeysButton.type = 'button';
|
||||
showKeysButton.textContent = 'Показать сгенерированные ключи';
|
||||
showKeysButton.addEventListener('click', () => navigate('registration-draft-keys-view'));
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.className = 'primary-btn';
|
||||
submitButton.type = 'button';
|
||||
@@ -143,8 +150,8 @@ export function render({ navigate }) {
|
||||
submitButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
|
||||
const cryptoState = getCryptoRuntimeState();
|
||||
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
||||
const cryptoState = getCryptoRuntimeState();
|
||||
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
|
||||
status.style.display = '';
|
||||
@@ -169,6 +176,33 @@ export function render({ navigate }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Используем предсгенерированный keyBundle или генерируем заново
|
||||
let keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
if (!keyBundle) {
|
||||
keyBundle = await authService.derivePasswordKeyBundle(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.password,
|
||||
);
|
||||
}
|
||||
|
||||
// Регистрация на Solana (смарт контракт)
|
||||
submitButton.textContent = 'Регистрация в Solana...';
|
||||
try {
|
||||
await registerUserOnSolana({
|
||||
login: state.registrationDraft.login,
|
||||
keyBundle,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
});
|
||||
} catch (solanaError) {
|
||||
const solanaMsg = String(solanaError?.message || '');
|
||||
// Пользователь уже зарегистрирован в Solana — продолжаем
|
||||
if (!solanaMsg.includes('already') && !solanaMsg.includes('UserAlreadyExists')) {
|
||||
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Регистрация на сервере SHiNE
|
||||
submitButton.textContent = 'Регистрация на сервере...';
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
|
||||
state.registrationDraft.flowType = 'registration';
|
||||
@@ -200,7 +234,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
card.children[1].append(walletRow);
|
||||
card.children[2].append(balanceRow);
|
||||
card.append(topupButton, submitButton, status);
|
||||
card.append(topupButton, showKeysButton, submitButton, status);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
|
||||
Reference in New Issue
Block a user