From 2f4f4d4b6cdb3d0b98465f2333aca0e063fb6371 Mon Sep 17 00:00:00 2001
From: hataa <79907651+hata33@users.noreply.github.com>
Date: Sun, 11 Oct 2026 12:54:51 +0800
Subject: [PATCH] feat(authz): admin user-management UI and disabled-account
copy (#4063 gap 3) (#6641)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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).
---
README.md | 2 +
README_zh.md | 5 +
frontend/AGENTS.md | 19 ++
frontend/src/app/(auth)/layout.tsx | 17 +-
frontend/src/app/(auth)/login/page.tsx | 22 +-
frontend/src/app/artifacts/view/page.tsx | 9 +-
frontend/src/app/workspace/layout.tsx | 6 +-
.../workspace/settings/settings-dialog.tsx | 57 +++--
.../workspace/settings/user-settings-page.tsx | 110 ++++++++++
.../workspace-settings-deep-link.tsx | 27 ++-
frontend/src/core/api/fetcher.ts | 21 +-
frontend/src/core/auth/AuthProvider.tsx | 31 ++-
frontend/src/core/auth/admin-users.ts | 66 ++++++
frontend/src/core/auth/server.ts | 12 +-
frontend/src/core/auth/types.ts | 9 +
frontend/src/core/i18n/locales/en-US.ts | 21 ++
frontend/src/core/i18n/locales/types.ts | 19 ++
frontend/src/core/i18n/locales/zh-CN.ts | 20 ++
.../app/login-account-disabled.dom.test.tsx | 158 ++++++++++++++
.../components/workspace/lazy-panels.test.ts | 2 +-
.../settings/user-settings-page.dom.test.tsx | 206 ++++++++++++++++++
.../tests/unit/core/api/fetcher.dom.test.ts | 19 ++
.../unit/core/auth/AuthProvider.dom.test.tsx | 89 ++++++++
.../unit/core/auth/admin-users.dom.test.ts | 138 ++++++++++++
frontend/tests/unit/core/auth/server.test.ts | 74 +++++++
25 files changed, 1119 insertions(+), 40 deletions(-)
create mode 100644 frontend/src/components/workspace/settings/user-settings-page.tsx
create mode 100644 frontend/src/core/auth/admin-users.ts
create mode 100644 frontend/tests/unit/app/login-account-disabled.dom.test.tsx
create mode 100644 frontend/tests/unit/components/workspace/settings/user-settings-page.dom.test.tsx
create mode 100644 frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx
create mode 100644 frontend/tests/unit/core/auth/admin-users.dom.test.ts
diff --git a/README.md b/README.md
index 573f985cf1..93ecf90c1a 100644
--- a/README.md
+++ b/README.md
@@ -532,6 +532,8 @@ When fine-grained authorization is enabled, Live Browser connections require `th
Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address.
+Administrators can suspend and restore accounts from **Settings → Users** (admin-only; the section is hidden for ordinary users). The page lists each account's email, role, and status and exposes the same enable/disable operation as `PATCH /api/v1/admin/users/{id}`; the last remaining active admin cannot be disabled, and the Gateway's rejection detail is shown verbatim. A disabled account is rejected by every authentication surface, and its live sessions end on the next request with a login page that states the account has been disabled. Password login for a disabled account deliberately still answers "incorrect email or password" so the state is not disclosed to whoever holds the credentials.
+
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
diff --git a/README_zh.md b/README_zh.md
index 4818192671..d3aaafd482 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -71,6 +71,7 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
- [Session Goals](#session-goals)
- [手动上下文压缩](#手动上下文压缩)
- [Sub-Agents](#sub-agents)
+ - [账号管理](#账号管理)
- [Sandbox 与文件系统](#sandbox-与文件系统)
- [Agentic Browser Control](#agentic-browser-control)
- [Context Engineering](#context-engineering)
@@ -790,6 +791,10 @@ lead agent 只会在委派具有明确净收益时动态拉起 sub-agents,例
例如,彼此独立的只读研究可以在并行节省的时间明显高于重复检索和结果合并成本时并发执行;而会修改相同文件、依赖连续测试反馈的仓库重构则由 lead agent 直接完成。当 `max_concurrent_subagents` 为 `1` 时,提示词会关闭并行和多批次路由指导,仅在专业能力或上下文隔离具有明确收益时保留委派。
+### 账号管理
+
+管理员可以在**设置 → 用户**中停用和恢复账号(仅管理员可见该入口,普通用户不显示)。页面列出每个账号的邮箱、角色和状态,提供与 `PATCH /api/v1/admin/users/{id}` 相同的启用/停用操作;最后一个活跃管理员不允许被停用,网关返回的拒绝原因会原样展示。被停用的账号在所有认证入口都会被拒绝,其现有会话在下一次请求时终止,登录页会提示"账号已停用,请联系管理员"。持有密码者对停用账号发起密码登录时,仍会刻意返回"邮箱或密码错误",避免向凭据持有者泄露账号状态。
+
### Sandbox 与文件系统
上传文档的转换大纲和预览会校验原文件版本,包括修改时间戳。检测到原文件版本变化后,即使大小不变,也不会再使用旧转换结果。缺少原文件时间戳的旧归属记录同样会被拒绝;可在启用 `uploads.auto_convert_documents: true` 后重新上传原文件以恢复转换大纲。文件都会保留,未通过校验的转换 Markdown 会在 Agent 的历史文件列表中作为独立文件显示。Windows 上的 `st_ctime_ns` 可能表示创建时间,因此等长度覆盖后若恢复原 `mtime`,校验可能无法识别变化。时间戳校验是保守的元数据校验,不能保证文件内容完全一致。
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 1ce2bbc1d8..478d06ba29 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -299,6 +299,25 @@ keeps the saved key, explicit removal sends an empty key. Saving invalidates bot
admin catalog and `MODELS_QUERY_KEY`. Editor unmount aborts probes and fences late
callbacks. Static demos and non-admin users must not query the management API.
+### Admin user management
+
+Settings → Users (admin-only nav entry; `?settings=users`) suspends and restores
+accounts through `core/auth/admin-users.ts` → `/api/v1/admin/users`. The page
+reuses `userSchema` for rows — both endpoints parse against it, so a
+malformed payload fails the load instead of rendering a phantom row
+(`disabled` optional — absent means not disabled) — and PATCHes only
+`{disabled}`; role stays a read-only column. The Gateway's rejection detail
+(last-active-admin 409) renders verbatim. A session
+that turns out to be disabled maps `account_disabled` (see `AUTH_ERROR_CODES`)
+to a login redirect with a stated reason (login surface only — sign-up keeps
+the backend message so a suspended account cannot be probed) on all three
+surfaces: SSR
+`AuthResult` tag (consumed exhaustively by the workspace/artifacts/auth
+layouts), the shared fetcher's 401 interception, and `AuthProvider.refreshUser`.
+Direct auth-page visits with a live suspended-session cookie get the SSR
+verdict via `AuthDisabledContext`, so the login form states the known reason
+without waiting for a deliberately generic login failure.
+
### Model reasoning capabilities
`/api/models` projects a per-model `reasoning` contract (issue #5073) beside the
diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx
index 43609faf92..84319d8b68 100644
--- a/frontend/src/app/(auth)/layout.tsx
+++ b/frontend/src/app/(auth)/layout.tsx
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { type ReactNode } from "react";
import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
-import { AuthProvider } from "@/core/auth/AuthProvider";
+import { AuthDisabledContext, AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
import { I18nProvider } from "@/core/i18n/context";
@@ -33,6 +33,21 @@ export default async function AuthLayout({
case "unauthenticated":
content = {children};
break;
+ case "account_disabled":
+ // Direct /login visits with a suspended session: the SSR verdict is
+ // the only place the reason is known (password login deliberately
+ // answers generically), so hand it to the page through context
+ // instead of discarding it. Layouts cannot read searchParams, so a
+ // param-guarded redirect is not available here either — and an
+ // unconditional redirect to the same login URL would loop.
+ content = (
+
+
+ {children}
+
+
+ );
+ break;
case "gateway_unavailable":
// Auth pages have no banner of their own, so render one here. The
// fallback's AuthProvider replaces the bare-HTML branch that
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index de392b113a..ef956e2d5c 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -3,13 +3,13 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
-import { useEffect, useState } from "react";
+import { useEffect, useContext, useState } from "react";
import { RememberSessionOption } from "@/components/auth/remember-session-option";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import { Input } from "@/components/ui/input";
-import { useAuth } from "@/core/auth/AuthProvider";
+import { AuthDisabledContext, useAuth } from "@/core/auth/AuthProvider";
import { resolveAuthNextPath } from "@/core/auth/next-path";
import {
loadRememberLoginPreference,
@@ -47,11 +47,17 @@ export default function LoginPage() {
// Extract error from query params (e.g., ?error=sso_failed)
const errorParam = searchParams.get("error");
+ // Direct auth-page entry with a suspended session: the layout hands the
+ // SSR account_disabled verdict down through context — the only place the
+ // reason is known before a (deliberately generic) login attempt.
+ const ssrDisabled = useContext(AuthDisabledContext);
const [error, setError] = useState(
errorParam
? (t.login.errors[errorParam as keyof typeof t.login.errors] ??
t.login.authFailed)
- : "",
+ : ssrDisabled
+ ? t.login.errors.account_disabled
+ : "",
);
// Soft hint shown after a failed login when SSO is configured: an SSO-only
// account has no local password, so the backend returns a generic
@@ -179,7 +185,15 @@ export default function LoginPage() {
if (!res.ok) {
const data = await res.json();
const authError = parseAuthError(data);
- setError(authError.message);
+ // account_disabled is the one code with user-facing copy of its own
+ // (#4063 gap 3): the backend message is not localized. Login-only:
+ // sign-up keeps the backend message verbatim so a suspended
+ // account's email cannot be probed through the register surface.
+ setError(
+ isLogin && authError.code === "account_disabled"
+ ? t.login.errors.account_disabled
+ : authError.message,
+ );
// On a failed login with SSO configured, surface a hint pointing at the
// SSO buttons — the "wrong password" may really mean "this is an SSO account".
if (isLogin && ssoProviders.length > 0) {
diff --git a/frontend/src/app/artifacts/view/page.tsx b/frontend/src/app/artifacts/view/page.tsx
index 7221d2d558..ab60b9d29e 100644
--- a/frontend/src/app/artifacts/view/page.tsx
+++ b/frontend/src/app/artifacts/view/page.tsx
@@ -10,7 +10,11 @@ import {
type ArtifactViewerTarget,
} from "@/core/artifacts/viewer";
import { getServerSideUser } from "@/core/auth/server";
-import { assertNever, buildLoginUrl } from "@/core/auth/types";
+import {
+ ACCOUNT_DISABLED_LOGIN_URL,
+ assertNever,
+ buildLoginUrl,
+} from "@/core/auth/types";
import { getI18n } from "@/core/i18n/server";
const POST_LOGIN_FALLBACK = "/workspace";
@@ -48,6 +52,9 @@ async function requireViewerAccess(target: ArtifactViewerTarget | null) {
target ? buildArtifactViewerURL(target) : POST_LOGIN_FALLBACK,
),
);
+ case "account_disabled":
+ // No `next`: a disabled account cannot sign back in to recover it.
+ redirect(ACCOUNT_DISABLED_LOGIN_URL);
case "needs_setup":
case "system_setup_required":
redirect("/setup");
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx
index 2fa583bd9b..4a15603d4b 100644
--- a/frontend/src/app/workspace/layout.tsx
+++ b/frontend/src/app/workspace/layout.tsx
@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
-import { assertNever } from "@/core/auth/types";
+import { ACCOUNT_DISABLED_LOGIN_URL, assertNever } from "@/core/auth/types";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
@@ -36,6 +36,10 @@ export default async function WorkspaceLayout({
redirect("/setup");
case "unauthenticated":
redirect("/login");
+ case "account_disabled":
+ // The login page renders the disabled-account reason from the error
+ // param instead of a plain sign-in form the user cannot complete.
+ redirect(ACCOUNT_DISABLED_LOGIN_URL);
case "gateway_unavailable":
// GatewayOfflineFallback supplies the AuthProvider; WorkspaceContent
// already mounts the banner inside its sidebar layout, so renderBanner
diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx
index 355ea34207..9e1c27268f 100644
--- a/frontend/src/components/workspace/settings/settings-dialog.tsx
+++ b/frontend/src/components/workspace/settings/settings-dialog.tsx
@@ -7,6 +7,7 @@ import {
InfoIcon,
BrainIcon,
PaletteIcon,
+ UserCogIcon,
UsersRoundIcon,
UserIcon,
} from "lucide-react";
@@ -20,6 +21,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
+import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
@@ -78,6 +80,11 @@ const ModelSettingsPage = dynamic(
import("./model-settings-page").then((module) => module.ModelSettingsPage),
{ loading: SettingsPageLoading },
);
+const UserSettingsPage = dynamic(
+ () =>
+ import("./user-settings-page").then((module) => module.UserSettingsPage),
+ { loading: SettingsPageLoading },
+);
const AboutSettingsPage = dynamic(
() =>
import("./about-settings-page").then((module) => module.AboutSettingsPage),
@@ -86,6 +93,7 @@ const AboutSettingsPage = dynamic(
export type SettingsSection =
| "models"
+ | "users"
| "account"
| "appearance"
| "channels"
@@ -101,6 +109,8 @@ type SettingsDialogProps = React.ComponentProps & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
+ const { user } = useAuth();
+ const isAdmin = user?.system_role === "admin";
const [activeSection, setActiveSection] =
useState(defaultSection);
@@ -112,8 +122,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
}
}, [defaultSection, dialogProps.open]);
- const sections = useMemo(
- () => [
+ const sections = useMemo(() => {
+ const items: {
+ id: SettingsSection;
+ label: string;
+ icon: typeof BotIcon;
+ }[] = [
{ id: "models", label: t.settings.sections.models, icon: BotIcon },
{
id: "account",
@@ -146,18 +160,30 @@ export function SettingsDialog(props: SettingsDialogProps) {
icon: UsersRoundIcon,
},
{ id: "about", label: t.settings.sections.about, icon: InfoIcon },
- ],
- [
- t.settings.sections.models,
- t.settings.sections.account,
- t.settings.sections.appearance,
- t.settings.sections.channels,
- t.settings.sections.memory,
- t.settings.sections.subagents,
- t.settings.sections.notification,
- t.settings.sections.about,
- ],
- );
+ ];
+ // User management is meaningless (and confusing) for non-admins, so the
+ // entry point itself is hidden; the page still guards independently in
+ // case a stale dialog store ever activates the section.
+ if (isAdmin) {
+ items.splice(1, 0, {
+ id: "users",
+ label: t.settings.sections.users,
+ icon: UserCogIcon,
+ });
+ }
+ return items;
+ }, [
+ isAdmin,
+ t.settings.sections.models,
+ t.settings.sections.users,
+ t.settings.sections.account,
+ t.settings.sections.appearance,
+ t.settings.sections.channels,
+ t.settings.sections.memory,
+ t.settings.sections.subagents,
+ t.settings.sections.notification,
+ t.settings.sections.about,
+ ]);
return (