mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
fix(frontend): static-website gate and deep-link fallback for user management (#6641 follow-ups) (#6691)
* fix(frontend): static-website gate and deep-link fallback for user management (#6641 follow-ups) Close the two leftovers from the #6641 review: - user-settings-page now gates on isStaticWebsiteOnly() like model management (the static demo user is nominally an admin, but there is no Gateway to query), and the users adminOnly copy gains the same 'unavailable in demos' sentence the models copy already carries. - the settings dialog derives the active section from the nav actually offered to the current user, so a ?settings=users deep link (or a stale dialog store) for a non-admin falls back to the first section instead of rendering an orphan panel with no highlighted entry. Tests: static demo loads nothing (mutation-verified red), non-admin deep link falls back to models, admin deep link lands on user management; full unit suite green. * test(frontend): pin the static-on x admin matrix cell at the dialog level Dialog-level coverage for the real demo path (review follow-up on #6691): admin + isStaticWebsiteOnly() + ?settings=users keeps the Users nav entry (the splice keys on the role only) while the page-level gate shows the admin-only note and issues no query. Mutation-verified: adding the mode flag to the nav splice turns this test red and only this test.
This commit is contained in:
parent
3f7a076972
commit
c89fed0be4
@ -306,7 +306,11 @@ 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
|
||||
`{disabled}`; role stays a read-only column. Like model management, the page
|
||||
also gates on `isStaticWebsiteOnly()` (the demo user is nominally an admin,
|
||||
but there is no Gateway to query), and the settings dialog falls back to the
|
||||
first nav section when a deep link or stale store activates a section the
|
||||
current user's nav does not offer. 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
|
||||
|
||||
@ -184,6 +184,14 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
t.settings.sections.notification,
|
||||
t.settings.sections.about,
|
||||
]);
|
||||
// A `?settings=<section>` deep link (or a stale dialog store) can activate
|
||||
// a section this user's nav does not offer — e.g. user management for a
|
||||
// non-admin — so fall back to the first available section instead of
|
||||
// rendering a panel with no highlighted entry. The base list above is
|
||||
// non-empty, so the fallback itself always resolves.
|
||||
const activeId = sections.some((section) => section.id === activeSection)
|
||||
? activeSection
|
||||
: sections[0]!.id;
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
@ -203,7 +211,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
<nav className="bg-sidebar min-h-0 overflow-y-auto rounded-lg border p-2">
|
||||
<ul className="space-y-1 pr-1">
|
||||
{sections.map(({ id, label, icon: Icon }) => {
|
||||
const active = activeSection === id;
|
||||
const active = activeId === id;
|
||||
return (
|
||||
<li key={id}>
|
||||
<button
|
||||
@ -226,15 +234,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
</nav>
|
||||
<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 />}
|
||||
{activeSection === "subagents" && <SubagentSettingsPage />}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "channels" && <ChannelsSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
{activeId === "models" && <ModelSettingsPage />}
|
||||
{activeId === "users" && <UserSettingsPage />}
|
||||
{activeId === "account" && <AccountSettingsPage />}
|
||||
{activeId === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeId === "memory" && <MemorySettingsPage />}
|
||||
{activeId === "subagents" && <SubagentSettingsPage />}
|
||||
{activeId === "notification" && <NotificationSettingsPage />}
|
||||
{activeId === "channels" && <ChannelsSettingsPage />}
|
||||
{activeId === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
} from "@/core/auth/admin-users";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@ -25,11 +26,13 @@ export function UserSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const text = t.settings.users;
|
||||
const client = useQueryClient();
|
||||
const isAdmin = user?.system_role === "admin";
|
||||
// The static-website demo user is nominally an admin with no Gateway behind
|
||||
// it, so the demo shows the admin-only note instead of a doomed query.
|
||||
const canManage = user?.system_role === "admin" && !isStaticWebsiteOnly();
|
||||
const users = useQuery({
|
||||
queryKey: ["admin-users", user?.id],
|
||||
queryFn: ({ signal }) => loadAdminUsers(signal),
|
||||
enabled: isAdmin,
|
||||
enabled: canManage,
|
||||
});
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -57,7 +60,7 @@ export function UserSettingsPage() {
|
||||
|
||||
return (
|
||||
<SettingsSection title={text.title} description={text.description}>
|
||||
{!isAdmin ? (
|
||||
{!canManage ? (
|
||||
<p>{text.adminOnly}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@ -1812,7 +1812,8 @@ export const enUS: Translations = {
|
||||
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.",
|
||||
adminOnly:
|
||||
"Only administrators can manage users. This feature is unavailable in demos.",
|
||||
loading: "Loading users…",
|
||||
failed: "Failed to load users.",
|
||||
reload: "Reload",
|
||||
|
||||
@ -1706,7 +1706,7 @@ export const zhCN: Translations = {
|
||||
title: "用户管理",
|
||||
description:
|
||||
"停用或恢复本部署中的账号登录。被停用的账号在所有认证入口都会被拒绝,其现有会话立即失效。",
|
||||
adminOnly: "只有管理员可以管理用户。",
|
||||
adminOnly: "只有管理员可以管理用户,演示模式不支持此功能。",
|
||||
loading: "正在加载用户…",
|
||||
failed: "用户列表加载失败。",
|
||||
reload: "重新加载",
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, expect, test, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
SettingsDialog,
|
||||
type SettingsSection,
|
||||
} from "@/components/workspace/settings/settings-dialog";
|
||||
import type * as AdminUsers from "@/core/auth/admin-users";
|
||||
import { loadAdminUsers, type AdminUser } from "@/core/auth/admin-users";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
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(),
|
||||
}));
|
||||
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: rs.fn() }));
|
||||
|
||||
const admin: AdminUser = {
|
||||
id: "u-admin",
|
||||
email: "admin@example.com",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
disabled: false,
|
||||
oauth_provider: null,
|
||||
};
|
||||
|
||||
const auth = (role: "admin" | "user") =>
|
||||
({ user: { id: "u-admin", system_role: role } }) as ReturnType<
|
||||
typeof useAuth
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
rs.mocked(useAuth).mockReturnValue(auth("admin"));
|
||||
rs.mocked(isStaticWebsiteOnly).mockReturnValue(false);
|
||||
rs.mocked(loadAdminUsers).mockResolvedValue([admin]);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
rs.clearAllMocks();
|
||||
});
|
||||
|
||||
// The deep-link bridge calls `openSettingsDialog(section)`, so
|
||||
// `defaultSection` is the section a `?settings=<section>` URL lands on.
|
||||
function mountDialog(section?: SettingsSection) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SettingsDialog open defaultSection={section} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("a non-admin `?settings=users` deep link falls back to the first section", async () => {
|
||||
rs.mocked(useAuth).mockReturnValue(auth("user"));
|
||||
mountDialog("users");
|
||||
expect(screen.queryByRole("button", { name: "Users" })).toBeNull();
|
||||
await screen.findByText(enUS.settings.models.adminOnly);
|
||||
expect(screen.queryByText(enUS.settings.users.adminOnly)).toBeNull();
|
||||
});
|
||||
|
||||
test("an admin `?settings=users` deep link lands on user management", async () => {
|
||||
mountDialog("users");
|
||||
expect(screen.getByRole("button", { name: "Users" })).toBeTruthy();
|
||||
await screen.findByText("admin@example.com");
|
||||
expect(screen.queryByText(enUS.settings.models.adminOnly)).toBeNull();
|
||||
});
|
||||
|
||||
test("a static-website admin deep link keeps the Users entry but loads nothing", async () => {
|
||||
// The nav splice keys on the role only, so the demo's nominal admin still
|
||||
// sees the Users entry; the page-level mode gate is what stops the query.
|
||||
rs.mocked(isStaticWebsiteOnly).mockReturnValue(true);
|
||||
mountDialog("users");
|
||||
expect(screen.getByRole("button", { name: "Users" })).toBeTruthy();
|
||||
await screen.findByText(enUS.settings.users.adminOnly);
|
||||
expect(loadAdminUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -19,6 +19,7 @@ import {
|
||||
} from "@/core/auth/admin-users";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
rs.mock("@/core/auth/AuthProvider", () => ({ useAuth: rs.fn() }));
|
||||
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
|
||||
@ -27,6 +28,7 @@ rs.mock("@/core/auth/admin-users", () => ({
|
||||
loadAdminUsers: rs.fn(),
|
||||
updateUserAccount: rs.fn(),
|
||||
}));
|
||||
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: rs.fn() }));
|
||||
|
||||
const admin: AdminUser = {
|
||||
id: "u-admin",
|
||||
@ -66,6 +68,9 @@ function rowFor(email: string) {
|
||||
|
||||
beforeEach(() => {
|
||||
rs.mocked(useAuth).mockReturnValue(auth("admin"));
|
||||
// Seed the static-mode gate per test so a `true` override cannot leak into
|
||||
// the next case via a surviving mock implementation.
|
||||
rs.mocked(isStaticWebsiteOnly).mockReturnValue(false);
|
||||
rs.mocked(loadAdminUsers).mockResolvedValue([admin, suspended, legacyRow]);
|
||||
});
|
||||
afterEach(() => {
|
||||
@ -92,6 +97,15 @@ test("non-admin sees the admin-only note and loads nothing", () => {
|
||||
expect(loadAdminUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("static-website demo shows the admin-only note and loads nothing", () => {
|
||||
// The static demo user is nominally an admin; only the mode gate stops
|
||||
// the doomed /api/v1/admin/users query.
|
||||
rs.mocked(isStaticWebsiteOnly).mockReturnValue(true);
|
||||
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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user