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

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

Починка page-тестов (Accounts/Domains/Templates/DomainDiff/App): AuthProvider
+ мок api.auth.me, спай-ассерты обновлены под projectId-первым-аргументом
сигнатур api.* (T5).
This commit is contained in:
2026-07-03 21:21:29 +07:00
parent 222d6c0453
commit 5a4d560e70
15 changed files with 658 additions and 54 deletions
+14 -5
View File
@@ -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}>
<MemoryRouter initialEntries={["/accounts"]}>
<AccountsPage />
</MemoryRouter>
<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"))
})
+19 -6
View File
@@ -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}>
<MemoryRouter initialEntries={["/domains/d1"]}>
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
</MemoryRouter>
<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 }])
})
+17 -8
View File
@@ -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}>
<MemoryRouter initialEntries={["/domains"]}>
<Routes>
<Route path="/domains" element={<DomainsPage />} />
<Route path="/domains/:id" element={<div>diff page</div>} />
</Routes>
</MemoryRouter>
<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 () => {
+68
View File
@@ -0,0 +1,68 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { LoginPage } from "./LoginPage"
import { AuthProvider } from "@/auth/AuthContext"
import { api, UnauthorizedError } from "@/api/client"
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<AuthProvider>
<MemoryRouter initialEntries={["/login"]}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<div>register page</div>} />
<Route path="/domains" element={<div>domains page</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(api.auth, "me").mockRejectedValue(new UnauthorizedError())
})
describe("LoginPage", () => {
it("ввод email+пароль и сабмит вызывает useAuth().login с введёнными данными", async () => {
const loginSpy = vi.spyOn(api.auth, "login").mockResolvedValue({
user: { id: "u1", email: "a@b.com" },
project: { id: "p1", name: "Default" },
})
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/email/i), "a@b.com")
await user.type(screen.getByLabelText(/пароль/i), "secret123")
await user.click(screen.getByRole("button", { name: /войти/i }))
await waitFor(() => expect(loginSpy).toHaveBeenCalledWith("a@b.com", "secret123"))
expect(await screen.findByText("domains page")).toBeInTheDocument()
})
it("ошибка входа отображается пользователю через role=alert", async () => {
vi.spyOn(api.auth, "login").mockRejectedValue(new Error("Неверный email или пароль"))
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/email/i), "a@b.com")
await user.type(screen.getByLabelText(/пароль/i), "wrong-password")
await user.click(screen.getByRole("button", { name: /войти/i }))
expect(await screen.findByRole("alert")).toHaveTextContent("Неверный email или пароль")
})
it("содержит ссылку на регистрацию", async () => {
renderPage()
const link = await screen.findByRole("link", { name: /зарегистрир/i })
expect(link).toHaveAttribute("href", "/register")
})
})
+145
View File
@@ -0,0 +1,145 @@
import { useId, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Link, Navigate } from "react-router-dom"
import { KeyRound, Loader2, LogIn, SquareTerminal } from "lucide-react"
import { useAuth } from "@/auth/AuthContext"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
const loginSchema = z.object({
email: z.string().trim().min(1, "Укажите email").email("Некорректный email"),
password: z.string().min(1, "Укажите пароль"),
})
type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() {
const { user, login } = useAuth()
const [authError, setAuthError] = useState<string | null>(null)
const emailFieldId = useId()
const passwordFieldId = useId()
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
})
// Already authenticated (fresh session on mount, or just logged in below) —
// don't show the login form, go straight to the app.
if (user) return <Navigate to="/domains" replace />
async function onSubmit(values: LoginForm) {
setAuthError(null)
try {
await login(values.email, values.password)
} catch {
setAuthError("Неверный email или пароль")
}
}
return (
<div className="flex h-screen w-full items-center justify-center bg-background px-4">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<SquareTerminal className="size-6 text-primary" strokeWidth={1.75} />
<div className="flex flex-col leading-none">
<span className="text-sm font-semibold tracking-tight">DNS Autoresolver</span>
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
console
</span>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-5"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor={emailFieldId}>Email</FieldLabel>
<FieldContent>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
{...field}
id={emailFieldId}
type="email"
autoComplete="email"
placeholder="you@example.com"
aria-invalid={!!errors.email}
/>
)}
/>
<FieldError errors={[errors.email]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
<FieldContent>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
{...field}
id={passwordFieldId}
type="password"
autoComplete="current-password"
placeholder="••••••••••••"
aria-invalid={!!errors.password}
/>
)}
/>
<FieldError errors={[errors.password]} />
</FieldContent>
</Field>
</FieldGroup>
{authError && (
<FieldDescription role="alert" className="flex items-center gap-2 text-destructive">
<KeyRound className="size-3.5 shrink-0" strokeWidth={1.75} />
{authError}
</FieldDescription>
)}
</FieldSet>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<LogIn className="size-4" strokeWidth={1.75} />
)}
Войти
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Нет учётной записи?{" "}
<Link to="/register" className="text-primary underline-offset-4 hover:underline">
Зарегистрироваться
</Link>
</p>
</div>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useId, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Link, Navigate } from "react-router-dom"
import { KeyRound, Loader2, SquareTerminal, UserPlus } from "lucide-react"
import { useAuth } from "@/auth/AuthContext"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
const registerSchema = z.object({
email: z.string().trim().min(1, "Укажите email").email("Некорректный email"),
password: z.string().min(8, "Минимум 8 символов"),
})
type RegisterForm = z.infer<typeof registerSchema>
export function RegisterPage() {
const { user, register: registerUser } = useAuth()
const [authError, setAuthError] = useState<string | null>(null)
const emailFieldId = useId()
const passwordFieldId = useId()
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
defaultValues: { email: "", password: "" },
})
// Already authenticated — skip straight to the app instead of showing the
// registration form again.
if (user) return <Navigate to="/domains" replace />
async function onSubmit(values: RegisterForm) {
setAuthError(null)
try {
await registerUser(values.email, values.password)
} catch (err) {
setAuthError(err instanceof Error ? err.message : "Не удалось зарегистрироваться")
}
}
return (
<div className="flex h-screen w-full items-center justify-center bg-background px-4">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<SquareTerminal className="size-6 text-primary" strokeWidth={1.75} />
<div className="flex flex-col leading-none">
<span className="text-sm font-semibold tracking-tight">DNS Autoresolver</span>
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
console
</span>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-5"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor={emailFieldId}>Email</FieldLabel>
<FieldContent>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
{...field}
id={emailFieldId}
type="email"
autoComplete="email"
placeholder="you@example.com"
aria-invalid={!!errors.email}
/>
)}
/>
<FieldError errors={[errors.email]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
<FieldContent>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
{...field}
id={passwordFieldId}
type="password"
autoComplete="new-password"
placeholder="••••••••••••"
aria-invalid={!!errors.password}
/>
)}
/>
<FieldError errors={[errors.password]} />
</FieldContent>
</Field>
</FieldGroup>
{authError && (
<FieldDescription role="alert" className="flex items-center gap-2 text-destructive">
<KeyRound className="size-3.5 shrink-0" strokeWidth={1.75} />
{authError}
</FieldDescription>
)}
</FieldSet>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<UserPlus className="size-4" strokeWidth={1.75} />
)}
Зарегистрироваться
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Уже есть аккаунт?{" "}
<Link to="/login" className="text-primary underline-offset-4 hover:underline">
Войти
</Link>
</p>
</div>
</div>
)
}
+15 -6
View File
@@ -3,10 +3,12 @@ import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { 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}>
<MemoryRouter initialEntries={["/templates"]}>
<TemplatesPage />
</MemoryRouter>
<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 () => {