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:
hataa 2026-10-12 09:37:15 +08:00 committed by GitHub
parent 3f7a076972
commit c89fed0be4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 129 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1706,7 +1706,7 @@ export const zhCN: Translations = {
title: "用户管理",
description:
"停用或恢复本部署中的账号登录。被停用的账号在所有认证入口都会被拒绝,其现有会话立即失效。",
adminOnly: "只有管理员可以管理用户。",
adminOnly: "只有管理员可以管理用户,演示模式不支持此功能。",
loading: "正在加载用户…",
failed: "用户列表加载失败。",
reload: "重新加载",

View File

@ -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();
});

View File

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