feat: admin skill public upload + user management page
- Admin users can choose to install skills to public (built-in) or custom category via UI radio selector in InstallSkillDialog - Backend: SkillStorage.ainstall_skill_from_archive accepts target_category param; non-admin 403 on public install - Backend: AgentStore/SkillStore add count_owned(user_id); UserRepository/LocalAuthProvider add list_users() - Backend: GET /api/v1/auth/users endpoint (admin-only) returns all users with skill_count and agent_count - Frontend: admin-only "用户管理" nav item in sidebar; /page/workspace/admin/users route with user table - i18n: added sidebar.users and adminUsers section in zh-CN/en-US Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e6e3c08d5
commit
40cd8a24af
25 files changed
+380
-40
No files matched your search
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function UserManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: users, isLoading, error } = useAllUsers(true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<SettingsSection
|
||||
title={t.adminUsers.title}
|
||||
description={t.adminUsers.description}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-destructive text-sm py-4">
|
||||
{String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && users && users.length === 0 && (
|
||||
<div className="text-muted-foreground text-sm py-8 text-center">
|
||||
{t.adminUsers.empty}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && users && users.length > 0 && (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, i) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
|
||||
>
|
||||
<td className="px-4 py-3">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.system_role === "admin" ? (
|
||||
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
|
||||
{user.id}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,6 +160,7 @@ function SkillSettingsList({
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -358,9 +359,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
function InstallSkillDialog({
|
||||
open,
|
||||
onClose,
|
||||
isAdmin,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const dialog = t.settings.skills.installDialog;
|
||||
@@ -369,6 +372,7 @@ function InstallSkillDialog({
|
||||
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
|
||||
@@ -377,6 +381,7 @@ function InstallSkillDialog({
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
setIsDragging(false);
|
||||
setTargetCategory("custom");
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
@@ -433,7 +438,7 @@ function InstallSkillDialog({
|
||||
async (overwrite: boolean) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await installMutation.mutateAsync({ file, overwrite });
|
||||
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
|
||||
toast.success(
|
||||
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
||||
"{name}",
|
||||
@@ -446,13 +451,15 @@ function InstallSkillDialog({
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
|
||||
);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
|
||||
const canOverwrite = !!validation && !validationError && isCustomConflict;
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
||||
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
@@ -500,6 +507,27 @@ function InstallSkillDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
||||
<div className="flex gap-4">
|
||||
{(["custom", "public"] as const).map((cat) => (
|
||||
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="targetCategory"
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => setTargetCategory(cat)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validateMutation.isPending && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
@@ -557,10 +585,10 @@ function InstallSkillDialog({
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
|
||||
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react";
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
@@ -70,6 +72,19 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{isAdmin && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/admin")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
|
||||
<UsersIcon />
|
||||
<span>{t.sidebar.users}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
system_role: "admin" | "user";
|
||||
skill_count: number;
|
||||
agent_count: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
export async function loginByUsername(
|
||||
username: string,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { listAllUsers } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "users"],
|
||||
queryFn: listAllUsers,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export const enUS: Translations = {
|
||||
recentChats: "Recent chats",
|
||||
demoChats: "Demo chats",
|
||||
agents: "Agents",
|
||||
users: "Users",
|
||||
},
|
||||
|
||||
// Agents
|
||||
@@ -471,9 +472,14 @@ export const enUS: Translations = {
|
||||
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
|
||||
conflictPublic:
|
||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
|
||||
installSuccess: "Skill '{name}' installed.",
|
||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
targetCategoryLabel: "Install to",
|
||||
categoryCustom: "Custom skill",
|
||||
categoryPublic: "Built-in (admin)",
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
@@ -554,6 +560,19 @@ export const enUS: Translations = {
|
||||
},
|
||||
},
|
||||
|
||||
adminUsers: {
|
||||
title: "User Management",
|
||||
description: "View all registered users and their associated skill and agent counts.",
|
||||
email: "Email",
|
||||
role: "Role",
|
||||
userId: "User ID",
|
||||
skillCount: "Skills",
|
||||
agentCount: "Agents",
|
||||
roleAdmin: "Admin",
|
||||
roleUser: "User",
|
||||
empty: "No users found",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
bellAria: "Open notifications",
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface Translations {
|
||||
chats: string;
|
||||
demoChats: string;
|
||||
agents: string;
|
||||
users: string;
|
||||
};
|
||||
|
||||
// Agents
|
||||
@@ -397,9 +398,13 @@ export interface Translations {
|
||||
warningsLabel: string;
|
||||
conflictCustom: string;
|
||||
conflictPublic: string;
|
||||
conflictPublicAdminHint: string;
|
||||
installSuccess: string;
|
||||
installSuccessReplaced: string;
|
||||
rejectInvalidExt: string;
|
||||
targetCategoryLabel: string;
|
||||
categoryCustom: string;
|
||||
categoryPublic: string;
|
||||
};
|
||||
};
|
||||
skillEvolution: {
|
||||
@@ -469,6 +474,20 @@ export interface Translations {
|
||||
};
|
||||
};
|
||||
|
||||
// Admin user management page
|
||||
adminUsers: {
|
||||
title: string;
|
||||
description: string;
|
||||
email: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
skillCount: string;
|
||||
agentCount: string;
|
||||
roleAdmin: string;
|
||||
roleUser: string;
|
||||
empty: string;
|
||||
};
|
||||
|
||||
// In-app notification inbox (bell in the workspace sidebar)
|
||||
notifications: NotificationsTranslations;
|
||||
}
|
||||
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
|
||||
recentChats: "最近的对话",
|
||||
demoChats: "演示对话",
|
||||
agents: "智能体",
|
||||
users: "用户管理",
|
||||
},
|
||||
|
||||
// Agents
|
||||
@@ -449,10 +450,14 @@ export const zhCN: Translations = {
|
||||
warningsLabel: "警告",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
|
||||
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
|
||||
installSuccess: "技能 “{name}” 已安装。",
|
||||
installSuccessReplaced: "技能 “{name}” 已替换。",
|
||||
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
||||
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
|
||||
installSuccess: `技能 '{name}' 已安装。`,
|
||||
installSuccessReplaced: `技能 '{name}' 已替换。`,
|
||||
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
|
||||
targetCategoryLabel: `安装到`,
|
||||
categoryCustom: `自定义技能`,
|
||||
categoryPublic: `内置技能(管理员)`,
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
@@ -526,6 +531,19 @@ export const zhCN: Translations = {
|
||||
},
|
||||
},
|
||||
|
||||
adminUsers: {
|
||||
title: "用户管理",
|
||||
description: "查看所有注册用户及其关联的技能和智能体数量。",
|
||||
email: "邮箱",
|
||||
role: "角色",
|
||||
userId: "用户 ID",
|
||||
skillCount: "技能数",
|
||||
agentCount: "智能体数",
|
||||
roleAdmin: "管理员",
|
||||
roleUser: "普通用户",
|
||||
empty: "暂无用户",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "通知",
|
||||
bellAria: "打开通知",
|
||||
|
||||
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
|
||||
|
||||
export async function installSkillUpload(
|
||||
file: File,
|
||||
options: { overwrite?: boolean } = {},
|
||||
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
|
||||
): Promise<InstallSkillResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const query = options.overwrite ? "?overwrite=true" : "";
|
||||
const params = new URLSearchParams();
|
||||
if (options.overwrite) params.set("overwrite", "true");
|
||||
if (options.targetCategory) params.set("target_category", options.targetCategory);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
||||
{ method: "POST", body: form },
|
||||
|
||||
@@ -45,8 +45,8 @@ export function useValidateSkillUpload() {
|
||||
export function useInstallSkillUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
|
||||
installSkillUpload(file, { overwrite }),
|
||||
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
|
||||
installSkillUpload(file, { overwrite, targetCategory }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
|
||||
|
||||
export default function UsersPage() {
|
||||
return <UserManagementPage />;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
|
||||
function WorkspaceLayout({
|
||||
children,
|
||||
@@ -149,6 +150,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<UsersPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user