hataa 2f4f4d4b6c
feat(authz): admin user-management UI and disabled-account copy (#4063 gap 3) (#6641)
* feat(authz): admin user-management UI and disabled-account copy (#4063 gap 3)

Frontend slice of the account lifecycle backend (#6566):

- Settings gains an admin-only Users page (core/auth/admin-users.ts ->
  GET/PATCH /api/v1/admin/users): email, read-only role, disabled state,
  and a suspend/restore toggle per row; the Gateway's rejection detail
  (last-active-admin 409) renders verbatim. The nav entry is hidden for
  non-admins and the page guards independently; ?settings=users joins the
  deep-link whitelist (now an exhaustive Record over SettingsSection).
- A session that turns out disabled maps account_disabled to a login
  redirect that states the reason on all three surfaces: the SSR
  AuthResult tag (consumed exhaustively by the workspace/artifacts/auth
  layouts), the shared fetcher's 401 interception, and
  AuthProvider.refreshUser. No next target is preserved - a disabled
  account cannot sign back in. Login failures surface the localized
  account_disabled copy when the code is present.
- userSchema stays disabled-optional (absent = not disabled); zh/en
  locales and docs updated in the same change.

* fix(authz): address review on admin user UI and disabled copy (#4063 gap 3)

Review follow-ups on PR #6641:

- Direct /login visits with a suspended session now surface the SSR
  account_disabled verdict through AuthDisabledContext instead of
  discarding it (willem-bd): the auth layout wraps pages with the
  provider and the login form seeds its reason from it — no redirect,
  so no loop. Regression tests cover both verdict states.
- admin-users.ts parses both endpoints against userSchema /
  z.array(userSchema) (zhfeng): malformed payloads fail the load
  instead of rendering phantom rows; toggle error copy is scoped to
  GatewayApiError with a localized fallback for anything else.
- The localized account_disabled copy is login-only (zhfeng): sign-up
  keeps the backend message verbatim so a suspended account's email
  cannot be probed through the register surface.
- Settings page folds the load banner and toggle error into a single
  alert region (zhfeng); adds coded-envelope 409, malformed-row, and
  load-failure tests; fetcher.ts documents the 401 body-consumption
  contract.
- prettier across touched files (CI format gate).

The admin-users query key keeps user?.id, matching the
model-settings-page precedent (converge later per review).
2026-10-11 12:54:51 +08:00

284 lines
7.4 KiB
TypeScript

import {
afterEach,
beforeEach,
describe,
expect,
test,
rs,
} from "@rstest/core";
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
import { userSchema } from "@/core/auth/types";
rs.mock("next/headers", () => ({
cookies: rs.fn(() => {
throw new Error("cookies should not be read in static website mode");
}),
}));
const ENV_KEYS = [
"DEER_FLOW_AUTH_DISABLED",
"DEER_FLOW_ENV",
"ENVIRONMENT",
"NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
] as const;
type EnvSnapshot = Partial<
Record<(typeof ENV_KEYS)[number], string | undefined>
>;
function snapshotEnv(): EnvSnapshot {
const snapshot: EnvSnapshot = {};
for (const key of ENV_KEYS) {
snapshot[key] = process.env[key];
}
return snapshot;
}
function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
const env = process.env as Record<string, string | undefined>;
if (value === undefined) {
delete env[key];
} else {
env[key] = value;
}
}
function restoreEnv(snapshot: EnvSnapshot) {
for (const key of ENV_KEYS) {
setEnv(key, snapshot[key]);
}
}
async function loadFreshServerAuth() {
rs.resetModules();
return await import("@/core/auth/server");
}
describe("getServerSideUser", () => {
let saved: EnvSnapshot;
beforeEach(() => {
saved = snapshotEnv();
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
setEnv("DEER_FLOW_ENV", undefined);
setEnv("ENVIRONMENT", undefined);
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
});
afterEach(() => {
restoreEnv(saved);
rs.unstubAllGlobals();
});
test("bypasses gateway auth in static website mode", async () => {
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "true");
const fetchSpy = rs.fn(() => {
throw new Error("fetch should not be called in static website mode");
});
rs.stubGlobal("fetch", fetchSpy);
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "authenticated",
user: STATIC_WEBSITE_USER,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
test("bypasses gateway auth in auth-disabled mode", async () => {
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
const fetchSpy = rs.fn(() => {
throw new Error("fetch should not be called in auth-disabled mode");
});
rs.stubGlobal("fetch", fetchSpy);
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "authenticated",
user: AUTH_DISABLED_USER,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
test("does not enable auth-disabled mode in explicit production environments", async () => {
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
setEnv("DEER_FLOW_ENV", "production");
const { isAuthDisabledMode } =
await import("@/core/auth/auth-disabled-user");
expect(isAuthDisabledMode()).toBe(false);
});
});
describe("getServerSideUser — gateway_unavailable contract (issue #3493)", () => {
let saved: EnvSnapshot;
beforeEach(() => {
saved = snapshotEnv();
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
});
afterEach(() => {
restoreEnv(saved);
rs.unstubAllGlobals();
rs.doUnmock("next/headers");
});
test("returns gateway_unavailable when /auth/me fetch rejects (e.g. AbortError)", async () => {
rs.doMock("next/headers", () => ({
cookies: rs.fn(async () => ({
get: (name: string) =>
name === "access_token" ? { value: "stub-token" } : undefined,
})),
}));
const abortErr = new DOMException("Aborted", "AbortError");
rs.stubGlobal(
"fetch",
rs.fn(() => Promise.reject(abortErr)),
);
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "gateway_unavailable",
});
});
test("returns gateway_unavailable when /auth/me responds with a 5xx", async () => {
rs.doMock("next/headers", () => ({
cookies: rs.fn(async () => ({
get: (name: string) =>
name === "access_token" ? { value: "stub-token" } : undefined,
})),
}));
rs.stubGlobal(
"fetch",
rs.fn(() =>
Promise.resolve(
new Response("upstream error", {
status: 503,
statusText: "Service Unavailable",
}),
),
),
);
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "gateway_unavailable",
});
});
});
// ── account_disabled contract (RFC #4063 gap 3) ────────────────────
describe("getServerSideUser — account_disabled contract", () => {
let saved: EnvSnapshot;
beforeEach(() => {
saved = snapshotEnv();
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
rs.doMock("next/headers", () => ({
cookies: rs.fn(async () => ({
get: (name: string) =>
name === "access_token" ? { value: "stub-token" } : undefined,
})),
}));
});
afterEach(() => {
restoreEnv(saved);
rs.unstubAllGlobals();
rs.doUnmock("next/headers");
});
function stubMeResponse(body: unknown, status = 401) {
rs.stubGlobal(
"fetch",
rs.fn(
async () =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
),
);
}
test("maps a 401 account_disabled session to the account_disabled tag", async () => {
stubMeResponse({
detail: { code: "account_disabled", message: "Account disabled" },
});
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "account_disabled",
});
});
test("keeps every other 401 a plain unauthenticated result", async () => {
stubMeResponse({
detail: { code: "token_expired", message: "Token expired" },
});
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "unauthenticated",
});
});
test("a non-JSON 401 body still degrades to unauthenticated", async () => {
rs.stubGlobal(
"fetch",
rs.fn(async () => new Response("nope", { status: 401 })),
);
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "unauthenticated",
});
});
});
// ── system_role contract (RFC #4063 gap 2) ─────────────────────────
describe("userSchema system_role", () => {
test("accepts a provider-configured custom role verbatim", () => {
const parsed = userSchema.parse({
id: "1",
email: "g@example.com",
system_role: "guest",
needs_setup: false,
oauth_provider: null,
permissions: null,
});
expect(parsed.system_role).toBe("guest");
});
test("still rejects an empty role string", () => {
expect(() =>
userSchema.parse({
id: "1",
email: "g@example.com",
system_role: "",
needs_setup: false,
disabled: false,
oauth_provider: null,
permissions: null,
}),
).toThrow();
});
});