feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+99 -18
View File
@@ -25,14 +25,44 @@ const instanceOpen = {
guild_count: 1,
};
const totpLabel = 'Код 2FA или резервный код';
/** Ответы для входа: логин и оболочка приложения. */
function loginRoutes(extra: Parameters<typeof installFetch>[0] = []) {
const user = makeUser();
return installFetch([
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
...extra,
]);
}
function loginBodies(fetchMock: ReturnType<typeof installFetch>) {
return recordedRequests(fetchMock).filter(
(request) => request.url.includes('/auth/login') && request.method === 'POST',
);
}
describe('страница входа', () => {
it('успешный вход переводит в /app', async () => {
const user = makeUser();
const fetchMock = installFetch([
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
it('поле кода 2FA показано сразу и принимает резервные коды', async () => {
installFetch([{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }]);
renderApp('/login');
const field = await screen.findByLabelText(totpLabel);
expect(field).toBeVisible();
expect(field).toHaveAttribute('autocomplete', 'one-time-code');
expect(field).toHaveAttribute('inputmode', 'text');
// Резервный код вида `a8eh-pshp-t8st` должен влезать целиком.
expect(Number(field.getAttribute('maxlength'))).toBeGreaterThanOrEqual(20);
expect(field).not.toHaveAttribute('pattern');
expect(screen.getByText(/резервный код вида xxxx-xxxx-xxxx/)).toBeVisible();
});
it('успешный вход переводит в /app и уходит без totp_code при пустом поле', async () => {
const fetchMock = loginRoutes([
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
]);
@@ -56,7 +86,59 @@ describe('страница входа', () => {
});
});
it('при auth.2fa_required показывает поле кода и повторяет вход с totp_code', async () => {
it('код 2FA уходит вместе с логином в первом же запросе', async () => {
const fetchMock = loginRoutes([
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.type(screen.getByLabelText(totpLabel), '123456');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(loginBodies(fetchMock)).toHaveLength(1);
});
expect(loginBodies(fetchMock)[0]?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: '123456',
});
});
it('резервный код из 14 символов уходит на сервер целиком', async () => {
const fetchMock = loginRoutes();
const { router } = renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
// Поле не обрезает значение: 14 символов с дефисами видны целиком.
expect(screen.getByLabelText(totpLabel)).toHaveValue('a8eh-pshp-t8st');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(loginBodies(fetchMock)[0]?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: 'a8eh-pshp-t8st',
});
});
// Вход не требует предварительной попытки без кода.
expect(loginBodies(fetchMock)).toHaveLength(1);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
});
it('при auth.2fa_required подсказывает про код и сохраняет логин с паролем', async () => {
const user = makeUser();
let loginAttempts = 0;
const fetchMock = installFetch([
@@ -80,24 +162,23 @@ describe('страница входа', () => {
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
const totpField = await screen.findByLabelText('Код 2FA');
expect(totpField).toBeVisible();
// Подсказка понятная, а введённые логин и пароль остались на месте.
expect(await screen.findByText(/Введите код 2FA или резервный код/)).toBeVisible();
expect(screen.getByLabelText('Почта')).toHaveValue('alice@example.com');
expect(screen.getByLabelText('Пароль')).toHaveValue('super-secret-1');
await visitor.type(totpField, '123456');
// Код, введённый после подсказки, не теряется при повторной отправке.
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
const logins = recordedRequests(fetchMock).filter(
(request) => request.url.includes('/auth/login') && request.method === 'POST',
);
expect(logins).toHaveLength(2);
expect(logins[1]?.body).toEqual({
expect(loginBodies(fetchMock)).toHaveLength(2);
expect(loginBodies(fetchMock)[1]?.body).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: '123456',
totp_code: 'a8eh-pshp-t8st',
});
});
});