Files
SHiNE-server/shine-UI/js/pages/registration-payment-view.js
T

441 lines
17 KiB
JavaScript

import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
refreshSessions,
setAuthError,
setAuthInfo,
state,
} from '../state.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
formatSol,
getBalanceSol,
getTopupSiteUrl,
} from '../services/solana-wallet-service.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import {
formatSolanaErrorDetails,
isUserAlreadyExistsSolanaError,
registerUserOnSolana,
} from '../services/solana-register-service.js';
import { defaultServerLogin } from '../deploy-config.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REQUIRED_SOL = 0.01;
const AUTO_LOGIN_INITIAL_DELAY_MS = 10000;
const AUTO_LOGIN_RETRY_MS = 2000;
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
function getExplorerClusterName(endpoint) {
const source = String(endpoint || '').trim().toLowerCase();
if (!source) return 'mainnet-beta';
if (source.includes('devnet')) return 'devnet';
if (source.includes('testnet')) return 'testnet';
return 'mainnet-beta';
}
function makeSolanaExplorerTxUrl(signature, endpoint) {
const cleanSignature = String(signature || '').trim();
if (!cleanSignature) return '';
const cluster = getExplorerClusterName(endpoint);
const url = new URL(`https://explorer.solana.com/tx/${encodeURIComponent(cleanSignature)}`);
if (cluster !== 'mainnet-beta') {
url.searchParams.set('cluster', cluster);
}
return url.toString();
}
function parseBalanceSol(value) {
const parsed = Number.parseFloat(String(value || '').replace(',', '.'));
return Number.isFinite(parsed) ? parsed : 0;
}
function getCryptoRuntimeState() {
const hasCrypto = Boolean(globalThis.crypto);
const hasGetRandomValues = Boolean(globalThis.crypto && typeof globalThis.crypto.getRandomValues === 'function');
const hasSubtle = Boolean(globalThis.crypto && (globalThis.crypto.subtle || globalThis.crypto.webkitSubtle));
const secureContext = window.isSecureContext === true;
return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext };
}
async function completeRegistrationLogin({ navigate, keyBundle }) {
await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.createSessionForExistingUser(
state.registrationDraft.login,
state.registrationDraft.password,
);
state.keyStorage.saveRoot = Boolean(state.keyStorage.saveRoot);
state.keyStorage.saveBlockchain = Boolean(state.keyStorage.saveBlockchain);
await authService.persistSelectedKeys(
result.login,
result.storagePwd,
keyBundle,
{
saveRoot: state.keyStorage.saveRoot,
saveBlockchain: state.keyStorage.saveBlockchain,
},
);
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
await clearStoredMessages().catch(() => {});
const resumed = await authService.resumeSession(result.login, result.sessionId);
const resumedLogin = resumed.login || result.login;
const resumedSessionId = resumed.sessionId || result.sessionId;
const resumedStoragePwd = resumed.storagePwd || result.storagePwd;
authorizeSession({
login: resumedLogin,
sessionId: resumedSessionId,
storagePwd: resumedStoragePwd,
});
state.loginDraft.login = resumedLogin;
state.loginDraft.password = '';
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.flowType = '';
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.passwordWordsLinked = false;
state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null;
state.registrationDraft.preGeneratedKeyBundle = null;
state.registrationPayment.walletAddress = '';
state.registrationPayment.balanceSOL = '0.0000';
await refreshSessions();
setAuthInfo(`Регистрация завершена. Вы автоматически вошли как @${resumedLogin}.`);
const nextHash = String(state.authReturnHash || '').trim();
state.authReturnHash = '';
if (nextHash.startsWith('/')) {
navigate(nextHash.slice(1));
} else {
navigate('profile-view');
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack';
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const walletValue = document.createElement('input');
walletValue.className = 'input';
walletValue.type = 'text';
walletValue.value = state.registrationPayment.walletAddress || '';
walletValue.readOnly = true;
const walletRow = document.createElement('div');
walletRow.className = 'inline-input-row';
const copyButton = document.createElement('button');
copyButton.className = 'ghost-btn';
copyButton.type = 'button';
copyButton.textContent = 'Скопировать номер';
copyButton.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(walletValue.value);
copyButton.textContent = 'Скопировано';
window.setTimeout(() => {
copyButton.textContent = 'Скопировать номер';
}, 1500);
} catch {
status.className = 'status-line is-unavailable';
status.textContent = 'Не удалось скопировать номер кошелька.';
status.style.display = '';
}
});
walletRow.append(walletValue, copyButton);
const balanceRow = document.createElement('div');
balanceRow.className = 'row wrap-row';
const balanceValue = document.createElement('strong');
balanceValue.textContent = `${formatSol(parseBalanceSol(state.registrationPayment.balanceSOL), 6)} SOL`;
const refreshButton = document.createElement('button');
refreshButton.className = 'square-btn';
refreshButton.type = 'button';
refreshButton.textContent = '↻';
refreshButton.title = 'Обновить';
const refreshBalance = async ({ showError = true, addressOverride = '' } = {}) => {
const address = String(addressOverride || walletValue.value || '').trim();
if (!address) return null;
refreshButton.disabled = true;
try {
const balance = await getBalanceSol({
endpoint: state.entrySettings.solanaServer,
address,
});
state.registrationPayment.balanceSOL = String(balance.sol);
balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`;
return Number(balance.sol) || 0;
} catch (error) {
if (showError) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось обновить баланс: ${error?.message || 'unknown'}`;
status.style.display = '';
}
return null;
} finally {
refreshButton.disabled = false;
}
};
const deriveUserWalletAddress = async () => {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.');
const { publicKeyB64 } = keyBundle.clientPair;
const raw = atob(publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await loadSolanaWeb3();
const address = new PublicKey(bytes).toBase58();
state.registrationPayment.walletAddress = address;
walletValue.value = address;
return address;
};
refreshButton.addEventListener('click', () => {
void refreshBalance();
});
balanceRow.append(balanceValue, refreshButton);
const topupButton = document.createElement('button');
topupButton.className = 'ghost-btn';
topupButton.type = 'button';
topupButton.textContent = 'Пополнить кошелёк';
topupButton.addEventListener('click', async () => {
try {
const walletAddress = await deriveUserWalletAddress();
window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось подготовить кошелёк: ${error?.message || 'unknown'}`;
status.style.display = '';
}
});
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';
submitButton.textContent = 'Зарегистрироваться';
submitButton.addEventListener('click', async () => {
status.style.display = 'none';
const cryptoState = getCryptoRuntimeState();
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
status.className = 'status-line is-unavailable';
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
status.style.display = '';
return;
}
try {
submitButton.disabled = true;
submitButton.textContent = 'Регистрация...';
const walletAddress = await deriveUserWalletAddress();
const currentBalance = await refreshBalance({ showError: true, addressOverride: walletAddress });
if (currentBalance == null) return;
if (currentBalance < MIN_REQUIRED_SOL) {
status.className = 'status-line is-unavailable';
status.textContent = `Для регистрации нужно минимум ${MIN_REQUIRED_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL. Пополните на промо-странице или попросите перевод у знакомого с тестовыми SOL.`;
status.style.display = '';
const openTopup = window.confirm('Открыть страницу пополнения с вашим кошельком?');
if (openTopup) {
window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
}
return;
}
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.');
// Регистрация на Solana (смарт контракт)
submitButton.textContent = 'Регистрация в Solana...';
let registrationTxId = '';
try {
const registrationResult = await registerUserOnSolana({
login: state.registrationDraft.login,
keyBundle,
solanaEndpoint: state.entrySettings.solanaServer,
accessServers: [state.entrySettings.shineServerLogin || defaultServerLogin],
promoCode: '',
});
registrationTxId = String(registrationResult?.signature || '').trim();
} catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError);
// Пользователь уже зарегистрирован в Solana — продолжаем
if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) {
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
}
}
renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId });
} catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message);
status.className = 'status-line is-unavailable';
status.textContent = message;
status.style.display = '';
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Зарегистрироваться';
}
});
card.innerHTML = `
<p class="auth-copy">Для регистрации в тестовой Solana нужно минимум 0,01 SOL на вашем кошельке.</p>
<label class="stack"><span class="field-label">Номер кошелька (client.key)</span></label>
<div class="stack">
<span class="field-label">Баланс (Solana)</span>
</div>
`;
card.children[1].append(walletRow);
card.children[2].append(balanceRow);
card.append(topupButton, showKeysButton, submitButton, status);
screen.append(
renderHeader({
title: 'Оплата регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
}),
card,
);
(async () => {
try {
const walletAddress = await deriveUserWalletAddress();
await refreshBalance({ addressOverride: walletAddress });
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`;
status.style.display = '';
}
})();
return screen;
}
function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) {
const screen = document.querySelector('section.stack');
if (!screen) return;
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
const card = screen.querySelector('.card.stack');
if (!card) return;
card.classList.add('registration-finish-card');
let initialDelayId = null;
let retryTimerId = null;
let loginInFlight = false;
let loginCompleted = false;
const txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer);
const title = document.createElement('h2');
title.className = 'registration-finish-title';
title.textContent = 'Поздравляем, регистрация завершена';
const hint = document.createElement('p');
hint.className = 'auth-copy registration-finish-text';
hint.textContent = 'Подождите 10 секунд, пока обновится транзакция вашей регистрации в блокчейне Solana. После этого вход в аккаунт произойдёт автоматически.';
const txIdLine = document.createElement('p');
txIdLine.className = 'meta-muted registration-finish-tx';
if (registrationTxId && txExplorerUrl) {
const txLabel = document.createElement('span');
txLabel.textContent = 'Tx ID регистрации: ';
const txLink = document.createElement('a');
txLink.className = 'registration-finish-tx-link';
txLink.href = txExplorerUrl;
txLink.target = '_blank';
txLink.rel = 'noopener noreferrer';
txLink.textContent = registrationTxId;
txIdLine.append(txLabel, txLink);
} else {
txIdLine.textContent = 'Tx ID регистрации: ожидаем подтверждённую подпись';
}
const progress = document.createElement('p');
progress.className = 'meta-muted registration-finish-progress';
progress.textContent = 'Подготавливаем проверку регистрации...';
const stopAutoLogin = () => {
if (initialDelayId) {
window.clearTimeout(initialDelayId);
initialDelayId = null;
}
if (retryTimerId) {
window.clearInterval(retryTimerId);
retryTimerId = null;
}
};
const tryAutoLogin = async () => {
if (loginCompleted || loginInFlight) return;
loginInFlight = true;
progress.textContent = 'Проверяем, прошла ли регистрация...';
status.style.display = 'none';
try {
await completeRegistrationLogin({ navigate, keyBundle });
loginCompleted = true;
stopAutoLogin();
} catch (error) {
if (!loginCompleted) {
progress.textContent = 'Пока ещё не прошла, подождите ещё секундочку...';
console.warn('Auto login after registration is not ready yet', toUserMessage(error, 'auto-login-wait'));
}
} finally {
loginInFlight = false;
}
};
if (headerBackButton) {
const replacement = headerBackButton.cloneNode(true);
replacement.addEventListener('click', () => {
loginCompleted = true;
stopAutoLogin();
navigate('start-view');
});
headerBackButton.replaceWith(replacement);
}
card.innerHTML = '';
card.append(title, hint, txIdLine, progress, status);
initialDelayId = window.setTimeout(() => {
if (loginCompleted) return;
void tryAutoLogin();
retryTimerId = window.setInterval(() => {
void tryAutoLogin();
}, AUTO_LOGIN_RETRY_MS);
}, AUTO_LOGIN_INITIAL_DELAY_MS);
}