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:
xuhdandClaude Sonnet 4.6 committed 2026-05-14 20:40:23 +08:00
1 parent 3e6e3c08d5
commit 40cd8a24af
25 files changed
+380 -40

No files matched your search

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