diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 559f557..2b72b2f 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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( - - - + + + + + , ) // 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() }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 78ee6a7..3f2fd46 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 below a one-liner instead of repeating both on every page. +function Protected({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + export function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ) } diff --git a/web/src/auth/AuthContext.test.tsx b/web/src/auth/AuthContext.test.tsx index 05f6d48..067f565 100644 --- a/web/src/auth/AuthContext.test.tsx +++ b/web/src/auth/AuthContext.test.tsx @@ -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( - - - , + + + + + , ) } @@ -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() + }) }) diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index 1c9fa77..f2477be 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -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(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(null) const [project, setProject] = useState(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 ( diff --git a/web/src/auth/ProtectedRoute.test.tsx b/web/src/auth/ProtectedRoute.test.tsx new file mode 100644 index 0000000..90bd7cc --- /dev/null +++ b/web/src/auth/ProtectedRoute.test.tsx @@ -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) { + vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({ + user: null, + project: null, + loading: false, + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + ...authValue, + }) + + return render( + + + login page} /> + +
protected content
+ + } + /> +
+
, + ) +} + +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() + }) +}) diff --git a/web/src/auth/ProtectedRoute.tsx b/web/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..5fc5c87 --- /dev/null +++ b/web/src/auth/ProtectedRoute.tsx @@ -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 ( +
+ +
+ ) + } + + if (!user) return + + return <>{children} +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index bfb5b1f..f0c6ffc 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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 (
@@ -64,10 +73,19 @@ export function Layout({ children }: { children: ReactNode }) {
-
+
{location.pathname} + {user && ( +
+ {user.email} + +
+ )}
{children}
diff --git a/web/src/main.tsx b/web/src/main.tsx index e2c192d..5d40620 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - - - + + + + + , ) diff --git a/web/src/pages/AccountsPage.test.tsx b/web/src/pages/AccountsPage.test.tsx index ca210ee..c82d8ab 100644 --- a/web/src/pages/AccountsPage.test.tsx +++ b/web/src/pages/AccountsPage.test.tsx @@ -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( - - - + + + + + , ) } 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")) }) diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index 1c4c601..4c64314 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -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( - - } /> - + + + } /> + + , ) } +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 }]) }) diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index bce0253..3822faf 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -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( - - - } /> - diff page
} /> - - + + + + } /> + diff page} /> + + + , ) } 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 () => { diff --git a/web/src/pages/LoginPage.test.tsx b/web/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..eca92f1 --- /dev/null +++ b/web/src/pages/LoginPage.test.tsx @@ -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( + + + + + } /> + register page} /> + domains page} /> + + + + , + ) +} + +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") + }) +}) diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..a9bb91e --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -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 + +export function LoginPage() { + const { user, login } = useAuth() + const [authError, setAuthError] = useState(null) + const emailFieldId = useId() + const passwordFieldId = useId() + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + 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 + + async function onSubmit(values: LoginForm) { + setAuthError(null) + try { + await login(values.email, values.password) + } catch { + setAuthError("Неверный email или пароль") + } + } + + return ( +
+
+
+ +
+ DNS Autoresolver + + console + +
+
+ +
+
+ + + Email + + ( + + )} + /> + + + + + + Пароль + + ( + + )} + /> + + + + + + {authError && ( + + + {authError} + + )} +
+ + +
+ +

+ Нет учётной записи?{" "} + + Зарегистрироваться + +

+
+
+ ) +} diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..3adc8ac --- /dev/null +++ b/web/src/pages/RegisterPage.tsx @@ -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 + +export function RegisterPage() { + const { user, register: registerUser } = useAuth() + const [authError, setAuthError] = useState(null) + const emailFieldId = useId() + const passwordFieldId = useId() + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { email: "", password: "" }, + }) + + // Already authenticated — skip straight to the app instead of showing the + // registration form again. + if (user) return + + async function onSubmit(values: RegisterForm) { + setAuthError(null) + try { + await registerUser(values.email, values.password) + } catch (err) { + setAuthError(err instanceof Error ? err.message : "Не удалось зарегистрироваться") + } + } + + return ( +
+
+
+ +
+ DNS Autoresolver + + console + +
+
+ +
+
+ + + Email + + ( + + )} + /> + + + + + + Пароль + + ( + + )} + /> + + + + + + {authError && ( + + + {authError} + + )} +
+ + +
+ +

+ Уже есть аккаунт?{" "} + + Войти + +

+
+
+ ) +} diff --git a/web/src/pages/TemplatesPage.test.tsx b/web/src/pages/TemplatesPage.test.tsx index cb7e86a..cc2cdab 100644 --- a/web/src/pages/TemplatesPage.test.tsx +++ b/web/src/pages/TemplatesPage.test.tsx @@ -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( - - - + + + + + , ) } 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 () => {