SHiNE-server/shine-UI/js/pages/register-view.js

509 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
checkLoginExistsOnSolana,
formatSolanaErrorDetails,
precheckLoginClassOnSolana,
} from '../services/solana-register-service.js';
import {
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
import { openRegistrationFaq } 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);
return { section, inputs, preview };
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
clearAuthMessages();
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';
loginInput.autocomplete = 'off';
loginInput.autocapitalize = 'off';
loginInput.spellcheck = false;
loginInput.value = state.registrationDraft.login;
loginInput.placeholder = 'Введите логин';
const passwordInput = document.createElement('input');
passwordInput.className = 'input';
passwordInput.type = 'password';
passwordInput.name = 'shine-register-password';
passwordInput.autocomplete = 'new-password';
passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false;
passwordInput.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';
statusText.textContent = 'Проверка логина: не выполнена';
const serverNotice = document.createElement('div');
serverNotice.className = 'card stack';
serverNotice.innerHTML = `
<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">Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.</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 = 'Если хотите подробнее понять схему деривации, ключи, первый сервер и формат 12 слов, откройте отдельный экран с вопросами.';
const faqButton = document.createElement('button');
faqButton.className = 'ghost-btn';
faqButton.type = 'button';
faqButton.textContent = 'Частые вопросы';
faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation'));
faqCard.append(faqTitle, faqText, faqButton);
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn';
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 passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'status-line';
let lastCheckedLogin = '';
let lastCheckedFree = false;
let lastCheckedClassName = '';
let generationRunId = 0;
function getCurrentPassword() {
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
}
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
passwordInput.style.display = wordsMode ? 'none' : '';
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) {
statusText.textContent = 'Введите логин';
formError.style.display = 'none';
return false;
}
if (login === lastCheckedLogin) {
if (!lastCheckedFree) {
statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'status-line is-unavailable';
} else if (lastCheckedClassName === 'free') {
statusText.textContent = 'Логин свободен ✅';
statusText.className = 'status-line is-available';
} else if (lastCheckedClassName === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'status-line is-unavailable';
} else if (lastCheckedClassName === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'status-line is-unavailable';
} else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'status-line is-unavailable';
}
formError.style.display = 'none';
return lastCheckedFree && lastCheckedClassName === 'free';
}
checkButton.disabled = true;
checkButton.textContent = 'Проверка...';
try {
const check = await checkLoginExistsOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
const isFree = !check.exists;
let className = '';
let precheckWarning = '';
if (isFree) {
try {
const precheck = await precheckLoginClassOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
className = precheck.className;
} catch (precheckError) {
className = 'free';
precheckWarning = formatSolanaErrorDetails(precheckError);
}
}
lastCheckedLogin = login;
lastCheckedFree = isFree;
lastCheckedClassName = className;
if (!isFree) {
statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'status-line is-unavailable';
} else if (className === 'free') {
statusText.textContent = precheckWarning
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
: 'Логин свободен ✅';
statusText.className = 'status-line is-available';
} else if (className === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'status-line is-unavailable';
} else if (className === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'status-line is-unavailable';
} else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'status-line is-unavailable';
}
formError.style.display = 'none';
return isFree && className === 'free';
} catch (error) {
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
statusText.textContent = `${base}. Детали: ${details}`;
statusText.className = 'status-line is-unavailable';
return false;
} finally {
checkButton.disabled = false;
checkButton.textContent = 'Проверить логин';
}
}
checkButton.addEventListener('click', runAvailabilityCheck);
loginInput.addEventListener('input', () => {
syncDraftState();
lastCheckedLogin = '';
});
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();
});
nextButton.addEventListener('click', async () => {
formError.style.display = 'none';
const isFree = await runAvailabilityCheck();
if (!isFree) return;
const prevLogin = String(state.registrationDraft.login || '');
const prevPassword = String(state.registrationDraft.password || '');
const nextLogin = String(loginInput.value.trim());
const nextPassword = 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;
}
renderSecurityConfirmStage();
});
function renderInputStage() {
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
`;
const loginField = form.children[0];
passwordField = form.children[1];
loginField.append(loginInput);
passwordField.append(passwordInput);
form.append(passwordModeToggle, wordsSection, passwordLengthText, serverNotice, checkButton, statusText, faqCard, formError);
actions.innerHTML = '';
actions.append(backButton, nextButton);
updatePasswordModeVisibility();
syncDraftState();
}
function renderSecurityConfirmStage() {
form.innerHTML = '';
const info = document.createElement('p');
info.className = 'auth-copy';
info.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.';
const details = document.createElement('p');
details.className = 'meta-muted';
details.textContent = 'Параметры: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.';
const details2 = document.createElement('p');
details2.className = 'meta-muted';
details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
const details3 = document.createElement('p');
details3.className = 'meta-muted';
details3.textContent = 'Замедление нужно специально: оно усложняет подбор пароля и повышает цену атак на видеокартах и GPU.';
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';
back2.type = 'button';
back2.textContent = 'Назад';
back2.addEventListener('click', renderInputStage);
const ok = document.createElement('button');
ok.className = 'primary-btn';
ok.type = 'button';
ok.textContent = 'Окей';
ok.addEventListener('click', startGenerationStage);
actions.innerHTML = '';
actions.append(back2, ok);
}
async function startGenerationStage() {
const runId = ++generationRunId;
form.innerHTML = '';
const title = document.createElement('p');
title.className = 'auth-copy';
title.textContent = 'Генерация ключей...';
const subtitle = document.createElement('p');
subtitle.className = 'meta-muted';
subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.';
const progressWrap = document.createElement('div');
progressWrap.className = 'registration-progress';
const progressBar = document.createElement('div');
progressBar.className = 'registration-progress-bar';
progressWrap.append(progressBar);
const progressText = document.createElement('p');
progressText.className = 'meta-muted';
progressText.textContent = 'Подготовка...';
const genError = document.createElement('p');
genError.className = 'status-line is-unavailable';
genError.style.display = 'none';
form.append(title, subtitle, progressWrap, progressText, genError);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ghost-btn';
cancelBtn.type = 'button';
cancelBtn.textContent = 'Отмена';
cancelBtn.addEventListener('click', () => {
generationRunId += 1;
renderSecurityConfirmStage();
});
actions.innerHTML = '';
actions.append(cancelBtn);
try {
if (!state.registrationDraft.preGeneratedKeyBundle) {
const keyBundle = await authService.derivePasswordKeyBundle(
state.registrationDraft.login,
state.registrationDraft.password,
{
onProgress: ({ percent, message }) => {
if (runId !== generationRunId) return;
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
progressBar.style.width = `${safePercent}%`;
progressText.textContent = `${safePercent}% · ${String(message || '').trim()}`;
},
isCancelled: () => runId !== generationRunId,
},
);
if (runId !== generationRunId) return;
state.registrationDraft.preGeneratedKeyBundle = keyBundle;
}
if (runId !== generationRunId) return;
progressBar.style.width = '100%';
progressText.textContent = '100%';
title.textContent = 'Ключи сгенерированы';
window.setTimeout(() => navigate('registration-payment-view'), 350);
} catch (error) {
if (runId !== generationRunId) return;
if (String(error?.message || '') === 'DERIVE_CANCELLED') {
renderSecurityConfirmStage();
return;
}
genError.textContent = `Ошибка генерации ключей: ${error?.message || 'неизвестная ошибка'}`;
genError.style.display = '';
const retry = document.createElement('button');
retry.className = 'primary-btn';
retry.type = 'button';
retry.textContent = 'Повторить';
retry.addEventListener('click', startGenerationStage);
const goBack = document.createElement('button');
goBack.className = 'ghost-btn';
goBack.type = 'button';
goBack.textContent = 'Назад';
goBack.addEventListener('click', renderSecurityConfirmStage);
actions.innerHTML = '';
actions.append(goBack, retry);
}
}
renderInputStage();
screen.append(
renderHeader({
title: 'Зарегистрироваться',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
form,
actions,
);
return screen;
}