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 ( + + {users.isLoading &&

{text.loading}

} + {(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. +
+

{users.error ? text.failed : error}

+
+ )} + {users.data?.length === 0 &&

{text.empty}

} +
    + {users.data?.map((row) => ( +
  • +
    +

    {row.email}

    +

    + {text.role}: {row.system_role} ·{" "} + {row.disabled ? text.disabled : text.active} +

    +
    + +
  • + ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/components/workspace/workspace-settings-deep-link.tsx b/frontend/src/components/workspace/workspace-settings-deep-link.tsx index 90bcf69f27..cbf2133171 100644 --- a/frontend/src/components/workspace/workspace-settings-deep-link.tsx +++ b/frontend/src/components/workspace/workspace-settings-deep-link.tsx @@ -9,20 +9,25 @@ import { useSettingsDialog, } from "./settings"; -const SETTINGS_SECTIONS = new Set([ - "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 = { + 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; } diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts index ba7eb7f74f..bed24af915 100644 --- a/frontend/src/core/api/fetcher.ts +++ b/frontend/src/core/api/fetcher.ts @@ -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. diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx index 959c965b7d..8c94e9d207 100644 --- a/frontend/src/core/auth/AuthProvider.tsx +++ b/frontend/src/core/auth/AuthProvider.tsx @@ -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(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)); } } diff --git a/frontend/src/core/auth/admin-users.ts b/frontend/src/core/auth/admin-users.ts new file mode 100644 index 0000000000..f4361f8a73 --- /dev/null +++ b/frontend/src/core/auth/admin-users.ts @@ -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 { + 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 => + request("", "GET", undefined, signal).then((data) => + adminUserArraySchema.parse(data), + ); + +export const updateUserAccount = ( + userId: string, + update: UserAccountUpdate, +): Promise => + request(`/${encodeURIComponent(userId)}`, "PATCH", update).then((data) => + userSchema.parse(data), + ); diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts index 622f52e941..16e970d58e 100644 --- a/frontend/src/core/auth/server.ts +++ b/frontend/src/core/auth/server.ts @@ -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 { 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}`); diff --git a/frontend/src/core/auth/types.ts b/frontend/src/core/auth/types.ts index bb8561ee5b..a837274161 100644 --- a/frontend/src/core/auth/types.ts +++ b/frontend/src/core/auth/types.ts @@ -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", diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 9cfc8c841f..63171fb9d3 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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.", }, }, }; diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index bdfb0b64ae..f3c563e66a 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; }; }; } diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 07cfdae7a9..71fe634935 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "账号已停用,请联系管理员。", }, }, }; diff --git a/frontend/tests/unit/app/login-account-disabled.dom.test.tsx b/frontend/tests/unit/app/login-account-disabled.dom.test.tsx new file mode 100644 index 0000000000..417f9cffde --- /dev/null +++ b/frontend/tests/unit/app/login-account-disabled.dom.test.tsx @@ -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( + "@/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("@/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); + 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(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(); + + 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(); + + 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( + + + , + ); + + // 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(); + + 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(); + + 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(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(); +}); diff --git a/frontend/tests/unit/components/workspace/lazy-panels.test.ts b/frontend/tests/unit/components/workspace/lazy-panels.test.ts index 17cdd1c569..37cfc0037d 100644 --- a/frontend/tests/unit/components/workspace/lazy-panels.test.ts +++ b/frontend/tests/unit/components/workspace/lazy-panels.test.ts @@ -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\//, ); diff --git a/frontend/tests/unit/components/workspace/settings/user-settings-page.dom.test.tsx b/frontend/tests/unit/components/workspace/settings/user-settings-page.dom.test.tsx new file mode 100644 index 0000000000..1e03820627 --- /dev/null +++ b/frontend/tests/unit/components/workspace/settings/user-settings-page.dom.test.tsx @@ -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("@/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( + + + , + ); + 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(); +}); diff --git a/frontend/tests/unit/core/api/fetcher.dom.test.ts b/frontend/tests/unit/core/api/fetcher.dom.test.ts index 8fb19390ef..ddd3bd07b4 100644 --- a/frontend/tests/unit/core/api/fetcher.dom.test.ts +++ b/frontend/tests/unit/core/api/fetcher.dom.test.ts @@ -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="); + }); }); diff --git a/frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx b/frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx new file mode 100644 index 0000000000..b49e4fdbc0 --- /dev/null +++ b/frontend/tests/unit/core/auth/AuthProvider.dom.test.tsx @@ -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 | 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( + + + , + ); +} + +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"); +}); diff --git a/frontend/tests/unit/core/auth/admin-users.dom.test.ts b/frontend/tests/unit/core/auth/admin-users.dom.test.ts new file mode 100644 index 0000000000..86d0fc0ca6 --- /dev/null +++ b/frontend/tests/unit/core/auth/admin-users.dom.test.ts @@ -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(); + }); +}); diff --git a/frontend/tests/unit/core/auth/server.test.ts b/frontend/tests/unit/core/auth/server.test.ts index 4770acf8d3..78549440d8 100644 --- a/frontend/tests/unit/core/auth/server.test.ts +++ b/frontend/tests/unit/core/auth/server.test.ts @@ -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", () => {