diff --git a/frontend/src/features/auth/forms/LoginForm.tsx b/frontend/src/features/auth/forms/LoginForm.tsx index a6e0f57..b3e93ac 100644 --- a/frontend/src/features/auth/forms/LoginForm.tsx +++ b/frontend/src/features/auth/forms/LoginForm.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useLocation, useNavigate } from 'react-router' +import { useLocation, useNavigate } from 'react-router' import { describeError } from '@shared/api/errors' import { Button } from '@shared/ui/Button' import { Notice } from '@shared/ui/Notice' @@ -52,12 +52,6 @@ export function LoginForm() { -
- {t('login.noAccount')}{' '} - - {t('login.createAccount')} - -
) } diff --git a/frontend/src/features/auth/forms/RegisterForm.tsx b/frontend/src/features/auth/forms/RegisterForm.tsx index b1f4aa0..bd4bde3 100644 --- a/frontend/src/features/auth/forms/RegisterForm.tsx +++ b/frontend/src/features/auth/forms/RegisterForm.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useNavigate } from 'react-router' +import { useLocation, useNavigate } from 'react-router' import { describeError } from '@shared/api/errors' import { Button } from '@shared/ui/Button' import { Notice } from '@shared/ui/Notice' @@ -13,6 +13,7 @@ export function RegisterForm() { const { t: tc } = useTranslation('common') const { signUp } = useSession() const navigate = useNavigate() + const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs' const [email, setEmail] = useState('') const [nick, setNick] = useState('') const [password, setPassword] = useState('') @@ -37,7 +38,7 @@ export function RegisterForm() { setBusy(true) try { await signUp(email.trim(), password, nick.trim()) - navigate('/configs', { replace: true }) + navigate(from, { replace: true }) } catch (err) { setError(describeError(tc, err, { 409: t('register.emailTaken') })) } finally { @@ -76,12 +77,6 @@ export function RegisterForm() { -- {t('register.haveAccount')}{' '} - - {t('register.login')} - -
) } diff --git a/frontend/src/features/auth/i18n/en.ts b/frontend/src/features/auth/i18n/en.ts index c59f265..d210bf7 100644 --- a/frontend/src/features/auth/i18n/en.ts +++ b/frontend/src/features/auth/i18n/en.ts @@ -3,28 +3,45 @@ import type { authRu } from './ru' export const authEn = { checkingSession: 'Checking session…', - splitPitch: 'Your LoVisual setup, on any computer: cloud configs, share codes for friends, and a showcase of ready-made builds.', + tabs: { + label: 'Log in or sign up', + login: 'Log in', + register: 'Sign up', + }, + hintsLabel: 'Hints', + next: { + link: 'Next: you confirm the code from the game.', + configs: 'Next: your cloud slots.', + account: 'Next: your account settings.', + showcase: 'Next: the showcase config goes into your slot.', + }, login: { - title: 'Log in', + title: 'Welcome back', emailLabel: 'Email', passwordLabel: 'Password', submit: 'Log in', - noAccount: "Don't have an account?", - createAccount: 'Sign up', wrongCredentials: 'Wrong email or password.', + hints: { + one: 'No account - the "Sign up" tab up top', + two: 'The password is for the site only', + three: 'The mod signs in by itself, with a code from its Account tab', + }, }, register: { title: 'New account', - subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.", emailLabel: 'Email', nickLabel: 'Site nickname', nickHint: "Doesn't have to match your Minecraft nick.", passwordLabel: 'Password', passwordHint: 'At least 8 characters.', submit: 'Create account', - haveAccount: 'Already have an account?', - login: 'Log in', emailTaken: 'That email is already taken. Log in or use another one.', + hints: { + one: 'Account - 4 cloud slots for your configs', + two: 'Slot code - send it to a friend or load it on another PC', + three: 'No password in the mod, it signs in with a code', + four: 'The mod works without an account too', + }, }, validation: { emailRequired: 'Enter your email.', diff --git a/frontend/src/features/auth/i18n/ru.ts b/frontend/src/features/auth/i18n/ru.ts index 672b50f..5f7e5fe 100644 --- a/frontend/src/features/auth/i18n/ru.ts +++ b/frontend/src/features/auth/i18n/ru.ts @@ -1,27 +1,44 @@ export const authRu = { checkingSession: 'Проверяем вход…', - splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.', + tabs: { + label: 'Вход или регистрация', + login: 'Вход', + register: 'Регистрация', + }, + hintsLabel: 'Подсказки', + next: { + link: 'Дальше: подтвердишь код из игры.', + configs: 'Дальше: твои облачные слоты.', + account: 'Дальше: настройки аккаунта.', + showcase: 'Дальше: заберёшь конфиг с витрины в свой слот.', + }, login: { - title: 'Вход', + title: 'С возвращением', emailLabel: 'Почта', passwordLabel: 'Пароль', submit: 'Войти', - noAccount: 'Нет аккаунта?', - createAccount: 'Создать', wrongCredentials: 'Неверная почта или пароль.', + hints: { + one: 'Нет аккаунта - вкладка «Регистрация» наверху', + two: 'Пароль нужен только на сайте', + three: 'Мод входит сам, по коду из вкладки «Аккаунт»', + }, }, register: { title: 'Новый аккаунт', - subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.', emailLabel: 'Почта', nickLabel: 'Ник на сайте', nickHint: 'Не обязательно совпадает с ником в Minecraft.', passwordLabel: 'Пароль', passwordHint: 'Минимум 8 символов.', submit: 'Создать аккаунт', - haveAccount: 'Уже есть аккаунт?', - login: 'Войти', emailTaken: 'Эта почта уже занята. Войди или используй другую.', + hints: { + one: 'Аккаунт - 4 облачных слота под конфиги', + two: 'Код слота - скинуть другу или открыть на другом компе', + three: 'Пароль в мод вводить не придётся, он входит по коду', + four: 'Без аккаунта мод тоже работает', + }, }, validation: { emailRequired: 'Введи почту.', diff --git a/frontend/src/features/auth/tests/pages.test.tsx b/frontend/src/features/auth/tests/pages.test.tsx new file mode 100644 index 0000000..13c18a5 --- /dev/null +++ b/frontend/src/features/auth/tests/pages.test.tsx @@ -0,0 +1,51 @@ +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, test } from 'vitest' +import { routes } from '@app/routes' +import { renderApp } from '@test/render' +import { json, mockFetch } from '@test/fetch' + +const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' } +const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) } + +test('the tab bar switches between login and register', async () => { + const user = userEvent.setup() + mockFetch(anon) + renderApp(routes, '/login') + expect(await screen.findByRole('heading', { level: 1, name: 'С возвращением' })).toBeInTheDocument() + const tabs = screen.getByRole('navigation', { name: 'Вход или регистрация' }) + expect(within(tabs).getByRole('link', { name: 'Вход' })).toHaveAttribute('aria-current', 'page') + + await user.click(within(tabs).getByRole('link', { name: 'Регистрация' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Новый аккаунт' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Регистрация' })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('list', { name: 'Подсказки' })).toHaveTextContent('4 облачных слота') +}) + +test('a redirect from /link says what comes next and survives the tab switch', async () => { + const user = userEvent.setup() + mockFetch(anon) + renderApp(routes, '/link') + expect(await screen.findByText('Дальше: подтвердишь код из игры.')).toBeInTheDocument() + await user.click(screen.getByRole('link', { name: 'Регистрация' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Новый аккаунт' })).toBeInTheDocument() + expect(screen.getByText('Дальше: подтвердишь код из игры.')).toBeInTheDocument() +}) + +test('registering after a redirect lands back where the visitor was going', async () => { + const user = userEvent.setup() + mockFetch({ + ...anon, + 'POST /auth/register': () => json({}, 201), + 'POST /auth/login': () => json({ access_token: 't' }), + 'GET /me': () => json(me), + }) + const { router } = renderApp(routes, '/link') + await user.click(await screen.findByRole('link', { name: 'Регистрация' })) + await user.type(await screen.findByLabelText('Почта'), 'a@b.c') + await user.type(screen.getByLabelText('Ник на сайте'), 'Rider') + await user.type(screen.getByLabelText('Пароль'), 'correct-horse') + await user.click(screen.getByRole('button', { name: 'Создать аккаунт' })) + await screen.findByLabelText('Код из игры') + expect(router.state.location.pathname).toBe('/link') +}) diff --git a/frontend/src/pages/public/auth/AuthScreen.tsx b/frontend/src/pages/public/auth/AuthScreen.tsx new file mode 100644 index 0000000..c77a439 --- /dev/null +++ b/frontend/src/pages/public/auth/AuthScreen.tsx @@ -0,0 +1,100 @@ +import { MotionConfig, motion } from 'motion/react' +import { type ReactNode, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, useLocation } from 'react-router' +import { Backdrop } from '@features/clickgui/parts/Backdrop' +import '@features/clickgui/parts/clickgui.css' +import { themeToCssVars } from '@features/clickgui/themeVars' +import { useActiveTheme } from '@features/themes/useActiveTheme' +import './auth.css' + +type Mode = 'login' | 'register' + +/** Where RequireAuth (or a "log in to copy" link) was sending the visitor, if anywhere we can name. */ +function nextStep(from: string | undefined): 'link' | 'configs' | 'account' | 'showcase' | undefined { + if (!from) return undefined + if (from.startsWith('/link')) return 'link' + if (from.startsWith('/configs')) return 'configs' + if (from.startsWith('/account')) return 'account' + if (from.startsWith('/showcase')) return 'showcase' +} + +function useClock(): string { + const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + const [now, setNow] = useState(format) + useEffect(() => { + const id = window.setInterval(() => setNow(format()), 15_000) + return () => window.clearInterval(id) + }, []) + return now +} + +/** + * The login and register pages as the mod's menu opened on an account tab: the ClickGui tab bar + * switches between the two pages, one glass panel holds the form, and the hint row along the + * bottom says what an account actually does (in the mod's own "key - action" voice). + */ +export function AuthScreen({ mode, title, hints, children }: { mode: Mode; title: string; hints: readonly string[]; children: ReactNode }) { + const { t } = useTranslation('auth') + const { theme } = useActiveTheme() + const location = useLocation() + const clock = useClock() + // Carry the redirect target across the tab switch, so registering from /link still ends on /link. + const state = location.state as { from?: string } | null + const step = nextStep(state?.from) + + return ( +{t(`next.${step}`)}
: null} + {children} +{t('splitPitch')}
-{t('register.subtitle')}
+