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:
+13
-2
@@ -1,19 +1,30 @@
|
||||
import { render, screen, within } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { vi, test, expect } from "vitest"
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
// Sidebar nav also renders a "Domains" link label, so scope the assertion
|
||||
// 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(screen.getByRole("link", { name: /domains/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
+20
-6
@@ -1,20 +1,34 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Routes, Route, Navigate } from "react-router-dom"
|
||||
import { ProtectedRoute } from "@/auth/ProtectedRoute"
|
||||
import { Layout } from "@/components/Layout"
|
||||
import { AccountsPage } from "@/pages/AccountsPage"
|
||||
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
||||
import { DomainsPage } from "@/pages/DomainsPage"
|
||||
import { LoginPage } from "@/pages/LoginPage"
|
||||
import { RegisterPage } from "@/pages/RegisterPage"
|
||||
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() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/" element={<Navigate to="/domains" replace />} />
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/domains" element={<Protected><DomainsPage /></Protected>} />
|
||||
<Route path="/domains/:id" element={<Protected><DomainDiffPage /></Protected>} />
|
||||
<Route path="/accounts" element={<Protected><AccountsPage /></Protected>} />
|
||||
<Route path="/templates" element={<Protected><TemplatesPage /></Protected>} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
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"
|
||||
|
||||
const USER = { id: "u1", email: "a@b.com" }
|
||||
@@ -21,11 +22,13 @@ function Probe() {
|
||||
)
|
||||
}
|
||||
|
||||
function renderProbe() {
|
||||
function renderProbe(qc: QueryClient = new QueryClient()) {
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
<Probe />
|
||||
</AuthProvider>,
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,4 +94,33 @@ describe("AuthContext", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("user").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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { api, UnauthorizedError } from "@/api/client"
|
||||
import type { User, Project } from "@/api/types"
|
||||
|
||||
interface AuthContextValue {
|
||||
export interface AuthContextValue {
|
||||
user: User | null
|
||||
project: Project | null
|
||||
loading: boolean
|
||||
@@ -13,10 +14,28 @@ interface AuthContextValue {
|
||||
|
||||
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 }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const qc = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -64,7 +83,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout()
|
||||
setUser(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 (
|
||||
<AuthContext.Provider value={{ user, project, loading, login, register, logout }}>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { NavLink, useLocation } from "react-router-dom"
|
||||
import { Globe, Users, LayoutTemplate, SquareTerminal } from "lucide-react"
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom"
|
||||
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"
|
||||
|
||||
const NAV = [
|
||||
@@ -11,6 +13,13 @@ const NAV = [
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
async function onLogout() {
|
||||
await logout()
|
||||
navigate("/login", { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
@@ -64,10 +73,19 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
{location.pathname}
|
||||
</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>
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
|
||||
+19
-2
@@ -4,18 +4,35 @@ import "@fontsource/ibm-plex-mono/500.css"
|
||||
import "./index.css"
|
||||
import React from "react"
|
||||
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 { UnauthorizedError } from "@/api/client"
|
||||
import { AuthProvider, notifyUnauthorized } from "@/auth/AuthContext"
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { AccountsPage } from "./AccountsPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Account } from "@/api/types"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
const accounts: Account[] = [
|
||||
{ id: "acc1", provider: "selectel", comment: "Main" },
|
||||
{ id: "acc2", provider: "selectel", comment: "Backup" },
|
||||
@@ -16,14 +18,21 @@ function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/accounts"]}>
|
||||
<AccountsPage />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -55,7 +64,7 @@ test("форма создания вызывает api.createAccount с введ
|
||||
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
|
||||
provider: "selectel",
|
||||
secret: "super-secret-token-123",
|
||||
comment: "New account",
|
||||
@@ -99,5 +108,5 @@ test("удаление учётки вызывает api.deleteAccount", async (
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /удалить.*main/i }))
|
||||
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("acc1"))
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "acc1"))
|
||||
})
|
||||
|
||||
@@ -3,20 +3,33 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainDiffPage } from "./DomainDiffPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi } from "vitest"
|
||||
import { vi, beforeEach } from "vitest"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/domains/d1"]}>
|
||||
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</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 () => {
|
||||
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||
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 })
|
||||
await user.click(applyBtn)
|
||||
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 и применить снова
|
||||
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
|
||||
await user.click(pruneToggle)
|
||||
await user.click(screen.getByRole("button", { name: /apply/i }))
|
||||
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 }])
|
||||
})
|
||||
|
||||
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainsPage } from "./DomainsPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Account, Domain, Template } from "@/api/types"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
const accounts: Account[] = [
|
||||
{ id: "acc1", provider: "selectel", comment: "Main" },
|
||||
{ id: "acc2", provider: "cloudflare", comment: "Backup" },
|
||||
@@ -24,17 +26,24 @@ function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/domains"]}>
|
||||
<Routes>
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<div>diff page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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, "listAccounts").mockResolvedValue(accounts)
|
||||
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
|
||||
@@ -64,7 +73,7 @@ test("кнопка импорта вызывает api.importZones с выбра
|
||||
|
||||
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 () => {
|
||||
@@ -77,7 +86,7 @@ test("привязка шаблона в строке домена вызыва
|
||||
await user.click(screen.getByRole("combobox", { name: /example\.com\./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 () => {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { TemplatesPage } from "./TemplatesPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Template } from "@/api/types"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
const templates: Template[] = [
|
||||
{
|
||||
id: "t1",
|
||||
@@ -21,14 +23,21 @@ function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={["/templates"]}>
|
||||
<TemplatesPage />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -65,7 +74,7 @@ test("создание шаблона с записью вызывает api.cre
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
|
||||
name: "New",
|
||||
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 waitFor(() =>
|
||||
expect(updateSpy).toHaveBeenCalledWith("t1", {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, "t1", {
|
||||
name: "Standard v2",
|
||||
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 waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("t1"))
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "t1"))
|
||||
})
|
||||
|
||||
test("ошибка создания шаблона отображается пользователю", async () => {
|
||||
|
||||
Reference in New Issue
Block a user