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 { openRegistrationFaq } from './registration-faq-view.js'; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; 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 }; } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; clearAuthMessages(); const form = document.createElement('div'); form.className = 'card stack'; let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords); 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 = passwordMode === 'single' ? state.registrationDraft.password : ''; passwordInput.placeholder = 'Введите пароль'; const { section: wordsSection, inputs: wordInputs, preview: wordsPreview, } = createWordsLayout({ words: passwordWords, onInput: (index, value) => { passwordWords[index] = value; syncDraftState(); }, }); 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 statusText = document.createElement('p'); statusText.className = 'meta-muted'; statusText.textContent = 'Проверка логина: не выполнена'; const serverNotice = document.createElement('div'); serverNotice.className = 'card stack'; serverNotice.innerHTML = `

Первый сервер SHiNE

Сейчас вашим первым и основным сервером будет серверный аккаунт ${state.entrySettings.shineServerLogin || 'shineupme'}.

Текущий адрес этого сервера: ${state.entrySettings.shineServerHttp || 'https://shineup.me'}.

Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.

`; const faqCard = document.createElement('div'); faqCard.className = 'card stack registration-faq-card'; const faqTitle = document.createElement('p'); faqTitle.className = 'field-label'; faqTitle.textContent = 'Частые вопросы перед регистрацией'; const faqText = document.createElement('p'); faqText.className = 'meta-muted'; faqText.textContent = 'Если хотите подробнее понять схему деривации, ключи, первый сервер и формат 12 слов, откройте отдельный экран с вопросами.'; const faqButton = document.createElement('button'); faqButton.className = 'ghost-btn'; faqButton.type = 'button'; faqButton.textContent = 'Частые вопросы'; faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation')); faqCard.append(faqTitle, faqText, faqButton); const formError = document.createElement('p'); formError.className = 'status-line is-unavailable'; formError.style.display = 'none'; const checkButton = document.createElement('button'); checkButton.className = 'ghost-btn'; checkButton.type = 'button'; checkButton.textContent = 'Проверить логин'; 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('start-view')); const nextButton = document.createElement('button'); nextButton.className = 'primary-btn'; nextButton.type = 'button'; nextButton.textContent = 'Далее'; let passwordField = null; const passwordLengthText = document.createElement('p'); passwordLengthText.className = 'status-line'; 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 updatePasswordModeVisibility() { const wordsMode = passwordMode === 'words'; wordsSection.style.display = wordsMode ? 'grid' : 'none'; if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid'; passwordInput.style.display = wordsMode ? 'none' : ''; updateWordsPreview(); } function syncDraftState() { state.registrationDraft.login = String(loginInput.value.trim()); state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); state.registrationDraft.password = getCurrentPassword(); } async function runAvailabilityCheck() { const login = loginInput.value.trim(); if (!login) { statusText.textContent = 'Введите логин'; formError.style.display = 'none'; return false; } if (login === lastCheckedLogin) { if (!lastCheckedFree) { statusText.textContent = 'Логин уже занят ❌'; statusText.className = 'status-line is-unavailable'; } else if (lastCheckedClassName === 'free') { statusText.textContent = 'Логин свободен ✅'; statusText.className = 'status-line is-available'; } else if (lastCheckedClassName === 'premium') { statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; statusText.className = 'status-line is-unavailable'; } else if (lastCheckedClassName === 'company') { statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; statusText.className = 'status-line is-unavailable'; } else { statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; statusText.className = 'status-line is-unavailable'; } formError.style.display = 'none'; return lastCheckedFree && lastCheckedClassName === 'free'; } checkButton.disabled = true; checkButton.textContent = 'Проверка...'; try { const check = await checkLoginExistsOnSolana({ login, solanaEndpoint: state.entrySettings.solanaServer, }); const isFree = !check.exists; let className = ''; let precheckWarning = ''; if (isFree) { 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) { statusText.textContent = 'Логин уже занят ❌'; statusText.className = 'status-line is-unavailable'; } else if (className === 'free') { statusText.textContent = precheckWarning ? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})` : 'Логин свободен ✅'; statusText.className = 'status-line is-available'; } else if (className === 'premium') { statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; statusText.className = 'status-line is-unavailable'; } else if (className === 'company') { statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; statusText.className = 'status-line is-unavailable'; } else { statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; statusText.className = 'status-line is-unavailable'; } formError.style.display = 'none'; return isFree && className === 'free'; } catch (error) { const base = toUserMessage(error, 'Не удалось проверить логин'); const details = formatSolanaErrorDetails(error); statusText.textContent = `${base}. Детали: ${details}`; statusText.className = 'status-line is-unavailable'; return false; } finally { checkButton.disabled = false; checkButton.textContent = 'Проверить логин'; } } checkButton.addEventListener('click', runAvailabilityCheck); loginInput.addEventListener('input', () => { syncDraftState(); lastCheckedLogin = ''; }); passwordInput.addEventListener('input', () => { syncDraftState(); }); passwordModeCheckbox.addEventListener('change', () => { const nextMode = passwordModeCheckbox.checked ? 'words' : 'single'; if (nextMode === passwordMode) return; if (nextMode === 'words') { passwordWords = emptyPasswordWords(); wordInputs.forEach((input) => { input.value = ''; }); passwordInput.value = ''; } else { passwordInput.value = composePasswordFromWords(passwordWords); } passwordMode = nextMode; updatePasswordModeVisibility(); syncDraftState(); }); 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; state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); if (credsChanged) { state.registrationDraft.preGeneratedKeyBundle = null; } renderSecurityConfirmStage(); }); function renderInputStage() { form.innerHTML = ` `; const loginField = form.children[0]; passwordField = form.children[1]; loginField.append(loginInput); passwordField.append(passwordInput); form.append(passwordModeToggle, wordsSection, passwordLengthText, serverNotice, checkButton, statusText, faqCard, formError); actions.innerHTML = ''; actions.append(backButton, nextButton); updatePasswordModeVisibility(); syncDraftState(); } function renderSecurityConfirmStage() { form.innerHTML = ''; const info = document.createElement('p'); info.className = 'auth-copy'; info.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.'; 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 = 'Из этого секрета строятся recovery key, root key, blockchain key и client key. Это может занять некоторое время.'; const details3 = document.createElement('p'); details3.className = 'meta-muted'; details3.textContent = 'Замедление нужно специально: оно усложняет подбор пароля и повышает цену атак на видеокартах и GPU.'; const details4 = document.createElement('p'); details4.className = 'meta-muted'; details4.textContent = `Длина вашего текущего пароля: ${getCurrentPassword().length} символов.`; form.append(info, details, details2, details3, details4); const back2 = document.createElement('button'); back2.className = 'ghost-btn'; back2.type = 'button'; back2.textContent = 'Назад'; back2.addEventListener('click', renderInputStage); const ok = document.createElement('button'); ok.className = 'primary-btn'; ok.type = 'button'; ok.textContent = 'Окей'; ok.addEventListener('click', startGenerationStage); actions.innerHTML = ''; actions.append(back2, ok); } async function startGenerationStage() { const runId = ++generationRunId; form.innerHTML = ''; const title = document.createElement('p'); title.className = 'auth-copy'; title.textContent = 'Генерация ключей...'; const subtitle = document.createElement('p'); subtitle.className = 'meta-muted'; subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.'; 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, progressWrap, progressText, genError); const cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost-btn'; cancelBtn.type = 'button'; cancelBtn.textContent = 'Отмена'; cancelBtn.addEventListener('click', () => { generationRunId += 1; renderSecurityConfirmStage(); }); 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') { renderSecurityConfirmStage(); 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', renderSecurityConfirmStage); actions.innerHTML = ''; actions.append(goBack, retry); } } renderInputStage(); screen.append( renderHeader({ title: 'Зарегистрироваться', leftAction: { label: '←', onClick: () => navigate('start-view') }, }), form, actions, ); return screen; }