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).
This commit is contained in:
hataa 2026-10-11 12:54:51 +08:00 committed by GitHub
parent ea68e5b3d7
commit 2f4f4d4b6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1119 additions and 40 deletions

View File

@ -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]

View File

@ -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`,校验可能无法识别变化。时间戳校验是保守的元数据校验,不能保证文件内容完全一致。

View File

@ -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

View File

@ -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 = <AuthProvider initialUser={null}>{children}</AuthProvider>;
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 = (
<AuthProvider initialUser={null}>
<AuthDisabledContext.Provider value={true}>
{children}
</AuthDisabledContext.Provider>
</AuthProvider>
);
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

View File

@ -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) {

View File

@ -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");

View File

@ -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

View File

@ -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<typeof Dialog> & {
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<SettingsSection>(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 (
<Dialog
{...dialogProps}
@ -182,7 +208,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
<li key={id}>
<button
type="button"
onClick={() => setActiveSection(id as SettingsSection)}
onClick={() => setActiveSection(id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
@ -201,6 +227,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 rounded-lg border">
<div className="space-y-8 p-6">
{activeSection === "models" && <ModelSettingsPage />}
{activeSection === "users" && <UserSettingsPage />}
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}

View File

@ -0,0 +1,110 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { GatewayApiError } from "@/core/api/errors";
import {
loadAdminUsers,
updateUserAccount,
type AdminUser,
} from "@/core/auth/admin-users";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { SettingsSection } from "./settings-section";
/**
* Admin-only user management (RFC #4063 gap 3): list every account and
* suspend/restore it. Role is displayed read-only — role assignment stays
* an operator/API concern in this slice.
*/
export function UserSettingsPage() {
const { user } = useAuth();
const { t } = useI18n();
const text = t.settings.users;
const client = useQueryClient();
const isAdmin = user?.system_role === "admin";
const users = useQuery({
queryKey: ["admin-users", user?.id],
queryFn: ({ signal }) => loadAdminUsers(signal),
enabled: isAdmin,
});
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function toggleDisabled(target: AdminUser) {
setPending(true);
setError(null);
try {
await updateUserAccount(target.id, { disabled: !target.disabled });
await client.invalidateQueries({ queryKey: ["admin-users"] });
} catch (err) {
// The Gateway's own detail text (the last-active-admin 409 in
// particular) is the most actionable message available; anything
// else (network failure, a malformed 200 body failing the schema)
// falls back to the localized copy instead of a raw exception blob.
setError(
err instanceof GatewayApiError && err.message
? err.message
: text.updateFailed,
);
} finally {
setPending(false);
}
}
return (
<SettingsSection title={text.title} description={text.description}>
{!isAdmin ? (
<p>{text.adminOnly}</p>
) : (
<div className="space-y-4">
<div className="flex gap-2">
<Button
variant="outline"
disabled={users.isFetching}
onClick={() => void users.refetch()}
>
{text.reload}
</Button>
</div>
{users.isLoading && <p role="status">{text.loading}</p>}
{(users.error ?? error) && (
// One region, one verdict: a failed list load outranks a stale
// toggle error; react-query clears `users.error` on a successful
// refetch, so the banners cannot stack.
<div role="alert" className="text-sm text-red-500">
<p>{users.error ? text.failed : error}</p>
</div>
)}
{users.data?.length === 0 && <p>{text.empty}</p>}
<ul className="space-y-3">
{users.data?.map((row) => (
<li
key={row.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4"
>
<div>
<p className="font-medium">{row.email}</p>
<p className="text-muted-foreground text-sm">
{text.role}: {row.system_role} ·{" "}
{row.disabled ? text.disabled : text.active}
</p>
</div>
<Button
variant="outline"
disabled={pending}
onClick={() => void toggleDisabled(row)}
>
{row.disabled ? text.enable : text.disable}
</Button>
</li>
))}
</ul>
</div>
)}
</SettingsSection>
);
}

View File

@ -9,20 +9,25 @@ import {
useSettingsDialog,
} from "./settings";
const SETTINGS_SECTIONS = new Set<SettingsSection>([
"models",
"account",
"appearance",
"channels",
"memory",
"subagents",
"notification",
"about",
]);
// Record form: the compiler enforces one entry per SettingsSection, so a new
// section cannot silently stay unreachable from `?settings=` deep links.
const SETTINGS_SECTIONS: Record<SettingsSection, true> = {
models: true,
users: true,
account: true,
appearance: true,
channels: true,
memory: true,
subagents: true,
notification: true,
about: true,
};
function asSettingsSection(value: string | null): SettingsSection | null {
if (!value) return null;
return SETTINGS_SECTIONS.has(value as SettingsSection)
// `=== true` rejects inherited keys ("toString" in obj is true) that both
// `in` and bare indexing would admit.
return SETTINGS_SECTIONS[value as SettingsSection] === true
? (value as SettingsSection)
: null;
}

View File

@ -1,4 +1,8 @@
import { buildLoginUrl } from "@/core/auth/types";
import {
ACCOUNT_DISABLED_LOGIN_URL,
buildLoginUrl,
parseAuthError,
} from "@/core/auth/types";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { UnauthorizedError } from "./errors";
@ -96,6 +100,21 @@ export async function fetch(
});
if (res.status === 401) {
// An operator-disabled account (#4063 gap 3) gets a distinct code on the
// session surface; it cannot sign back in, so skip the `next` round-trip
// and land on a login page that states the reason.
// Body-consumption contract: the JSON body is consumed for the code and
// must never be read again below this line — every branch exits by
// throwing UnauthorizedError, so a future fall-through (e.g. telemetry)
// must not re-read `res`.
const code = await res
.json()
.then((data) => parseAuthError(data).code)
.catch(() => null);
if (code === "account_disabled") {
window.location.href = ACCOUNT_DISABLED_LOGIN_URL;
throw new UnauthorizedError();
}
// Include the search string: routes that carry their target in the query
// (e.g. the standalone artifact viewer) are otherwise unrecoverable after
// login, which lands on the default workspace instead.

View File

@ -12,7 +12,12 @@ import React, {
import { isStaticWebsiteOnly } from "../static-mode";
import { type User, buildLoginUrl } from "./types";
import {
ACCOUNT_DISABLED_LOGIN_URL,
type User,
buildLoginUrl,
parseAuthError,
} from "./types";
// Re-export for consumers
export type { User };
@ -31,6 +36,15 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
* True when the SSR auth verdict for this auth-page render was
* `account_disabled`: the visitor holds a live session cookie for a
* suspended account, so /login states the known reason immediately instead
* of waiting for a login attempt the backend deliberately answers with the
* generic invalid-credentials message (#4063 gap 3).
*/
export const AuthDisabledContext = createContext(false);
interface AuthProviderProps {
children: ReactNode;
initialUser: User | null;
@ -79,10 +93,19 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
const data = await res.json();
setUser(data);
} else if (res.status === 401) {
// Session expired or invalid
setUser(null);
// Redirect to login if on a protected route
if (pathname?.startsWith("/workspace")) {
const code = await res
.json()
.then((data) => parseAuthError(data).code)
.catch(() => null);
if (code === "account_disabled") {
// Operator-disabled account (#4063 gap 3): the login page states
// the reason. Unlike an expired session there is nothing to come
// back to, so no `next` is preserved and the path gate does not
// apply — every authenticated page is equally unreachable.
router.push(ACCOUNT_DISABLED_LOGIN_URL);
} else if (pathname?.startsWith("/workspace")) {
// Session expired or invalid — redirect on protected routes
router.push(buildLoginUrl(pathname));
}
}

View File

@ -0,0 +1,66 @@
import { z } from "zod";
import { throwGatewayApiError } from "@/core/api/errors";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import { type User, userSchema } from "./types";
/**
* Admin user-management surface (RFC #4063 gap 3).
*
* The Gateway list/update endpoints return the same UserResponse shape as
* ``/auth/me`` (id, email, system_role, needs_setup, oauth_provider,
* disabled), so ``userSchema`` is reused verbatim — both payloads are
* parsed, and a malformed body (dropped id, non-boolean disabled) rejects
* into the caller's error path instead of rendering a phantom row.
* ``disabled`` is optional in the schema — absent means not disabled — and
* every writer here sends it explicitly, so the rows this module refreshes
* always carry it.
*/
export type AdminUser = User;
const adminUserArraySchema = z.array(userSchema);
/** Partial account update accepted by PATCH /api/v1/admin/users/{id}. */
export interface UserAccountUpdate {
system_role?: string;
disabled?: boolean;
}
async function request(
suffix: string,
method: "GET" | "PATCH",
body?: UserAccountUpdate,
signal?: AbortSignal,
): Promise<unknown> {
const response = await fetch(
`${getBackendBaseURL()}/api/v1/admin/users${suffix}`,
{
method,
signal,
...(body
? {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
: {}),
},
);
if (!response.ok)
await throwGatewayApiError(response, "Admin user request failed");
return response.json();
}
export const loadAdminUsers = (signal?: AbortSignal): Promise<AdminUser[]> =>
request("", "GET", undefined, signal).then((data) =>
adminUserArraySchema.parse(data),
);
export const updateUserAccount = (
userId: string,
update: UserAccountUpdate,
): Promise<AdminUser> =>
request(`/${encodeURIComponent(userId)}`, "PATCH", update).then((data) =>
userSchema.parse(data),
);

View File

@ -6,7 +6,7 @@ import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types";
import { type AuthResult, parseAuthError, userSchema } from "./types";
/**
* Fetch the authenticated user from the gateway using the request's cookies.
@ -89,6 +89,16 @@ export async function getServerSideUser(): Promise<AuthResult> {
return { tag: "authenticated", user: parsed.data };
}
if (res.status === 401 || res.status === 403) {
// A disabled account gets a distinct code so the login redirect can
// say why (#4063 gap 3); every other rejected session (expired,
// invalid, revoked) stays a plain unauthenticated bounce.
const code = await res
.json()
.then((data) => parseAuthError(data).code)
.catch(() => null);
if (code === "account_disabled") {
return { tag: "account_disabled" };
}
return { tag: "unauthenticated" };
}
console.error(`[SSR auth] /api/v1/auth/me responded ${res.status}`);

View File

@ -35,6 +35,7 @@ export type AuthResult =
| { tag: "needs_setup"; user: User }
| { tag: "system_setup_required" }
| { tag: "unauthenticated" }
| { tag: "account_disabled" }
| { tag: "gateway_unavailable" }
| { tag: "config_error"; message: string };
@ -46,6 +47,13 @@ export function buildLoginUrl(returnPath: string): string {
return `/login?next=${encodeURIComponent(returnPath)}`;
}
/**
* Login-page URL that surfaces the account-disabled reason (#4063 gap 3).
* A disabled account cannot sign back in, so unlike buildLoginUrl there is
* no `next` target to preserve.
*/
export const ACCOUNT_DISABLED_LOGIN_URL = "/login?error=account_disabled";
// ── Backend error response parsing ────────────────────────────────
const AUTH_ERROR_CODES = [
@ -53,6 +61,7 @@ const AUTH_ERROR_CODES = [
"token_expired",
"token_invalid",
"user_not_found",
"account_disabled",
"email_already_exists",
"provider_not_found",
"not_authenticated",

View File

@ -1808,8 +1808,27 @@ export const enUS: Translations = {
connection_failed:
"Connection test failed. Check the endpoint, credentials, model ID and streaming/tool support.",
},
users: {
title: "User management",
description:
"Suspend or restore sign-in for accounts on this deployment. A disabled account is rejected by every authentication surface; its active sessions end immediately.",
adminOnly: "Only administrators can manage users.",
loading: "Loading users…",
failed: "Failed to load users.",
reload: "Reload",
empty: "No users yet.",
email: "Email",
role: "Role",
status: "Status",
active: "Active",
disabled: "Disabled",
disable: "Disable",
enable: "Enable",
updateFailed: "Failed to update the account.",
},
sections: {
models: "Models",
users: "Users",
account: "Account",
appearance: "Appearance",
channels: "Channels",
@ -2371,6 +2390,8 @@ export const enUS: Translations = {
"An account with this email already exists. Please sign in with your password or contact your administrator.",
sso_not_allowed:
"SSO login is not allowed for your account. Contact your administrator.",
account_disabled:
"This account has been disabled. Contact your administrator.",
},
},
};

View File

@ -1531,8 +1531,26 @@ export interface Translations {
tool_call_missing: string;
connection_failed: string;
};
users: {
title: string;
description: string;
adminOnly: string;
loading: string;
failed: string;
reload: string;
empty: string;
email: string;
role: string;
status: string;
active: string;
disabled: string;
disable: string;
enable: string;
updateFailed: string;
};
sections: {
models: string;
users: string;
account: string;
appearance: string;
channels: string;
@ -1928,6 +1946,7 @@ export interface Translations {
sso_cancelled: string;
sso_account_exists: string;
sso_not_allowed: string;
account_disabled: string;
};
};
}

View File

@ -1702,8 +1702,27 @@ export const zhCN: Translations = {
connection_failed:
"连接测试失败,请检查接口地址、凭据、模型 ID 以及流式输出和工具调用支持。",
},
users: {
title: "用户管理",
description:
"停用或恢复本部署中的账号登录。被停用的账号在所有认证入口都会被拒绝,其现有会话立即失效。",
adminOnly: "只有管理员可以管理用户。",
loading: "正在加载用户…",
failed: "用户列表加载失败。",
reload: "重新加载",
empty: "暂无用户。",
email: "邮箱",
role: "角色",
status: "状态",
active: "正常",
disabled: "已停用",
disable: "停用",
enable: "启用",
updateFailed: "账号更新失败。",
},
sections: {
models: "模型",
users: "用户",
account: "账号",
appearance: "外观",
channels: "渠道",
@ -2232,6 +2251,7 @@ export const zhCN: Translations = {
sso_account_exists:
"该邮箱对应的账号已存在。请使用密码登录或联系管理员。",
sso_not_allowed: "你的账号不允许使用 SSO 登录。请联系管理员。",
account_disabled: "账号已停用,请联系管理员。",
},
},
};

View File

@ -0,0 +1,158 @@
import { afterEach, beforeEach, expect, test, rs } from "@rstest/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import LoginPage from "@/app/(auth)/login/page";
import type * as AuthProviderModule from "@/core/auth/AuthProvider";
import { AuthDisabledContext, useAuth } from "@/core/auth/AuthProvider";
import type * as Setup from "@/core/auth/setup";
import { enUS } from "@/core/i18n/locales/en-US";
// Keep the real module (AuthDisabledContext must be the same object the
// page consumes); override only the hook.
rs.mock("@/core/auth/AuthProvider", () => ({
...(rs.requireActual<typeof AuthProviderModule>(
"@/core/auth/AuthProvider",
) as object),
useAuth: rs.fn(),
}));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
rs.mock("next-themes", () => ({
useTheme: () => ({ theme: "light", resolvedTheme: "light" }),
}));
rs.mock("@/components/ui/flickering-grid", () => ({
FlickeringGrid: () => null,
}));
rs.mock("@/core/auth/setup", () => ({
...(rs.requireActual<typeof Setup>("@/core/auth/setup") as object),
fetchSetupStatus: rs.fn(async () => ({
needs_setup: false,
registration_enabled: true,
})),
}));
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
beforeEach(() => {
rs.mocked(useAuth).mockReturnValue({
isAuthenticated: false,
} as ReturnType<typeof useAuth>);
globalThis.fetch = rs.fn(async () =>
jsonResponse({ providers: [] }),
) as unknown as typeof globalThis.fetch;
});
afterEach(() => {
cleanup();
rs.clearAllMocks();
});
function fillAndSubmit() {
fireEvent.change(screen.getByLabelText(enUS.login.email), {
target: { value: "suspended@example.com" },
});
fireEvent.change(
screen.getByLabelText<HTMLInputElement>(enUS.login.password),
{
target: { value: "correct-horse-battery" },
},
);
fireEvent.click(screen.getByRole("button", { name: enUS.login.signIn }));
}
test("an account_disabled rejection shows the localized reason", async () => {
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{ detail: { code: "account_disabled", message: "Account disabled" } },
401,
),
) as unknown as typeof globalThis.fetch;
render(<LoginPage />);
await waitFor(() => screen.getByRole("button", { name: enUS.login.signIn }));
fillAndSubmit();
expect(
await screen.findByText(enUS.login.errors.account_disabled),
).toBeTruthy();
});
test("other rejections keep showing the backend message", async () => {
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{
detail: {
code: "invalid_credentials",
message: "Incorrect email or password",
},
},
401,
),
) as unknown as typeof globalThis.fetch;
render(<LoginPage />);
await waitFor(() => screen.getByRole("button", { name: enUS.login.signIn }));
fillAndSubmit();
expect(await screen.findByText("Incorrect email or password")).toBeTruthy();
});
test("a direct /login visit with a suspended session states the reason", () => {
render(
<AuthDisabledContext.Provider value={true}>
<LoginPage />
</AuthDisabledContext.Provider>,
);
// SSR verdict, not a login attempt: the message is present on mount.
expect(screen.getByText(enUS.login.errors.account_disabled)).toBeTruthy();
});
test("an ordinary /login visit shows no suspension note", () => {
render(<LoginPage />);
expect(screen.queryByText(enUS.login.errors.account_disabled)).toBeNull();
});
test("sign-up keeps the backend message so a disabled account cannot be probed", async () => {
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{ detail: { code: "account_disabled", message: "Account disabled" } },
401,
),
) as unknown as typeof globalThis.fetch;
render(<LoginPage />);
await waitFor(() => screen.getByRole("button", { name: enUS.login.signIn }));
// Switch to the register form, then submit against the same 401 code.
fireEvent.click(
screen.getByRole("button", { name: enUS.login.noAccountSignUp }),
);
fireEvent.change(screen.getByLabelText(enUS.login.email), {
target: { value: "suspended@example.com" },
});
fireEvent.change(
screen.getByLabelText<HTMLInputElement>(enUS.login.password),
{
target: { value: "correct-horse-battery" },
},
);
fireEvent.click(
screen.getByRole("button", { name: enUS.login.createAccount }),
);
expect(await screen.findByText("Account disabled")).toBeTruthy();
expect(screen.queryByText(enUS.login.errors.account_disabled)).toBeNull();
});

View File

@ -23,7 +23,7 @@ describe("interaction-only bundle boundaries", () => {
const dialog = read(
"src/components/workspace/settings/settings-dialog.tsx",
);
expect(dialog.match(/dynamic\(/g)).toHaveLength(8);
expect(dialog.match(/dynamic\(/g)).toHaveLength(9);
expect(dialog).not.toMatch(
/import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//,
);

View File

@ -0,0 +1,206 @@
import { afterEach, beforeEach, expect, test, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { UserSettingsPage } from "@/components/workspace/settings/user-settings-page";
import { GatewayApiError } from "@/core/api/errors";
import type * as AdminUsers from "@/core/auth/admin-users";
import {
loadAdminUsers,
updateUserAccount,
type AdminUser,
} from "@/core/auth/admin-users";
import { useAuth } from "@/core/auth/AuthProvider";
import { enUS } from "@/core/i18n/locales/en-US";
rs.mock("@/core/auth/AuthProvider", () => ({ useAuth: rs.fn() }));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
rs.mock("@/core/auth/admin-users", () => ({
...(rs.requireActual<typeof AdminUsers>("@/core/auth/admin-users") as object),
loadAdminUsers: rs.fn(),
updateUserAccount: rs.fn(),
}));
const admin: AdminUser = {
id: "u-admin",
email: "admin@example.com",
system_role: "admin",
needs_setup: false,
disabled: false,
oauth_provider: null,
};
const suspended: AdminUser = {
id: "u-user",
email: "user@example.com",
system_role: "user",
needs_setup: false,
disabled: true,
oauth_provider: null,
};
// Pre-gap-3 backends omit `disabled` entirely — absent means not disabled.
const legacyRow: AdminUser = {
id: "u-legacy",
email: "legacy@example.com",
system_role: "user",
needs_setup: false,
oauth_provider: null,
};
const auth = (role: "admin" | "user") =>
({ user: { id: "u-admin", system_role: role } }) as ReturnType<
typeof useAuth
>;
function rowFor(email: string) {
const row = screen.getByText(email).closest("li");
expect(row).not.toBeNull();
return row as HTMLElement;
}
beforeEach(() => {
rs.mocked(useAuth).mockReturnValue(auth("admin"));
rs.mocked(loadAdminUsers).mockResolvedValue([admin, suspended, legacyRow]);
});
afterEach(() => {
cleanup();
rs.clearAllMocks();
});
function mount() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<UserSettingsPage />
</QueryClientProvider>,
);
return client;
}
test("non-admin sees the admin-only note and loads nothing", () => {
rs.mocked(useAuth).mockReturnValue(auth("user"));
mount();
expect(screen.getByText(enUS.settings.users.adminOnly)).toBeTruthy();
expect(loadAdminUsers).not.toHaveBeenCalled();
});
test("lists email, read-only role, and disabled state per account", async () => {
mount();
await screen.findByText("admin@example.com");
const activeRow = rowFor("admin@example.com");
expect(
within(activeRow).getByText(`Role: admin · ${enUS.settings.users.active}`),
).toBeTruthy();
const disabledRow = rowFor("user@example.com");
expect(
within(disabledRow).getByText(
`Role: user · ${enUS.settings.users.disabled}`,
),
).toBeTruthy();
const legacy = rowFor("legacy@example.com");
expect(
within(legacy).getByText(`Role: user · ${enUS.settings.users.active}`),
).toBeTruthy();
});
test("disable toggle PATCHes only the disabled field and refreshes", async () => {
mount();
rs.mocked(updateUserAccount).mockResolvedValue({ ...admin, disabled: true });
await screen.findByText("admin@example.com");
fireEvent.click(
within(rowFor("admin@example.com")).getByRole("button", {
name: enUS.settings.users.disable,
}),
);
await waitFor(() =>
expect(updateUserAccount).toHaveBeenCalledWith("u-admin", {
disabled: true,
}),
);
// The invalidation refires the active list query, so the row's status
// flips without a manual reload.
await waitFor(() => expect(loadAdminUsers).toHaveBeenCalledTimes(2));
});
test("enable toggle targets the suspended account", async () => {
mount();
rs.mocked(updateUserAccount).mockResolvedValue({
...suspended,
disabled: false,
});
await screen.findByText("user@example.com");
fireEvent.click(
within(rowFor("user@example.com")).getByRole("button", {
name: enUS.settings.users.enable,
}),
);
await waitFor(() =>
expect(updateUserAccount).toHaveBeenCalledWith("u-user", {
disabled: false,
}),
);
});
test("a 409 rejection surfaces the gateway detail verbatim", async () => {
mount();
rs.mocked(updateUserAccount).mockRejectedValueOnce(
new GatewayApiError({
message: "cannot disable the last remaining active admin",
status: 409,
code: null,
params: {},
rawMessage: "",
}),
);
await screen.findByText("admin@example.com");
fireEvent.click(
within(rowFor("admin@example.com")).getByRole("button", {
name: enUS.settings.users.disable,
}),
);
expect(
await screen.findByText("cannot disable the last remaining active admin"),
).toBeTruthy();
});
test("a non-gateway failure falls back to the localized copy", async () => {
mount();
rs.mocked(updateUserAccount).mockRejectedValueOnce(new Error("boom"));
await screen.findByText("admin@example.com");
fireEvent.click(
within(rowFor("admin@example.com")).getByRole("button", {
name: enUS.settings.users.disable,
}),
);
expect(
await screen.findByText(enUS.settings.users.updateFailed),
).toBeTruthy();
expect(screen.queryByText("boom")).toBeNull();
});
test("a failed load shows one alert with the localized copy", async () => {
// mockRejectedValueOnce outranks the beforeEach default for the mount query.
rs.mocked(loadAdminUsers).mockRejectedValueOnce(new Error("gateway down"));
mount();
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.getByText(enUS.settings.users.failed)).toBeTruthy();
expect(screen.queryByText("gateway down")).toBeNull();
});

View File

@ -38,4 +38,23 @@ describe("api fetcher unauthorized redirect", () => {
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
);
});
it("redirects an account_disabled 401 to a login page that states the reason", async () => {
globalThis.fetch = rs.fn(
async () =>
new Response(
JSON.stringify({
detail: { code: "account_disabled", message: "Account disabled" },
}),
{ status: 401, headers: { "Content-Type": "application/json" } },
),
) as unknown as typeof globalThis.fetch;
window.history.replaceState({}, "", "/workspace");
await expect(apiFetch("/api/v1/auth/me")).rejects.toThrow();
expect(window.location.href).toContain("/login?error=account_disabled");
// A disabled account cannot sign back in, so no return target is kept.
expect(window.location.href).not.toContain("next=");
});
});

View File

@ -0,0 +1,89 @@
import { afterEach, beforeEach, expect, test, rs } from "@rstest/core";
import { act, cleanup, render } from "@testing-library/react";
import type React from "react";
import { AuthProvider, useAuth } from "@/core/auth/AuthProvider";
import type { User } from "@/core/auth/types";
// refreshUser's 401 handling runs through next/navigation; capture the push
// target instead of a real router.
const push = rs.fn();
rs.mock("next/navigation", () => ({
useRouter: () => ({ push }),
usePathname: () => "/workspace",
}));
const initialUser: User = {
id: "u-1",
email: "admin@example.com",
system_role: "admin",
needs_setup: false,
oauth_provider: null,
};
let ctx: ReturnType<typeof useAuth> | null = null;
function Probe() {
ctx = useAuth();
return null;
}
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
beforeEach(() => {
window.history.replaceState({}, "", "/workspace");
});
afterEach(() => {
cleanup();
push.mockReset();
ctx = null;
});
function mount() {
render(
<AuthProvider initialUser={initialUser}>
<Probe />
</AuthProvider>,
);
}
test("an account_disabled 401 ends the session and states the reason", async () => {
mount();
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{
detail: { code: "account_disabled", message: "Account disabled" },
},
401,
),
) as unknown as typeof globalThis.fetch;
await act(async () => {
await ctx!.refreshUser();
});
expect(ctx!.user).toBeNull();
// No `next` param: a disabled account cannot sign back in.
expect(push).toHaveBeenCalledWith("/login?error=account_disabled");
});
test("every other 401 keeps the return-to-workspace redirect", async () => {
mount();
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{ detail: { code: "token_expired", message: "Token expired" } },
401,
),
) as unknown as typeof globalThis.fetch;
await act(async () => {
await ctx!.refreshUser();
});
expect(ctx!.user).toBeNull();
expect(push).toHaveBeenCalledWith("/login?next=%2Fworkspace");
});

View File

@ -0,0 +1,138 @@
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();
});
});

View File

@ -177,6 +177,80 @@ describe("getServerSideUser — gateway_unavailable contract (issue #3493)", ()
});
});
// ── 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", () => {