feat(api-tokens): add PAT management UI, CSRF handling, and auth-loading fixes

Add an API tokens management route to create, list, and revoke PATs, showing
the plaintext token once on creation with scope and server-id selection.

Mirror the nz-csrf cookie into the X-CSRF-Token header on unsafe fetcher
methods (POST/PUT/PATCH/DELETE) for the server-side double-submit check, and
self-heal expired sessions via refresh-token without a recursive fetch loop.

Gate protected routes behind resolved auth state to avoid pre-auth SWR fetches,
and fix the login loading/race so stale probes cannot clobber the session.

Add i18n keys for the new screens across all locales.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-30 15:56:53 +00:00
co-authored by cloudcode
parent f47d4a8772
commit f07c557029
37 changed files with 2041 additions and 69 deletions
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const realFetch = global.fetch
function setCookie(value: string) {
Object.defineProperty(document, "cookie", { value, configurable: true })
}
function jsonOk() {
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}
beforeEach(() => {
setCookie("")
vi.resetModules()
})
afterEach(() => {
global.fetch = realFetch
vi.restoreAllMocks()
})
// Auto-refresh is a POST behind the CSRF gate. When the session still lacks the
// nz-csrf cookie (e.g. just upgraded), firing the refresh without a header only
// burns the 1h throttle window and 403s. The refresh must instead wait until a
// CSRF token is available so it can actually succeed once the cookie is seeded.
test("auto refresh is deferred while nz-csrf cookie is missing", async () => {
const { FetcherMethod, fetcher } = await import("../api/api")
setCookie("nz-jwt=session") // jwt present, but no nz-csrf yet
const urls: string[] = []
global.fetch = vi.fn(async (input: any) => {
urls.push(String(input))
return jsonOk()
}) as unknown as typeof fetch
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false)
})
// Once the cookie exists, the next GET must be allowed to fire the refresh —
// proving the missing-cookie skip did not permanently consume the throttle.
test("auto refresh fires after nz-csrf cookie becomes available", async () => {
const { FetcherMethod, fetcher } = await import("../api/api")
const urls: string[] = []
global.fetch = vi.fn(async (input: any) => {
urls.push(String(input))
return jsonOk()
}) as unknown as typeof fetch
setCookie("nz-jwt=session") // first GET: no csrf, refresh skipped
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false)
setCookie("nz-jwt=session; nz-csrf=seeded") // backend seeded it
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(true)
})