UI: FAQ регистрации и режим пароля из 12 слов

This commit is contained in:
AidarKC 2026-06-20 19:05:45 +04:00
parent 3b12e14e71
commit fec5e49304
11 changed files with 766 additions and 64 deletions

View File

@ -0,0 +1,25 @@
# Регистрация: FAQ и режим пароля из 12 слов
- краткое описание:
- на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
- добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
- такой же режим добавлен и на экран входа по логину и паролю.
- что проверять:
- на стартовом экране открыть `Зарегистрироваться`;
- убедиться, что внизу экрана есть кнопки FAQ;
- открыть несколько вопросов и проверить возврат обратно на регистрацию;
- включить галочку `Представить пароль в виде 12 слов`;
- убедиться, что появляется сетка с нумерованными полями в 3 колонки;
- ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
- выключить галочку и проверить, что пароль остаётся собранным в одном поле;
- открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
- пройти регистрацию до шага оплаты без ошибок интерфейса.
- ожидаемый результат:
- FAQ открывается отдельным экраном и содержит понятные ответы;
- режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
- пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
- статус:
- pending

View File

@ -1,2 +1,2 @@
client.version=1.2.223
client.version=1.2.225
server.version=1.2.211

View File

@ -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,

View File

@ -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 = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: логин нормализуется как trim().toLowerCase(). При непустом пароле используется Argon2id, затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">Схема деривации: логин нормализуется как trim().toLowerCase(). При непустом пароле используется Argon2id, затем из результата строится секрет.</p>
<p class="meta-muted">Из секрета строятся root key, blockchain key и device key. Обычно можно не вникать в это подробно и просто хранить всё на своём устройстве.</p>
<p class="meta-muted">Режим 12 слов ничего не меняет в протоколе: слова просто склеиваются в один обычный пароль длиной до 256 символов.</p>
<p class="meta-muted">Если пароль пустой используется прежний детерминированный режим совместимости.</p>
<p class="meta-muted">Для тестов можно оставить пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32.</p>
`;
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 = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label>
`;
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;

View File

@ -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 }) {
<p class="field-label">Первый сервер SHiNE</p>
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p>
<p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p>
<p class="meta-muted">При регистрации этот сервер будет записан в вашу PDA как первый сервер доступа.</p>
<p class="meta-muted">При желании его можно изменить в настройках до регистрации или позже. В будущем будет поддерживаться мультисерверная модель, но сейчас используется один основной сервер.</p>
<p class="meta-muted">Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.</p>
`;
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 = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.</p>
<p class="meta-muted">Если пароль пустой используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
<p class="meta-muted">Схема деривации: логин и пароль проходят через Argon2id, после чего получается главный секрет.</p>
<p class="meta-muted">Из этого секрета строятся три ключа: root key для основной публичной записи и важных изменений, blockchain key для подписания действий SHiNE в блокчейне, device key для входа и работы конкретного устройства.</p>
<p class="meta-muted">Разделение нужно, чтобы можно было аккуратнее выдавать права устройствам. Но если у вас нет большой суммы на счёте и нет повышенного риска, обычно можно просто хранить всё на своём устройстве.</p>
<p class="meta-muted">Режим 12 слов не меняет формат пароля и не меняет API: слова просто склеиваются в одну строку длиной до 256 символов.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.</p>
`;
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 = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label>
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
`;
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');

View File

@ -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 = `
<div class="badge alt">Вопросы о регистрации</div>
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
`;
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;
}

View File

@ -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;

View File

@ -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',

View File

@ -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 }, () => '');
}

View File

@ -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: '',

View File

@ -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; /* крупнее (бар-иконки); герой ниже ещё больше */