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
|
|
@ -1,184 +0,0 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import heroImg from './assets/hero.png'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import './App.css'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const router = createBrowserRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
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 символов.'
|
||||
}
|
||||
|
|
@ -1 +1,52 @@
|
|||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "@fontsource/comfortaa/400.css";
|
||||
@import "@fontsource/comfortaa/700.css";
|
||||
@import "@fontsource/iosevka/400.css";
|
||||
@import "@fontsource/iosevka/700.css";
|
||||
|
||||
@theme {
|
||||
--color-abyss: #0d1416;
|
||||
--color-slate: #142226;
|
||||
--color-shoal: #1f3238;
|
||||
--color-frost: #9fb3b0;
|
||||
--color-snow: #eef5f3;
|
||||
--color-ice: #5cc8e7;
|
||||
--color-ember: #e8835a;
|
||||
|
||||
--font-display: "Comfortaa", ui-rounded, system-ui, sans-serif;
|
||||
--font-sans: "Inter Variable", system-ui, sans-serif;
|
||||
--font-code: "Iosevka", ui-monospace, monospace;
|
||||
|
||||
--text-hero: 3.8125rem;
|
||||
--text-hero--line-height: 1.05;
|
||||
--radius-panel: 10px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
@apply bg-abyss text-snow font-sans antialiased;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
@apply font-display font-bold;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-ice);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
frontend/src/pages/public/LoginPage.tsx
Normal file
10
frontend/src/pages/public/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { LoginForm } from '../../features/auth/forms/LoginForm'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Вход</h1>
|
||||
<LoginForm />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
13
frontend/src/pages/public/RegisterPage.tsx
Normal file
13
frontend/src/pages/public/RegisterPage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Новый аккаунт</h1>
|
||||
<p className="max-w-sm text-frost">
|
||||
Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.
|
||||
</p>
|
||||
<RegisterForm />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
101
frontend/src/shared/api/client.ts
Normal file
101
frontend/src/shared/api/client.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly retryAfter: number | null
|
||||
|
||||
constructor(status: number, message: string, retryAfter: number | null = null) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestOptions = { method?: string; json?: unknown; body?: BodyInit; signal?: AbortSignal }
|
||||
|
||||
export type ApiClient = {
|
||||
request<T>(path: string, options?: RequestOptions): Promise<T>
|
||||
refresh(): Promise<boolean>
|
||||
setAccessToken(token: string | null): void
|
||||
hasAccessToken(): boolean
|
||||
}
|
||||
|
||||
async function toError(res: Response): Promise<ApiError> {
|
||||
let message = `HTTP ${res.status}`
|
||||
try {
|
||||
const body: unknown = await res.json()
|
||||
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') message = body.error
|
||||
} catch {
|
||||
// non-JSON error body: keep the status text
|
||||
}
|
||||
const retry = Number(res.headers.get('retry-after'))
|
||||
return new ApiError(res.status, message, Number.isFinite(retry) && retry > 0 ? retry : null)
|
||||
}
|
||||
|
||||
export function createApiClient(
|
||||
baseUrl = '/api',
|
||||
// Resolved at call time so tests can stub the global fetch.
|
||||
fetchImpl: typeof fetch = (input, init) => fetch(input, init),
|
||||
): ApiClient {
|
||||
let accessToken: string | null = null
|
||||
let refreshing: Promise<boolean> | null = null
|
||||
|
||||
async function refreshOnce(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetchImpl(`${baseUrl}/auth/refresh`, { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
accessToken = null
|
||||
return false
|
||||
}
|
||||
const body = (await res.json()) as { access_token: string }
|
||||
accessToken = body.access_token
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(): Promise<boolean> {
|
||||
refreshing ??= refreshOnce().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
function send(path: string, options: RequestOptions): Promise<Response> {
|
||||
const headers = new Headers()
|
||||
let body = options.body
|
||||
if (options.json !== undefined) {
|
||||
headers.set('content-type', 'application/json')
|
||||
body = JSON.stringify(options.json)
|
||||
}
|
||||
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
return fetchImpl(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body,
|
||||
credentials: 'include',
|
||||
signal: options.signal,
|
||||
})
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
let res = await send(path, options)
|
||||
if (res.status === 401 && !path.startsWith('/auth/') && (await refresh())) {
|
||||
res = await send(path, options)
|
||||
}
|
||||
if (!res.ok) throw await toError(res)
|
||||
const text = await res.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
refresh,
|
||||
setAccessToken: (token) => {
|
||||
accessToken = token
|
||||
},
|
||||
hasAccessToken: () => accessToken !== null,
|
||||
}
|
||||
}
|
||||
|
||||
export const api = createApiClient()
|
||||
22
frontend/src/shared/api/errors.ts
Normal file
22
frontend/src/shared/api/errors.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { ApiError } from './client'
|
||||
|
||||
const BY_STATUS: Record<number, string> = {
|
||||
400: 'Проверь введённые данные.',
|
||||
401: 'Нужно войти в аккаунт.',
|
||||
403: 'Недостаточно прав.',
|
||||
404: 'Не найдено.',
|
||||
409: 'Уже существует.',
|
||||
}
|
||||
|
||||
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 429) {
|
||||
return err.retryAfter
|
||||
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
|
||||
: 'Слишком много попыток. Подожди немного.'
|
||||
}
|
||||
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
|
||||
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
|
||||
}
|
||||
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
|
||||
}
|
||||
82
frontend/src/shared/api/tests/client.test.ts
Normal file
82
frontend/src/shared/api/tests/client.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { ApiError, createApiClient } from '../client'
|
||||
import { describeError } from '../errors'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('sends json with bearer token and cookies', async () => {
|
||||
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
|
||||
const api = createApiClient()
|
||||
api.setAccessToken('tok')
|
||||
await api.request('/configs/1', { method: 'PUT', json: { a: 1 } })
|
||||
const [, init] = fetch.mock.calls[0]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('authorization')).toBe('Bearer tok')
|
||||
expect(headers.get('content-type')).toBe('application/json')
|
||||
expect(init?.credentials).toBe('include')
|
||||
expect(init?.body).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
test('401 triggers one refresh and one retry', async () => {
|
||||
let calls = 0
|
||||
const fetch = mockFetch({
|
||||
'GET /me': () => (++calls === 1 ? json({ error: 'unauthorized' }, 401) : json({ id: 'x' })),
|
||||
'POST /auth/refresh': () => json({ access_token: 'new' }),
|
||||
})
|
||||
const api = createApiClient()
|
||||
await expect(api.request('/me')).resolves.toEqual({ id: 'x' })
|
||||
expect(fetch.mock.calls.filter(([u]) => String(u).endsWith('/auth/refresh'))).toHaveLength(1)
|
||||
expect(new Headers(fetch.mock.calls[2][1]?.headers).get('authorization')).toBe('Bearer new')
|
||||
})
|
||||
|
||||
test('concurrent 401s share a single refresh', async () => {
|
||||
let refreshes = 0
|
||||
mockFetch({
|
||||
'GET /a': (init) => (new Headers(init.headers).get('authorization') ? json(1) : json({}, 401)),
|
||||
'GET /b': (init) => (new Headers(init.headers).get('authorization') ? json(2) : json({}, 401)),
|
||||
'POST /auth/refresh': () => {
|
||||
refreshes++
|
||||
return json({ access_token: 't' })
|
||||
},
|
||||
})
|
||||
const api = createApiClient()
|
||||
await Promise.all([api.request('/a'), api.request('/b')])
|
||||
expect(refreshes).toBe(1)
|
||||
})
|
||||
|
||||
test('failed refresh surfaces the original 401 and clears the token', async () => {
|
||||
mockFetch({
|
||||
'GET /me': () => json({ error: 'unauthorized' }, 401),
|
||||
'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401),
|
||||
})
|
||||
const api = createApiClient()
|
||||
api.setAccessToken('stale')
|
||||
await expect(api.request('/me')).rejects.toMatchObject({ status: 401 })
|
||||
expect(api.hasAccessToken()).toBe(false)
|
||||
})
|
||||
|
||||
test('auth endpoints never trigger refresh', async () => {
|
||||
const fetch = mockFetch({ 'POST /auth/login': () => json({ error: 'unauthorized' }, 401) })
|
||||
await expect(createApiClient().request('/auth/login', { method: 'POST', json: {} })).rejects.toBeInstanceOf(
|
||||
ApiError,
|
||||
)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('429 carries retry-after; 204 resolves undefined', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
|
||||
'DELETE /device/links/1': () => new Response(null, { status: 204 }),
|
||||
})
|
||||
const api = createApiClient()
|
||||
await expect(api.request('/auth/login', { method: 'POST' })).rejects.toMatchObject({ status: 429, retryAfter: 42 })
|
||||
await expect(api.request('/device/links/1', { method: 'DELETE' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('describeError covers 429, network failure and status defaults', () => {
|
||||
expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
|
||||
expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
|
||||
expect(describeError(new TypeError('Failed to fetch'))).toBe(
|
||||
'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
|
||||
)
|
||||
expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
})
|
||||
8
frontend/src/shared/types.ts
Normal file
8
frontend/src/shared/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export type Me = {
|
||||
id: string
|
||||
email: string
|
||||
display_nick: string
|
||||
role: 'user' | 'admin'
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
28
frontend/src/shared/ui/Button.tsx
Normal file
28
frontend/src/shared/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'quiet' | 'danger'
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
primary: 'bg-ice text-abyss hover:bg-ice/90',
|
||||
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
|
||||
danger: 'bg-transparent text-ember border border-ember/60 hover:border-ember',
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
busy = false,
|
||||
disabled,
|
||||
className = '',
|
||||
type = 'button',
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; busy?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
frontend/src/shared/ui/Notice.tsx
Normal file
10
frontend/src/shared/ui/Notice.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Notice({ tone, children }: { tone: 'info' | 'error'; children: ReactNode }) {
|
||||
const styles = tone === 'error' ? 'border-ember/60 text-ember' : 'border-shoal text-frost'
|
||||
return (
|
||||
<div role={tone === 'error' ? 'alert' : 'status'} className={`rounded-md border px-3 py-2 text-sm ${styles}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/shared/ui/ShareCode.tsx
Normal file
29
frontend/src/shared/ui/ShareCode.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Button } from './Button'
|
||||
|
||||
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const t = setTimeout(() => setCopied(false), 2000)
|
||||
return () => clearTimeout(t)
|
||||
}, [copied])
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{label ? <span className="text-sm text-frost">{label}</span> : null}
|
||||
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
|
||||
<Button variant="quiet" onClick={copy}>
|
||||
Скопировать
|
||||
</Button>
|
||||
<span aria-live="polite" className="text-sm text-frost">
|
||||
{copied ? 'Скопировано' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
frontend/src/shared/ui/TextField.tsx
Normal file
35
frontend/src/shared/ui/TextField.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useId, type InputHTMLAttributes } from 'react'
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className = '',
|
||||
...rest
|
||||
}: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string; hint?: string }) {
|
||||
const id = useId()
|
||||
const describedBy = error ? `${id}-error` : hint ? `${id}-hint` : undefined
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||
<label htmlFor={id} className="text-sm text-frost">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className="rounded-md border border-shoal bg-abyss px-3 py-2 text-snow placeholder:text-frost/60 focus:border-ice aria-invalid:border-ember"
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p id={`${id}-error`} className="text-sm text-ember">
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p id={`${id}-hint`} className="text-sm text-frost">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/src/shared/ui/tests/ui.test.tsx
Normal file
36
frontend/src/shared/ui/tests/ui.test.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { Button } from '../Button'
|
||||
import { TextField } from '../TextField'
|
||||
import { ShareCode } from '../ShareCode'
|
||||
import { Notice } from '../Notice'
|
||||
|
||||
test('busy button is disabled and announces busy', () => {
|
||||
render(<Button busy>Сохранить</Button>)
|
||||
const b = screen.getByRole('button', { name: 'Сохранить' })
|
||||
expect(b).toBeDisabled()
|
||||
expect(b).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
|
||||
test('text field links label and error', () => {
|
||||
render(<TextField label="Почта" error="Введи почту" />)
|
||||
const input = screen.getByLabelText('Почта')
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(input).toHaveAccessibleDescription('Введи почту')
|
||||
})
|
||||
|
||||
test('share code copies to clipboard and confirms', async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
|
||||
render(<ShareCode code="7KQ3M9XA" />)
|
||||
expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Скопировать' }))
|
||||
expect(writeText).toHaveBeenCalledWith('7KQ3M9XA')
|
||||
expect(await screen.findByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('error notice is an alert', () => {
|
||||
render(<Notice tone="error">Неверный код</Notice>)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
|
||||
})
|
||||
21
frontend/src/test/fetch.ts
Normal file
21
frontend/src/test/fetch.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { vi } from 'vitest'
|
||||
|
||||
type Handler = (init: RequestInit) => Response | Promise<Response>
|
||||
|
||||
export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
})
|
||||
}
|
||||
|
||||
export function mockFetch(routes: Record<string, Handler>) {
|
||||
const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const url = new URL(String(input), 'http://localhost')
|
||||
const key = `${(init.method ?? 'GET').toUpperCase()} ${url.pathname.replace(/^\/api/, '')}`
|
||||
const handler = routes[key]
|
||||
return handler ? handler(init) : json({ error: `no mock for ${key}` }, 404)
|
||||
})
|
||||
vi.stubGlobal('fetch', fn)
|
||||
return fn
|
||||
}
|
||||
17
frontend/src/test/render.tsx
Normal file
17
frontend/src/test/render.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router'
|
||||
import { SessionProvider } from '../features/auth/session'
|
||||
|
||||
export function renderApp(routes: RouteObject[], initialPath = '/') {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const router = createMemoryRouter(routes, { initialEntries: [initialPath] })
|
||||
const result = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
return { ...result, router, client }
|
||||
}
|
||||
6
frontend/src/test/setup.ts
Normal file
6
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
9
frontend/src/test/smoke.test.tsx
Normal file
9
frontend/src/test/smoke.test.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('router renders a route', () => {
|
||||
const router = createMemoryRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
render(<RouterProvider router={router} />)
|
||||
expect(screen.getByRole('heading', { name: 'LoVisual' })).toBeInTheDocument()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue