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'; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; clearAuthMessages(); const form = document.createElement('div'); form.className = 'card stack'; 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.value = state.registrationDraft.password; passwordInput.placeholder = 'Введите пароль (можно оставить пустым)'; 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'}.

При регистрации этот сервер будет записан в вашу PDA как первый сервер доступа.

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

`; const formError = document.createElement('p'); formError.className = 'status-line is-unavailable'; formError.style.display = 'none'; const advanced = document.createElement('details'); advanced.className = 'card stack'; advanced.innerHTML = ` Расширенные

Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.

В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.

Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).

Для тесто оставьте пустой пароль.

Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.

`; const checkButton = document.createElement('button'); checkButton.className = 'ghost-btn'; checkButton.type = 'button'; checkButton.textContent = 'Проверить логин'; let lastCheckedLogin = ''; let lastCheckedFree = false; let lastCheckedClassName = ''; let generationRunId = 0; 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 = 'is-unavailable'; } else if (lastCheckedClassName === 'free') { statusText.textContent = 'Логин свободен ✅'; statusText.className = 'is-available'; } else if (lastCheckedClassName === 'premium') { statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; statusText.className = 'is-unavailable'; } else if (lastCheckedClassName === 'company') { statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; statusText.className = 'is-unavailable'; } else { statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; statusText.className = '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 = 'is-unavailable'; } else if (className === 'free') { statusText.textContent = precheckWarning ? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})` : 'Логин свободен ✅'; statusText.className = 'is-available'; } else if (className === 'premium') { statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; statusText.className = 'is-unavailable'; } else if (className === 'company') { statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; statusText.className = 'is-unavailable'; } else { statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; statusText.className = '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 = 'is-unavailable'; return false; } finally { checkButton.disabled = false; checkButton.textContent = 'Проверить логин'; } } checkButton.addEventListener('click', runAvailabilityCheck); 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 = 'Далее'; 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 = String(passwordInput.value || ''); if (nextPassword.length === 0) { formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.'; formError.style.display = ''; return; } const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword; state.registrationDraft.login = nextLogin; state.registrationDraft.password = nextPassword; if (credsChanged) { state.registrationDraft.preGeneratedKeyBundle = null; } renderSecurityConfirmStage(); }); actions.append(backButton, nextButton); function renderInputStage() { form.innerHTML = ` `; form.children[0].append(loginInput); form.children[1].append(passwordInput); form.append(serverNotice, checkButton, statusText, advanced, formError); actions.innerHTML = ''; actions.append(backButton, nextButton); backButton.disabled = false; nextButton.disabled = false; } 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 = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.'; const details3 = document.createElement('p'); details3.className = 'meta-muted'; details3.textContent = 'Это необходимо, чтобы усложнить подбор пароля и секрета.'; form.append(info, details, details2, details3); 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.style.width = '100%'; progressWrap.style.height = '10px'; progressWrap.style.border = '1px solid rgba(180,180,180,.5)'; progressWrap.style.borderRadius = '6px'; progressWrap.style.overflow = 'hidden'; const progressBar = document.createElement('div'); progressBar.style.height = '100%'; progressBar.style.width = '0%'; progressBar.style.background = 'rgba(80, 160, 255, 0.9)'; progressBar.style.transition = 'width 180ms linear'; 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; }