From fec5e4930405095ede9e51736c92d97820b234cdf4fbb6d5777df7e577b175ec Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 20 Jun 2026 19:05:45 +0400 Subject: [PATCH] =?UTF-8?q?UI:=20FAQ=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B8=D0=B7=2012=20=D1=81=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-20_1839_registration_faq_and_12_words.md | 25 ++ VERSION.properties | 2 +- shine-UI/js/app.js | 2 + shine-UI/js/pages/login-password-view.js | 148 +++++++++- shine-UI/js/pages/register-view.js | 259 ++++++++++++++---- shine-UI/js/pages/registration-faq-view.js | 229 ++++++++++++++++ shine-UI/js/pages/registration-keys-view.js | 5 + shine-UI/js/router.js | 1 + shine-UI/js/services/password-words.js | 18 ++ shine-UI/js/state.js | 8 + shine-UI/styles/components.css | 133 +++++++++ 11 files changed, 766 insertions(+), 64 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md create mode 100644 shine-UI/js/pages/registration-faq-view.js create mode 100644 shine-UI/js/services/password-words.js diff --git a/Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md b/Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md new file mode 100644 index 0000000..64ade24 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md @@ -0,0 +1,25 @@ +# Регистрация: FAQ и режим пароля из 12 слов + +- краткое описание: + - на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки; + - добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API; + - такой же режим добавлен и на экран входа по логину и паролю. + +- что проверять: + - на стартовом экране открыть `Зарегистрироваться`; + - убедиться, что внизу экрана есть кнопки FAQ; + - открыть несколько вопросов и проверить возврат обратно на регистрацию; + - включить галочку `Представить пароль в виде 12 слов`; + - убедиться, что появляется сетка с нумерованными полями в 3 колонки; + - ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают; + - выключить галочку и проверить, что пароль остаётся собранным в одном поле; + - открыть экран входа по паролю и повторить те же проверки для режима `12 слов`; + - пройти регистрацию до шага оплаты без ошибок интерфейса. + +- ожидаемый результат: + - FAQ открывается отдельным экраном и содержит понятные ответы; + - режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль; + - пароль не отправляется в новом формате, а продолжает использоваться как одна строка. + +- статус: + - pending diff --git a/VERSION.properties b/VERSION.properties index 9eb540d..0a1ce67 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.223 +client.version=1.2.225 server.version=1.2.211 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 8e851ee..3d0348c 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -35,6 +35,7 @@ import { import * as startView from './pages/start-view.js?v=202606142105'; import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240'; import * as registerView from './pages/register-view.js'; +import * as registrationFaqView from './pages/registration-faq-view.js'; import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940'; import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; @@ -81,6 +82,7 @@ const routes = { 'start-view': startView, 'entry-settings-view': entrySettingsView, 'register-view': registerView, + 'registration-faq-view': registrationFaqView, 'registration-payment-view': registrationPaymentView, 'registration-keys-view': registrationKeysView, 'registration-draft-keys-view': registrationDraftKeysView, diff --git a/shine-UI/js/pages/login-password-view.js b/shine-UI/js/pages/login-password-view.js index 2c1541f..eef0a47 100644 --- a/shine-UI/js/pages/login-password-view.js +++ b/shine-UI/js/pages/login-password-view.js @@ -7,6 +7,55 @@ import { state, } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; +import { + composePasswordFromWords, + emptyPasswordWords, + normalizePasswordWords, + PASSWORD_MAX_LENGTH, + PASSWORD_WORDS_COUNT, +} from '../services/password-words.js'; + +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, preview); + return { section, inputs, preview }; +} export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false }; @@ -19,6 +68,9 @@ export function render({ navigate }) { const form = document.createElement('div'); form.className = 'card stack'; + let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; + let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords); + const loginInput = document.createElement('input'); loginInput.className = 'input'; loginInput.type = 'text'; @@ -35,21 +87,47 @@ export function render({ navigate }) { passwordInput.autocomplete = 'new-password'; passwordInput.autocapitalize = 'off'; passwordInput.spellcheck = false; - passwordInput.value = state.loginDraft.password; - passwordInput.placeholder = 'Введите пароль (можно оставить пустым)'; + passwordInput.maxLength = PASSWORD_MAX_LENGTH; + passwordInput.value = passwordMode === 'single' ? state.loginDraft.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 hint = document.createElement('p'); hint.className = 'meta-muted'; - hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.'; + hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.'; const advanced = document.createElement('details'); advanced.className = 'card stack'; advanced.innerHTML = ` Расширенные -

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

