feat(admin): 用户管理 - 支持管理员添加用户 (#104)
* feat(canvas): 绘图工具 - 支持自定义 tldraw License Key * docs(canvas): 绘图工具 - 添加 License Key 页面截图 * feat(admin): 用户管理 - 支持管理员添加用户
This commit is contained in:
@@ -179,6 +179,25 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, users)
|
||||
})
|
||||
r.POST("/admin/users", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req service.CreateAdminUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
created, err := svc.CreateAdminUser(user, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": created})
|
||||
})
|
||||
r.GET("/admin/references", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CreateAdminUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role model.UserRole `json:"role"`
|
||||
Status model.UserStatus `json:"status"`
|
||||
}
|
||||
type UpdateUserRequest struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
@@ -186,6 +194,78 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest) (*AdminUser, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username := normalizeUsername(req.Username)
|
||||
email := normalizeEmail(req.Email)
|
||||
displayName := normalizeDisplayName(req.DisplayName, username)
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePassword(req.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if email != "" {
|
||||
if err := validateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Role != model.UserRoleAdmin && req.Role != model.UserRoleUser {
|
||||
return nil, BadAuthRequest("\u7528\u6237\u89d2\u8272\u65e0\u6548")
|
||||
}
|
||||
if req.Status != model.UserStatusActive && req.Status != model.UserStatusDisabled {
|
||||
return nil, BadAuthRequest("\u7528\u6237\u72b6\u6001\u65e0\u6548")
|
||||
}
|
||||
if _, err := s.repo.UserByUsername(username); err == nil {
|
||||
return nil, BadAuthRequest("\u7528\u6237\u540d\u5df2\u5b58\u5728")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if email != "" {
|
||||
if _, err := s.repo.UserByEmail(email); err == nil {
|
||||
return nil, BadAuthRequest("\u90ae\u7bb1\u5df2\u88ab\u6ce8\u518c")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
passwordHash, err := hashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
user := &model.User{
|
||||
ID: newID(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Role: req.Role,
|
||||
Status: req.Status,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.repo.Create(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureSignupBonus(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "user.create", "user", user.ID, "\u521b\u5efa\u7528\u6237\u8d26\u53f7", map[string]any{"role": user.Role, "status": user.Status}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.repo.CreditAccount(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminUser{
|
||||
User: *user,
|
||||
AvailableMicrocredits: account.AvailableMicrocredits,
|
||||
ReservedMicrocredits: account.ReservedMicrocredits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUser(actor *model.User, userID string, req UpdateUserRequest) (*model.User, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,9 +7,68 @@ import (
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCreateAdminUserCreatesActiveUserWithPasswordAndAudit(t *testing.T) {
|
||||
db := newBulkUserTestDB(t)
|
||||
actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if err := db.Create(&actor).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := (&Service{repo: repository.New(db)}).CreateAdminUser(&actor, CreateAdminUserRequest{
|
||||
Username: "new-user",
|
||||
DisplayName: "New User",
|
||||
Email: "new-user@example.com",
|
||||
Password: "strong-password",
|
||||
Role: model.UserRoleUser,
|
||||
Status: model.UserStatusActive,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Username != "new-user" || created.DisplayName != "New User" || created.Email != "new-user@example.com" {
|
||||
t.Fatalf("created user = %+v", created)
|
||||
}
|
||||
if created.Role != model.UserRoleUser || created.Status != model.UserStatusActive {
|
||||
t.Fatalf("created user role/status = %q/%q", created.Role, created.Status)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(created.PasswordHash), []byte("strong-password")); err != nil {
|
||||
t.Fatalf("password hash does not match: %v", err)
|
||||
}
|
||||
var account model.CreditAccount
|
||||
if err := db.First(&account, "user_id = ?", created.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableMicrocredits <= 0 {
|
||||
t.Fatalf("available credits = %d, want positive signup bonus", account.AvailableMicrocredits)
|
||||
}
|
||||
var audit model.AdminAuditEvent
|
||||
if err := db.Where("action = ? AND target_id = ?", "user.create", created.ID).First(&audit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audit.ActorUserID != actor.ID {
|
||||
t.Fatalf("audit actor = %q, want %q", audit.ActorUserID, actor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) {
|
||||
db := newBulkUserTestDB(t)
|
||||
actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if err := db.Create(&actor).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &Service{repo: repository.New(db)}
|
||||
input := CreateAdminUserRequest{Username: "duplicate", DisplayName: "Duplicate", Password: "strong-password", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||||
if _, err := svc.CreateAdminUser(&actor, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.CreateAdminUser(&actor, input); err == nil {
|
||||
t.Fatal("CreateAdminUser() duplicate username error = nil")
|
||||
}
|
||||
}
|
||||
func TestBulkDisableUsersDisablesUsersSessionsAndWritesAudits(t *testing.T) {
|
||||
db := newBulkUserTestDB(t)
|
||||
actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
@@ -95,7 +154,7 @@ func newBulkUserTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { App, Button, Drawer, Form, Input, Select } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { updateAdminUser, type AdminUser, type LocalUser } from "@/services/api/auth";
|
||||
import { createAdminUser, updateAdminUser, type AdminUser, type LocalUser } from "@/services/api/auth";
|
||||
|
||||
type UserFormValues = Pick<LocalUser, "displayName" | "email" | "role" | "status">;
|
||||
|
||||
@@ -100,3 +100,104 @@ export function AdminUserEditDrawer({
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
type CreateUserFormValues = {
|
||||
username: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
role: LocalUser["role"];
|
||||
status: LocalUser["status"];
|
||||
};
|
||||
|
||||
export function AdminUserCreateDrawer({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (user: AdminUser) => void;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm<CreateUserFormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ role: "user", status: "active" });
|
||||
}, [form, open]);
|
||||
|
||||
const close = () => {
|
||||
if (saving) return;
|
||||
if (!form.isFieldsTouched()) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "\u653e\u5f03\u6dfb\u52a0\u7528\u6237\uff1f",
|
||||
content: "\u5c1a\u672a\u4fdd\u5b58\u7684\u7528\u6237\u4fe1\u606f\u5c06\u4e22\u5931\u3002",
|
||||
okText: "\u653e\u5f03\u5e76\u5173\u95ed",
|
||||
cancelText: "\u7ee7\u7eed\u7f16\u8f91",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: onClose,
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await createAdminUser({
|
||||
username: values.username.trim(),
|
||||
displayName: values.displayName.trim(),
|
||||
email: values.email?.trim() || "",
|
||||
password: values.password,
|
||||
role: values.role,
|
||||
status: values.status,
|
||||
});
|
||||
onCreated(result.user);
|
||||
form.resetFields();
|
||||
onClose();
|
||||
message.success("\u7528\u6237\u5df2\u521b\u5efa");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "\u521b\u5efa\u7528\u6237\u5931\u8d25");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={"\u6dfb\u52a0\u7528\u6237"}
|
||||
open={open}
|
||||
width="min(520px, 100vw)"
|
||||
onClose={close}
|
||||
maskClosable={!saving}
|
||||
destroyOnHidden
|
||||
extra={<Button type="primary" loading={saving} onClick={() => void save()}>{"\u4fdd\u5b58"}</Button>}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="username" label={"\u7528\u6237\u540d"} rules={[{ required: true, whitespace: true, message: "\u8bf7\u8f93\u5165\u7528\u6237\u540d" }]}>
|
||||
<Input placeholder={"3-32 \u4f4d\u5b57\u6bcd\u3001\u6570\u5b57\u3001\u4e0b\u5212\u7ebf\u6216\u8fde\u5b57\u7b26"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayName" label={"\u663e\u793a\u540d\u79f0"} rules={[{ required: true, whitespace: true, message: "\u8bf7\u586b\u5199\u663e\u793a\u540d\u79f0" }]}>
|
||||
<Input placeholder={"\u7528\u6237\u5728\u4ea7\u54c1\u5185\u663e\u793a\u7684\u540d\u79f0"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label={"\u90ae\u7bb1"} rules={[{ type: "email", message: "\u8bf7\u8f93\u5165\u6709\u6548\u90ae\u7bb1" }]}>
|
||||
<Input placeholder="name@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={"\u521d\u59cb\u5bc6\u7801"} rules={[{ required: true, message: "\u8bf7\u8bbe\u7f6e\u521d\u59cb\u5bc6\u7801" }]}>
|
||||
<Input.Password placeholder={"\u81f3\u5c11 8 \u4f4d"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label={"\u89d2\u8272"}>
|
||||
<Select options={[{ label: "\u7ba1\u7406\u5458", value: "admin" }, { label: "\u666e\u901a\u7528\u6237", value: "user" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label={"\u8d26\u53f7\u72b6\u6001"}>
|
||||
<Select options={[{ label: "\u5df2\u542f\u7528", value: "active" }, { label: "\u5df2\u505c\u7528", value: "disabled" }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Checkbox, Dropdown, Input, Table, Tag } from "antd";
|
||||
import { Ban, ChevronDown, Search, Settings2 } from "lucide-react";
|
||||
import { Ban, ChevronDown, Search, Settings2, UserPlus } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ListToolbar, PaginationBar, TableSurface } from "@/components/layout/workspace-page";
|
||||
@@ -10,7 +10,7 @@ import { AdminBatchBar, AdminTableEmpty, AdminTableSkeleton } from "../component
|
||||
import { useTableUrlState } from "../lib/use-table-url-state";
|
||||
import { AdminUserDetailDrawer } from "../components/admin-user-detail-drawer";
|
||||
import { createUserColumns, userColumnOptions, type UserColumnKey } from "./users-columns";
|
||||
import { AdminUserEditDrawer } from "./users-drawer";
|
||||
import { AdminUserCreateDrawer, AdminUserEditDrawer } from "./users-drawer";
|
||||
|
||||
const columnStorageKey = "admin-users-visible-columns";
|
||||
const allColumnKeys = userColumnOptions.map((item) => item.key);
|
||||
@@ -25,6 +25,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null);
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [createUserOpen, setCreateUserOpen] = useState(false);
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [bulkDisabling, setBulkDisabling] = useState(false);
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<UserColumnKey>>(() => {
|
||||
@@ -74,6 +75,13 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
|
||||
onUserChanged?.(nextUser);
|
||||
}, [onUserChanged]);
|
||||
|
||||
const addUser = useCallback((user: AdminUser) => {
|
||||
setUsers((items) => [user, ...items].slice(0, state.pageSize));
|
||||
setTotal((value) => value + 1);
|
||||
onUserChanged?.(user);
|
||||
setCreateUserOpen(false);
|
||||
}, [onUserChanged, state.pageSize]);
|
||||
|
||||
const toggleStatus = useCallback(async (user: AdminUser) => {
|
||||
try {
|
||||
if (user.status === "active") {
|
||||
@@ -94,7 +102,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
|
||||
actorId: actor?.id,
|
||||
visibleColumns,
|
||||
onView: (user) => setDetailUserId(user.id),
|
||||
onEdit: setEditingUser,
|
||||
onEdit: (user) => { setCreateUserOpen(false); setEditingUser(user); },
|
||||
onToggleStatus: toggleStatus,
|
||||
}), [actor?.id, toggleStatus, visibleColumns]);
|
||||
|
||||
@@ -129,6 +137,8 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
|
||||
active={hasFilters}
|
||||
onReset={resetFilters}
|
||||
trailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={<UserPlus className="size-4" />} onClick={() => { setEditingUser(null); setCreateUserOpen(true); }}>{"\u6dfb\u52a0\u7528\u6237"}</Button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
dropdownRender={() => (
|
||||
@@ -156,6 +166,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
|
||||
>
|
||||
<Button icon={<Settings2 className="size-4" />}>列设置</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
@@ -217,6 +228,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
|
||||
</TableSurface>
|
||||
|
||||
<AdminUserDetailDrawer userId={detailUserId} onClose={() => setDetailUserId(null)} />
|
||||
<AdminUserCreateDrawer open={createUserOpen} onClose={() => setCreateUserOpen(false)} onCreated={addUser} />
|
||||
<AdminUserEditDrawer user={editingUser} actorId={actor?.id} onClose={() => setEditingUser(null)} onSaved={replaceUser} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -345,6 +345,10 @@ export function listAdminUsers(params: AdminListParams = {}) {
|
||||
return request<{ users: AdminUser[]; total: number; page: number; limit: number }>(api.get("/admin/users", { params }));
|
||||
}
|
||||
|
||||
export function createAdminUser(input: { username: string; displayName: string; email?: string; password: string; role: LocalUser["role"]; status: LocalUser["status"] }) {
|
||||
return request<{ user: AdminUser }>(api.post("/admin/users", input));
|
||||
}
|
||||
|
||||
export function getAdminReferences() {
|
||||
return request<AdminReferenceData>(api.get("/admin/references"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user