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
+205 -54
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');