feat(web): Login/Register страницы, protected routes, logout

- ProtectedRoute: loading -> спиннер, !user -> /login, иначе children
- LoginPage/RegisterPage: field+react-hook-form/zod, ошибка через role=alert,
  редирект на /domains при успехе/уже авторизован
- main.tsx: AuthProvider + QueryCache/MutationCache onError -> notifyUnauthorized
  на UnauthorizedError (сброс сессии из кода вне React-дерева)
- AuthContext: logout и notifyUnauthorized чистят react-query кэш (qc.clear())
- Layout: email пользователя + кнопка Выйти
- App: /login и /register публичные (авторизованный -> /domains), остальное
  под ProtectedRoute

Починка page-тестов (Accounts/Domains/Templates/DomainDiff/App): AuthProvider
+ мок api.auth.me, спай-ассерты обновлены под projectId-первым-аргументом
сигнатур api.* (T5).
This commit is contained in:
2026-07-03 21:21:29 +07:00
parent 222d6c0453
commit 5a4d560e70
15 changed files with 658 additions and 54 deletions
+16 -5
View File
@@ -1,19 +1,30 @@
import { render, screen, within } from "@testing-library/react" import { render, screen, within } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom" import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { vi, test, expect } from "vitest"
import { App } from "./App" import { App } from "./App"
import { AuthProvider } from "@/auth/AuthContext"
import { api } from "@/api/client"
test("renders navigation and redirects to domains", async () => {
vi.spyOn(api.auth, "me").mockResolvedValue({
user: { id: "u1", email: "a@b.com" },
project: { id: "p1", name: "Default" },
})
vi.spyOn(api, "listDomains").mockResolvedValue([])
test("renders navigation and redirects to domains", () => {
render( render(
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={["/"]}> <AuthProvider>
<App /> <MemoryRouter initialEntries={["/"]}>
</MemoryRouter> <App />
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
// Sidebar nav also renders a "Domains" link label, so scope the assertion // Sidebar nav also renders a "Domains" link label, so scope the assertion
// to the routed page content to unambiguously confirm the redirect + page. // to the routed page content to unambiguously confirm the redirect + page.
const main = screen.getByRole("main") const main = await screen.findByRole("main")
expect(within(main).getByText("Domains")).toBeInTheDocument() expect(within(main).getByText("Domains")).toBeInTheDocument()
expect(screen.getByRole("link", { name: /domains/i })).toBeInTheDocument() expect(screen.getByRole("link", { name: /domains/i })).toBeInTheDocument()
}) })
+23 -9
View File
@@ -1,20 +1,34 @@
import type { ReactNode } from "react"
import { Routes, Route, Navigate } from "react-router-dom" import { Routes, Route, Navigate } from "react-router-dom"
import { ProtectedRoute } from "@/auth/ProtectedRoute"
import { Layout } from "@/components/Layout" import { Layout } from "@/components/Layout"
import { AccountsPage } from "@/pages/AccountsPage" import { AccountsPage } from "@/pages/AccountsPage"
import { DomainDiffPage } from "@/pages/DomainDiffPage" import { DomainDiffPage } from "@/pages/DomainDiffPage"
import { DomainsPage } from "@/pages/DomainsPage" import { DomainsPage } from "@/pages/DomainsPage"
import { LoginPage } from "@/pages/LoginPage"
import { RegisterPage } from "@/pages/RegisterPage"
import { TemplatesPage } from "@/pages/TemplatesPage" import { TemplatesPage } from "@/pages/TemplatesPage"
// Every non-auth route shares the same guard + chrome; wrapping here keeps
// each <Route> below a one-liner instead of repeating both on every page.
function Protected({ children }: { children: ReactNode }) {
return (
<ProtectedRoute>
<Layout>{children}</Layout>
</ProtectedRoute>
)
}
export function App() { export function App() {
return ( return (
<Layout> <Routes>
<Routes> <Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Navigate to="/domains" replace />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/domains" element={<DomainsPage />} /> <Route path="/" element={<Navigate to="/domains" replace />} />
<Route path="/domains/:id" element={<DomainDiffPage />} /> <Route path="/domains" element={<Protected><DomainsPage /></Protected>} />
<Route path="/accounts" element={<AccountsPage />} /> <Route path="/domains/:id" element={<Protected><DomainDiffPage /></Protected>} />
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/accounts" element={<Protected><AccountsPage /></Protected>} />
</Routes> <Route path="/templates" element={<Protected><TemplatesPage /></Protected>} />
</Layout> </Routes>
) )
} }
+37 -5
View File
@@ -1,7 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react" import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event" import userEvent from "@testing-library/user-event"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { describe, it, expect, vi, beforeEach } from "vitest" import { describe, it, expect, vi, beforeEach } from "vitest"
import { AuthProvider, useAuth } from "./AuthContext" import { AuthProvider, notifyUnauthorized, useAuth } from "./AuthContext"
import { api, UnauthorizedError } from "@/api/client" import { api, UnauthorizedError } from "@/api/client"
const USER = { id: "u1", email: "a@b.com" } const USER = { id: "u1", email: "a@b.com" }
@@ -21,11 +22,13 @@ function Probe() {
) )
} }
function renderProbe() { function renderProbe(qc: QueryClient = new QueryClient()) {
return render( return render(
<AuthProvider> <QueryClientProvider client={qc}>
<Probe /> <AuthProvider>
</AuthProvider>, <Probe />
</AuthProvider>
</QueryClientProvider>,
) )
} }
@@ -91,4 +94,33 @@ describe("AuthContext", () => {
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe("none")) await waitFor(() => expect(screen.getByTestId("user").textContent).toBe("none"))
expect(screen.getByTestId("project").textContent).toBe("none") expect(screen.getByTestId("project").textContent).toBe("none")
}) })
it("logout clears the react-query cache", async () => {
vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT })
vi.spyOn(api.auth, "logout").mockResolvedValue(undefined)
const qc = new QueryClient()
const clearSpy = vi.spyOn(qc, "clear")
const user = userEvent.setup()
renderProbe(qc)
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe(USER.email))
await user.click(screen.getByRole("button", { name: "logout" }))
await waitFor(() => expect(clearSpy).toHaveBeenCalled())
})
it("notifyUnauthorized (triggered by any 401 elsewhere in the app) drops the session and clears the cache", async () => {
vi.spyOn(api.auth, "me").mockResolvedValue({ user: USER, project: PROJECT })
const qc = new QueryClient()
const clearSpy = vi.spyOn(qc, "clear")
renderProbe(qc)
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe(USER.email))
notifyUnauthorized()
await waitFor(() => expect(screen.getByTestId("user").textContent).toBe("none"))
expect(screen.getByTestId("project").textContent).toBe("none")
expect(clearSpy).toHaveBeenCalled()
})
}) })
+35 -2
View File
@@ -1,8 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react" import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { api, UnauthorizedError } from "@/api/client" import { api, UnauthorizedError } from "@/api/client"
import type { User, Project } from "@/api/types" import type { User, Project } from "@/api/types"
interface AuthContextValue { export interface AuthContextValue {
user: User | null user: User | null
project: Project | null project: Project | null
loading: boolean loading: boolean
@@ -13,10 +14,28 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | undefined>(undefined) const AuthContext = createContext<AuthContextValue | undefined>(undefined)
// AuthProvider registers a handler here so code outside the React tree (the
// QueryClient's QueryCache/MutationCache onError, wired up in main.tsx) can
// report a 401 from *any* query/mutation and have AuthContext drop the
// session — the same "unauthenticated" state ProtectedRoute already reacts
// to. There is exactly one AuthProvider in the app, so a single module-level
// slot is enough; it's registered/unregistered via useEffect below.
type UnauthorizedHandler = () => void
let unauthorizedHandler: UnauthorizedHandler | null = null
export function registerUnauthorizedHandler(handler: UnauthorizedHandler | null) {
unauthorizedHandler = handler
}
export function notifyUnauthorized() {
unauthorizedHandler?.()
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [project, setProject] = useState<Project | null>(null) const [project, setProject] = useState<Project | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const qc = useQueryClient()
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -64,7 +83,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await api.auth.logout() await api.auth.logout()
setUser(null) setUser(null)
setProject(null) setProject(null)
}, []) qc.clear()
}, [qc])
// Any query/mutation elsewhere in the app that hits a 401 reports it here
// (see notifyUnauthorized/registerUnauthorizedHandler above) — drop the
// session the same way logout() would, so ProtectedRoute redirects to
// /login instead of the UI silently sitting on stale, now-invalid data.
useEffect(() => {
registerUnauthorizedHandler(() => {
setUser(null)
setProject(null)
qc.clear()
})
return () => registerUnauthorizedHandler(null)
}, [qc])
return ( return (
<AuthContext.Provider value={{ user, project, loading, login, register, logout }}> <AuthContext.Provider value={{ user, project, loading, login, register, logout }}>
+57
View File
@@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { describe, it, expect, vi } from "vitest"
import { ProtectedRoute } from "./ProtectedRoute"
import * as AuthContextModule from "./AuthContext"
function renderWithAuth(authValue: Partial<AuthContextModule.AuthContextValue>) {
vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({
user: null,
project: null,
loading: false,
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
...authValue,
})
return render(
<MemoryRouter initialEntries={["/domains"]}>
<Routes>
<Route path="/login" element={<div>login page</div>} />
<Route
path="/domains"
element={
<ProtectedRoute>
<div>protected content</div>
</ProtectedRoute>
}
/>
</Routes>
</MemoryRouter>,
)
}
describe("ProtectedRoute", () => {
it("показывает спиннер, пока идёт проверка сессии", () => {
renderWithAuth({ user: null, loading: true })
expect(screen.queryByText("protected content")).not.toBeInTheDocument()
expect(screen.queryByText("login page")).not.toBeInTheDocument()
expect(screen.getByRole("status")).toBeInTheDocument()
})
it("редиректит на /login, когда пользователь не авторизован", () => {
renderWithAuth({ user: null, loading: false })
expect(screen.getByText("login page")).toBeInTheDocument()
expect(screen.queryByText("protected content")).not.toBeInTheDocument()
})
it("рендерит children, когда пользователь авторизован", () => {
renderWithAuth({ user: { id: "u1", email: "a@b.com" }, loading: false })
expect(screen.getByText("protected content")).toBeInTheDocument()
expect(screen.queryByText("login page")).not.toBeInTheDocument()
})
})
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react"
import { Navigate } from "react-router-dom"
import { Loader2 } from "lucide-react"
import { useAuth } from "@/auth/AuthContext"
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return (
<div
role="status"
aria-label="Проверка сессии"
className="flex h-screen w-full items-center justify-center bg-background"
>
<Loader2 className="size-6 animate-spin text-muted-foreground" strokeWidth={1.75} />
</div>
)
}
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
+21 -3
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { NavLink, useLocation } from "react-router-dom" import { NavLink, useLocation, useNavigate } from "react-router-dom"
import { Globe, Users, LayoutTemplate, SquareTerminal } from "lucide-react" import { Globe, LogOut, Users, LayoutTemplate, SquareTerminal } from "lucide-react"
import { useAuth } from "@/auth/AuthContext"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const NAV = [ const NAV = [
@@ -11,6 +13,13 @@ const NAV = [
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
async function onLogout() {
await logout()
navigate("/login", { replace: true })
}
return ( return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
@@ -64,10 +73,19 @@ export function Layout({ children }: { children: ReactNode }) {
</aside> </aside>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-11 shrink-0 items-center border-b border-border px-6"> <header className="flex h-11 shrink-0 items-center justify-between border-b border-border px-6">
<span className="font-dns text-xs text-muted-foreground"> <span className="font-dns text-xs text-muted-foreground">
{location.pathname} {location.pathname}
</span> </span>
{user && (
<div className="flex items-center gap-3">
<span className="font-dns text-xs text-muted-foreground">{user.email}</span>
<Button variant="ghost" size="sm" onClick={onLogout}>
<LogOut className="size-3.5" strokeWidth={1.75} />
Выйти
</Button>
</div>
)}
</header> </header>
<main className="flex-1 overflow-auto">{children}</main> <main className="flex-1 overflow-auto">{children}</main>
</div> </div>
+22 -5
View File
@@ -4,18 +4,35 @@ import "@fontsource/ibm-plex-mono/500.css"
import "./index.css" import "./index.css"
import React from "react" import React from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryCache, QueryClient, QueryClientProvider, MutationCache } from "@tanstack/react-query"
import { BrowserRouter } from "react-router-dom" import { BrowserRouter } from "react-router-dom"
import { UnauthorizedError } from "@/api/client"
import { AuthProvider, notifyUnauthorized } from "@/auth/AuthContext"
import { App } from "./App" import { App } from "./App"
const queryClient = new QueryClient() // A 401 from *any* query or mutation means the session died server-side
// (expired/destroyed cookie) — drop it from here rather than requiring every
// hook in useApi.ts to remember to handle it individually. AuthContext reacts
// via notifyUnauthorized (registered by AuthProvider), which resets
// user/project and clears the cache; ProtectedRoute then redirects to
// /login on the next render.
function onQueryError(error: unknown) {
if (error instanceof UnauthorizedError) notifyUnauthorized()
}
const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: onQueryError }),
mutationCache: new MutationCache({ onError: onQueryError }),
})
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <AuthProvider>
<App /> <BrowserRouter>
</BrowserRouter> <App />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) )
+14 -5
View File
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom" import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { AccountsPage } from "./AccountsPage" import { AccountsPage } from "./AccountsPage"
import { AuthProvider } from "@/auth/AuthContext"
import { api } from "@/api/client" import { api } from "@/api/client"
import { vi, beforeEach, test, expect } from "vitest" import { vi, beforeEach, test, expect } from "vitest"
import type { Account } from "@/api/types" import type { Account } from "@/api/types"
const PROJECT_ID = "p1"
const accounts: Account[] = [ const accounts: Account[] = [
{ id: "acc1", provider: "selectel", comment: "Main" }, { id: "acc1", provider: "selectel", comment: "Main" },
{ id: "acc2", provider: "selectel", comment: "Backup" }, { id: "acc2", provider: "selectel", comment: "Backup" },
@@ -16,14 +18,21 @@ function renderPage() {
const qc = new QueryClient() const qc = new QueryClient()
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/accounts"]}> <AuthProvider>
<AccountsPage /> <MemoryRouter initialEntries={["/accounts"]}>
</MemoryRouter> <AccountsPage />
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(api.auth, "me").mockResolvedValue({
user: { id: "u1", email: "a@b.com" },
project: { id: PROJECT_ID, name: "Default" },
})
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts) vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
}) })
@@ -55,7 +64,7 @@ test("форма создания вызывает api.createAccount с введ
await user.click(screen.getByRole("button", { name: /добавить учётку/i })) await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
await waitFor(() => await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith({ expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
provider: "selectel", provider: "selectel",
secret: "super-secret-token-123", secret: "super-secret-token-123",
comment: "New account", comment: "New account",
@@ -99,5 +108,5 @@ test("удаление учётки вызывает api.deleteAccount", async (
await user.click(screen.getByRole("button", { name: /удалить.*main/i })) await user.click(screen.getByRole("button", { name: /удалить.*main/i }))
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("acc1")) await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "acc1"))
}) })
+19 -6
View File
@@ -3,20 +3,33 @@ import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom" import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { DomainDiffPage } from "./DomainDiffPage" import { DomainDiffPage } from "./DomainDiffPage"
import { AuthProvider } from "@/auth/AuthContext"
import { api } from "@/api/client" import { api } from "@/api/client"
import { vi } from "vitest" import { vi, beforeEach } from "vitest"
const PROJECT_ID = "p1"
function renderPage() { function renderPage() {
const qc = new QueryClient() const qc = new QueryClient()
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/domains/d1"]}> <AuthProvider>
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes> <MemoryRouter initialEntries={["/domains/d1"]}>
</MemoryRouter> <Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(api.auth, "me").mockResolvedValue({
user: { id: "u1", email: "a@b.com" },
project: { id: PROJECT_ID, name: "Default" },
})
})
test("apply sends applyPrunes=false by default, true only after opting in", async () => { test("apply sends applyPrunes=false by default, true only after opting in", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({ vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }], updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
@@ -30,12 +43,12 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
const applyBtn = await screen.findByRole("button", { name: /apply/i }) const applyBtn = await screen.findByRole("button", { name: /apply/i })
await user.click(applyBtn) await user.click(applyBtn)
await waitFor(() => expect(applySpy).toHaveBeenCalled()) await waitFor(() => expect(applySpy).toHaveBeenCalled())
expect(applySpy.mock.calls[0][1]).toEqual({ applyUpdates: true, applyPrunes: false }) expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: false }])
// включить prune и применить снова // включить prune и применить снова
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i }) const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
await user.click(pruneToggle) await user.click(pruneToggle)
await user.click(screen.getByRole("button", { name: /apply/i })) await user.click(screen.getByRole("button", { name: /apply/i }))
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2)) await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
expect(applySpy.mock.calls[1][1]).toEqual({ applyUpdates: true, applyPrunes: true }) expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
}) })
+17 -8
View File
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom" import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { DomainsPage } from "./DomainsPage" import { DomainsPage } from "./DomainsPage"
import { AuthProvider } from "@/auth/AuthContext"
import { api } from "@/api/client" import { api } from "@/api/client"
import { vi, beforeEach, test, expect } from "vitest" import { vi, beforeEach, test, expect } from "vitest"
import type { Account, Domain, Template } from "@/api/types" import type { Account, Domain, Template } from "@/api/types"
const PROJECT_ID = "p1"
const accounts: Account[] = [ const accounts: Account[] = [
{ id: "acc1", provider: "selectel", comment: "Main" }, { id: "acc1", provider: "selectel", comment: "Main" },
{ id: "acc2", provider: "cloudflare", comment: "Backup" }, { id: "acc2", provider: "cloudflare", comment: "Backup" },
@@ -24,17 +26,24 @@ function renderPage() {
const qc = new QueryClient() const qc = new QueryClient()
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/domains"]}> <AuthProvider>
<Routes> <MemoryRouter initialEntries={["/domains"]}>
<Route path="/domains" element={<DomainsPage />} /> <Routes>
<Route path="/domains/:id" element={<div>diff page</div>} /> <Route path="/domains" element={<DomainsPage />} />
</Routes> <Route path="/domains/:id" element={<div>diff page</div>} />
</MemoryRouter> </Routes>
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(api.auth, "me").mockResolvedValue({
user: { id: "u1", email: "a@b.com" },
project: { id: PROJECT_ID, name: "Default" },
})
vi.spyOn(api, "listDomains").mockResolvedValue(domains) vi.spyOn(api, "listDomains").mockResolvedValue(domains)
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts) vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
vi.spyOn(api, "listTemplates").mockResolvedValue(templates) vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
@@ -64,7 +73,7 @@ test("кнопка импорта вызывает api.importZones с выбра
await user.click(screen.getByRole("button", { name: /импортировать зоны/i })) await user.click(screen.getByRole("button", { name: /импортировать зоны/i }))
await waitFor(() => expect(importSpy).toHaveBeenCalledWith("acc2")) await waitFor(() => expect(importSpy).toHaveBeenCalledWith(PROJECT_ID, "acc2"))
}) })
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => { test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
@@ -77,7 +86,7 @@ test("привязка шаблона в строке домена вызыва
await user.click(screen.getByRole("combobox", { name: /example\.com\./i })) await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
await user.click(await screen.findByRole("option", { name: /^standard$/i })) await user.click(await screen.findByRole("option", { name: /^standard$/i }))
await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith("d1", "t1")) await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "t1"))
}) })
test("ошибка привязки шаблона отображается пользователю", async () => { test("ошибка привязки шаблона отображается пользователю", async () => {
+68
View File
@@ -0,0 +1,68 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { LoginPage } from "./LoginPage"
import { AuthProvider } from "@/auth/AuthContext"
import { api, UnauthorizedError } from "@/api/client"
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<AuthProvider>
<MemoryRouter initialEntries={["/login"]}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<div>register page</div>} />
<Route path="/domains" element={<div>domains page</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(api.auth, "me").mockRejectedValue(new UnauthorizedError())
})
describe("LoginPage", () => {
it("ввод email+пароль и сабмит вызывает useAuth().login с введёнными данными", async () => {
const loginSpy = vi.spyOn(api.auth, "login").mockResolvedValue({
user: { id: "u1", email: "a@b.com" },
project: { id: "p1", name: "Default" },
})
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/email/i), "a@b.com")
await user.type(screen.getByLabelText(/пароль/i), "secret123")
await user.click(screen.getByRole("button", { name: /войти/i }))
await waitFor(() => expect(loginSpy).toHaveBeenCalledWith("a@b.com", "secret123"))
expect(await screen.findByText("domains page")).toBeInTheDocument()
})
it("ошибка входа отображается пользователю через role=alert", async () => {
vi.spyOn(api.auth, "login").mockRejectedValue(new Error("Неверный email или пароль"))
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/email/i), "a@b.com")
await user.type(screen.getByLabelText(/пароль/i), "wrong-password")
await user.click(screen.getByRole("button", { name: /войти/i }))
expect(await screen.findByRole("alert")).toHaveTextContent("Неверный email или пароль")
})
it("содержит ссылку на регистрацию", async () => {
renderPage()
const link = await screen.findByRole("link", { name: /зарегистрир/i })
expect(link).toHaveAttribute("href", "/register")
})
})
+145
View File
@@ -0,0 +1,145 @@
import { useId, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Link, Navigate } from "react-router-dom"
import { KeyRound, Loader2, LogIn, SquareTerminal } from "lucide-react"
import { useAuth } from "@/auth/AuthContext"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
const loginSchema = z.object({
email: z.string().trim().min(1, "Укажите email").email("Некорректный email"),
password: z.string().min(1, "Укажите пароль"),
})
type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() {
const { user, login } = useAuth()
const [authError, setAuthError] = useState<string | null>(null)
const emailFieldId = useId()
const passwordFieldId = useId()
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
})
// Already authenticated (fresh session on mount, or just logged in below) —
// don't show the login form, go straight to the app.
if (user) return <Navigate to="/domains" replace />
async function onSubmit(values: LoginForm) {
setAuthError(null)
try {
await login(values.email, values.password)
} catch {
setAuthError("Неверный email или пароль")
}
}
return (
<div className="flex h-screen w-full items-center justify-center bg-background px-4">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<SquareTerminal className="size-6 text-primary" strokeWidth={1.75} />
<div className="flex flex-col leading-none">
<span className="text-sm font-semibold tracking-tight">DNS Autoresolver</span>
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
console
</span>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-5"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor={emailFieldId}>Email</FieldLabel>
<FieldContent>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
{...field}
id={emailFieldId}
type="email"
autoComplete="email"
placeholder="you@example.com"
aria-invalid={!!errors.email}
/>
)}
/>
<FieldError errors={[errors.email]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
<FieldContent>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
{...field}
id={passwordFieldId}
type="password"
autoComplete="current-password"
placeholder="••••••••••••"
aria-invalid={!!errors.password}
/>
)}
/>
<FieldError errors={[errors.password]} />
</FieldContent>
</Field>
</FieldGroup>
{authError && (
<FieldDescription role="alert" className="flex items-center gap-2 text-destructive">
<KeyRound className="size-3.5 shrink-0" strokeWidth={1.75} />
{authError}
</FieldDescription>
)}
</FieldSet>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<LogIn className="size-4" strokeWidth={1.75} />
)}
Войти
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Нет учётной записи?{" "}
<Link to="/register" className="text-primary underline-offset-4 hover:underline">
Зарегистрироваться
</Link>
</p>
</div>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useId, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Link, Navigate } from "react-router-dom"
import { KeyRound, Loader2, SquareTerminal, UserPlus } from "lucide-react"
import { useAuth } from "@/auth/AuthContext"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
const registerSchema = z.object({
email: z.string().trim().min(1, "Укажите email").email("Некорректный email"),
password: z.string().min(8, "Минимум 8 символов"),
})
type RegisterForm = z.infer<typeof registerSchema>
export function RegisterPage() {
const { user, register: registerUser } = useAuth()
const [authError, setAuthError] = useState<string | null>(null)
const emailFieldId = useId()
const passwordFieldId = useId()
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
defaultValues: { email: "", password: "" },
})
// Already authenticated — skip straight to the app instead of showing the
// registration form again.
if (user) return <Navigate to="/domains" replace />
async function onSubmit(values: RegisterForm) {
setAuthError(null)
try {
await registerUser(values.email, values.password)
} catch (err) {
setAuthError(err instanceof Error ? err.message : "Не удалось зарегистрироваться")
}
}
return (
<div className="flex h-screen w-full items-center justify-center bg-background px-4">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<SquareTerminal className="size-6 text-primary" strokeWidth={1.75} />
<div className="flex flex-col leading-none">
<span className="text-sm font-semibold tracking-tight">DNS Autoresolver</span>
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
console
</span>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-5"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor={emailFieldId}>Email</FieldLabel>
<FieldContent>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
{...field}
id={emailFieldId}
type="email"
autoComplete="email"
placeholder="you@example.com"
aria-invalid={!!errors.email}
/>
)}
/>
<FieldError errors={[errors.email]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
<FieldContent>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
{...field}
id={passwordFieldId}
type="password"
autoComplete="new-password"
placeholder="••••••••••••"
aria-invalid={!!errors.password}
/>
)}
/>
<FieldError errors={[errors.password]} />
</FieldContent>
</Field>
</FieldGroup>
{authError && (
<FieldDescription role="alert" className="flex items-center gap-2 text-destructive">
<KeyRound className="size-3.5 shrink-0" strokeWidth={1.75} />
{authError}
</FieldDescription>
)}
</FieldSet>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<UserPlus className="size-4" strokeWidth={1.75} />
)}
Зарегистрироваться
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Уже есть аккаунт?{" "}
<Link to="/login" className="text-primary underline-offset-4 hover:underline">
Войти
</Link>
</p>
</div>
</div>
)
}
+15 -6
View File
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom" import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { TemplatesPage } from "./TemplatesPage" import { TemplatesPage } from "./TemplatesPage"
import { AuthProvider } from "@/auth/AuthContext"
import { api } from "@/api/client" import { api } from "@/api/client"
import { vi, beforeEach, test, expect } from "vitest" import { vi, beforeEach, test, expect } from "vitest"
import type { Template } from "@/api/types" import type { Template } from "@/api/types"
const PROJECT_ID = "p1"
const templates: Template[] = [ const templates: Template[] = [
{ {
id: "t1", id: "t1",
@@ -21,14 +23,21 @@ function renderPage() {
const qc = new QueryClient() const qc = new QueryClient()
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/templates"]}> <AuthProvider>
<TemplatesPage /> <MemoryRouter initialEntries={["/templates"]}>
</MemoryRouter> <TemplatesPage />
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(api.auth, "me").mockResolvedValue({
user: { id: "u1", email: "a@b.com" },
project: { id: PROJECT_ID, name: "Default" },
})
vi.spyOn(api, "listTemplates").mockResolvedValue(templates) vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
}) })
@@ -65,7 +74,7 @@ test("создание шаблона с записью вызывает api.cre
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i })) await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
await waitFor(() => await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith({ expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
name: "New", name: "New",
records: [{ type: "A", name: "www", ttl: 3600, values: ["1.1.1.1"] }], records: [{ type: "A", name: "www", ttl: 3600, values: ["1.1.1.1"] }],
}), }),
@@ -88,7 +97,7 @@ test("редактирование шаблона вызывает api.updateTem
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i })) await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
await waitFor(() => await waitFor(() =>
expect(updateSpy).toHaveBeenCalledWith("t1", { expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, "t1", {
name: "Standard v2", name: "Standard v2",
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }], records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
}), }),
@@ -105,7 +114,7 @@ test("удаление шаблона вызывает api.deleteTemplate", asyn
await user.click(screen.getByRole("button", { name: /удалить шаблон standard/i })) await user.click(screen.getByRole("button", { name: /удалить шаблон standard/i }))
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("t1")) await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "t1"))
}) })
test("ошибка создания шаблона отображается пользователю", async () => { test("ошибка создания шаблона отображается пользователю", async () => {