import { renderHeader } from '../components/header.js'; import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { checkLoginExistsOnSolana, formatSolanaErrorDetails, isSolanaRpcUnavailableError, precheckLoginClassOnSolana, } from '../services/solana-register-service.js'; import { emptyPasswordWords, PASSWORD_MAX_LENGTH, } from '../services/password-words.js'; import { openRegistrationFaq } from './registration-faq-view.js'; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; const MIN_REGISTRATION_LOGIN_LENGTH = 8; function normalizeLoginForTemporaryUiGuard(login) { const source = String(login || '').trim(); if (!source || source.length > 20) return null; let normalized = ''; for (const ch of source) { if (ch === '_') continue; if (!/[0-9A-Za-z]/.test(ch)) return null; normalized += ch.toLowerCase(); } if (!normalized || normalized.length > 20) return null; return normalized; } function makePasswordToggleIcons() { return { eye: ` `, eyeOff: ` `, }; } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack registration-screen'; clearAuthMessages(); const form = document.createElement('div'); form.className = 'card stack registration-form'; state.registrationDraft.passwordMode = 'single'; state.registrationDraft.passwordWords = emptyPasswordWords(); state.registrationDraft.passwordWordsLinked = false; state.registrationDraft.usePromoCode = false; state.registrationDraft.promoCode = ''; let loginCheckTimer = 0; let loginCheckRunId = 0; const loginInput = document.createElement('input'); loginInput.className = 'input'; loginInput.type = 'text'; loginInput.autocomplete = 'off'; loginInput.autocapitalize = 'off'; loginInput.spellcheck = false; loginInput.value = state.registrationDraft.login; loginInput.placeholder = 'Введите логин'; const passwordInput = document.createElement('input'); passwordInput.className = 'input'; passwordInput.type = 'password'; passwordInput.name = 'shine-register-password'; passwordInput.autocomplete = 'new-password'; passwordInput.autocapitalize = 'off'; passwordInput.spellcheck = false; passwordInput.maxLength = PASSWORD_MAX_LENGTH; passwordInput.value = String(state.registrationDraft.password || ''); passwordInput.placeholder = 'Введите пароль'; const passwordIcons = makePasswordToggleIcons(); const passwordToggleButton = document.createElement('button'); passwordToggleButton.className = 'icon-btn key-toggle-btn registration-password-toggle'; passwordToggleButton.type = 'button'; passwordToggleButton.setAttribute('aria-label', 'Показать пароль'); passwordToggleButton.setAttribute('title', 'Показать пароль'); passwordToggleButton.innerHTML = passwordIcons.eyeOff; const passwordInputRow = document.createElement('div'); passwordInputRow.className = 'inline-input-row'; passwordInputRow.append(passwordInput, passwordToggleButton); const statusText = document.createElement('p'); statusText.className = 'status-line registration-login-status'; statusText.style.display = 'none'; const formError = document.createElement('p'); formError.className = 'status-line is-unavailable'; formError.style.display = 'none'; // Компактная ссылка на экран «Вопросы о регистрации» (registration-faq-view). const faqButton = document.createElement('button'); faqButton.className = 'registration-faq-link'; faqButton.type = 'button'; faqButton.textContent = 'Вопросы о регистрации'; faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation')); const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; const nextButton = document.createElement('button'); nextButton.className = 'primary-btn'; nextButton.type = 'button'; nextButton.textContent = 'Далее'; const passwordLengthText = document.createElement('p'); passwordLengthText.className = 'password-length-hint'; let lastCheckedLogin = ''; let lastCheckedFree = false; let lastCheckedClassName = ''; let generationRunId = 0; function getCurrentPassword() { return String(passwordInput.value || ''); } function updatePasswordLength() { passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`; } function setStatusMessage(message, kind = '') { statusText.textContent = message; statusText.className = kind ? `status-line registration-login-status ${kind}` : 'status-line registration-login-status'; statusText.style.display = message ? '' : 'none'; } function resetLoginCheckState() { lastCheckedLogin = ''; lastCheckedFree = false; lastCheckedClassName = ''; loginCheckRunId += 1; if (loginCheckTimer) { window.clearTimeout(loginCheckTimer); loginCheckTimer = 0; } } function scheduleAvailabilityCheck() { resetLoginCheckState(); setStatusMessage(''); if (!loginInput.value.trim()) return; loginCheckTimer = window.setTimeout(() => { loginCheckTimer = 0; runAvailabilityCheck({ automatic: true }).catch(() => {}); }, 1500); } function togglePasswordVisibility() { const reveal = passwordInput.type === 'password'; passwordInput.type = reveal ? 'text' : 'password'; passwordToggleButton.innerHTML = reveal ? passwordIcons.eye : passwordIcons.eyeOff; passwordToggleButton.setAttribute('aria-label', reveal ? 'Скрыть пароль' : 'Показать пароль'); passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль'); } function syncDraftState() { state.registrationDraft.login = String(loginInput.value.trim()); state.registrationDraft.password = getCurrentPassword(); } async function runAvailabilityCheck({ automatic = false } = {}) { const runId = ++loginCheckRunId; const login = loginInput.value.trim(); if (!login) { setStatusMessage(automatic ? '' : 'Введите логин'); formError.style.display = 'none'; return false; } const normalizedLogin = normalizeLoginForTemporaryUiGuard(login); if (!normalizedLogin) { setStatusMessage('Логин содержит недопустимые символы или имеет неверную длину ❌', 'is-unavailable'); formError.style.display = 'none'; return false; } if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) { setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, 'is-unavailable'); formError.style.display = 'none'; return false; } if (login === lastCheckedLogin) { if (!lastCheckedFree) { setStatusMessage('Логин уже занят ❌', 'is-unavailable'); } else if (lastCheckedClassName === 'free') { setStatusMessage('Логин свободен ✅', 'is-available'); } else if (lastCheckedClassName === 'premium') { setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable'); } else if (lastCheckedClassName === 'company') { setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable'); } else { setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); } formError.style.display = 'none'; return lastCheckedFree && lastCheckedClassName === 'free'; } setStatusMessage('Проверяем логин...'); try { const check = await checkLoginExistsOnSolana({ login, solanaEndpoint: state.entrySettings.solanaServer, }); if (runId !== loginCheckRunId) return false; const isFree = !check.exists; let className = ''; if (isFree) { try { const precheck = await precheckLoginClassOnSolana({ login, solanaEndpoint: state.entrySettings.solanaServer, }); className = precheck.className; } catch (precheckError) { className = 'free'; console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError)); } } lastCheckedLogin = login; lastCheckedFree = isFree; lastCheckedClassName = className; if (!isFree) { setStatusMessage('Логин уже занят ❌', 'is-unavailable'); } else if (className === 'free') { setStatusMessage('Логин свободен ✅', 'is-available'); } else if (className === 'premium') { setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable'); } else if (className === 'company') { setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable'); } else { setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); } formError.style.display = 'none'; return isFree && className === 'free'; } catch (error) { if (runId !== loginCheckRunId) return false; if (isSolanaRpcUnavailableError(error)) { setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable'); return false; } const base = toUserMessage(error, 'Не удалось проверить логин'); const details = formatSolanaErrorDetails(error); setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable'); return false; } } passwordToggleButton.addEventListener('click', togglePasswordVisibility); loginInput.addEventListener('input', () => { syncDraftState(); scheduleAvailabilityCheck(); }); passwordInput.addEventListener('input', () => { syncDraftState(); updatePasswordLength(); }); nextButton.addEventListener('click', async () => { formError.style.display = 'none'; const isFree = await runAvailabilityCheck(); if (!isFree) return; const prevLogin = String(state.registrationDraft.login || ''); const prevPassword = String(state.registrationDraft.password || ''); const nextLogin = String(loginInput.value.trim()); const nextPassword = getCurrentPassword(); if (nextPassword.length === 0) { formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.'; formError.style.display = ''; return; } if (nextPassword.length > PASSWORD_MAX_LENGTH) { formError.textContent = `Пароль получился слишком длинным. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`; formError.style.display = ''; return; } const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword; state.registrationDraft.login = nextLogin; state.registrationDraft.password = nextPassword; if (credsChanged) { state.registrationDraft.preGeneratedKeyBundle = null; } startGenerationStage(); }); function renderInputStage() { form.innerHTML = ''; const loginField = document.createElement('label'); loginField.className = 'stack'; loginField.innerHTML = 'Логин'; const passwordLabel = document.createElement('label'); passwordLabel.className = 'stack registration-password-single'; passwordLabel.innerHTML = 'Пароль'; form.append(loginField, statusText, passwordLabel); loginField.append(loginInput); passwordLabel.append(passwordInputRow); form.append(passwordLengthText, formError, faqButton); actions.innerHTML = ''; actions.append(nextButton); updatePasswordLength(); syncDraftState(); } async function startGenerationStage() { const runId = ++generationRunId; form.innerHTML = ''; const title = document.createElement('p'); title.className = 'auth-copy'; title.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.'; const subtitle = document.createElement('p'); subtitle.className = 'meta-muted'; subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены recovery key, root key, blockchain key и client key.'; const details = document.createElement('p'); details.className = 'meta-muted'; details.textContent = 'Параметры: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.'; const details2 = document.createElement('p'); details2.className = 'meta-muted'; details2.textContent = 'Замедление нужно специально: оно усложняет подбор пароля и повышает цену атак на видеокартах и GPU.'; const details3 = document.createElement('p'); details3.className = 'meta-muted'; details3.textContent = `Длина вашего текущего пароля: ${getCurrentPassword().length} символов.`; const progressWrap = document.createElement('div'); progressWrap.className = 'registration-progress'; const progressBar = document.createElement('div'); progressBar.className = 'registration-progress-bar'; progressWrap.append(progressBar); const progressText = document.createElement('p'); progressText.className = 'meta-muted'; progressText.textContent = 'Подготовка...'; const genError = document.createElement('p'); genError.className = 'status-line is-unavailable'; genError.style.display = 'none'; form.append(title, subtitle, details, details2, details3, progressWrap, progressText, genError); const cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost-btn'; cancelBtn.type = 'button'; cancelBtn.textContent = 'Отмена'; cancelBtn.addEventListener('click', () => { generationRunId += 1; renderInputStage(); }); actions.innerHTML = ''; actions.append(cancelBtn); try { if (!state.registrationDraft.preGeneratedKeyBundle) { const keyBundle = await authService.derivePasswordKeyBundle( state.registrationDraft.login, state.registrationDraft.password, { onProgress: ({ percent, message }) => { if (runId !== generationRunId) return; const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); progressBar.style.width = `${safePercent}%`; progressText.textContent = `${safePercent}% · ${String(message || '').trim()}`; }, isCancelled: () => runId !== generationRunId, }, ); if (runId !== generationRunId) return; state.registrationDraft.preGeneratedKeyBundle = keyBundle; } if (runId !== generationRunId) return; progressBar.style.width = '100%'; progressText.textContent = '100%'; title.textContent = 'Ключи сгенерированы'; window.setTimeout(() => navigate('registration-payment-view'), 350); } catch (error) { if (runId !== generationRunId) return; if (String(error?.message || '') === 'DERIVE_CANCELLED') { renderInputStage(); return; } genError.textContent = `Ошибка генерации ключей: ${error?.message || 'неизвестная ошибка'}`; genError.style.display = ''; const retry = document.createElement('button'); retry.className = 'primary-btn'; retry.type = 'button'; retry.textContent = 'Повторить'; retry.addEventListener('click', startGenerationStage); const goBack = document.createElement('button'); goBack.className = 'ghost-btn'; goBack.type = 'button'; goBack.textContent = 'Назад'; goBack.addEventListener('click', renderInputStage); actions.innerHTML = ''; actions.append(goBack, retry); } } renderInputStage(); screen.append( renderHeader({ title: 'Зарегистрироваться', leftAction: { label: '←', onClick: () => { resetRegistrationFlow(); navigate('start-view'); }, }, }), form, actions, ); return screen; }