SHA256
UI: FAQ регистрации и режим пароля из 12 слов
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user