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:
+16
-5
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 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
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 { 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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user