@@ -64,10 +73,19 @@ export function Layout({ children }: { children: ReactNode }) {
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 } />
-
-
+