mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
用户详情
This commit is contained in:
+177
@@ -253,6 +253,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "认证源 ID 或名称",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -330,6 +331,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "认证源 ID 或名称",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -397,6 +399,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "认证源 ID 或名称",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -1948,6 +1951,156 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取用户详情",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "用户 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "用户详情",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/user.user"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "用户不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "删除用户",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "用户 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "删除成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限、尝试删除管理员或当前用户",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "用户不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/status": {
|
||||
"put": {
|
||||
"security": [
|
||||
@@ -2225,6 +2378,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "外部帐号绑定记录 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -4084,6 +4238,11 @@ const docTemplate = `{
|
||||
"UploadStatusPending": "待使用",
|
||||
"UploadStatusUsed": "已使用"
|
||||
},
|
||||
"x-enum-descriptions": [
|
||||
"待使用",
|
||||
"已使用",
|
||||
"已删除"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"UploadStatusPending",
|
||||
"UploadStatusUsed",
|
||||
@@ -4723,9 +4882,18 @@ const docTemplate = `{
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -4738,14 +4906,23 @@ const docTemplate = `{
|
||||
"last_login_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"website": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "认证源 ID 或名称",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -323,6 +324,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "认证源 ID 或名称",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -390,6 +392,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "认证源 ID 或名称",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -1941,6 +1944,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取用户详情",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "用户 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "用户详情",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/user.user"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "用户不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "删除用户",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "用户 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "删除成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限、尝试删除管理员或当前用户",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "用户不存在",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/status": {
|
||||
"put": {
|
||||
"security": [
|
||||
@@ -2218,6 +2371,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "外部帐号绑定记录 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
@@ -4077,6 +4231,11 @@
|
||||
"UploadStatusPending": "待使用",
|
||||
"UploadStatusUsed": "已使用"
|
||||
},
|
||||
"x-enum-descriptions": [
|
||||
"待使用",
|
||||
"已使用",
|
||||
"已删除"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"UploadStatusPending",
|
||||
"UploadStatusUsed",
|
||||
@@ -4716,9 +4875,18 @@
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -4731,14 +4899,23 @@
|
||||
"last_login_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"website": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -409,6 +409,10 @@ definitions:
|
||||
UploadStatusDeleted: 已删除
|
||||
UploadStatusPending: 待使用
|
||||
UploadStatusUsed: 已使用
|
||||
x-enum-descriptions:
|
||||
- 待使用
|
||||
- 已使用
|
||||
- 已删除
|
||||
x-enum-varnames:
|
||||
- UploadStatusPending
|
||||
- UploadStatusUsed
|
||||
@@ -841,8 +845,14 @@ definitions:
|
||||
properties:
|
||||
avatar_url:
|
||||
type: string
|
||||
bio:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
gender:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
is_active:
|
||||
@@ -851,12 +861,18 @@ definitions:
|
||||
type: boolean
|
||||
last_login_at:
|
||||
type: string
|
||||
location:
|
||||
type: string
|
||||
nickname:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
website:
|
||||
type: string
|
||||
type: object
|
||||
util.ResponseAny:
|
||||
properties:
|
||||
@@ -1010,6 +1026,7 @@ paths:
|
||||
description: 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限
|
||||
parameters:
|
||||
- description: 认证源 ID 或名称
|
||||
format: int64
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -1049,6 +1066,7 @@ paths:
|
||||
description: 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限
|
||||
parameters:
|
||||
- description: 认证源 ID 或名称
|
||||
format: int64
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -1099,6 +1117,7 @@ paths:
|
||||
description: 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限
|
||||
parameters:
|
||||
- description: 认证源 ID 或名称
|
||||
format: int64
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -2038,6 +2057,97 @@ paths:
|
||||
summary: 创建用户
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/users/{id}:
|
||||
delete:
|
||||
description: 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户
|
||||
parameters:
|
||||
- description: 用户 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 删除成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限、尝试删除管理员或当前用户
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"404":
|
||||
description: 用户不存在
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 删除用户
|
||||
tags:
|
||||
- admin
|
||||
get:
|
||||
description: 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段
|
||||
parameters:
|
||||
- description: 用户 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 用户详情
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/user.user'
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"404":
|
||||
description: 用户不存在
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取用户详情
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/users/{id}/status:
|
||||
put:
|
||||
consumes:
|
||||
@@ -2235,6 +2345,7 @@ paths:
|
||||
description: 解除当前登录用户与指定外部帐号的绑定关系,需要登录
|
||||
parameters:
|
||||
- description: 外部帐号绑定记录 ID
|
||||
format: int64
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
|
||||
@@ -14,16 +14,32 @@ import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Filter,
|
||||
Globe,
|
||||
Layers,
|
||||
Loader2,
|
||||
Mail,
|
||||
MapPin,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserX,
|
||||
VenusAndMars,
|
||||
X
|
||||
} from "lucide-react"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import {AdminUser} from "@/lib/services"
|
||||
import {cn, formatDateTime} from "@/lib/utils"
|
||||
@@ -51,11 +67,16 @@ export function UsersManager() {
|
||||
setSearchUsername,
|
||||
setStatusFilter,
|
||||
fetchUsers,
|
||||
updateUserStatus
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
deleteUser
|
||||
} = useAdminUsers()
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,12 +91,38 @@ export function UsersManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = (user: AdminUser) => {
|
||||
const handleShowDetail = async (user: AdminUser) => {
|
||||
setSelectedUser(user)
|
||||
setDetailOpen(true)
|
||||
setDetailLoading(true)
|
||||
|
||||
try {
|
||||
const detail = await getUserDetail(user.id)
|
||||
setSelectedUser(detail)
|
||||
} catch {
|
||||
setSelectedUser(user)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await deleteUser(deleteTarget)
|
||||
if (selectedUser?.id === deleteTarget.id) {
|
||||
setDetailOpen(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const displayValue = (value?: string) => value && value.trim() ? value : "-"
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername)
|
||||
@@ -318,7 +365,7 @@ export function UsersManager() {
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[80px] py-2 h-8">操作</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -394,6 +441,26 @@ export function UsersManager() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{!user.is_admin && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(user)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -435,6 +502,13 @@ export function UsersManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLoading && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
正在刷新详情
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
|
||||
@@ -445,9 +519,64 @@ export function UsersManager() {
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">个人资料</h4>
|
||||
<div className="rounded-lg border divide-y bg-background/50">
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<Mail className="size-3" />
|
||||
邮箱
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.email)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<Smartphone className="size-3" />
|
||||
手机
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.phone)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<VenusAndMars className="size-3" />
|
||||
性别
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.gender)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<MapPin className="size-3" />
|
||||
所在地
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.location)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<Globe className="size-3" />
|
||||
网站
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.website)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-3.5 text-sm">
|
||||
<span className="text-[10px] text-muted-foreground">简介</span>
|
||||
<span className="break-words text-[10px] leading-5">{displayValue(selectedUser.bio)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
|
||||
<div className="rounded-lg border divide-y bg-background/50">
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">账户状态</span>
|
||||
<Badge variant={selectedUser.is_active ? "secondary" : "outline"} className="text-[10px]">
|
||||
{selectedUser.is_active ? "正常" : "禁用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">管理员</span>
|
||||
<span className="font-mono text-[10px]">{selectedUser.is_admin ? "是" : "否"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">最后登录</span>
|
||||
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
|
||||
@@ -468,7 +597,7 @@ export function UsersManager() {
|
||||
</div>
|
||||
|
||||
{!selectedUser.is_admin && (
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
|
||||
<Button
|
||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||
className={cn(
|
||||
@@ -491,6 +620,14 @@ export function UsersManager() {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 text-xs font-medium"
|
||||
onClick={() => setDeleteTarget(selectedUser)}
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除用户
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -498,6 +635,24 @@ export function UsersManager() {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
|
||||
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -37,8 +37,10 @@ interface AdminUsersContextState {
|
||||
|
||||
fetchUsers: (force?: boolean) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
getUserDetail: (id: string) => Promise<AdminUser>
|
||||
updateUserStatus: (user: AdminUser) => Promise<void>
|
||||
createUser: (req: CreateUserRequest) => Promise<AdminUser>
|
||||
deleteUser: (user: AdminUser) => Promise<void>
|
||||
}
|
||||
|
||||
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes cache
|
||||
@@ -189,6 +191,10 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}
|
||||
|
||||
const getUserDetail = async (id: string) => {
|
||||
return AdminService.getUser(id)
|
||||
}
|
||||
|
||||
const createUser = async (req: CreateUserRequest) => {
|
||||
try {
|
||||
const newUser = await AdminService.createUser(req)
|
||||
@@ -204,6 +210,19 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = async (user: AdminUser) => {
|
||||
try {
|
||||
await AdminService.deleteUser(user.id)
|
||||
setUsers(prev => prev.filter(u => u.id !== user.id))
|
||||
setTotal(prev => Math.max(0, prev - 1))
|
||||
cacheRef.current = {}
|
||||
toast.success(`已删除用户 ${ user.username }`)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除用户失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const value = {
|
||||
users,
|
||||
total,
|
||||
@@ -221,8 +240,10 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
setStatusFilter,
|
||||
fetchUsers,
|
||||
refresh,
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
createUser
|
||||
createUser,
|
||||
deleteUser
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -289,6 +289,15 @@ export class AdminService extends BaseService {
|
||||
return this.get<ListUsersResponse>('/users', request as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* @param id - 用户 ID
|
||||
* @returns 用户完整资料
|
||||
*/
|
||||
static async getUser(id: string): Promise<AdminUser> {
|
||||
return this.get<AdminUser>(`/users/${ id }`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户状态
|
||||
* @param id - 用户 ID
|
||||
@@ -326,6 +335,14 @@ export class AdminService extends BaseService {
|
||||
return this.post<AdminUser>('/users', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* @param id - 用户 ID
|
||||
*/
|
||||
static async deleteUser(id: string): Promise<void> {
|
||||
return this.delete<void>(`/users/${ id }`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
* @returns 系统状态指标数据
|
||||
|
||||
@@ -194,12 +194,24 @@ export interface AdminUser {
|
||||
username: string;
|
||||
/** 昵称 */
|
||||
nickname: string;
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
/** 头像 URL */
|
||||
avatar_url: string;
|
||||
/** 是否激活 */
|
||||
is_active: boolean;
|
||||
/** 是否管理员 */
|
||||
is_admin: boolean;
|
||||
/** 个人简介 */
|
||||
bio?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 性别 */
|
||||
gender?: string;
|
||||
/** 个人网站 */
|
||||
website?: string;
|
||||
/** 所在地 */
|
||||
location?: string;
|
||||
/** 最后登录时间 */
|
||||
last_login_at: string;
|
||||
/** 创建时间 */
|
||||
@@ -363,4 +375,3 @@ export interface UpdateTemplateRequest {
|
||||
content: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ package user
|
||||
const (
|
||||
userNotFound = "用户不存在"
|
||||
cannotDisable = "不能禁用管理员用户"
|
||||
cannotDelete = "不能删除管理员用户"
|
||||
cannotDeleteSelf = "不能删除当前登录用户"
|
||||
updateUserFailed = "更新用户状态失败"
|
||||
deleteUserFailed = "删除用户失败"
|
||||
usernameExists = "用户名已存在"
|
||||
usernameRequired = "用户名不能为空"
|
||||
passwordTooShort = "密码长度不能少于 8 位"
|
||||
|
||||
@@ -19,9 +19,11 @@ package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/db/idgen"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
@@ -45,9 +47,15 @@ type user struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Bio string `json:"bio"`
|
||||
Phone string `json:"phone"`
|
||||
Gender string `json:"gender"`
|
||||
Website string `json:"website"`
|
||||
Location string `json:"location"`
|
||||
LastLoginAt time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -59,6 +67,35 @@ type listUsersResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func parseUserID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, util.Err(userNotFound))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func toUser(u model.User) user {
|
||||
return user{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Email: u.Email,
|
||||
AvatarURL: u.AvatarURL,
|
||||
IsActive: u.IsActive,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Bio: u.Bio,
|
||||
Phone: u.Phone,
|
||||
Gender: u.Gender,
|
||||
Website: u.Website,
|
||||
Location: u.Location,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers 获取用户列表
|
||||
// @Summary 获取用户列表
|
||||
// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
|
||||
@@ -118,6 +155,43 @@ func ListUsers(c *gin.Context) {
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUser 获取用户详情
|
||||
// @Summary 获取用户详情
|
||||
// @Description 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "用户 ID"
|
||||
// @Success 200 {object} util.ResponseAny{data=user.user} "用户详情"
|
||||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 404 {object} util.ResponseAny "用户不存在"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/users/{id} [get]
|
||||
func GetUser(c *gin.Context) {
|
||||
id, ok := parseUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var targetUser model.User
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Select("id, username, nickname, email, avatar_url, is_active, is_admin, "+
|
||||
"bio, phone, gender, website, location, last_login_at, created_at, updated_at").
|
||||
Where("id = ?", id).
|
||||
First(&targetUser).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, util.Err(userNotFound))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(toUser(targetUser)))
|
||||
}
|
||||
|
||||
// updateUserStatusRequest 更新用户状态请求
|
||||
type updateUserStatusRequest struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -146,7 +220,10 @@ func UpdateUserStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
id, ok := parseUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var targetUser struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
@@ -181,6 +258,70 @@ func UpdateUserStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户
|
||||
// @Summary 删除用户
|
||||
// @Description 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "用户 ID"
|
||||
// @Success 200 {object} util.ResponseAny{data=string} "删除成功"
|
||||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限、尝试删除管理员或当前用户"
|
||||
// @Failure 404 {object} util.ResponseAny "用户不存在"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/users/{id} [delete]
|
||||
func DeleteUser(c *gin.Context) {
|
||||
id, ok := parseUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
if currUser != nil && currUser.ID == id {
|
||||
c.JSON(http.StatusForbidden, util.Err(cannotDeleteSelf))
|
||||
return
|
||||
}
|
||||
|
||||
var targetUser struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
IsAdmin bool `gorm:"column:is_admin"`
|
||||
}
|
||||
if err := db.DB(c.Request.Context()).
|
||||
Table("users").
|
||||
Select("id, is_admin").
|
||||
Where("id = ?", id).
|
||||
First(&targetUser).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, util.Err(userNotFound))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if targetUser.IsAdmin {
|
||||
c.JSON(http.StatusForbidden, util.Err(cannotDelete))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", id).Delete(&model.AccessToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", id).Delete(&model.ExternalAccount{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Delete(&model.User{}).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(deleteUserFailed))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
// createUserRequest 创建用户请求
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=64"`
|
||||
@@ -257,17 +398,5 @@ func CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
res := user{
|
||||
ID: newUser.ID,
|
||||
Username: newUser.Username,
|
||||
Nickname: newUser.Nickname,
|
||||
AvatarURL: newUser.AvatarURL,
|
||||
IsActive: newUser.IsActive,
|
||||
IsAdmin: newUser.IsAdmin,
|
||||
LastLoginAt: newUser.LastLoginAt,
|
||||
CreatedAt: newUser.CreatedAt,
|
||||
UpdatedAt: newUser.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(res))
|
||||
c.JSON(http.StatusOK, util.OK(toUser(newUser)))
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
|
||||
|
||||
adminGroup.GET("/users", ListUsers)
|
||||
adminGroup.POST("/users", CreateUser)
|
||||
adminGroup.GET("/users/:id", GetUser)
|
||||
adminGroup.PUT("/users/:id/status", UpdateUserStatus)
|
||||
adminGroup.DELETE("/users/:id", DeleteUser)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -170,6 +172,72 @@ func TestListUsers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
targetUser := model.User{
|
||||
ID: 1001,
|
||||
Username: "alice",
|
||||
Password: "secret-hash",
|
||||
Nickname: "Alice Nickname",
|
||||
Email: "alice@example.com",
|
||||
AvatarURL: "https://example.com/avatar.png",
|
||||
IsActive: true,
|
||||
IsAdmin: false,
|
||||
Bio: "hello",
|
||||
Phone: "123456",
|
||||
Gender: "female",
|
||||
Website: "https://example.com",
|
||||
Location: "Shanghai",
|
||||
}
|
||||
if err := dbConn.Create(&targetUser).Error; err != nil {
|
||||
t.Fatalf("failed to seed user: %v", err)
|
||||
}
|
||||
|
||||
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true}
|
||||
router := setupTestRouter(adminUser)
|
||||
|
||||
t.Run("get full user profile", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/users/1001", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp util.ResponseAny
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var resUser user
|
||||
if err := json.Unmarshal(dataBytes, &resUser); err != nil {
|
||||
t.Fatalf("failed to parse response data: %v", err)
|
||||
}
|
||||
|
||||
if resUser.Email != targetUser.Email || resUser.Bio != targetUser.Bio || resUser.Phone != targetUser.Phone ||
|
||||
resUser.Gender != targetUser.Gender || resUser.Website != targetUser.Website || resUser.Location != targetUser.Location {
|
||||
t.Errorf("profile fields were not returned correctly: %+v", resUser)
|
||||
}
|
||||
if bytes.Contains(dataBytes, []byte("secret-hash")) {
|
||||
t.Error("response should not include password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get non-existent user", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/users/9999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateUserStatus(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
@@ -349,3 +417,117 @@ func TestCreateUser(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
regularUser := model.User{
|
||||
ID: 1001,
|
||||
Username: "alice",
|
||||
IsActive: true,
|
||||
IsAdmin: false,
|
||||
}
|
||||
adminUser := model.User{
|
||||
ID: 1002,
|
||||
Username: "bob",
|
||||
IsActive: true,
|
||||
IsAdmin: true,
|
||||
}
|
||||
selfUser := model.User{
|
||||
ID: 1003,
|
||||
Username: "charlie",
|
||||
IsActive: true,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
if err := dbConn.Create(®ularUser).Error; err != nil {
|
||||
t.Fatalf("failed to seed regular user: %v", err)
|
||||
}
|
||||
if err := dbConn.Create(&adminUser).Error; err != nil {
|
||||
t.Fatalf("failed to seed admin user: %v", err)
|
||||
}
|
||||
if err := dbConn.Create(&selfUser).Error; err != nil {
|
||||
t.Fatalf("failed to seed self user: %v", err)
|
||||
}
|
||||
if err := dbConn.Create(&model.AccessToken{
|
||||
UserID: regularUser.ID,
|
||||
Name: "api",
|
||||
TokenHash: "hash-for-delete-user-test",
|
||||
MaskedToken: "at_****test",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed access token: %v", err)
|
||||
}
|
||||
if err := dbConn.Create(&model.ExternalAccount{
|
||||
ID: 5001,
|
||||
AuthSourceID: 1,
|
||||
UserID: regularUser.ID,
|
||||
ExternalID: "external-alice",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed external account: %v", err)
|
||||
}
|
||||
|
||||
router := setupTestRouter(&selfUser)
|
||||
|
||||
t.Run("delete regular user successfully", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1001", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := dbConn.Model(&model.User{}).Where("id = ?", 1001).Count(&count).Error; err != nil {
|
||||
t.Fatalf("failed to count deleted user: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected deleted user count 0, got %d", count)
|
||||
}
|
||||
|
||||
if err := dbConn.Model(&model.AccessToken{}).Where("user_id = ?", 1001).Count(&count).Error; err != nil {
|
||||
t.Fatalf("failed to count deleted access tokens: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected deleted access token count 0, got %d", count)
|
||||
}
|
||||
|
||||
if err := dbConn.Model(&model.ExternalAccount{}).Where("user_id = ?", 1001).Count(&count).Error; err != nil {
|
||||
t.Fatalf("failed to count deleted external accounts: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected deleted external account count 0, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot delete admin user", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1002", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot delete current user", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/1003", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete non-existent user", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/admin/users/9999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -215,7 +215,9 @@ func Serve() {
|
||||
// Users
|
||||
adminRouter.GET("/users", admin_user.ListUsers)
|
||||
adminRouter.POST("/users", admin_user.CreateUser)
|
||||
adminRouter.GET("/users/:id", admin_user.GetUser)
|
||||
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
|
||||
adminRouter.DELETE("/users/:id", admin_user.DeleteUser)
|
||||
|
||||
// System Config
|
||||
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)
|
||||
|
||||
Reference in New Issue
Block a user