UI: FAQ регистрации и режим пароля из 12 слов
This commit is contained in:
parent
3b12e14e71
commit
fec5e49304
@ -0,0 +1,25 @@
|
|||||||
|
# Регистрация: FAQ и режим пароля из 12 слов
|
||||||
|
|
||||||
|
- краткое описание:
|
||||||
|
- на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
|
||||||
|
- добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
|
||||||
|
- такой же режим добавлен и на экран входа по логину и паролю.
|
||||||
|
|
||||||
|
- что проверять:
|
||||||
|
- на стартовом экране открыть `Зарегистрироваться`;
|
||||||
|
- убедиться, что внизу экрана есть кнопки FAQ;
|
||||||
|
- открыть несколько вопросов и проверить возврат обратно на регистрацию;
|
||||||
|
- включить галочку `Представить пароль в виде 12 слов`;
|
||||||
|
- убедиться, что появляется сетка с нумерованными полями в 3 колонки;
|
||||||
|
- ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
|
||||||
|
- выключить галочку и проверить, что пароль остаётся собранным в одном поле;
|
||||||
|
- открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
|
||||||
|
- пройти регистрацию до шага оплаты без ошибок интерфейса.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- FAQ открывается отдельным экраном и содержит понятные ответы;
|
||||||
|
- режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
|
||||||
|
- пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.223
|
client.version=1.2.225
|
||||||
server.version=1.2.211
|
server.version=1.2.211
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import {
|
|||||||
import * as startView from './pages/start-view.js?v=202606142105';
|
import * as startView from './pages/start-view.js?v=202606142105';
|
||||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
||||||
import * as registerView from './pages/register-view.js';
|
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 registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
||||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||||
@ -81,6 +82,7 @@ const routes = {
|
|||||||
'start-view': startView,
|
'start-view': startView,
|
||||||
'entry-settings-view': entrySettingsView,
|
'entry-settings-view': entrySettingsView,
|
||||||
'register-view': registerView,
|
'register-view': registerView,
|
||||||
|
'registration-faq-view': registrationFaqView,
|
||||||
'registration-payment-view': registrationPaymentView,
|
'registration-payment-view': registrationPaymentView,
|
||||||
'registration-keys-view': registrationKeysView,
|
'registration-keys-view': registrationKeysView,
|
||||||
'registration-draft-keys-view': registrationDraftKeysView,
|
'registration-draft-keys-view': registrationDraftKeysView,
|
||||||
|
|||||||
@ -7,6 +7,55 @@ import {
|
|||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.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 };
|
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||||
|
|
||||||
@ -19,6 +68,9 @@ export function render({ navigate }) {
|
|||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'card stack';
|
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');
|
const loginInput = document.createElement('input');
|
||||||
loginInput.className = 'input';
|
loginInput.className = 'input';
|
||||||
loginInput.type = 'text';
|
loginInput.type = 'text';
|
||||||
@ -35,21 +87,47 @@ export function render({ navigate }) {
|
|||||||
passwordInput.autocomplete = 'new-password';
|
passwordInput.autocomplete = 'new-password';
|
||||||
passwordInput.autocapitalize = 'off';
|
passwordInput.autocapitalize = 'off';
|
||||||
passwordInput.spellcheck = false;
|
passwordInput.spellcheck = false;
|
||||||
passwordInput.value = state.loginDraft.password;
|
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||||
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
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');
|
const hint = document.createElement('p');
|
||||||
hint.className = 'meta-muted';
|
hint.className = 'meta-muted';
|
||||||
hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.';
|
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
||||||
|
|
||||||
const advanced = document.createElement('details');
|
const advanced = document.createElement('details');
|
||||||
advanced.className = 'card stack';
|
advanced.className = 'card stack';
|
||||||
advanced.innerHTML = `
|
advanced.innerHTML = `
|
||||||
<summary>Расширенные</summary>
|
<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">Для тестов можно оставить пустой пароль.</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');
|
const status = document.createElement('p');
|
||||||
@ -60,13 +138,59 @@ export function render({ navigate }) {
|
|||||||
testLoginsHint.className = 'meta-muted';
|
testLoginsHint.className = 'meta-muted';
|
||||||
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).';
|
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).';
|
||||||
|
|
||||||
|
function getCurrentPassword() {
|
||||||
|
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDraftState() {
|
||||||
|
state.loginDraft.login = loginInput.value.trim();
|
||||||
|
state.loginDraft.passwordMode = passwordMode;
|
||||||
|
state.loginDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||||
|
state.loginDraft.password = getCurrentPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWordsPreview() {
|
||||||
|
const password = composePasswordFromWords(passwordWords);
|
||||||
|
const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length;
|
||||||
|
wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePasswordModeVisibility() {
|
||||||
|
const wordsMode = passwordMode === 'words';
|
||||||
|
wordsSection.hidden = !wordsMode;
|
||||||
|
passwordInput.parentElement.hidden = wordsMode;
|
||||||
|
updateWordsPreview();
|
||||||
|
}
|
||||||
|
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<label class="stack"><span class="field-label">Логин</span></label>
|
||||||
<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[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
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');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
@ -83,14 +207,18 @@ export function render({ navigate }) {
|
|||||||
enterButton.textContent = 'Войти';
|
enterButton.textContent = 'Войти';
|
||||||
enterButton.addEventListener('click', async () => {
|
enterButton.addEventListener('click', async () => {
|
||||||
status.style.display = 'none';
|
status.style.display = 'none';
|
||||||
state.loginDraft.login = loginInput.value.trim();
|
syncDraftState();
|
||||||
state.loginDraft.password = passwordInput.value;
|
|
||||||
|
|
||||||
if (!state.loginDraft.login) {
|
if (!state.loginDraft.login) {
|
||||||
status.textContent = 'Введите логин.';
|
status.textContent = 'Введите логин.';
|
||||||
status.style.display = '';
|
status.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
|
||||||
|
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
||||||
|
status.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAuthBusy(true);
|
setAuthBusy(true);
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
@ -103,6 +231,8 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.flowType = 'login';
|
state.registrationDraft.flowType = 'login';
|
||||||
state.registrationDraft.login = result.login;
|
state.registrationDraft.login = result.login;
|
||||||
state.registrationDraft.password = state.loginDraft.password;
|
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.sessionId = result.sessionId;
|
||||||
state.registrationDraft.storagePwd = result.storagePwd;
|
state.registrationDraft.storagePwd = result.storagePwd;
|
||||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, clearAuthMessages, state } from '../state.js';
|
import { authService, clearAuthMessages, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import {
|
||||||
@ -6,9 +6,59 @@ import {
|
|||||||
formatSolanaErrorDetails,
|
formatSolanaErrorDetails,
|
||||||
precheckLoginClassOnSolana,
|
precheckLoginClassOnSolana,
|
||||||
} from '../services/solana-register-service.js';
|
} 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 };
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
@ -18,6 +68,9 @@ export function render({ navigate }) {
|
|||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'card stack';
|
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');
|
const loginInput = document.createElement('input');
|
||||||
loginInput.className = 'input';
|
loginInput.className = 'input';
|
||||||
loginInput.type = 'text';
|
loginInput.type = 'text';
|
||||||
@ -34,8 +87,33 @@ export function render({ navigate }) {
|
|||||||
passwordInput.autocomplete = 'new-password';
|
passwordInput.autocomplete = 'new-password';
|
||||||
passwordInput.autocapitalize = 'off';
|
passwordInput.autocapitalize = 'off';
|
||||||
passwordInput.spellcheck = false;
|
passwordInput.spellcheck = false;
|
||||||
passwordInput.value = state.registrationDraft.password;
|
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||||
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)';
|
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');
|
const statusText = document.createElement('p');
|
||||||
statusText.className = 'meta-muted';
|
statusText.className = 'meta-muted';
|
||||||
@ -47,10 +125,32 @@ export function render({ navigate }) {
|
|||||||
<p class="field-label">Первый сервер SHiNE</p>
|
<p class="field-label">Первый сервер SHiNE</p>
|
||||||
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</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">Текущий адрес этого сервера: <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');
|
const formError = document.createElement('p');
|
||||||
formError.className = 'status-line is-unavailable';
|
formError.className = 'status-line is-unavailable';
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
@ -59,11 +159,11 @@ export function render({ navigate }) {
|
|||||||
advanced.className = 'card stack';
|
advanced.className = 'card stack';
|
||||||
advanced.innerHTML = `
|
advanced.innerHTML = `
|
||||||
<summary>Расширенные</summary>
|
<summary>Расширенные</summary>
|
||||||
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
|
<p class="meta-muted">Схема деривации: логин и пароль проходят через Argon2id, после чего получается главный секрет.</p>
|
||||||
<p class="meta-muted">В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.</p>
|
<p class="meta-muted">Из этого секрета строятся три ключа: root key для основной публичной записи и важных изменений, blockchain key для подписания действий SHiNE в блокчейне, device key для входа и работы конкретного устройства.</p>
|
||||||
<p class="meta-muted">Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
|
<p class="meta-muted">Разделение нужно, чтобы можно было аккуратнее выдавать права устройствам. Но если у вас нет большой суммы на счёте и нет повышенного риска, обычно можно просто хранить всё на своём устройстве.</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>
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const checkButton = document.createElement('button');
|
const checkButton = document.createElement('button');
|
||||||
@ -71,11 +171,49 @@ export function render({ navigate }) {
|
|||||||
checkButton.type = 'button';
|
checkButton.type = 'button';
|
||||||
checkButton.textContent = 'Проверить логин';
|
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 lastCheckedLogin = '';
|
||||||
let lastCheckedFree = false;
|
let lastCheckedFree = false;
|
||||||
let lastCheckedClassName = '';
|
let lastCheckedClassName = '';
|
||||||
let generationRunId = 0;
|
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() {
|
async function runAvailabilityCheck() {
|
||||||
const login = loginInput.value.trim();
|
const login = loginInput.value.trim();
|
||||||
if (!login) {
|
if (!login) {
|
||||||
@ -87,19 +225,19 @@ export function render({ navigate }) {
|
|||||||
if (login === lastCheckedLogin) {
|
if (login === lastCheckedLogin) {
|
||||||
if (!lastCheckedFree) {
|
if (!lastCheckedFree) {
|
||||||
statusText.textContent = 'Логин уже занят ❌';
|
statusText.textContent = 'Логин уже занят ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (lastCheckedClassName === 'free') {
|
} else if (lastCheckedClassName === 'free') {
|
||||||
statusText.textContent = 'Логин свободен ✅';
|
statusText.textContent = 'Логин свободен ✅';
|
||||||
statusText.className = 'is-available';
|
statusText.className = 'status-line is-available';
|
||||||
} else if (lastCheckedClassName === 'premium') {
|
} else if (lastCheckedClassName === 'premium') {
|
||||||
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (lastCheckedClassName === 'company') {
|
} else if (lastCheckedClassName === 'company') {
|
||||||
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else {
|
} else {
|
||||||
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
}
|
}
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
return lastCheckedFree && lastCheckedClassName === 'free';
|
return lastCheckedFree && lastCheckedClassName === 'free';
|
||||||
@ -132,21 +270,21 @@ export function render({ navigate }) {
|
|||||||
lastCheckedClassName = className;
|
lastCheckedClassName = className;
|
||||||
if (!isFree) {
|
if (!isFree) {
|
||||||
statusText.textContent = 'Логин уже занят ❌';
|
statusText.textContent = 'Логин уже занят ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (className === 'free') {
|
} else if (className === 'free') {
|
||||||
statusText.textContent = precheckWarning
|
statusText.textContent = precheckWarning
|
||||||
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
|
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
|
||||||
: 'Логин свободен ✅';
|
: 'Логин свободен ✅';
|
||||||
statusText.className = 'is-available';
|
statusText.className = 'status-line is-available';
|
||||||
} else if (className === 'premium') {
|
} else if (className === 'premium') {
|
||||||
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else if (className === 'company') {
|
} else if (className === 'company') {
|
||||||
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
} else {
|
} else {
|
||||||
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
}
|
}
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
return isFree && className === 'free';
|
return isFree && className === 'free';
|
||||||
@ -154,7 +292,7 @@ export function render({ navigate }) {
|
|||||||
const base = toUserMessage(error, 'Не удалось проверить логин');
|
const base = toUserMessage(error, 'Не удалось проверить логин');
|
||||||
const details = formatSolanaErrorDetails(error);
|
const details = formatSolanaErrorDetails(error);
|
||||||
statusText.textContent = `${base}. Детали: ${details}`;
|
statusText.textContent = `${base}. Детали: ${details}`;
|
||||||
statusText.className = 'is-unavailable';
|
statusText.className = 'status-line is-unavailable';
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
checkButton.disabled = false;
|
checkButton.disabled = false;
|
||||||
@ -164,19 +302,32 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
checkButton.addEventListener('click', runAvailabilityCheck);
|
checkButton.addEventListener('click', runAvailabilityCheck);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
loginInput.addEventListener('input', () => {
|
||||||
actions.className = 'auth-footer-actions';
|
syncDraftState();
|
||||||
|
lastCheckedLogin = '';
|
||||||
|
});
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
passwordInput.addEventListener('input', () => {
|
||||||
backButton.className = 'ghost-btn';
|
syncDraftState();
|
||||||
backButton.type = 'button';
|
});
|
||||||
backButton.textContent = 'Назад';
|
|
||||||
backButton.addEventListener('click', () => navigate('start-view'));
|
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 () => {
|
nextButton.addEventListener('click', async () => {
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
const isFree = await runAvailabilityCheck();
|
const isFree = await runAvailabilityCheck();
|
||||||
@ -185,16 +336,23 @@ export function render({ navigate }) {
|
|||||||
const prevLogin = String(state.registrationDraft.login || '');
|
const prevLogin = String(state.registrationDraft.login || '');
|
||||||
const prevPassword = String(state.registrationDraft.password || '');
|
const prevPassword = String(state.registrationDraft.password || '');
|
||||||
const nextLogin = String(loginInput.value.trim());
|
const nextLogin = String(loginInput.value.trim());
|
||||||
const nextPassword = String(passwordInput.value || '');
|
const nextPassword = getCurrentPassword();
|
||||||
if (nextPassword.length === 0) {
|
if (nextPassword.length === 0) {
|
||||||
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
|
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
|
||||||
formError.style.display = '';
|
formError.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (nextPassword.length > PASSWORD_MAX_LENGTH) {
|
||||||
|
formError.textContent = `Пароль получился слишком длинным. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
||||||
|
formError.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
|
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
|
||||||
|
|
||||||
state.registrationDraft.login = nextLogin;
|
state.registrationDraft.login = nextLogin;
|
||||||
state.registrationDraft.password = nextPassword;
|
state.registrationDraft.password = nextPassword;
|
||||||
|
state.registrationDraft.passwordMode = passwordMode;
|
||||||
|
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||||
if (credsChanged) {
|
if (credsChanged) {
|
||||||
state.registrationDraft.preGeneratedKeyBundle = null;
|
state.registrationDraft.preGeneratedKeyBundle = null;
|
||||||
}
|
}
|
||||||
@ -202,20 +360,18 @@ export function render({ navigate }) {
|
|||||||
renderSecurityConfirmStage();
|
renderSecurityConfirmStage();
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(backButton, nextButton);
|
|
||||||
|
|
||||||
function renderInputStage() {
|
function renderInputStage() {
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<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[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
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.innerHTML = '';
|
||||||
actions.append(backButton, nextButton);
|
actions.append(backButton, nextButton);
|
||||||
backButton.disabled = false;
|
updatePasswordModeVisibility();
|
||||||
nextButton.disabled = false;
|
syncDraftState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSecurityConfirmStage() {
|
function renderSecurityConfirmStage() {
|
||||||
@ -223,8 +379,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const info = document.createElement('p');
|
const info = document.createElement('p');
|
||||||
info.className = 'auth-copy';
|
info.className = 'auth-copy';
|
||||||
info.textContent =
|
info.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.';
|
||||||
'Для повышения безопасности мы генерируем секрет из вашего пароля с помощью Argon2id.';
|
|
||||||
|
|
||||||
const details = document.createElement('p');
|
const details = document.createElement('p');
|
||||||
details.className = 'meta-muted';
|
details.className = 'meta-muted';
|
||||||
@ -232,14 +387,17 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const details2 = document.createElement('p');
|
const details2 = document.createElement('p');
|
||||||
details2.className = 'meta-muted';
|
details2.className = 'meta-muted';
|
||||||
details2.textContent =
|
details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
|
||||||
'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
|
|
||||||
|
|
||||||
const details3 = document.createElement('p');
|
const details3 = document.createElement('p');
|
||||||
details3.className = 'meta-muted';
|
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');
|
const back2 = document.createElement('button');
|
||||||
back2.className = 'ghost-btn';
|
back2.className = 'ghost-btn';
|
||||||
@ -270,17 +428,10 @@ export function render({ navigate }) {
|
|||||||
subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.';
|
subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.';
|
||||||
|
|
||||||
const progressWrap = document.createElement('div');
|
const progressWrap = document.createElement('div');
|
||||||
progressWrap.style.width = '100%';
|
progressWrap.className = 'registration-progress';
|
||||||
progressWrap.style.height = '10px';
|
|
||||||
progressWrap.style.border = '1px solid rgba(180,180,180,.5)';
|
|
||||||
progressWrap.style.borderRadius = '6px';
|
|
||||||
progressWrap.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
const progressBar = document.createElement('div');
|
const progressBar = document.createElement('div');
|
||||||
progressBar.style.height = '100%';
|
progressBar.className = 'registration-progress-bar';
|
||||||
progressBar.style.width = '0%';
|
|
||||||
progressBar.style.background = 'rgba(80, 160, 255, 0.9)';
|
|
||||||
progressBar.style.transition = 'width 180ms linear';
|
|
||||||
progressWrap.append(progressBar);
|
progressWrap.append(progressBar);
|
||||||
|
|
||||||
const progressText = document.createElement('p');
|
const progressText = document.createElement('p');
|
||||||
|
|||||||
229
shine-UI/js/pages/registration-faq-view.js
Normal file
229
shine-UI/js/pages/registration-faq-view.js
Normal 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;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { clearStoredMessages } from '../services/message-store.js';
|
|||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||||
|
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
@ -122,8 +123,12 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
state.loginDraft.login = state.registrationDraft.login;
|
state.loginDraft.login = state.registrationDraft.login;
|
||||||
state.loginDraft.password = '';
|
state.loginDraft.password = '';
|
||||||
|
state.loginDraft.passwordMode = 'single';
|
||||||
|
state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
|
||||||
state.registrationDraft.flowType = '';
|
state.registrationDraft.flowType = '';
|
||||||
state.registrationDraft.password = '';
|
state.registrationDraft.password = '';
|
||||||
|
state.registrationDraft.passwordMode = 'single';
|
||||||
|
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
|
||||||
state.registrationDraft.storagePwd = '';
|
state.registrationDraft.storagePwd = '';
|
||||||
state.registrationDraft.sessionId = '';
|
state.registrationDraft.sessionId = '';
|
||||||
state.registrationDraft.pendingKeyBundle = null;
|
state.registrationDraft.pendingKeyBundle = null;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export const PRE_AUTH_PAGES = [
|
|||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
'register-view',
|
'register-view',
|
||||||
|
'registration-faq-view',
|
||||||
'registration-payment-view',
|
'registration-payment-view',
|
||||||
'registration-draft-keys-view',
|
'registration-draft-keys-view',
|
||||||
'registration-keys-view',
|
'registration-keys-view',
|
||||||
|
|||||||
18
shine-UI/js/services/password-words.js
Normal file
18
shine-UI/js/services/password-words.js
Normal 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 }, () => '');
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
DEFAULT_SHINE_SERVER_WS,
|
DEFAULT_SHINE_SERVER_WS,
|
||||||
resolveShineServerByServerLogin,
|
resolveShineServerByServerLogin,
|
||||||
} from './services/shine-server-resolver.js';
|
} from './services/shine-server-resolver.js';
|
||||||
|
import { emptyPasswordWords } from './services/password-words.js';
|
||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
@ -260,15 +261,22 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
flowType: '',
|
flowType: '',
|
||||||
login: '',
|
login: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
passwordMode: 'single',
|
||||||
|
passwordWords: emptyPasswordWords(),
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
storagePwd: '',
|
storagePwd: '',
|
||||||
pendingKeyBundle: null,
|
pendingKeyBundle: null,
|
||||||
pendingSessionMaterial: null,
|
pendingSessionMaterial: null,
|
||||||
preGeneratedKeyBundle: null,
|
preGeneratedKeyBundle: null,
|
||||||
},
|
},
|
||||||
|
registrationHelp: {
|
||||||
|
selectedTopic: 'keys-storage',
|
||||||
|
},
|
||||||
loginDraft: {
|
loginDraft: {
|
||||||
login: storedSession?.login || '',
|
login: storedSession?.login || '',
|
||||||
password: '',
|
password: '',
|
||||||
|
passwordMode: 'single',
|
||||||
|
passwordWords: emptyPasswordWords(),
|
||||||
},
|
},
|
||||||
registrationPayment: {
|
registrationPayment: {
|
||||||
walletAddress: '',
|
walletAddress: '',
|
||||||
|
|||||||
@ -407,6 +407,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-screen--lower {
|
||||||
|
align-content: start;
|
||||||
|
padding-top: clamp(80px, 18vh, 180px);
|
||||||
|
}
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
width: 126px;
|
width: 126px;
|
||||||
height: 126px;
|
height: 126px;
|
||||||
@ -434,6 +439,22 @@
|
|||||||
width: 100%;
|
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 {
|
.auth-footer-actions {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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);
|
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). */
|
/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */
|
||||||
.toolbar-icon-img {
|
.toolbar-icon-img {
|
||||||
--tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */
|
--tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user