mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97095e8f12 | |||
| 23be2f9296 | |||
| 113ea25aa4 | |||
| c230d5a744 | |||
| c02b649b46 | |||
| fb54d6da61 | |||
| 9c7896df50 | |||
| 01ebec6dfb | |||
| 2816152536 | |||
| b029714c7a | |||
| e0f452eaae | |||
| d721a8fd74 | |||
| 1e349e5cde |
@@ -352,5 +352,6 @@ frontend/lib/services/<service-name>/
|
||||
```
|
||||
|
||||
- 服务类继承 `BaseService`,定义 `basePath`,并暴露有类型的静态方法。
|
||||
- **防止回调 `this` 上下文丢失(核心规范)**:在传递服务类的静态方法作为组件事件回调(如 `onClick`)或 React Query 的 `mutationFn`/`queryFn` 时,**禁止直接传递静态方法引用**(如 `mutationFn: DnsAccountService.create`),必须使用箭头函数包裹以防止 `this` 上下文丢失导致运行时崩溃(如 `mutationFn: (payload) => DnsAccountService.create(payload)`)。
|
||||
- 在 `frontend/lib/services/index.ts` 中注册新服务。
|
||||
|
||||
|
||||
@@ -204,28 +204,6 @@ The version number format is fixed as `YYYYMMDD-NNN`. Historical versions are im
|
||||
|
||||

|
||||
|
||||
## Management Panel & API
|
||||
|
||||
The management panel includes:
|
||||
|
||||
* Reverse Proxy Rules
|
||||
* Configuration Versions
|
||||
* Node Management
|
||||
* Application Records
|
||||
* TLS Certificates
|
||||
* Domain Management
|
||||
* Pages Static Hosting
|
||||
* WAF Rule Groups
|
||||
* Intranet Tunnels
|
||||
* Uptime Kuma Monitoring Sync
|
||||
* SSO Login Configuration
|
||||
* User Management
|
||||
* Settings
|
||||
* Version Updates
|
||||
* PoW Rules
|
||||
|
||||
After logging in to the dashboard, access Swagger UI at: `/swagger/index.html`
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under [Apache License 2.0](./LICENSE).
|
||||
|
||||
@@ -21,7 +21,7 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
|
||||
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
|
||||
>
|
||||
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
|
||||
|
||||
@@ -127,8 +127,8 @@ services:
|
||||
|
||||
默认账号:
|
||||
|
||||
* 用户名:`root`
|
||||
* 密码:`123456`
|
||||
* 用户名:`admin`
|
||||
* 密码:`12345678`
|
||||
|
||||
### 2. 安装 Agent
|
||||
|
||||
@@ -164,28 +164,6 @@ docker run -d --name openflare-agent --restart unless-stopped \
|
||||
|
||||

|
||||
|
||||
## 管理端与接口
|
||||
|
||||
管理端当前覆盖:
|
||||
|
||||
* 反代规则
|
||||
* 配置版本
|
||||
* 节点管理
|
||||
* 应用记录
|
||||
* TLS 证书
|
||||
* 域名管理
|
||||
* Pages 静态托管
|
||||
* WAF 规则组
|
||||
* 内网穿透(Tunnels)
|
||||
* Uptime Kuma 监控同步
|
||||
* SSO 登录配置
|
||||
* 用户管理
|
||||
* 设置
|
||||
* 版本更新
|
||||
* PoW 规则
|
||||
|
||||
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 64 KiB |
@@ -18,6 +18,28 @@ sidebar: false
|
||||
|
||||
## [unreleased]
|
||||
|
||||
## [v3.0.2] - 2026-06-30
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 PostgreSQL 自增主键序列在历史数据迁移(INSERT 指定显式 ID)后与实际数据不同步的问题,通过新增全局序列同步脚本一键重置所有相关表的自增计数器。
|
||||
|
||||
## [v3.0.1] - 2026-06-30
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增管理后台用户个人信息编辑与重置密码功能。
|
||||
- 新增用户列表邮箱列展示以及基于邮箱的搜索过滤。
|
||||
- 后端新增 `reset-passwd` 命令行工具,支持通过命令行直接重置用户密码。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复添加 DNS 账号时因直接传递 class static 方法作为 React Query 的 mutationFn 导致 JavaScript 丢失 `this` 上下文报错 `this.post is not a function` 的问题。
|
||||
- 修复侧边栏一级菜单项当前页面字体颜色被硬编码为 `#6366F1` 的问题,改用 CSS 主题变量 `text-sidebar-primary`,以保证在多主题系统下的色彩一致性。
|
||||
- 修复默认(Default)主题因遗漏声明 `destructive-foreground` 变量,导致删除按钮(如确认删除证书弹窗)在某些状态下渲染为黑底黑字而无法阅读的问题。
|
||||
- 修复 Cobra 命令行初始化注册逻辑,确保所有应用运行模式(All, API, Worker, Scheduler)都正确注册为 Cobra 子命令。
|
||||
- 优化系统设置页面的色彩定义,移除硬编码的 Indigo 靛蓝色以适配多主题切换。
|
||||
|
||||
## [v3.0.0] - 2026-06-27
|
||||
|
||||
### 升级与迁移注意事项
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose ps
|
||||
docker compose logs -f openflare
|
||||
```
|
||||
|
||||
首次访问 `http://localhost:3000`,默认账号为 `root` / `123456`。登录后请立即修改默认密码。
|
||||
首次访问 `http://localhost:3000`,默认账号为 `admin` / `12345678`。登录后请立即修改默认密码。
|
||||
|
||||
## 源码启动 Server
|
||||
|
||||
@@ -117,22 +117,6 @@ go run main.go all
|
||||
|
||||
Docker 部署是 Agent 推荐的部署方式。Docker 部署时直接运行 Agent 镜像,该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
|
||||
|
||||
> [!NOTE]
|
||||
> Agent 镜像已完成非 Root 安全加固,统一以普通用户 `openflare` 权限运行,通过内核 capabilities 授权(`cap_net_bind_service`)监听 80/443 特权端口,并自动重定向临时文件和 PID 路径至容器内 `/data` 目录以防止写入冲突。
|
||||
|
||||
挂载配置文件:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-v ./agent.json:/etc/openflare/agent.json:ro \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
使用环境变量:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
@@ -219,26 +203,3 @@ export LOG_LEVEL='info'
|
||||
默认情况下,Agent 在 HTTP 心跳成功后会尝试升级为 WebSocket。升级成功时,Server 发布或激活配置会立即通知 Agent;如果 WebSocket 无法建立或意外断开,Agent 会自动退回 HTTP 心跳同步。
|
||||
|
||||
WAF 地域规则依赖 Agent 本地 `GeoLite2-Country.mmdb`。Agent 启动时会在 `data_dir/etc/openflare/GeoLite2-Country.mmdb` 初始化内置数据库,并按配置周期尝试更新;更新失败只记录警告,不影响配置同步与 OpenResty reload。
|
||||
|
||||
## 升级与卸载
|
||||
|
||||
Server:
|
||||
|
||||
* Root 用户可在管理端顶栏检查并升级正式版。
|
||||
* 如需尝试 preview 版本,可手动检查对应发布。
|
||||
* 也可通过上传 Server 二进制的方式执行确认升级。
|
||||
|
||||
Agent:
|
||||
|
||||
* Agent 默认只跟随正式版自动更新。
|
||||
* Agent 自更新从 GitHub Release 拉取目标二进制,优先使用 Release API 的 `digest` 字段做 SHA-256 校验;仅当 digest 为空(历史 Release)时才回退读取同名 `.sha256` 侧车文件,校验通过后才替换本地可执行文件。
|
||||
* 安装脚本可重复执行,用于重装或升级 Agent。
|
||||
* preview 升级需要手动触发。
|
||||
|
||||
卸载 Agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
卸载脚本会停止 Agent、删除 systemd 服务和安装目录,不会删除本机 OpenResty。
|
||||
|
||||
@@ -345,7 +345,7 @@ Server 默认监听 `3000` 端口,启动成功后可以使用浏览器访问
|
||||
|
||||
| 用户名 | 密码 |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
| `admin` | `12345678` |
|
||||
|
||||
> [!WARNING]
|
||||
> 为了你的系统安全,首次登录后请立即前往个人设置页面修改默认密码。
|
||||
|
||||
@@ -134,7 +134,7 @@ http://localhost:3000
|
||||
|
||||
| 用户名 | 密码 |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
| `admin` | `12345678` |
|
||||
|
||||
> [!WARNING]
|
||||
> 为了你的系统安全,首次登录后请立即修改默认密码。
|
||||
|
||||
@@ -79,7 +79,7 @@ NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
|
||||
|
||||
## 默认账号无法登录
|
||||
|
||||
默认账号是 `root` / `123456`。首次登录后如果已经修改密码,应使用修改后的密码。
|
||||
默认账号是 `admin` / `12345678`。首次登录后如果已经修改密码,应使用修改后的密码。
|
||||
|
||||
排查步骤:
|
||||
|
||||
@@ -90,7 +90,7 @@ NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
|
||||
|
||||
### 应急重置管理员密码
|
||||
|
||||
如果忘记了 `root` 账户的密码,可以通过直接更新数据库中的密码哈希值将其重置为 `123456`(登录后请务必立即修改):
|
||||
如果忘记了 `admin` 账户的密码,可以通过直接更新数据库中的密码哈希值将其重置为 `12345678`(登录后请务必立即修改):
|
||||
|
||||
#### 1. 若使用 SQLite 数据库
|
||||
停止 Server 运行,使用 sqlite3 客户端打开数据库文件:
|
||||
@@ -99,16 +99,16 @@ sqlite3 /path/to/openflare.db
|
||||
```
|
||||
执行以下 SQL 语句:
|
||||
```sql
|
||||
UPDATE users SET password_hash = '$2a$10$wN9aE3zTz83rO7R1uKlhuehJtA3c604pX4Z12B/9.5c0X337t1L4m' WHERE username = 'root';
|
||||
UPDATE users SET password = '$2a$10$eXpE9i/6S3gPT94/G0mu0.B8ser66ARETFz5NWYSYcrQ4JmtSrMXu' WHERE username = 'admin';
|
||||
```
|
||||
输入 `.exit` 退出并重新启动 Server。
|
||||
|
||||
#### 2. 若使用 PostgreSQL 数据库
|
||||
通过您的数据库连接工具(如 psql、pgAdmin 或 DBeaver)连接到 PostgreSQL 实例,选择对应的 `openflare` 数据库,执行以下 SQL 语句:
|
||||
```sql
|
||||
UPDATE users SET password_hash = '$2a$10$wN9aE3zTz83rO7R1uKlhuehJtA3c604pX4Z12B/9.5c0X337t1L4m' WHERE username = 'root';
|
||||
UPDATE users SET password = '$2a$10$eXpE9i/6S3gPT94/G0mu0.B8ser66ARETFz5NWYSYcrQ4JmtSrMXu' WHERE username = 'admin';
|
||||
```
|
||||
执行成功后即可使用默认密码 `123456` 重新登录管理后台。
|
||||
执行成功后即可使用默认密码 `12345678` 重新登录管理后台。
|
||||
|
||||
## Agent 无法注册或一直离线
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ export function EventsTab() {
|
||||
<Switch
|
||||
checked={event.enabled}
|
||||
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
className="scale-75"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -324,7 +324,7 @@ export function SettingsTab() {
|
||||
}
|
||||
})
|
||||
}}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
className="scale-75"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function AdminSettingsPageClient() {
|
||||
if (loading || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Loader2} from "lucide-react"
|
||||
|
||||
interface EditUserForm {
|
||||
nickname: string
|
||||
email: string
|
||||
is_admin: boolean
|
||||
password?: string
|
||||
}
|
||||
|
||||
export function EditUserModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: AdminUser | null
|
||||
}) {
|
||||
const { updateUser, deleteUser, getUserDetail } = useAdminUsers()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
const isSelf = currentUser && user && currentUser.id.toString() === user.id.toString()
|
||||
|
||||
const [form, setForm] = useState<EditUserForm>({
|
||||
nickname: "",
|
||||
email: "",
|
||||
is_admin: false,
|
||||
password: "",
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (isOpen && user) {
|
||||
setForm({
|
||||
nickname: user.nickname || "",
|
||||
email: user.email || "",
|
||||
is_admin: user.is_admin || false,
|
||||
password: "",
|
||||
})
|
||||
setErrors({})
|
||||
|
||||
getUserDetail(user.id)
|
||||
.then((detail) => {
|
||||
if (active && detail) {
|
||||
setForm({
|
||||
nickname: detail.nickname || "",
|
||||
email: detail.email || "",
|
||||
is_admin: detail.is_admin || false,
|
||||
password: "",
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore fetching error
|
||||
})
|
||||
} else {
|
||||
setSaving(false)
|
||||
}
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isOpen, user, getUserDetail])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = "邮箱不能为空"
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = "邮箱格式不正确"
|
||||
}
|
||||
|
||||
if (form.password && form.password.length < 8) {
|
||||
newErrors.password = "密码长度不能少于 8 位"
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!user || !validate()) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateUser(user.id, {
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
is_admin: form.is_admin,
|
||||
password: form.password?.trim() || undefined,
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
// Errors are handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await deleteUser(user)
|
||||
onClose()
|
||||
} catch {
|
||||
// Errors are handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改用户档案、授予/撤销管理权限或重置其密码。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">用户名 (不可修改)</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={user.username}
|
||||
disabled
|
||||
className="bg-muted/50 cursor-not-allowed select-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nickname">昵称 (选填)</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">重置密码 (选填)</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="留空表示保持当前密码,输入则重置 (至少 8 位)"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
|
||||
<div>
|
||||
<div className="font-medium text-sm">管理员权限</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isSelf ? "不能撤销当前登录用户的管理员权限。" : "开启后此账号将拥有后台管理权限。"}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.is_admin}
|
||||
disabled={!!isSelf || saving}
|
||||
onCheckedChange={async (checked) => {
|
||||
setForm((prev) => ({ ...prev, is_admin: checked }))
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateUser(user.id, {
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
is_admin: checked,
|
||||
})
|
||||
} catch {
|
||||
setForm((prev) => ({ ...prev, is_admin: !checked }))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t mt-2">
|
||||
<div>
|
||||
{!user.is_admin && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
删除用户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" type="button" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} variant="secondary">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {user.nickname || user.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, UserCheck,} from "lucide-react"
|
||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, UserCheck,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {cn, formatDateTime} from "@/lib/utils"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
selectedUser: AdminUser | null
|
||||
@@ -16,7 +16,6 @@ interface UserDetailSheetProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
detailLoading: boolean
|
||||
onStatusToggle: (user: AdminUser) => Promise<void>
|
||||
onDeleteTarget: (user: AdminUser) => void
|
||||
}
|
||||
|
||||
export function UserDetailSheet({
|
||||
@@ -25,7 +24,6 @@ export function UserDetailSheet({
|
||||
onOpenChange,
|
||||
detailLoading,
|
||||
onStatusToggle,
|
||||
onDeleteTarget,
|
||||
}: UserDetailSheetProps) {
|
||||
|
||||
const displayValue = (value?: string) => value && value.trim() ? value : "-"
|
||||
@@ -56,7 +54,7 @@ export function UserDetailSheet({
|
||||
UID: {selectedUser.id}
|
||||
</Badge>
|
||||
{selectedUser.is_admin && (
|
||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
|
||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
@@ -156,15 +154,10 @@ export function UserDetailSheet({
|
||||
</div>
|
||||
|
||||
{!selectedUser.is_admin && (
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
|
||||
<Button
|
||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||
className={cn(
|
||||
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
|
||||
selectedUser.is_active
|
||||
? "bg-red-500 hover:bg-red-600 text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
className="w-full h-9 text-xs font-medium transition-all active:scale-[0.98]"
|
||||
onClick={() => onStatusToggle(selectedUser)}
|
||||
>
|
||||
{selectedUser.is_active ? (
|
||||
@@ -179,14 +172,6 @@ export function UserDetailSheet({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 text-xs font-medium"
|
||||
onClick={() => onDeleteTarget(selectedUser)}
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除用户
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,17 +18,19 @@ export function UserFilterBar() {
|
||||
pageSize,
|
||||
searchUserId,
|
||||
searchUsername,
|
||||
searchEmail,
|
||||
statusFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearchUserId,
|
||||
setSearchUsername,
|
||||
setSearchEmail,
|
||||
setStatusFilter,
|
||||
fetchUsers
|
||||
} = useAdminUsers()
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername || searchEmail)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
@@ -49,8 +51,8 @@ export function UserFilterBar() {
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
variant="default"
|
||||
className="text-[10px] h-3 px-1 rounded-full"
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
@@ -72,6 +74,12 @@ export function UserFilterBar() {
|
||||
value={searchUsername}
|
||||
onChange={(e) => setSearchUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||
placeholder="输入邮箱..."
|
||||
value={searchEmail}
|
||||
onChange={(e) => setSearchEmail(e.target.value)}
|
||||
/>
|
||||
{hasSearchFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -80,6 +88,7 @@ export function UserFilterBar() {
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setSearchEmail("")
|
||||
}}
|
||||
>
|
||||
清除
|
||||
@@ -105,8 +114,8 @@ export function UserFilterBar() {
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
variant="default"
|
||||
className="text-[10px] h-3 px-1 rounded-full"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
@@ -160,6 +169,7 @@ export function UserFilterBar() {
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setSearchEmail("")
|
||||
setStatusFilter('all')
|
||||
}}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -6,18 +6,8 @@ import {Switch} from "@/components/ui/switch"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {Eye, Layers, Loader2, Plus, Trash2, UserRound, UserX,} from "lucide-react"
|
||||
import {Eye, Layers, Pencil, Plus, UserRound, UserX,} 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 type {AdminUser} from "@/lib/services/admin"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
@@ -28,6 +18,7 @@ import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||
import {CreateUserModal} from "./components/create-user-modal"
|
||||
import {UserFilterBar} from "./components/user-filter-bar"
|
||||
import {UserDetailSheet} from "./components/user-detail-sheet"
|
||||
import {EditUserModal} from "./components/edit-user-modal"
|
||||
|
||||
export default function UsersPage() {
|
||||
const {
|
||||
@@ -36,16 +27,14 @@ export default function UsersPage() {
|
||||
error,
|
||||
fetchUsers,
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
deleteUser
|
||||
updateUserStatus
|
||||
} = 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)
|
||||
const [editTarget, setEditTarget] = useState<AdminUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
@@ -74,21 +63,7 @@ export default function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-4">
|
||||
@@ -125,7 +100,8 @@ export default function UsersPage() {
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[160px] 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="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||
@@ -163,6 +139,10 @@ export default function UsersPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||
{user.email || "-"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||
{formatDateTime(user.last_login_at)}
|
||||
</TableCell>
|
||||
@@ -181,7 +161,7 @@ export default function UsersPage() {
|
||||
checked={user.is_active}
|
||||
onCheckedChange={() => handleStatusToggle(user)}
|
||||
disabled={user.is_admin}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -201,23 +181,18 @@ export default function UsersPage() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!user.is_admin && (
|
||||
<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>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => setEditTarget(user)}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
编辑用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -235,27 +210,16 @@ export default function UsersPage() {
|
||||
onOpenChange={setDetailOpen}
|
||||
detailLoading={detailLoading}
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onDeleteTarget={setDeleteTarget}
|
||||
/>
|
||||
|
||||
{/* 删除确认警告弹窗 */}
|
||||
<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>
|
||||
{/* 编辑用户弹窗 */}
|
||||
<EditUserModal
|
||||
user={editTarget}
|
||||
isOpen={!!editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{/* 新建用户模态弹窗 */}
|
||||
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import {DnsAccountService} from '@/lib/services/openflare';
|
||||
import type {DnsAccountMutationPayload} from '@/lib/services/openflare';
|
||||
|
||||
import {getErrorMessage} from './website-utils';
|
||||
|
||||
@@ -52,7 +53,7 @@ export function DnsAccountCreateDialog({
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: DnsAccountService.create,
|
||||
mutationFn: (payload: DnsAccountMutationPayload) => DnsAccountService.create(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
||||
form.reset();
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace;
|
||||
--font-sans: var(--font-inter), 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'PingFang SC', 'Microsoft YaHei', monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -25,6 +25,7 @@
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
@@ -61,6 +62,7 @@
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(98.5% 0% 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
@@ -95,6 +97,7 @@
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(98.5% 0% 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
@@ -193,4 +196,4 @@
|
||||
80% { transform: rotate(-1deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {Metadata} from "next";
|
||||
import {Inter} from "next/font/google";
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {ThemeProvider} from "@/components/layout/theme-provider";
|
||||
import {CustomThemeProvider} from "@/lib/theme";
|
||||
@@ -10,6 +11,12 @@ import {SiteTitleUpdater} from "@/components/providers/title-updater";
|
||||
import {RobotsMeta} from "@/components/layout/robots-meta";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenFlare",
|
||||
description: "OpenFlare 边缘节点与反向代理管理平台",
|
||||
@@ -23,7 +30,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className="hide-scrollbar font-sans"
|
||||
className={`hide-scrollbar font-sans ${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
|
||||
@@ -194,7 +194,7 @@ export function AccessTokenMain() {
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{accessTokensQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (accessTokensQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
@@ -231,7 +231,7 @@ export function AccessTokenMain() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleCopyText(token.masked_token, token.id)}
|
||||
>
|
||||
{copiedId === token.id ? (
|
||||
@@ -245,7 +245,7 @@ export function AccessTokenMain() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleRotateToken(token.id, token.name)}
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
>
|
||||
@@ -305,7 +305,7 @@ export function AccessTokenMain() {
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0"
|
||||
className="rounded-xl border border-dashed focus:border-primary focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{user?.is_admin && (
|
||||
@@ -375,15 +375,15 @@ export function AccessTokenMain() {
|
||||
{newCreatedToken && (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 明文 Token 文本框 */}
|
||||
<div className="flex items-center gap-2 rounded-xl bg-indigo-500/5 border border-dashed border-indigo-500/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-indigo-600 font-semibold leading-relaxed">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed">
|
||||
{newCreatedToken.token}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/30 transition-colors"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors"
|
||||
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
|
||||
>
|
||||
{copiedId === 9999 ? (
|
||||
|
||||
@@ -88,7 +88,7 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<KeyRound className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -125,9 +125,9 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
</div>
|
||||
|
||||
{/* 当前白名单列表 */}
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-indigo-500" />
|
||||
<ShieldAlert className="size-4 text-primary" />
|
||||
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
|
||||
</div>
|
||||
|
||||
@@ -137,14 +137,14 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
<Badge
|
||||
key={type}
|
||||
variant="secondary"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 dark:bg-indigo-500/20 border border-indigo-500/20"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-primary/10 text-primary dark:bg-primary/20 border border-primary/20"
|
||||
>
|
||||
{availableTypes.find(t => t.value === type)?.label || type}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveType(type)}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
|
||||
className="rounded-full outline-hidden hover:bg-indigo-500/20 p-0.5 text-indigo-600 dark:text-indigo-400 cursor-pointer disabled:cursor-not-allowed"
|
||||
className="rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutList className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -141,11 +141,11 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5 flex-1 min-w-0 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500 shrink-0" />}
|
||||
{Icon && <Icon className="size-4 text-primary shrink-0" />}
|
||||
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
|
||||
{isReadOnly && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
|
||||
@@ -171,8 +171,8 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-3.5 rounded-lg border border-dashed border-indigo-500/20 bg-indigo-500/5 flex items-start gap-2.5">
|
||||
<Info className="size-4 text-indigo-500 shrink-0 mt-0.5" />
|
||||
<div className="p-3.5 rounded-lg border border-dashed border-primary/20 bg-primary/5 flex items-start gap-2.5">
|
||||
<Info className="size-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-semibold text-foreground">安全提示:</span>
|
||||
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ProfileMain() {
|
||||
>
|
||||
<Avatar className="size-20 md:size-24 border-2 border-primary/10 shadow-md">
|
||||
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
|
||||
<AvatarFallback className="text-2xl bg-indigo-600 text-white font-bold">
|
||||
<AvatarFallback className="text-2xl bg-primary text-primary-foreground font-bold">
|
||||
{(user.nickname || user.username).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -342,7 +342,7 @@ export function ProfileMain() {
|
||||
href={user.website.startsWith("http") ? user.website : `http://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:underline"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{user.website}
|
||||
</a>
|
||||
@@ -473,7 +473,7 @@ export function ProfileMain() {
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="border-b pb-4 flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Link2 className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -487,7 +487,7 @@ export function ProfileMain() {
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
|
||||
{externalAccountBindingsQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-4 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
@@ -534,7 +534,7 @@ export function ProfileMain() {
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
|
||||
{publicAuthSourcesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-4 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
@@ -543,16 +543,16 @@ export function ProfileMain() {
|
||||
key={source.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-8"
|
||||
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-primary/5 hover:text-primary hover:border-primary/30 transition-all duration-300 group h-8"
|
||||
onClick={() => {
|
||||
void bindSourceMutation.mutateAsync(source.name)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="size-3 text-muted-foreground group-hover:text-indigo-500" />
|
||||
<Link2 className="size-3 text-muted-foreground group-hover:text-primary" />
|
||||
<span>绑定 {source.display_name || source.name}</span>
|
||||
</div>
|
||||
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
|
||||
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-primary" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -583,7 +583,7 @@ export function ProfileMain() {
|
||||
>
|
||||
<Avatar className="size-20 border-2 border-primary/5 shadow-md">
|
||||
<AvatarImage src={avatarUrl} alt={nickname} />
|
||||
<AvatarFallback className="text-xl bg-indigo-600 text-white font-bold">
|
||||
<AvatarFallback className="text-xl bg-primary text-primary-foreground font-bold">
|
||||
{(nickname || "U").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -245,7 +245,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Settings className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -263,11 +263,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500" />}
|
||||
{Icon && <Icon className="size-4 text-primary" />}
|
||||
<span className="font-medium text-sm text-foreground">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">{item.description}</p>
|
||||
@@ -283,11 +283,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
|
||||
{/* 登录状态保持时间 (选择后立即更改) */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm md:col-span-2"
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm md:col-span-2"
|
||||
>
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 text-indigo-500" />
|
||||
<Clock className="size-4 text-primary" />
|
||||
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
|
||||
@@ -340,7 +340,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Globe className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -390,7 +390,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
|
||||
source.client_secret_configured
|
||||
? "bg-indigo-500/5 text-indigo-500 border-indigo-500/10"
|
||||
? "bg-primary/5 text-primary border-primary/10"
|
||||
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
|
||||
}`}>
|
||||
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
|
||||
@@ -407,7 +407,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedSource(source)
|
||||
setAuthSourceModalOpen(true)
|
||||
@@ -458,7 +458,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Shield className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -193,7 +193,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 text-primary">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -310,7 +310,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function TemplatesManager() {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -154,14 +154,14 @@ export function TemplatesManager() {
|
||||
{(templatesQuery.data ?? []).map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{tmpl.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
tmpl.is_system
|
||||
? "bg-indigo-500/10 text-indigo-500 border-indigo-500/20"
|
||||
? "bg-primary/10 text-primary border-primary/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{tmpl.is_system ? "系统内置" : "自定义"}
|
||||
@@ -171,7 +171,7 @@ export function TemplatesManager() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
标识符: <span className="font-mono text-indigo-500 bg-indigo-500/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
标识符: <span className="font-mono text-primary bg-primary/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
|
||||
</div>
|
||||
{tmpl.description && (
|
||||
@@ -185,7 +185,7 @@ export function TemplatesManager() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
onClick={() => handleOpenEdit(tmpl)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
|
||||
@@ -473,7 +473,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-bold data-[active=true]:text-[#6366F1] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-bold data-[active=true]:text-sidebar-primary data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import type {AdminUser, CreateUserRequest, ListUsersRequest} from "@/lib/services/admin"
|
||||
import type {AdminUser, CreateUserRequest, ListUsersRequest, UpdateUserRequest} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
|
||||
/** 用户列表查询参数 */
|
||||
@@ -12,6 +12,7 @@ export interface UserQueryParams {
|
||||
page_size: number
|
||||
user_id?: string
|
||||
username?: string
|
||||
email?: string
|
||||
status?: 'all' | 'active' | 'inactive'
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ interface AdminUsersContextState {
|
||||
pageSize: number
|
||||
searchUserId: string
|
||||
searchUsername: string
|
||||
searchEmail: string
|
||||
statusFilter: 'all' | 'active' | 'inactive'
|
||||
|
||||
// Actions
|
||||
@@ -34,6 +36,7 @@ interface AdminUsersContextState {
|
||||
setPageSize: (size: number) => void
|
||||
setSearchUserId: (userId: string) => void
|
||||
setSearchUsername: (username: string) => void
|
||||
setSearchEmail: (email: string) => void
|
||||
setStatusFilter: (status: 'all' | 'active' | 'inactive') => void
|
||||
|
||||
fetchUsers: (force?: boolean) => Promise<void>
|
||||
@@ -41,6 +44,7 @@ interface AdminUsersContextState {
|
||||
getUserDetail: (id: string) => Promise<AdminUser>
|
||||
updateUserStatus: (user: AdminUser) => Promise<void>
|
||||
createUser: (req: CreateUserRequest) => Promise<AdminUser>
|
||||
updateUser: (id: string, req: UpdateUserRequest) => Promise<void>
|
||||
deleteUser: (user: AdminUser) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -60,9 +64,11 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [searchUserId, setSearchUserId] = useState("")
|
||||
const [searchUsername, setSearchUsername] = useState("")
|
||||
const [searchEmail, setSearchEmail] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||
const [debouncedSearchUserId, setDebouncedSearchUserId] = useState("")
|
||||
const [debouncedSearchUsername, setDebouncedSearchUsername] = useState("")
|
||||
const [debouncedSearchEmail, setDebouncedSearchEmail] = useState("")
|
||||
|
||||
// Cache
|
||||
const cacheRef = useRef<Record<string, { data: AdminUser[], total: number, timestamp: number }>>({})
|
||||
@@ -73,12 +79,17 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchUserId(searchUserId)
|
||||
setDebouncedSearchUsername(searchUsername)
|
||||
if (searchUserId !== debouncedSearchUserId || searchUsername !== debouncedSearchUsername) {
|
||||
setDebouncedSearchEmail(searchEmail)
|
||||
if (
|
||||
searchUserId !== debouncedSearchUserId ||
|
||||
searchUsername !== debouncedSearchUsername ||
|
||||
searchEmail !== debouncedSearchEmail
|
||||
) {
|
||||
setPage(1) // Reset to page 1 on search change
|
||||
}
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchUserId, searchUsername, debouncedSearchUserId, debouncedSearchUsername])
|
||||
}, [searchUserId, searchUsername, searchEmail, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail])
|
||||
|
||||
const generateCacheKey = (params: UserQueryParams) => {
|
||||
return JSON.stringify(params)
|
||||
@@ -90,6 +101,7 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
page_size: pageSize,
|
||||
user_id: debouncedSearchUserId || undefined,
|
||||
username: debouncedSearchUsername || undefined,
|
||||
email: debouncedSearchEmail || undefined,
|
||||
status: statusFilter
|
||||
}
|
||||
|
||||
@@ -127,7 +139,8 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
page,
|
||||
page_size: pageSize,
|
||||
user_id: debouncedSearchUserId || undefined,
|
||||
username: debouncedSearchUsername || undefined
|
||||
username: debouncedSearchUsername || undefined,
|
||||
email: debouncedSearchEmail || undefined
|
||||
}
|
||||
|
||||
const response = await services.adminUser.listUsers(requestParams)
|
||||
@@ -160,7 +173,7 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [page, pageSize, debouncedSearchUserId, debouncedSearchUsername, statusFilter])
|
||||
}, [page, pageSize, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail, statusFilter])
|
||||
|
||||
// Removed auto-fetch useEffect. Consumer (UsersManager) should trigger fetch.
|
||||
|
||||
@@ -224,6 +237,20 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}
|
||||
|
||||
const updateUser = async (id: string, req: UpdateUserRequest) => {
|
||||
try {
|
||||
await services.adminUser.updateUser(id, req)
|
||||
setUsers(prev => prev.map(u =>
|
||||
u.id === id ? { ...u, ...req } : u
|
||||
))
|
||||
cacheRef.current = {}
|
||||
toast.success('更新用户信息成功')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '更新用户信息失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const value = {
|
||||
users,
|
||||
total,
|
||||
@@ -233,17 +260,20 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
pageSize,
|
||||
searchUserId,
|
||||
searchUsername,
|
||||
searchEmail,
|
||||
statusFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearchUserId,
|
||||
setSearchUsername,
|
||||
setSearchEmail,
|
||||
setStatusFilter,
|
||||
fetchUsers,
|
||||
refresh,
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export type {
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
UpdateUserRequest,
|
||||
SystemStatus,
|
||||
AppUpdateStatus,
|
||||
Schedule,
|
||||
@@ -40,4 +41,4 @@ export type {
|
||||
StorageDriver,
|
||||
StorageConfig,
|
||||
ObjectStorageConfig,
|
||||
} from './types';
|
||||
} from './types';
|
||||
|
||||
@@ -310,6 +310,8 @@ export interface ListUsersRequest {
|
||||
user_id?: string;
|
||||
/** 用户名前缀过滤(可选) */
|
||||
username?: string;
|
||||
/** 邮箱前缀过滤(可选) */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +350,20 @@ export interface CreateUserRequest {
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户请求参数
|
||||
*/
|
||||
export interface UpdateUserRequest {
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 邮箱 */
|
||||
email: string;
|
||||
/** 是否管理员 */
|
||||
is_admin?: boolean;
|
||||
/** 密码(可选重置) */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证源信息
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CreateUserRequest,
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserRequest,
|
||||
UpdateUserStatusRequest,
|
||||
} from './types';
|
||||
|
||||
@@ -29,4 +30,8 @@ export class AdminUserService extends BaseService {
|
||||
static async deleteUser(id: string): Promise<void> {
|
||||
return this.delete<void>(`/users/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateUser(id: string, request: UpdateUserRequest): Promise<void> {
|
||||
return this.put<void>(`/users/${id}`, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ export type {
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
SystemStatus,
|
||||
AppUpdateStatus,
|
||||
Schedule,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"accent": "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
"destructive": "oklch(0.577 0.245 27.325)",
|
||||
"destructive-foreground": "oklch(98.5% 0% 0)",
|
||||
"border": "oklch(0.92 0.004 286.32)",
|
||||
"input": "oklch(0.92 0.004 286.32)",
|
||||
"ring": "oklch(0.705 0.015 286.067)",
|
||||
@@ -53,6 +54,7 @@
|
||||
"accent": "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
"destructive": "oklch(0.704 0.191 22.216)",
|
||||
"destructive-foreground": "oklch(98.5% 0% 0)",
|
||||
"border": "oklch(1 0 0 / 10%)",
|
||||
"input": "oklch(1 0 0 / 15%)",
|
||||
"ring": "oklch(0.552 0.016 285.938)",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
@@ -61,6 +62,7 @@
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(98.5% 0% 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
@@ -95,6 +97,7 @@
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(98.5% 0% 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
package user
|
||||
|
||||
const (
|
||||
userNotFound = "用户不存在"
|
||||
cannotDisable = "不能禁用管理员用户"
|
||||
cannotDelete = "不能删除管理员用户"
|
||||
cannotDeleteSelf = "不能删除当前登录用户"
|
||||
updateUserFailed = "更新用户状态失败"
|
||||
deleteUserFailed = "删除用户失败"
|
||||
usernameExists = "用户名已存在"
|
||||
usernameRequired = "用户名不能为空"
|
||||
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
createUserFailed = "创建用户失败"
|
||||
emailRequired = "邮箱不能为空"
|
||||
emailExists = "邮箱已被注册"
|
||||
userNotFound = "用户不存在"
|
||||
cannotDisable = "不能禁用管理员用户"
|
||||
cannotDelete = "不能删除管理员用户"
|
||||
cannotDeleteSelf = "不能删除当前登录用户"
|
||||
updateUserFailed = "更新用户状态失败"
|
||||
deleteUserFailed = "删除用户失败"
|
||||
usernameExists = "用户名已存在"
|
||||
usernameRequired = "用户名不能为空"
|
||||
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
createUserFailed = "创建用户失败"
|
||||
emailRequired = "邮箱不能为空"
|
||||
emailExists = "邮箱已被注册"
|
||||
cannotRevokeSelfAdmin = "不能撤销当前登录用户的管理员权限"
|
||||
updateUserInfoFailed = "更新用户信息失败"
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ func listUsers(ctx context.Context, req listUsersRequest) (int64, []model.User,
|
||||
return repository.ListAdminUsers(ctx, repository.AdminUserListFilter{
|
||||
UserID: req.UserID,
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
Email: strings.TrimSpace(req.Email),
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
})
|
||||
@@ -132,3 +133,80 @@ func createUser(ctx context.Context, req createUserRequest) (model.User, error)
|
||||
}
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
type updateUserParam struct {
|
||||
ID uint64
|
||||
Nickname string
|
||||
Email string
|
||||
IsAdmin bool
|
||||
Password string
|
||||
}
|
||||
|
||||
func updateUser(ctx context.Context, currentUserID uint64, param updateUserParam) error {
|
||||
param.Nickname = strings.TrimSpace(param.Nickname)
|
||||
param.Email = strings.TrimSpace(param.Email)
|
||||
param.Password = strings.TrimSpace(param.Password)
|
||||
|
||||
if param.Email == "" {
|
||||
return errors.New(emailRequired)
|
||||
}
|
||||
|
||||
targetUser, err := repository.GetAdminUserDetail(ctx, param.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 不能撤销当前登录用户的管理员权限
|
||||
if currentUserID == param.ID && !param.IsAdmin && targetUser.IsAdmin {
|
||||
return errors.New(cannotRevokeSelfAdmin)
|
||||
}
|
||||
|
||||
// 如果修改了邮箱,检查邮箱是否被其他用户占用
|
||||
if targetUser.Email != param.Email {
|
||||
count, err := repository.CountUsersByEmail(ctx, param.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(emailExists)
|
||||
}
|
||||
}
|
||||
|
||||
// 密码强度校验(如果输入了新密码)
|
||||
if param.Password != "" && len(param.Password) < minPasswordLength {
|
||||
return errors.New(passwordTooShort)
|
||||
}
|
||||
|
||||
// 是否需要撤销 Token (重置密码或取消管理员)
|
||||
needRevokeTokens := (param.Password != "") || (targetUser.IsAdmin && !param.IsAdmin)
|
||||
var tokens []model.AccessToken
|
||||
if needRevokeTokens {
|
||||
_ = db.DB(ctx).Where("user_id = ?", param.ID).Find(&tokens).Error
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
targetUser.Nickname = param.Nickname
|
||||
if targetUser.Nickname == "" {
|
||||
targetUser.Nickname = targetUser.Username
|
||||
}
|
||||
targetUser.Email = param.Email
|
||||
targetUser.IsAdmin = param.IsAdmin
|
||||
|
||||
if param.Password != "" {
|
||||
if err := targetUser.SetEncryptedPassword(param.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 执行更新
|
||||
err = repository.UpdateUser(ctx, &targetUser)
|
||||
if err == nil {
|
||||
oauth.InvalidateCachedUser(ctx, param.ID)
|
||||
if needRevokeTokens {
|
||||
for _, token := range tokens {
|
||||
oauth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type listUsersRequest struct {
|
||||
PageSize int `form:"page_size" binding:"min=1,max=100"`
|
||||
UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"`
|
||||
Username string `form:"username"`
|
||||
Email string `form:"email"`
|
||||
}
|
||||
|
||||
type user struct {
|
||||
@@ -289,3 +290,59 @@ func CreateUser(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(toUser(newUser)))
|
||||
}
|
||||
|
||||
// updateUserRequest 更新用户信息请求
|
||||
type updateUserRequest struct {
|
||||
Nickname string `json:"nickname" binding:"max=64"`
|
||||
Email string `json:"email" binding:"required,email,max=255"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Password string `json:"password" binding:"omitempty,min=8,max=64"`
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户信息
|
||||
// @Summary 更新用户信息
|
||||
// @Description 更新指定用户的昵称、邮箱、管理员权限,并可选重置密码,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "用户 ID"
|
||||
// @Param request body user.updateUserRequest true "更新参数"
|
||||
// @Success 200 {object} response.Any{data=string} "更新成功"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 403 {object} response.Any "无管理员权限或尝试修改自身权限"
|
||||
// @Failure 404 {object} response.Any "用户不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/admin/users/{id} [put]
|
||||
func UpdateUser(c *gin.Context) {
|
||||
var req updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := parseUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
currUser, _ := oauth.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
err := updateUser(c.Request.Context(), currUser.ID, updateUserParam{
|
||||
ID: id,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
IsAdmin: req.IsAdmin,
|
||||
Password: req.Password,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if abortUserLogicError(c, err, userNotFound, []string{cannotRevokeSelfAdmin}, []string{emailRequired, emailExists, passwordTooShort}) {
|
||||
return
|
||||
}
|
||||
response.AbortInternal(c, updateUserInfoFailed)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/bootstrap"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/db/migrator"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
usernameFlag string
|
||||
passwordFlag string
|
||||
)
|
||||
|
||||
const (
|
||||
passwdCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
defaultPasswordLength = 16
|
||||
)
|
||||
|
||||
func generateRandomPassword(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, b := range bytes {
|
||||
bytes[i] = passwdCharset[int(b)%len(passwdCharset)]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
var resetPasswdCmd = &cobra.Command{
|
||||
Use: "reset-passwd",
|
||||
Short: "重置指定账号密码",
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
migrator.Migrate()
|
||||
},
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
ctx := context.Background()
|
||||
runBootstrap(bootstrap.Options{})
|
||||
|
||||
var username string
|
||||
if usernameFlag != "" {
|
||||
username = usernameFlag
|
||||
} else {
|
||||
fmt.Print("请输入用户名: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Fatalf("读取用户名失败: %v\n", err)
|
||||
}
|
||||
username = strings.TrimSpace(input)
|
||||
if username == "" {
|
||||
log.Fatal("用户名不能为空\n")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := repository.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Fatalf("错误: 用户 '%s' 不存在\n", username)
|
||||
}
|
||||
log.Fatalf("查询用户失败: %v\n", err)
|
||||
}
|
||||
|
||||
var password string
|
||||
if passwordFlag != "" {
|
||||
password = passwordFlag
|
||||
} else {
|
||||
password, err = generateRandomPassword(defaultPasswordLength)
|
||||
if err != nil {
|
||||
log.Fatalf("生成随机密码失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := user.SetEncryptedPassword(password); err != nil {
|
||||
log.Fatalf("加密密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&user).Update("password", user.Password).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate existing tokens
|
||||
var tokens []model.AccessToken
|
||||
if err := tx.Where("user_id = ?", user.ID).Find(&tokens).Error; err == nil {
|
||||
for _, token := range tokens {
|
||||
oauth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Where("user_id = ?", user.ID).Delete(&model.AccessToken{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("重置密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
oauth.InvalidateCachedUser(ctx, user.ID)
|
||||
|
||||
fmt.Println("成功重置密码!")
|
||||
fmt.Printf("用户名: %s\n", user.Username)
|
||||
fmt.Printf("新密码: %s\n", password)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
resetPasswdCmd.Flags().StringVar(&usernameFlag, "user", "", "重置密码的目标用户名")
|
||||
resetPasswdCmd.Flags().StringVar(&passwordFlag, "password", "", "新密码(若不指定,则自动生成随机密码)")
|
||||
rootCmd.AddCommand(resetPasswdCmd)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
)
|
||||
|
||||
func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
ID: 1001,
|
||||
Username: "testuser1",
|
||||
Nickname: "Test User 1",
|
||||
Email: "test1@example.com",
|
||||
IsActive: true,
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
_ = user.SetEncryptedPassword("oldpassword")
|
||||
if err := dbConn.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Create access token to test invalidation/deletion
|
||||
token := model.AccessToken{
|
||||
ID: 1,
|
||||
UserID: user.ID,
|
||||
Name: "testtoken",
|
||||
TokenHash: "somehash",
|
||||
MaskedToken: "some...",
|
||||
}
|
||||
if err := dbConn.Create(&token).Error; err != nil {
|
||||
t.Fatalf("failed to create access token: %v", err)
|
||||
}
|
||||
|
||||
// Override PreRun to bypass goose migrations in unit tests
|
||||
oldPreRun := resetPasswdCmd.PreRun
|
||||
resetPasswdCmd.PreRun = nil
|
||||
defer func() { resetPasswdCmd.PreRun = oldPreRun }()
|
||||
|
||||
// Execute command with args
|
||||
rootCmd.SetArgs([]string{"reset-passwd", "--user", "testuser1", "--password", "newpassword123"})
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := rootCmd.Execute()
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("command execute failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("成功重置密码!")) {
|
||||
t.Errorf("expected output to contain success message, got: %s", output)
|
||||
}
|
||||
|
||||
// Verify password in DB
|
||||
var dbUser model.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
if !dbUser.CheckPassword("newpassword123") {
|
||||
t.Errorf("password was not updated correctly in DB")
|
||||
}
|
||||
|
||||
// Verify token deleted
|
||||
var count int64
|
||||
dbConn.Model(&model.AccessToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("expected access tokens to be deleted, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
ID: 1002,
|
||||
Username: "testuser2",
|
||||
Nickname: "Test User 2",
|
||||
Email: "test2@example.com",
|
||||
IsActive: true,
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
_ = user.SetEncryptedPassword("oldpassword")
|
||||
if err := dbConn.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Override PreRun
|
||||
oldPreRun := resetPasswdCmd.PreRun
|
||||
resetPasswdCmd.PreRun = nil
|
||||
defer func() { resetPasswdCmd.PreRun = oldPreRun }()
|
||||
|
||||
// Reset flags
|
||||
usernameFlag = ""
|
||||
passwordFlag = ""
|
||||
|
||||
// Execute command with args (no --password)
|
||||
rootCmd.SetArgs([]string{"reset-passwd", "--user", "testuser2"})
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := rootCmd.Execute()
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("command execute failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("成功重置密码!")) {
|
||||
t.Errorf("expected output to contain success message, got: %s", output)
|
||||
}
|
||||
|
||||
// Verify password in DB (should be updated and not equal to old one)
|
||||
var dbUser model.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
if dbUser.CheckPassword("oldpassword") {
|
||||
t.Errorf("expected password to change, but it matches the old one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
ID: 1003,
|
||||
Username: "testuser3",
|
||||
Nickname: "Test User 3",
|
||||
Email: "test3@example.com",
|
||||
IsActive: true,
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
_ = user.SetEncryptedPassword("oldpassword")
|
||||
if err := dbConn.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Override PreRun
|
||||
oldPreRun := resetPasswdCmd.PreRun
|
||||
resetPasswdCmd.PreRun = nil
|
||||
defer func() { resetPasswdCmd.PreRun = oldPreRun }()
|
||||
|
||||
// Mock stdin
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer inR.Close()
|
||||
defer inW.Close()
|
||||
|
||||
oldStdin := os.Stdin
|
||||
os.Stdin = inR
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
// Write username to stdin
|
||||
_, _ = inW.WriteString("testuser3\n")
|
||||
inW.Close()
|
||||
|
||||
// Reset flags
|
||||
usernameFlag = ""
|
||||
passwordFlag = ""
|
||||
rootCmd.SetArgs([]string{"reset-passwd"})
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = rootCmd.Execute()
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("command execute failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("成功重置密码!")) {
|
||||
t.Errorf("expected output to contain success message, got: %s", output)
|
||||
}
|
||||
|
||||
// Verify user password changed in DB
|
||||
var dbUser model.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
if dbUser.CheckPassword("oldpassword") {
|
||||
t.Errorf("expected password to change, but it matches the old one")
|
||||
}
|
||||
}
|
||||
+14
-17
@@ -47,23 +47,7 @@ var rootCmd = &cobra.Command{
|
||||
},
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
// 无参数时默认以融合模式启动所有服务
|
||||
if len(args) == 0 {
|
||||
allCmd.Run(allCmd, args)
|
||||
return
|
||||
}
|
||||
appMode := args[0]
|
||||
switch appMode {
|
||||
case "api":
|
||||
apiCmd.Run(apiCmd, args)
|
||||
case "scheduler":
|
||||
schedulerCmd.Run(schedulerCmd, args)
|
||||
case "worker":
|
||||
workerCmd.Run(workerCmd, args)
|
||||
case "all":
|
||||
allCmd.Run(allCmd, args)
|
||||
default:
|
||||
log.Fatal("[CMD] unknown app mode\n")
|
||||
}
|
||||
allCmd.Run(allCmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,6 +60,19 @@ func shutdownTraceProvider() {
|
||||
func init() {
|
||||
rootCmd.Version = buildinfo.Version
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
// 1. 为需要迁移的子命令动态绑定原先 rootCmd.PreRun 拥有的数据库迁移行为
|
||||
migratePreRun := func(_ *cobra.Command, _ []string) {
|
||||
migrator.Migrate()
|
||||
migrator.MigrateClickHouse()
|
||||
}
|
||||
allCmd.PreRun = migratePreRun
|
||||
apiCmd.PreRun = migratePreRun
|
||||
workerCmd.PreRun = migratePreRun
|
||||
schedulerCmd.PreRun = migratePreRun
|
||||
|
||||
// 2. 集中将这些命令注册为真正的子命令,以解决 Cobra 的 unknown command 校验限制
|
||||
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd)
|
||||
}
|
||||
|
||||
// Execute 执行根命令
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
SELECT setval(pg_get_serial_sequence('w_users', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM w_users), 0), 1), COALESCE((SELECT MAX(id) FROM w_users), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('w_auth_sources', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM w_auth_sources), 0), 1), COALESCE((SELECT MAX(id) FROM w_auth_sources), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('w_external_accounts', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM w_external_accounts), 0), 1), COALESCE((SELECT MAX(id) FROM w_external_accounts), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_origins', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_origins), 0), 1), COALESCE((SELECT MAX(id) FROM of_origins), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_apply_logs', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_apply_logs), 0), 1), COALESCE((SELECT MAX(id) FROM of_apply_logs), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_proxy_routes', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_proxy_routes), 0), 1), COALESCE((SELECT MAX(id) FROM of_proxy_routes), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_nodes', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_nodes), 0), 1), COALESCE((SELECT MAX(id) FROM of_nodes), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_waf_rule_groups', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_rule_groups), 0), 1), COALESCE((SELECT MAX(id) FROM of_waf_rule_groups), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_waf_ip_groups', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_ip_groups), 0), 1), COALESCE((SELECT MAX(id) FROM of_waf_ip_groups), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_tls_certificates', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_tls_certificates), 0), 1), COALESCE((SELECT MAX(id) FROM of_tls_certificates), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_managed_domains', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_managed_domains), 0), 1), COALESCE((SELECT MAX(id) FROM of_managed_domains), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_dns_accounts', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_dns_accounts), 0), 1), COALESCE((SELECT MAX(id) FROM of_dns_accounts), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_acme_accounts', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_acme_accounts), 0), 1), COALESCE((SELECT MAX(id) FROM of_acme_accounts), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_pages_projects', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_pages_projects), 0), 1), COALESCE((SELECT MAX(id) FROM of_pages_projects), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_pages_deployments', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_pages_deployments), 0), 1), COALESCE((SELECT MAX(id) FROM of_pages_deployments), 0) > 0);
|
||||
SELECT setval(pg_get_serial_sequence('of_pages_deployment_files', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_pages_deployment_files), 0), 1), COALESCE((SELECT MAX(id) FROM of_pages_deployment_files), 0) > 0);
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
SELECT 1;
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
@@ -55,6 +55,7 @@ func GetFirstAdminUser(ctx context.Context) (model.User, error) {
|
||||
type AdminUserListFilter struct {
|
||||
UserID *uint64
|
||||
Username string
|
||||
Email string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
@@ -68,6 +69,9 @@ func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []m
|
||||
if filter.Username != "" {
|
||||
query = query.Where("username LIKE ?", filter.Username+"%")
|
||||
}
|
||||
if filter.Email != "" {
|
||||
query = query.Where("email LIKE ?", filter.Email+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -77,7 +81,7 @@ func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []m
|
||||
var users []model.User
|
||||
offset := (filter.Page - 1) * filter.PageSize
|
||||
if err := query.
|
||||
Select("id, username, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at").
|
||||
Select("id, username, nickname, email, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at").
|
||||
Order("id ASC").
|
||||
Offset(offset).
|
||||
Limit(filter.PageSize).
|
||||
@@ -170,3 +174,8 @@ func ListUsersByIDs(ctx context.Context, ids []uint64) ([]model.User, error) {
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates all fields of an existing user.
|
||||
func UpdateUser(ctx context.Context, user *model.User) error {
|
||||
return db.DB(ctx).Save(user).Error
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ func registerAdminIAMRoutes(adminRouter *gin.RouterGroup) {
|
||||
users.POST("", admin_user.CreateUser)
|
||||
users.GET("/:id", admin_user.GetUser)
|
||||
users.PUT("/:id/status", admin_user.UpdateUserStatus)
|
||||
users.PUT("/:id", admin_user.UpdateUser)
|
||||
users.DELETE("/:id", admin_user.DeleteUser)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user