feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
+99
-18
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user