chore(history): squash 100 commit(s) from 2026-09-24
- fix(backend): case-insensitive unique email, revoke PUBLIC schema access, pin Argon2id params - test(backend): assert password length cap boundary (256 ok, 257 rejected) - docs(backend): plan — typed JWT token kinds so refresh/device tokens cannot pass as access tokens - feat(backend): JWT access/refresh token issue and verify - feat(backend): accounts repository (create/find_by_email/find_by_id) - refactor(gui): LoVisualAddonManagerScreen 989→10 файлов addon/ (8.5.2) - docs(backend): plan — fix sqlx::migrate! path in integration tests - feat(backend): POST /auth/register and /auth/login - refactor(settings): SettingsPanelComponent 715→81 + 6 helpers (8.5.2) - docs(backend): plan — harden device flow (single-use codes, bounded store, 404/429) - feat(backend): OAuth device authorization grant for mod login - fix(backend): first confirm wins for device codes - docs(backend): plan — split Task 8 (refactor) and Task 9 (avatars), harden avatar handling - refactor(mixins): LocalPlayerMixin 691→111 + 4 handlers (8.5.2) - refactor(visuals): Trails 687→130 (8.5.2) - refactor(render): ItemBatchRenderer 677->100 (8.5.2) - refactor(visuals): ReimaginedVisual 674→118 + 5 helpers (8.5.2) - refactor(config): ConfigSerializer 668→91 + 4 helpers (8.5.2) - refactor(hud): DynamicIsland 661→158 + 4 helpers (8.5.2) - refactor(render): GlStencilFramebufferSupport 666→169 (8.5.2) - refactor(gui): MenuScreen 669→128 + 4 helpers (8.5.2) - refactor(render): UiStyle 644→170 + 3 helpers (8.5.2) - refactor(gui): ModuleComponent 613→98 + 4 helpers (8.5.2) - refactor(media): MediaSessionService 616→200 + 4 helpers (8.5.2) - refactor(gui): RelationsComponent 661→59 + 4 helpers (8.5.2) - chore(license): strip GPL file headers from all Java sources - refactor(aiming): PointTracker 583→168 + 2 helpers (8.5.2) - refactor(gui): LoVisualProxyManagerScreen 591→132 + 2 helpers (8.5.2) - refactor(visuals): KillEffect 588→96 + 4 helpers (8.5.2) - refactor(render): MeshBuilder +4 helpers (8.5.2) - refactor(visuals): extract WorldParticlesRender helper (8.5.2) - refactor(world): ExplosionDamageUtil 551→116 + 2 helpers (8.5.2) - refactor(visuals): TazikHat 596->179 + Model + Palette in hats/tazik (8.5.2) - chore(license): strip GPL header from remaining 30 files and make strip script variant-aware - refactor(gui): ThemeComponent 561->166 + CardRenderer + ScrollState (8.5.2) - refactor(hud): CustomHotbar 556→178 + Renderer + Selection + SelectionGradient (8.5.2) - refactor(gui): ThemeCardRenderer perf + readability polish - refactor(clickgui): CooldownRulesSetting 596->198 + Editor + DetailRenderer (8.5.2) - refactor(hud): HudNotifier 561->200 + Painter + runtime/HudNotifierRuntime (8.5.2) - refactor(theme): Themes 555->168 + impl/Transition + impl/Blending + impl/ProfileCodec (8.5.2) - refactor(theme): EditableClickGuiTheme 205->185 + JavaDoc (8.5.2) - refactor(theme): ThemeStore 491->128 + store/ThemeStoreJson + store/ThemeStoreIO (8.5.2) - refactor(clickgui): ClickGuiRenderer 604->200 compacted one-line delegators + JavaDoc (8.5.2) - refactor(mainmenu): LoVisualMainMenuScreen 551->161 + impl/Painter + impl/Renderer + impl/TextUtil (8.5.2) - refactor(clickgui): ClickGuiTextEditorState 531->187 + impl/EditorCaret + impl/EditorPainter (8.5.2) - refactor(tab): TabListModel 525->139 + model/Collector + model/Reader + model/Signature + model/TextSplitter (8.5.2) - refactor(backend): shared bearer helper and test helpers, build_app takes Config, validate JWT secret strength - refactor(module): ModuleManager 521->198 + impl/Registrar + impl/Dispatcher (8.5.2) - feat(backend): avatar upload with decode, square crop, PNG re-encode and S3 storage - refactor(clip): ClipFunction 512->146 + impl/Geometry + impl/Debug (8.5.2) - docs(backend): implementation plans for gateway (auth hardening, gRPC, rate limits) and configs-service - refactor(iris-patch): ShaderPatchEngine 499->146 + impl/Repo (8.5.2) - chore(frontend): add router, react-query, fonts and vitest; dev proxy to gateway - refactor(hud): ScriptedListHudPanel 499->158 + panel/Props + panel/Signature (8.5.2) - refactor(hud): BaseHudElement 499->199 + impl/Registry + impl/Namer + impl/Prewarm (8.5.2) - refactor(clickgui): Setting 498->170 + impl/Localization + impl/I18n (8.5.2) - refact(viewmodel): split swing animations into camera/swing package - refact(kineticlyrics): split module into stage, playback and modes - rename(holeesp): module HoleESP -> CrystalHoles - refact(crystalholes): split module into crystal scanner, renderer and safety - refact(addonmanager): split manager into lifecycle, runtime, descriptors and profiles - refact(accountconfig): split config into store, session and value helpers - refactor(render): CustomTextRenderer 229->195, extract glyph-pass into GradientTexts helper - docs(TODO): mark AddonManager split done; close 9.2 refactor gate - refactor(media): LinuxMediaSession 441->148, split reader + track/seek state - refactor(nametags): split NameTags into facade + impl helpers - refactor(clickgui): split MainSettingsComponent into facade + scroll + model - refactor(hud): split CustomBar into facade, model and BarSettings - docs(frontend): implementation plan with design system from the mod theme - feat(frontend): design tokens from the mod theme, fonts and shared UI kit - fix(accounts): run migrations on startup, offload Argon2, validate register input, JSON error shape - docs(gateway): plan note on splitting auth handlers before refresh endpoints - feat(frontend): API client with silent refresh, error descriptions and test helpers - style(mod): group compact one-line bulk query methods in ModuleManager - feat(frontend): session restore, login and registration with client-side validation - refactor(hud): split CustomHealthBar into facade + painter + script renderer - docs(mod): record the 2026-09-24 HUD/settings split wave in TODO phase 8.5 - refactor(rhi): split GlStencilShapeClipBackend into facade + native-state + pass-lifecycle helpers - refactor(rhi): split VulkanRenderStateBridge into facade + MSAA and stencil state helpers - refactor(backtrack): split BacktrackController into facade + model + impl helpers - refactor(svg): split SvgPathParser into facade + arc geometry + command/curve helpers - refactor(mixin): split ClientPacketListenerMixin into hook-only mixin + handlers - refactor(renderer3d): un-nest batch bindings + culling into sibling impl types - refactor(renderwarp): extract static factories + geometry into impl helpers - feat(backend): add common crate with shared JWT, internal gateway contract and accounts proto - refactor(guimixin): move hook bodies into handlers, keep mixin as hooks + shadows - feat(accounts): accept only gateway traffic, read identity from gateway header - refactor(cacheduiscriptruntime): extract engine, hashing and frame stats into impl - refactor(customskyboxrenderer): extract projection, shader passes and sun into impl - refactor(betterchatstoremanager): extract persistence, key/path and hover helpers into impl - feat(accounts): rotating opaque refresh tokens in httpOnly cookie, /auth/refresh and /auth/logout - refactor(targetesp): extract crystal rendering subsystem into impl/TargetEspCrystalRenderer - refactor(betterchathovercache): extract disk codec and lookup indexing into impl/ChatHoverCacheCodec - refactor(microsoftauth): split HTTP transport, device-code and Xbox flows into impl/ - refactor(pvpcooldowns): extract local item-rule engine and defaults into impl/PvpCooldownRules - refactor(lovisual): extract HUD/world render orchestration into HudRender helper - refactor(statuseffectheuristics): extract palette/inference into ParticlePalette and color utils into ParticleColors - refactor(dropesp): extract overlay/label render subsystem into impl/DropEspOverlayRenderer - refactor(proxy): extract SOCKS handshake message builders into ProxyProtocolMessages - refactor(eagleutil): promote EdgeRecovery controller and RecoveryMode to top-level class
This commit is contained in:
parent
9d08fa910a
commit
72bc4c7148
1897 changed files with 36199 additions and 39289 deletions
10
frontend/src/features/auth/RequireAuth.tsx
Normal file
10
frontend/src/features/auth/RequireAuth.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
import { useSession } from './session'
|
||||
|
||||
export function RequireAuth() {
|
||||
const { status } = useSession()
|
||||
const location = useLocation()
|
||||
if (status === 'loading') return <p className="text-frost">Проверяем вход…</p>
|
||||
if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
return <Outlet />
|
||||
}
|
||||
21
frontend/src/features/auth/api.ts
Normal file
21
frontend/src/features/auth/api.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
import type { Me } from '../../shared/types'
|
||||
|
||||
export async function login(email: string, password: string): Promise<void> {
|
||||
const res = await api.request<{ access_token: string }>('/auth/login', { method: 'POST', json: { email, password } })
|
||||
api.setAccessToken(res.access_token)
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string, nick: string): Promise<void> {
|
||||
await api.request('/auth/register', { method: 'POST', json: { email, password, nick } })
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.request('/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
api.setAccessToken(null)
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchMe = () => api.request<Me>('/me')
|
||||
51
frontend/src/features/auth/forms/LoginForm.tsx
Normal file
51
frontend/src/features/auth/forms/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { TextField } from '../../../shared/ui/TextField'
|
||||
import { useSession } from '../session'
|
||||
|
||||
export function LoginForm() {
|
||||
const { signIn } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await signIn(email.trim(), password)
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 401: 'Неверная почта или пароль.' }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField label="Почта" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Войти
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Нет аккаунта? <Link to="/register" className="text-ice underline-offset-4 hover:underline">Создать</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
81
frontend/src/features/auth/forms/RegisterForm.tsx
Normal file
81
frontend/src/features/auth/forms/RegisterForm.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { TextField } from '../../../shared/ui/TextField'
|
||||
import { useSession } from '../session'
|
||||
import { validateEmail, validateNick, validatePassword } from '../validation'
|
||||
|
||||
export function RegisterForm() {
|
||||
const { signUp } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [nick, setNick] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [emailError, setEmailError] = useState<string>()
|
||||
const [nickError, setNickError] = useState<string>()
|
||||
const [passwordError, setPasswordError] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(undefined)
|
||||
|
||||
const emailErr = validateEmail(email)
|
||||
const nickErr = validateNick(nick)
|
||||
const passwordErr = validatePassword(password)
|
||||
setEmailError(emailErr)
|
||||
setNickError(nickErr)
|
||||
setPasswordError(passwordErr)
|
||||
if (emailErr || nickErr || passwordErr) return
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
await signUp(email.trim(), password, nick.trim())
|
||||
navigate('/configs', { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 409: 'Эта почта уже занята. Войди или используй другую.' }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField
|
||||
label="Почта"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
error={emailError}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Ник на сайте"
|
||||
autoComplete="nickname"
|
||||
value={nick}
|
||||
error={nickError}
|
||||
hint={nickError ? undefined : 'Не обязательно совпадает с ником в Minecraft.'}
|
||||
onChange={(e) => setNick(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
error={passwordError}
|
||||
hint={passwordError ? undefined : 'Минимум 8 символов.'}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Создать аккаунт
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Уже есть аккаунт? <Link to="/login" className="text-ice underline-offset-4 hover:underline">Войти</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
60
frontend/src/features/auth/session.tsx
Normal file
60
frontend/src/features/auth/session.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import { api } from '../../shared/api/client'
|
||||
import type { Me } from '../../shared/types'
|
||||
import { fetchMe, login, logout, register } from './api'
|
||||
|
||||
type Session = {
|
||||
status: 'loading' | 'anonymous' | 'authenticated'
|
||||
me: Me | null
|
||||
signIn(email: string, password: string): Promise<void>
|
||||
signUp(email: string, password: string, nick: string): Promise<void>
|
||||
signOut(): Promise<void>
|
||||
}
|
||||
|
||||
const SessionContext = createContext<Session | null>(null)
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const client = useQueryClient()
|
||||
// Restores the session after a reload: the refresh cookie survives, the
|
||||
// in-memory access token does not.
|
||||
const restored = useQuery({ queryKey: ['session-restore'], queryFn: () => api.refresh(), staleTime: Infinity })
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, enabled: restored.data === true })
|
||||
|
||||
const signIn = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
await login(email, password)
|
||||
client.setQueryData(['session-restore'], true)
|
||||
await client.fetchQuery({ queryKey: ['me'], queryFn: fetchMe })
|
||||
},
|
||||
[client],
|
||||
)
|
||||
|
||||
const signUp = useCallback(
|
||||
async (email: string, password: string, nick: string) => {
|
||||
await register(email, password, nick)
|
||||
await signIn(email, password)
|
||||
},
|
||||
[signIn],
|
||||
)
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await logout()
|
||||
client.clear()
|
||||
client.setQueryData(['session-restore'], false)
|
||||
}, [client])
|
||||
|
||||
const value = useMemo<Session>(() => {
|
||||
const me = meQuery.data ?? null
|
||||
const status = restored.isPending || (restored.data && meQuery.isPending) ? 'loading' : me ? 'authenticated' : 'anonymous'
|
||||
return { status, me, signIn, signUp, signOut }
|
||||
}, [restored.isPending, restored.data, meQuery.isPending, meQuery.data, signIn, signUp, signOut])
|
||||
|
||||
return <SessionContext value={value}>{children}</SessionContext>
|
||||
}
|
||||
|
||||
export function useSession(): Session {
|
||||
const session = useContext(SessionContext)
|
||||
if (!session) throw new Error('useSession outside SessionProvider')
|
||||
return session
|
||||
}
|
||||
53
frontend/src/features/auth/tests/forms.test.tsx
Normal file
53
frontend/src/features/auth/tests/forms.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { LoginForm } from '../forms/LoginForm'
|
||||
import { RegisterForm } from '../forms/RegisterForm'
|
||||
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('wrong password shows a clear error', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ ...anon, 'POST /auth/login': () => json({ error: 'unauthorized' }, 401) })
|
||||
renderApp([{ path: '/login', element: <LoginForm /> }], '/login')
|
||||
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
|
||||
await user.type(screen.getByLabelText('Пароль'), 'wrong-password')
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Неверная почта или пароль.')
|
||||
})
|
||||
|
||||
test('successful login goes to configs', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ ...anon, 'POST /auth/login': () => json({ access_token: 't' }), 'GET /me': () => json(me) })
|
||||
renderApp([{ path: '/login', element: <LoginForm /> }, { path: '/configs', element: <p>Мои конфиги</p> }], '/login')
|
||||
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
|
||||
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
expect(await screen.findByText('Мои конфиги')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('register validates before calling the server', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetch = mockFetch(anon)
|
||||
renderApp([{ path: '/register', element: <RegisterForm /> }], '/register')
|
||||
await user.type(await screen.findByLabelText('Почта'), 'not-an-email')
|
||||
await user.type(screen.getByLabelText('Пароль'), 'short')
|
||||
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
|
||||
expect(screen.getByLabelText('Почта')).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByLabelText('Пароль')).toHaveAccessibleDescription('Минимум 8 символов.')
|
||||
expect(fetch.mock.calls.some(([u]) => String(u).includes('/auth/register'))).toBe(false)
|
||||
})
|
||||
|
||||
test('taken email explains what to do', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ ...anon, 'POST /auth/register': () => json({ error: 'email already registered' }, 409) })
|
||||
renderApp([{ path: '/register', element: <RegisterForm /> }], '/register')
|
||||
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: 'Создать аккаунт' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Эта почта уже занята')
|
||||
})
|
||||
30
frontend/src/features/auth/tests/session.test.tsx
Normal file
30
frontend/src/features/auth/tests/session.test.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
import { RequireAuth } from '../RequireAuth'
|
||||
import { useSession } from '../session'
|
||||
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' }
|
||||
|
||||
function Whoami() {
|
||||
const { me } = useSession()
|
||||
return <p>Привет, {me?.display_nick}</p>
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', element: <p>Страница входа</p> },
|
||||
{ element: <RequireAuth />, children: [{ path: '/configs', element: <Whoami /> }] },
|
||||
]
|
||||
|
||||
test('restores the session from the refresh cookie', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({ access_token: 't' }), 'GET /me': () => json(me) })
|
||||
renderApp(routes, '/configs')
|
||||
expect(await screen.findByText('Привет, Rider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('anonymous visitor is sent to login', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) })
|
||||
renderApp(routes, '/configs')
|
||||
expect(await screen.findByText('Страница входа')).toBeInTheDocument()
|
||||
})
|
||||
16
frontend/src/features/auth/validation.ts
Normal file
16
frontend/src/features/auth/validation.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export function validateEmail(email: string): string | undefined {
|
||||
const v = email.trim()
|
||||
if (!v) return 'Введи почту.'
|
||||
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return 'Похоже, в почте опечатка.'
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): string | undefined {
|
||||
if ([...password].length < 8) return 'Минимум 8 символов.'
|
||||
if (new TextEncoder().encode(password).length > 256) return 'Слишком длинный пароль.'
|
||||
}
|
||||
|
||||
export function validateNick(nick: string): string | undefined {
|
||||
const len = [...nick.trim()].length
|
||||
if (len === 0) return 'Придумай ник.'
|
||||
if (len > 32) return 'Не длиннее 32 символов.'
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue