feat(frontend): full redesign of login/register
Owner's complaint: the two-pane layout's decorative right-side panel
("Твои настройки LoVisual на любом компьютере...") made no sense next
to a login form. Dropped the whole two-pane concept, not just its
visuals.
Login/register now render as the mod's own in-game account screen:
night-lake backdrop (the same Backdrop the landing hero uses), a
clock + Вход/Регистрация tab switcher styled like the mod's own
ClickGui tabs, a single glass panel on the mod's own category
colours, and a real mod-style hint row ("X - Y") instead of marketing
copy. What an account actually gets you is stated as plain hints
(4 cloud slots, share a slot's code, the mod signs itself in by code,
no account required to just use the mod) instead of a sales pitch.
- AuthSplit.tsx removed; AuthScreen.tsx + auth.css replace it
- LoginForm/RegisterForm: dropped the redundant login<->register text
link now that the tab switcher does that job; RegisterForm now
honours the original `from` redirect (previously always went to
/configs even when registration started from /link)
- Rewrote auth i18n copy to match the site's established voice
Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
parent
c2c7e8fa91
commit
756ebc6d86
10 changed files with 366 additions and 74 deletions
|
|
@ -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() {
|
|||
<Button type="submit" busy={busy}>
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
{t('login.noAccount')}{' '}
|
||||
<Link to="/register" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('login.createAccount')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Button type="submit" busy={busy}>
|
||||
{t('register.submit')}
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
{t('register.haveAccount')}{' '}
|
||||
<Link to="/login" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('register.login')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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: 'Введи почту.',
|
||||
|
|
|
|||
51
frontend/src/features/auth/tests/pages.test.tsx
Normal file
51
frontend/src/features/auth/tests/pages.test.tsx
Normal file
|
|
@ -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')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue