import { renderHeader } from '../components/header.js'; import { authService, clearAuthMessages, state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { checkLoginExistsOnSolana, formatSolanaErrorDetails, precheckLoginClassOnSolana, } from '../services/solana-register-service.js'; import { composePasswordFromWords, emptyPasswordWords, normalizePasswordWords, PASSWORD_MAX_LENGTH, PASSWORD_WORDS_COUNT, } from '../services/password-words.js'; import { sha256Text } from '../services/crypto-utils.js'; import { openRegistrationFaq } from './registration-faq-view.js'; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; // ВРЕМЕННАЯ UI-ЗАГЛУШКА: // пока полноценная логика продажи/выдачи коротких имён не внедрена on-chain, // UI пускает логины 5..7 символов только по временному коду. const TEMP_MIN_LOGIN_WITHOUT_PROMO = 8; const TEMP_ABSOLUTE_MIN_LOGIN_LEN = 5; const TEMP_PROMO_HASH_HEX = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'; function bytesToHex(bytes) { return Array.from(bytes || [], (b) => b.toString(16).padStart(2, '0')).join(''); } async function isTemporaryPromoAccepted(value) { const digest = await sha256Text(String(value || '')); return bytesToHex(digest) === TEMP_PROMO_HASH_HEX; } 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 createWordsLayout({ words, onInput }) { const section = document.createElement('div'); section.className = 'registration-words-block'; const grid = document.createElement('div'); grid.className = 'registration-words-grid'; const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => { const row = document.createElement('label'); row.className = 'registration-word-row'; const number = document.createElement('span'); number.className = 'registration-word-number'; number.textContent = `${index + 1}.`; const input = document.createElement('input'); input.className = 'input registration-word-input'; input.type = 'text'; input.autocomplete = 'off'; input.autocapitalize = 'off'; input.spellcheck = false; input.maxLength = 32; input.value = words[index]; input.addEventListener('input', () => onInput(index, input.value)); row.append(number, input); grid.append(row); return input; }); const hint = document.createElement('p'); hint.className = 'meta-muted'; hint.textContent = 'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.'; const preview = document.createElement('p'); preview.className = 'status-line'; section.append(grid, hint); return { section, inputs, preview }; } 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'; let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords); let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked); let usePromoCode = Boolean(state.registrationDraft.usePromoCode); 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 { section: wordsSection, inputs: wordInputs, preview: wordsPreview, } = createWordsLayout({ words: passwordWords, onInput: (index, value) => { passwordWords[index] = value; passwordWordsLinked = true; syncDraftState(); updateWordsPreview(); }, }); const passwordModeToggle = document.createElement('label'); passwordModeToggle.className = 'registration-toggle'; const passwordModeCheckbox = document.createElement('input'); passwordModeCheckbox.type = 'checkbox'; passwordModeCheckbox.checked = passwordMode === 'words'; const passwordModeLabel = document.createElement('span'); passwordModeLabel.textContent = 'Представить пароль в виде 12 слов'; passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel); const promoToggle = document.createElement('label'); promoToggle.className = 'registration-toggle'; const promoCheckbox = document.createElement('input'); promoCheckbox.type = 'checkbox'; promoCheckbox.checked = usePromoCode; const promoToggleLabel = document.createElement('span'); promoToggleLabel.textContent = 'У меня есть промокод'; promoToggle.append(promoCheckbox, promoToggleLabel); const promoField = document.createElement('label'); promoField.className = 'stack'; const promoFieldLabel = document.createElement('span'); promoFieldLabel.className = 'field-label'; promoFieldLabel.textContent = 'Промокод'; const promoInput = document.createElement('input'); promoInput.className = 'input'; promoInput.type = 'text'; promoInput.autocomplete = 'off'; promoInput.autocapitalize = 'off'; promoInput.spellcheck = false; promoInput.value = String(state.registrationDraft.promoCode || ''); promoInput.placeholder = 'Вставьте промокод'; const promoHint = document.createElement('p'); promoHint.className = 'meta-muted'; promoHint.textContent = 'Временный режим: логины длиной 5-7 символов доступны только по специальному коду. Любое другое значение считается неверным промокодом.'; promoField.append(promoFieldLabel, promoInput, promoHint); 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 = 'Далее'; let passwordField = null; const passwordLengthText = document.createElement('p'); passwordLengthText.className = 'password-length-hint'; let lastCheckedLogin = ''; let lastCheckedFree = false; let lastCheckedClassName = ''; let generationRunId = 0; function getCurrentPassword() { return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || ''); } function updateWordsPreview() { const password = getCurrentPassword(); const text = `Итоговая длина пароля: ${password.length} символов.`; wordsPreview.textContent = text; passwordLengthText.textContent = text; } 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 updatePasswordModeVisibility() { const wordsMode = passwordMode === 'words'; wordsSection.style.display = wordsMode ? 'grid' : 'none'; if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid'; passwordInputRow.style.display = wordsMode ? 'grid' : 'none'; updateWordsPreview(); } function syncDraftState() { state.registrationDraft.login = String(loginInput.value.trim()); state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); state.registrationDraft.passwordWordsLinked = passwordWordsLinked; state.registrationDraft.password = getCurrentPassword(); state.registrationDraft.usePromoCode = usePromoCode; state.registrationDraft.promoCode = String(promoInput.value || '').trim(); } function updatePromoVisibility() { promoField.style.display = usePromoCode ? 'grid' : 'none'; } async function runAvailabilityCheck({ automatic = false } = {}) { const runId = ++loginCheckRunId; const login = loginInput.value.trim(); const promoCode = String(promoInput.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 < TEMP_ABSOLUTE_MIN_LOGIN_LEN) { setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable'); formError.style.display = 'none'; return false; } const wantsShortLogin = normalizedLogin.length < TEMP_MIN_LOGIN_WITHOUT_PROMO; let promoAccepted = false; if (usePromoCode) { if (!promoCode) { setStatusMessage('Введите промокод ❌', 'is-unavailable'); formError.style.display = 'none'; return false; } promoAccepted = await isTemporaryPromoAccepted(promoCode); if (!promoAccepted) { setStatusMessage('Неверный промокод ❌', 'is-unavailable'); formError.style.display = 'none'; return false; } } else if (wantsShortLogin) { setStatusMessage(`Логины короче ${TEMP_MIN_LOGIN_WITHOUT_PROMO} символов временно доступны только по специальному коду ❌`, 'is-unavailable'); formError.style.display = 'none'; return false; } if (login === lastCheckedLogin) { if (!lastCheckedFree) { setStatusMessage('Логин уже занят ❌', 'is-unavailable'); } else if (lastCheckedClassName === 'promo') { setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available'); } 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' || lastCheckedClassName === 'promo'); } setStatusMessage('Проверяем логин...'); try { const check = await checkLoginExistsOnSolana({ login, solanaEndpoint: state.entrySettings.solanaServer, }); if (runId !== loginCheckRunId) return false; const isFree = !check.exists; let className = ''; let precheckWarning = ''; if (isFree) { if (promoAccepted) { className = 'promo'; } else { try { const precheck = await precheckLoginClassOnSolana({ login, solanaEndpoint: state.entrySettings.solanaServer, }); className = precheck.className; } catch (precheckError) { className = 'free'; precheckWarning = formatSolanaErrorDetails(precheckError); } } } lastCheckedLogin = login; lastCheckedFree = isFree; lastCheckedClassName = className; if (!isFree) { setStatusMessage('Логин уже занят ❌', 'is-unavailable'); } else if (className === 'promo') { setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available'); } else if (className === 'free') { setStatusMessage( precheckWarning ? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})` : 'Логин свободен ✅', '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' || className === 'promo'); } catch (error) { if (runId !== loginCheckRunId) 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', () => { if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) { passwordWords = emptyPasswordWords(); passwordWordsLinked = false; wordInputs.forEach((input) => { input.value = ''; }); } syncDraftState(); updateWordsPreview(); }); passwordModeCheckbox.addEventListener('change', () => { const nextMode = passwordModeCheckbox.checked ? 'words' : 'single'; if (nextMode === passwordMode) return; if (nextMode === 'words') { if (!passwordWordsLinked) { passwordWords = emptyPasswordWords(); wordInputs.forEach((input) => { input.value = ''; }); passwordInput.value = ''; } } else { passwordInput.value = composePasswordFromWords(passwordWords); passwordWordsLinked = true; } passwordMode = nextMode; updatePasswordModeVisibility(); updateWordsPreview(); syncDraftState(); }); promoCheckbox.addEventListener('change', () => { usePromoCode = promoCheckbox.checked; resetLoginCheckState(); updatePromoVisibility(); syncDraftState(); if (usePromoCode) { setStatusMessage('Временный код включён: для логинов 5-7 символов будет локальная проверка', 'is-available'); } else { setStatusMessage(''); scheduleAvailabilityCheck(); } }); promoInput.addEventListener('input', () => { resetLoginCheckState(); syncDraftState(); if (usePromoCode) scheduleAvailabilityCheck(); }); nextButton.addEventListener('click', async () => { formError.style.display = 'none'; const promoCode = String(promoInput.value || '').trim(); if (usePromoCode && !promoCode) { formError.textContent = 'Если включён временный код, поле должно быть заполнено.'; formError.style.display = ''; return; } 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; state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); state.registrationDraft.passwordWordsLinked = passwordWordsLinked; state.registrationDraft.usePromoCode = usePromoCode; state.registrationDraft.promoCode = promoCode; 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); passwordField = passwordLabel; loginField.append(loginInput); passwordField.append(passwordInputRow); form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton); actions.innerHTML = ''; actions.append(nextButton); updatePasswordModeVisibility(); updatePromoVisibility(); 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: () => navigate('start-view') }, }), form, actions, ); return screen; }