407 lines
16 KiB
JavaScript
407 lines
16 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 SOLANA_SYNC_WAIT_SEC = 12;
|
||
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
|
||
|
||
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.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...';
|
||
try {
|
||
await registerUserOnSolana({
|
||
login: state.registrationDraft.login,
|
||
keyBundle,
|
||
solanaEndpoint: state.entrySettings.solanaServer,
|
||
accessServers: [state.entrySettings.shineServerLogin || defaultServerLogin],
|
||
});
|
||
} catch (solanaError) {
|
||
const solanaMsg = formatSolanaErrorDetails(solanaError);
|
||
// Пользователь уже зарегистрирован в Solana — продолжаем
|
||
if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) {
|
||
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
|
||
}
|
||
}
|
||
|
||
renderSolanaDoneStage({ navigate, status, keyBundle });
|
||
} 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 }) {
|
||
const screen = document.querySelector('section.stack');
|
||
if (!screen) return;
|
||
const card = screen.querySelector('.card.stack');
|
||
if (!card) return;
|
||
|
||
let remainingSec = SOLANA_SYNC_WAIT_SEC;
|
||
let timerId = null;
|
||
let loginStarted = false;
|
||
|
||
const info = document.createElement('p');
|
||
info.className = 'auth-copy';
|
||
info.textContent = 'Регистрация завершена успешно.';
|
||
|
||
const hint = document.createElement('p');
|
||
hint.className = 'meta-muted';
|
||
hint.textContent = 'Нужно подождать 10–15 секунд, пока в блокчейне SHiNE обновится ваша запись о регистрации. После этого мы автоматически войдём в ваш аккаунт.';
|
||
|
||
const timer = document.createElement('p');
|
||
timer.className = 'meta-muted';
|
||
timer.textContent = `Готовим автоматический вход: ${remainingSec} сек`;
|
||
|
||
const tryLoginBtn = document.createElement('button');
|
||
tryLoginBtn.className = 'primary-btn';
|
||
tryLoginBtn.type = 'button';
|
||
tryLoginBtn.textContent = 'Входим автоматически...';
|
||
tryLoginBtn.disabled = true;
|
||
|
||
const statusHint = document.createElement('p');
|
||
statusHint.className = 'meta-muted';
|
||
statusHint.textContent = 'Ничего нажимать не нужно. Если сервер ответит раньше, войдём сразу.';
|
||
|
||
const stopTimer = () => {
|
||
if (timerId) {
|
||
window.clearInterval(timerId);
|
||
timerId = null;
|
||
}
|
||
};
|
||
|
||
const startAutoLogin = async () => {
|
||
if (loginStarted) return;
|
||
loginStarted = true;
|
||
stopTimer();
|
||
status.style.display = 'none';
|
||
timer.textContent = 'Пробуем автоматически войти...';
|
||
tryLoginBtn.textContent = 'Входим...';
|
||
try {
|
||
await completeRegistrationLogin({ navigate, keyBundle });
|
||
} catch (error) {
|
||
loginStarted = false;
|
||
status.className = 'status-line is-unavailable';
|
||
status.textContent = toUserMessage(error, 'Пока не удалось автоматически войти. Попробуйте ещё раз через несколько секунд.');
|
||
status.style.display = '';
|
||
timer.textContent = 'Автовход пока не удался.';
|
||
tryLoginBtn.disabled = false;
|
||
tryLoginBtn.textContent = 'Попробовать войти сейчас';
|
||
}
|
||
};
|
||
|
||
const updateTimerUi = () => {
|
||
if (remainingSec > 0) {
|
||
timer.textContent = `Готовим автоматический вход: ${remainingSec} сек`;
|
||
tryLoginBtn.textContent = `Автовход через ${remainingSec} сек`;
|
||
tryLoginBtn.disabled = true;
|
||
} else {
|
||
timer.textContent = 'Запускаем автоматический вход...';
|
||
tryLoginBtn.textContent = 'Входим...';
|
||
tryLoginBtn.disabled = true;
|
||
void startAutoLogin();
|
||
}
|
||
};
|
||
|
||
tryLoginBtn.addEventListener('click', async () => {
|
||
if (loginStarted) return;
|
||
await startAutoLogin();
|
||
});
|
||
|
||
card.innerHTML = '';
|
||
card.append(info, hint, statusHint, timer, tryLoginBtn, status);
|
||
updateTimerUi();
|
||
timerId = window.setInterval(() => {
|
||
remainingSec -= 1;
|
||
updateTimerUi();
|
||
}, 1000);
|
||
}
|