mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* 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).
139 lines
3.7 KiB
TypeScript
139 lines
3.7 KiB
TypeScript
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
test,
|
|
rs,
|
|
} from "@rstest/core";
|
|
|
|
import { GatewayApiError } from "@/core/api/errors";
|
|
import { loadAdminUsers, updateUserAccount } from "@/core/auth/admin-users";
|
|
|
|
const jsonResponse = (body: unknown, status = 200) =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
describe("admin user management client", () => {
|
|
let originalFetch: typeof globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
test("loadAdminUsers requests the admin list endpoint", async () => {
|
|
const fetchSpy = rs.fn(async () =>
|
|
jsonResponse([
|
|
{
|
|
id: "u-1",
|
|
email: "a@example.com",
|
|
system_role: "admin",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
disabled: true,
|
|
},
|
|
]),
|
|
);
|
|
globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch;
|
|
|
|
const users = await loadAdminUsers();
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
"/api/v1/admin/users",
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
expect(users).toEqual([
|
|
{
|
|
id: "u-1",
|
|
email: "a@example.com",
|
|
system_role: "admin",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
disabled: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("updateUserAccount PATCHes a JSON body with only the changed fields", async () => {
|
|
const fetchSpy = rs.fn(async () =>
|
|
jsonResponse({
|
|
id: "u-1",
|
|
email: "a@example.com",
|
|
system_role: "admin",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
disabled: true,
|
|
}),
|
|
);
|
|
globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch;
|
|
|
|
await updateUserAccount("u 1/id", { disabled: true });
|
|
|
|
const [url, init] = rs.mocked(fetchSpy).mock.calls[0] as unknown as [
|
|
string,
|
|
RequestInit & { body: string },
|
|
];
|
|
expect(url).toBe("/api/v1/admin/users/u%201%2Fid");
|
|
expect(init.method).toBe("PATCH");
|
|
expect(init.headers).toMatchObject({ "Content-Type": "application/json" });
|
|
expect(JSON.parse(init.body)).toEqual({ disabled: true });
|
|
});
|
|
|
|
test("a 409 conflict surfaces the gateway detail as the error message", async () => {
|
|
globalThis.fetch = rs.fn(async () =>
|
|
jsonResponse(
|
|
{ detail: "cannot disable the last remaining active admin" },
|
|
409,
|
|
),
|
|
) as unknown as typeof globalThis.fetch;
|
|
|
|
const error = await updateUserAccount("u-1", { disabled: true }).catch(
|
|
(err: unknown) => err,
|
|
);
|
|
|
|
expect(error).toBeInstanceOf(GatewayApiError);
|
|
expect(error).toMatchObject({
|
|
status: 409,
|
|
message: "cannot disable the last remaining active admin",
|
|
});
|
|
});
|
|
|
|
test("a coded-envelope 409 still surfaces its message verbatim", async () => {
|
|
globalThis.fetch = rs.fn(async () =>
|
|
jsonResponse(
|
|
{
|
|
detail: {
|
|
code: "last_active_admin",
|
|
message: "cannot disable the last remaining active admin",
|
|
},
|
|
},
|
|
409,
|
|
),
|
|
) as unknown as typeof globalThis.fetch;
|
|
|
|
const error = await updateUserAccount("u-1", { disabled: true }).catch(
|
|
(err: unknown) => err,
|
|
);
|
|
|
|
expect(error).toBeInstanceOf(GatewayApiError);
|
|
expect(error).toMatchObject({
|
|
status: 409,
|
|
message: "cannot disable the last remaining active admin",
|
|
});
|
|
});
|
|
|
|
test("a malformed row rejects instead of rendering a phantom user", async () => {
|
|
globalThis.fetch = rs.fn(async () =>
|
|
jsonResponse([{ email: "no-id@example.com" }]),
|
|
) as unknown as typeof globalThis.fetch;
|
|
|
|
await expect(loadAdminUsers()).rejects.toThrow();
|
|
});
|
|
});
|