+

Схема деривации: логин нормализуется как trim().toLowerCase(). При непустом пароле используется Argon2id, затем из результата строится секрет.

+

Из секрета строятся root key, blockchain key и device key. Обычно можно не вникать в это подробно и просто хранить всё на своём устройстве.

+

Режим 12 слов ничего не меняет в протоколе: слова просто склеиваются в один обычный пароль длиной до 256 символов.

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

-

Для тестов можно оставить пустой пароль.

-

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

+

Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32.

`; const status = document.createElement('p'); @@ -60,13 +138,59 @@ export function render({ navigate }) { testLoginsHint.className = 'meta-muted'; testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).'; + function getCurrentPassword() { + return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || ''); + } + + function syncDraftState() { + state.loginDraft.login = loginInput.value.trim(); + state.loginDraft.passwordMode = passwordMode; + state.loginDraft.passwordWords = normalizePasswordWords(passwordWords); + state.loginDraft.password = getCurrentPassword(); + } + + function updateWordsPreview() { + const password = composePasswordFromWords(passwordWords); + const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length; + wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`; + } + + function updatePasswordModeVisibility() { + const wordsMode = passwordMode === 'words'; + wordsSection.hidden = !wordsMode; + passwordInput.parentElement.hidden = wordsMode; + updateWordsPreview(); + } + form.innerHTML = ` `; form.children[0].append(loginInput); form.children[1].append(passwordInput); - form.append(hint, advanced, status, testLoginsHint); + form.append(passwordModeToggle, wordsSection, hint, advanced, status, testLoginsHint); + updatePasswordModeVisibility(); + syncDraftState(); + + loginInput.addEventListener('input', syncDraftState); + 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(); + }); const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; @@ -83,14 +207,18 @@ export function render({ navigate }) { enterButton.textContent = 'Войти'; enterButton.addEventListener('click', async () => { status.style.display = 'none'; - state.loginDraft.login = loginInput.value.trim(); - state.loginDraft.password = passwordInput.value; + syncDraftState(); if (!state.loginDraft.login) { status.textContent = 'Введите логин.'; status.style.display = ''; return; } + if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) { + status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`; + status.style.display = ''; + return; + } setAuthBusy(true); setAuthError(''); @@ -103,6 +231,8 @@ export function render({ navigate }) { state.registrationDraft.flowType = 'login'; state.registrationDraft.login = result.login; state.registrationDraft.password = state.loginDraft.password; + state.registrationDraft.passwordMode = state.loginDraft.passwordMode; + state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords); state.registrationDraft.sessionId = result.sessionId; state.registrationDraft.storagePwd = result.storagePwd; state.registrationDraft.pendingKeyBundle = result.keyBundle; diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index 2f34291..d808732 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -1,4 +1,4 @@ -import { renderHeader } from '../components/header.js'; +import { renderHeader } from '../components/header.js'; import { authService, clearAuthMessages, state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { @@ -6,9 +6,59 @@ import { 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, REGISTRATION_FAQ_TOPICS } 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, preview); + return { section, inputs, preview }; +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -18,6 +68,9 @@ export function render({ navigate }) { 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'; @@ -34,8 +87,33 @@ export function render({ navigate }) { passwordInput.autocomplete = 'new-password'; passwordInput.autocapitalize = 'off'; passwordInput.spellcheck = false; - passwordInput.value = state.registrationDraft.password; - passwordInput.placeholder = 'Введите пароль (можно оставить пустым)'; + 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'; @@ -47,10 +125,32 @@ export function render({ navigate }) {

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

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

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

-

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

-

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

+

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

`; + 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 = 'Нажмите на вопрос, чтобы открыть отдельный экран с кратким объяснением.'; + + const faqButtons = document.createElement('div'); + faqButtons.className = 'registration-faq-grid'; + REGISTRATION_FAQ_TOPICS.forEach((topic) => { + const button = document.createElement('button'); + button.className = 'ghost-btn'; + button.type = 'button'; + button.textContent = topic.shortTitle; + button.addEventListener('click', () => openRegistrationFaq(navigate, topic.id)); + faqButtons.append(button); + }); + faqCard.append(faqTitle, faqText, faqButtons); + const formError = document.createElement('p'); formError.className = 'status-line is-unavailable'; formError.style.display = 'none'; @@ -59,11 +159,11 @@ export function render({ navigate }) { advanced.className = 'card stack'; advanced.innerHTML = ` Расширенные -

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

-

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

-

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

-

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

-

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

+

Схема деривации: логин и пароль проходят через Argon2id, после чего получается главный секрет.

+

Из этого секрета строятся три ключа: root key для основной публичной записи и важных изменений, blockchain key для подписания действий SHiNE в блокчейне, device key для входа и работы конкретного устройства.

+

Разделение нужно, чтобы можно было аккуратнее выдавать права устройствам. Но если у вас нет большой суммы на счёте и нет повышенного риска, обычно можно просто хранить всё на своём устройстве.

+

Режим 12 слов не меняет формат пароля и не меняет API: слова просто склеиваются в одну строку длиной до 256 символов.

+

Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.

`; const checkButton = document.createElement('button'); @@ -71,11 +171,49 @@ export function render({ navigate }) { 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 lastCheckedLogin = ''; let lastCheckedFree = false; let lastCheckedClassName = ''; let generationRunId = 0; + function getCurrentPassword() { + return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || ''); + } + + function updateWordsPreview() { + const password = composePasswordFromWords(passwordWords); + const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length; + wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`; + } + + function updatePasswordModeVisibility() { + const wordsMode = passwordMode === 'words'; + wordsSection.hidden = !wordsMode; + passwordInput.parentElement.hidden = wordsMode; + 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) { @@ -87,19 +225,19 @@ export function render({ navigate }) { if (login === lastCheckedLogin) { if (!lastCheckedFree) { statusText.textContent = 'Логин уже занят ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } else if (lastCheckedClassName === 'free') { statusText.textContent = 'Логин свободен ✅'; - statusText.className = 'is-available'; + statusText.className = 'status-line is-available'; } else if (lastCheckedClassName === 'premium') { statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } else if (lastCheckedClassName === 'company') { statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } else { statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } formError.style.display = 'none'; return lastCheckedFree && lastCheckedClassName === 'free'; @@ -132,21 +270,21 @@ export function render({ navigate }) { lastCheckedClassName = className; if (!isFree) { statusText.textContent = 'Логин уже занят ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } else if (className === 'free') { statusText.textContent = precheckWarning ? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})` : 'Логин свободен ✅'; - statusText.className = 'is-available'; + statusText.className = 'status-line is-available'; } else if (className === 'premium') { statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } else if (className === 'company') { statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } else { statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; } formError.style.display = 'none'; return isFree && className === 'free'; @@ -154,7 +292,7 @@ export function render({ navigate }) { const base = toUserMessage(error, 'Не удалось проверить логин'); const details = formatSolanaErrorDetails(error); statusText.textContent = `${base}. Детали: ${details}`; - statusText.className = 'is-unavailable'; + statusText.className = 'status-line is-unavailable'; return false; } finally { checkButton.disabled = false; @@ -164,19 +302,32 @@ export function render({ navigate }) { checkButton.addEventListener('click', runAvailabilityCheck); - const actions = document.createElement('div'); - actions.className = 'auth-footer-actions'; + loginInput.addEventListener('input', () => { + syncDraftState(); + lastCheckedLogin = ''; + }); - const backButton = document.createElement('button'); - backButton.className = 'ghost-btn'; - backButton.type = 'button'; - backButton.textContent = 'Назад'; - backButton.addEventListener('click', () => navigate('start-view')); + 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(); + }); - 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(); @@ -185,16 +336,23 @@ export function render({ navigate }) { const prevLogin = String(state.registrationDraft.login || ''); const prevPassword = String(state.registrationDraft.password || ''); const nextLogin = String(loginInput.value.trim()); - const nextPassword = String(passwordInput.value || ''); + 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; } @@ -202,20 +360,18 @@ export function render({ navigate }) { 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); + form.append(passwordModeToggle, wordsSection, serverNotice, checkButton, statusText, faqCard, advanced, formError); actions.innerHTML = ''; actions.append(backButton, nextButton); - backButton.disabled = false; - nextButton.disabled = false; + updatePasswordModeVisibility(); + syncDraftState(); } function renderSecurityConfirmStage() { @@ -223,8 +379,7 @@ export function render({ navigate }) { const info = document.createElement('p'); info.className = 'auth-copy'; - info.textContent = - 'Для повышения безопасности мы генерируем секрет из вашего пароля с помощью Argon2id.'; + info.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.'; const details = document.createElement('p'); details.className = 'meta-muted'; @@ -232,14 +387,17 @@ export function render({ navigate }) { const details2 = document.createElement('p'); details2.className = 'meta-muted'; - details2.textContent = - 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.'; + details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.'; const details3 = document.createElement('p'); details3.className = 'meta-muted'; - details3.textContent = 'Это необходимо, чтобы усложнить подбор пароля и секрета.'; + details3.textContent = 'Замедление нужно специально: оно усложняет подбор пароля и повышает цену атак на видеокартах и GPU.'; - form.append(info, details, details2, details3); + 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'; @@ -270,17 +428,10 @@ export function render({ navigate }) { 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'; + progressWrap.className = 'registration-progress'; 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'; + progressBar.className = 'registration-progress-bar'; progressWrap.append(progressBar); const progressText = document.createElement('p'); diff --git a/shine-UI/js/pages/registration-faq-view.js b/shine-UI/js/pages/registration-faq-view.js new file mode 100644 index 0000000..e888962 --- /dev/null +++ b/shine-UI/js/pages/registration-faq-view.js @@ -0,0 +1,229 @@ +import { renderHeader } from '../components/header.js'; +import { state } from '../state.js'; + +export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false }; + +export const REGISTRATION_FAQ_TOPICS = [ + { + id: 'keys-storage', + shortTitle: 'Где ключи', + title: 'У кого хранятся ключи?', + paragraphs: [ + 'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.', + 'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.', + 'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.', + ], + }, + { + id: 'reliability', + shortTitle: 'Надёжность', + title: 'Насколько это надёжно?', + paragraphs: [ + 'Мы делаем ставку на открытость: клиентский код открыт, серверный код открыт, протокол открыт. Это позволяет проверять систему независимо, а не верить обещаниям на слово.', + 'Мы рекомендуем использовать браузеры с открытым исходным кодом. Позже планируются отдельные приложения для Android, iPhone, Ubuntu Touch и Linux, тоже с открытым кодом.', + 'Проект распространяется по лицензии AGPL v3. Часть важных данных и регистрационных записей также опирается на блокчейн-слой, чтобы уменьшать зависимость от одной закрытой стороны.', + ], + }, + { + id: 'key-derivation', + shortTitle: 'Деривация', + title: 'Как генерируются ключи и что делает пароль?', + paragraphs: [ + 'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.', + 'Уже из этого секрета детерминированно строятся три основных ключа: root key, blockchain key и device key.', + 'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.', + ], + }, + { + id: 'three-keys', + shortTitle: 'Три ключа', + title: 'Зачем нужны три ключа?', + paragraphs: [ + 'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.', + 'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.', + 'Device key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.', + 'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.', + ], + }, + { + id: 'generation-time', + shortTitle: 'Зачем время', + title: 'Зачем нужна заметная пауза при генерации?', + paragraphs: [ + 'Генерация специально сделана не мгновенной. Это усложняет массовый подбор паролей.', + 'Argon2id расходует и время, и память, поэтому атаки на GPU и видеокартах становятся заметно дороже и медленнее.', + 'Небольшая задержка при создании секрета здесь работает как дополнительная защита.', + ], + }, + { + id: 'strong-password', + shortTitle: 'Какой пароль', + title: 'Какой пароль считается надёжным?', + paragraphs: [ + 'Минимально разумный уровень сейчас начинается примерно от 8 символов.', + 'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.', + 'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.', + ], + }, + { + id: 'one-or-twelve', + shortTitle: '1 или 12 слов', + title: 'Чем отличается один пароль от режима 12 слов?', + paragraphs: [ + 'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.', + 'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.', + 'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.', + ], + }, + { + id: 'why-own-password', + shortTitle: 'Зачем свой', + title: 'Почему лучше иметь свой пароль и свои ключи?', + paragraphs: [ + 'Чем дальше, тем проще будет подделывать фотографию, голос, интонацию и даже другие привычные признаки личности с помощью нейросетей.', + 'На расстоянии всё сложнее будет понять, что перед вами действительно вы, если опираться только на внешние признаки.', + 'Поэтому персональные ключи, которые храните только вы, становятся надёжнее, чем зависимость от сторонней организации, которая держит ключи у себя.', + ], + }, + { + id: 'first-server', + shortTitle: 'Первый сервер', + title: 'Что такое первый сервер SHiNE?', + paragraphs: [ + 'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.', + 'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.', + 'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.', + 'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.', + ], + }, + { + id: 'hardware-device', + shortTitle: 'ESP32', + title: 'Нужно ли отдельное устройство для ключей?', + paragraphs: [ + 'Идеальный вариант для важных ключей: отдельное физическое устройство, которое вы контролируете сами.', + 'Если пока не хотите покупать отдельное устройство, можно пользоваться телефоном. Но отдельный контроллер или мини-устройство обычно даёт лучший контроль и более понятную модель доверия.', + 'Красивая готовая модель Waveshare на ESP32-S3 Touch AMOLED 2.16 стоит около 32 долларов. Есть и более дешёвые варианты на открытых чипах, примерно от 10 до 15 долларов.', + 'Если у вас другая модель, под неё можно адаптировать открытую прошивку. Для простых переносов это реально сделать довольно быстро.', + ], + links: [ + { + label: 'Документация Waveshare ESP32-S3 Touch AMOLED 2.16', + href: 'https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16', + }, + ], + }, + { + id: 'wallet-device', + shortTitle: 'Кошелёк', + title: 'Можно ли использовать такое устройство как кошелёк?', + paragraphs: [ + 'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.', + 'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.', + 'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.', + ], + }, +]; + +function getTopicById(topicId) { + return REGISTRATION_FAQ_TOPICS.find((topic) => topic.id === topicId) || REGISTRATION_FAQ_TOPICS[0]; +} + +export function openRegistrationFaq(navigate, topicId) { + state.registrationHelp.selectedTopic = getTopicById(topicId).id; + navigate('registration-faq-view'); +} + +export function render({ navigate }) { + const selectedTopic = getTopicById(state.registrationHelp?.selectedTopic); + const screen = document.createElement('section'); + screen.className = 'stack'; + + const heroCard = document.createElement('div'); + heroCard.className = 'card stack registration-faq-hero'; + heroCard.innerHTML = ` +
Вопросы о регистрации
+

Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.

+ `; + + const topicCard = document.createElement('div'); + topicCard.className = 'card stack registration-faq-topic'; + + const question = document.createElement('h2'); + question.className = 'registration-faq-title'; + question.textContent = selectedTopic.title; + topicCard.append(question); + + selectedTopic.paragraphs.forEach((paragraph) => { + const p = document.createElement('p'); + p.className = 'auth-copy'; + p.textContent = paragraph; + topicCard.append(p); + }); + + if (Array.isArray(selectedTopic.links) && selectedTopic.links.length > 0) { + selectedTopic.links.forEach((linkItem) => { + const link = document.createElement('a'); + link.className = 'link-card'; + link.href = linkItem.href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = linkItem.label; + topicCard.append(link); + }); + } + + const topicsCard = document.createElement('div'); + topicsCard.className = 'card stack'; + + const topicsLabel = document.createElement('p'); + topicsLabel.className = 'field-label'; + topicsLabel.textContent = 'Другие вопросы'; + + const topicsGrid = document.createElement('div'); + topicsGrid.className = 'registration-faq-grid'; + + REGISTRATION_FAQ_TOPICS.forEach((topic) => { + const button = document.createElement('button'); + button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn'; + button.type = 'button'; + button.textContent = topic.shortTitle; + button.addEventListener('click', () => { + state.registrationHelp.selectedTopic = topic.id; + navigate('registration-faq-view'); + }); + topicsGrid.append(button); + }); + + topicsCard.append(topicsLabel, topicsGrid); + + 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('register-view')); + + const registerButton = document.createElement('button'); + registerButton.className = 'primary-btn'; + registerButton.type = 'button'; + registerButton.textContent = 'К регистрации'; + registerButton.addEventListener('click', () => navigate('register-view')); + + actions.append(backButton, registerButton); + + screen.append( + renderHeader({ + title: 'Вопросы о регистрации', + leftAction: { label: '←', onClick: () => navigate('register-view') }, + }), + heroCard, + topicCard, + topicsCard, + actions, + ); + + return screen; +} diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index 8543d15..461f08a 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -11,6 +11,7 @@ import { clearStoredMessages } from '../services/message-store.js'; import { toUserMessage } from '../services/ui-error-texts.js'; export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false }; +const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => ''); export function render({ navigate }) { const screen = document.createElement('section'); @@ -122,8 +123,12 @@ export function render({ navigate }) { state.loginDraft.login = state.registrationDraft.login; 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; diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 26d5df4..aa83be5 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -6,6 +6,7 @@ export const PRE_AUTH_PAGES = [ 'start-view', 'entry-settings-view', 'register-view', + 'registration-faq-view', 'registration-payment-view', 'registration-draft-keys-view', 'registration-keys-view', diff --git a/shine-UI/js/services/password-words.js b/shine-UI/js/services/password-words.js new file mode 100644 index 0000000..29f5ad9 --- /dev/null +++ b/shine-UI/js/services/password-words.js @@ -0,0 +1,18 @@ +export const PASSWORD_WORDS_COUNT = 12; +export const PASSWORD_MAX_LENGTH = 256; + +export function normalizePasswordWords(wordsLike) { + const words = Array.isArray(wordsLike) ? wordsLike : []; + return Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => String(words[index] || '')); +} + +export function composePasswordFromWords(wordsLike) { + return normalizePasswordWords(wordsLike) + .map((word) => word.trim()) + .filter(Boolean) + .join(' '); +} + +export function emptyPasswordWords() { + return Array.from({ length: PASSWORD_WORDS_COUNT }, () => ''); +} diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 09ecfe0..d6be9a2 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -7,6 +7,7 @@ import { DEFAULT_SHINE_SERVER_WS, resolveShineServerByServerLogin, } from './services/shine-server-resolver.js'; +import { emptyPasswordWords } from './services/password-words.js'; const clone = (value) => JSON.parse(JSON.stringify(value)); const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1'; @@ -260,15 +261,22 @@ function createInitialState({ withStoredSession = true } = {}) { flowType: '', login: '', password: '', + passwordMode: 'single', + passwordWords: emptyPasswordWords(), sessionId: '', storagePwd: '', pendingKeyBundle: null, pendingSessionMaterial: null, preGeneratedKeyBundle: null, }, + registrationHelp: { + selectedTopic: 'keys-storage', + }, loginDraft: { login: storedSession?.login || '', password: '', + passwordMode: 'single', + passwordWords: emptyPasswordWords(), }, registrationPayment: { walletAddress: '', diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index d962b97..885e618 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -407,6 +407,11 @@ text-align: center; } +.auth-screen--lower { + align-content: start; + padding-top: clamp(80px, 18vh, 180px); +} + .auth-logo { width: 126px; height: 126px; @@ -434,6 +439,22 @@ width: 100%; } +.login-panel { + width: min(100%, 360px); + gap: 14px; +} + +.login-panel--wide { + width: min(100%, 420px); +} + +.login-panel-title { + font-size: 28px; + font-weight: 700; + color: var(--text); + text-align: center; +} + .auth-footer-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -4161,6 +4182,118 @@ html, body { overflow-x: hidden; } text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); } +.registration-toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(132, 162, 228, 0.22); + background: rgba(20, 31, 52, 0.72); + color: #eef3ff; + line-height: 1.4; +} + +.registration-toggle input { + width: 18px; + height: 18px; + accent-color: #d4af37; +} + +.registration-words-block[hidden] { + display: none; +} + +.registration-words-block { + display: grid; + gap: 10px; +} + +.registration-words-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.registration-word-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.registration-word-number { + font-size: 12px; + color: #b2c2e6; + min-width: 18px; + text-align: right; +} + +.registration-word-input { + min-height: 44px; + padding-left: 10px; +} + +.registration-faq-card { + gap: 12px; +} + +.registration-faq-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.registration-faq-grid .ghost-btn, +.registration-faq-grid .secondary-btn { + min-height: 44px; + text-align: left; +} + +@media (max-width: 740px) { + .registration-words-grid, + .registration-faq-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 460px) { + .registration-words-grid, + .registration-faq-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.registration-faq-hero { + gap: 12px; +} + +.registration-faq-topic { + gap: 12px; +} + +.registration-faq-title { + margin: 0; + font-size: 22px; + line-height: 1.2; + color: #f6deb0; +} + +.registration-progress { + width: 100%; + height: 10px; + border: 1px solid rgba(180, 180, 180, 0.5); + border-radius: 6px; + overflow: hidden; +} + +.registration-progress-bar { + height: 100%; + width: 0%; + background: rgba(80, 160, 255, 0.9); + transition: width 180ms linear; +} + /* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */ .toolbar-icon-img { --tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */