mirror of
https://github.com/beilunyang/moemail.git
synced 2026-09-07 08:16:37 +08:00
feat: Implement OpenAPI with API Key authentication and role-based access control
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
<a href="#邮箱域名配置">邮箱域名配置</a> •
|
<a href="#邮箱域名配置">邮箱域名配置</a> •
|
||||||
<a href="#权限系统">权限系统</a> •
|
<a href="#权限系统">权限系统</a> •
|
||||||
<a href="#Webhook 集成">Webhook 集成</a> •
|
<a href="#Webhook 集成">Webhook 集成</a> •
|
||||||
|
<a href="#OpenAPI">OpenAPI</a> •
|
||||||
<a href="#环境变量">环境变量</a> •
|
<a href="#环境变量">环境变量</a> •
|
||||||
<a href="#Github OAuth App 配置">Github OAuth App 配置</a> •
|
<a href="#Github OAuth App 配置">Github OAuth App 配置</a> •
|
||||||
<a href="#贡献">贡献</a> •
|
<a href="#贡献">贡献</a> •
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
|
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
|
||||||
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
|
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
|
||||||
- 🛡️ **权限系统**:支持基于角色的权限控制系统
|
- 🛡️ **权限系统**:支持基于角色的权限控制系统
|
||||||
|
- 🔑 **OpenAPI**:支持通过 API Key 访问 OpenAPI
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -276,48 +278,39 @@ pnpm deploy:cleanup
|
|||||||
|
|
||||||
本项目采用基于角色的权限控制系统(RBAC)。
|
本项目采用基于角色的权限控制系统(RBAC)。
|
||||||
|
|
||||||
### 权限配置
|
### 角色配置
|
||||||
|
|
||||||
新用户默认角色由皇帝在个人中心的网站设置中配置:
|
新用户默认角色由皇帝在个人中心的网站设置中配置:
|
||||||
|
- 公爵:新用户将获得临时邮箱、Webhook 配置权限以及 API Key 管理权限
|
||||||
- 骑士:新用户将获得临时邮箱和 Webhook 配置权限
|
- 骑士:新用户将获得临时邮箱和 Webhook 配置权限
|
||||||
- 平民:新用户无任何权限,需要等待皇帝册封为骑士
|
- 平民:新用户无任何权限,需要等待皇帝册封为骑士或公爵
|
||||||
|
|
||||||
### 角色管理
|
|
||||||
|
|
||||||
1. **册封骑士**
|
|
||||||
- 皇帝可以在个人中心的角色管理面板中册封骑士
|
|
||||||
- 通过用户邮箱查找并册封
|
|
||||||
- 平民被册封后将获得临时邮箱和 Webhook 配置权限
|
|
||||||
- 不能册封已经是骑士的用户
|
|
||||||
- 不能册封皇帝
|
|
||||||
|
|
||||||
2. **贬为平民**
|
|
||||||
- 皇帝可以将骑士贬为平民
|
|
||||||
- 被贬为平民后将失去所有权限
|
|
||||||
- 不能贬低已经是平民的用户
|
|
||||||
- 不能贬低皇帝
|
|
||||||
|
|
||||||
### 角色等级
|
### 角色等级
|
||||||
|
|
||||||
系统包含三个角色等级:
|
系统包含四个角色等级:
|
||||||
|
|
||||||
1. **皇帝(Emperor)**
|
1. **皇帝(Emperor)**
|
||||||
- 网站所有者
|
- 网站所有者
|
||||||
- 拥有所有权限
|
- 拥有所有权限
|
||||||
- 可以配置新用户默认角色
|
- 每个站点只能有一个皇帝
|
||||||
- 可以册封骑士或将骑士贬为平民
|
|
||||||
- 每个站点仅允许一位皇帝
|
|
||||||
|
|
||||||
2. **骑士(Knight)**
|
2. **公爵(Duke)**
|
||||||
|
- 超级用户
|
||||||
|
- 可以使用临时邮箱功能
|
||||||
|
- 可以配置 Webhook
|
||||||
|
- 可以使用创建 API Key 调用 OpenAPI
|
||||||
|
- 可以被皇帝贬为骑士或平民
|
||||||
|
|
||||||
|
3. **骑士(Knight)**
|
||||||
- 高级用户
|
- 高级用户
|
||||||
- 可以使用临时邮箱功能
|
- 可以使用临时邮箱功能
|
||||||
- 可以配置 Webhook
|
- 可以配置 Webhook
|
||||||
- 可以被皇帝贬为平民
|
- 可以被皇帝贬为平民或册封为公爵
|
||||||
|
|
||||||
3. **平民(Civilian)**
|
3. **平民(Civilian)**
|
||||||
- 普通用户
|
- 普通用户
|
||||||
- 无任何权限
|
- 无任何权限
|
||||||
- 可以被皇帝册封为骑士
|
- 可以被皇帝册封为骑士或者公爵
|
||||||
|
|
||||||
### 角色升级
|
### 角色升级
|
||||||
|
|
||||||
@@ -325,9 +318,16 @@ pnpm deploy:cleanup
|
|||||||
- 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝,即网站所有者
|
- 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝,即网站所有者
|
||||||
- 站点已有皇帝后,无法再提升其他用户为皇帝
|
- 站点已有皇帝后,无法再提升其他用户为皇帝
|
||||||
|
|
||||||
2. **成为骑士**
|
2. **角色变更**
|
||||||
- 皇帝在个人中心页面对平民进行册封
|
- 皇帝可以在个人中心页面将其他用户设为公爵、骑士或平民
|
||||||
- 或由皇帝设置新用户默认为骑士角色
|
|
||||||
|
### 权限说明
|
||||||
|
|
||||||
|
- **邮箱管理**:创建和管理临时邮箱
|
||||||
|
- **Webhook 管理**:配置邮件通知的 Webhook
|
||||||
|
- **API Key 管理**:创建和管理 API 访问密钥
|
||||||
|
- **用户管理**:升降用户角色
|
||||||
|
- **系统配置**:管理系统全局设置
|
||||||
|
|
||||||
|
|
||||||
## Webhook 集成
|
## Webhook 集成
|
||||||
@@ -380,6 +380,90 @@ pnpx cloudflared tunnel --url http://localhost:3001
|
|||||||
- Webhook 接口应在 10 秒内响应
|
- Webhook 接口应在 10 秒内响应
|
||||||
- 非 2xx 响应码会触发重试
|
- 非 2xx 响应码会触发重试
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
本项目提供了 OpenAPI 接口,支持通过 API Key 进行访问。API Key 可以在个人中心页面创建(需要是公爵或皇帝角色)。
|
||||||
|
|
||||||
|
### 使用 API Key
|
||||||
|
|
||||||
|
在请求头中添加 API Key:
|
||||||
|
```http
|
||||||
|
X-API-Key: YOUR_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 接口
|
||||||
|
|
||||||
|
#### 创建临时邮箱
|
||||||
|
```http
|
||||||
|
POST /api/emails/generate
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"expiryTime": 3600000,
|
||||||
|
"domain": "moemail.app"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
参数说明:
|
||||||
|
- `name`: 邮箱前缀,可选
|
||||||
|
- `expiryTime`: 有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)
|
||||||
|
- `domain`: 邮箱域名,可通过 `/api/emails/domains` 获取可用域名列表
|
||||||
|
|
||||||
|
#### 获取邮箱列表
|
||||||
|
```http
|
||||||
|
GET /api/emails?cursor=xxx&limit=10
|
||||||
|
```
|
||||||
|
参数说明:
|
||||||
|
- `cursor`: 分页游标,可选
|
||||||
|
- `limit`: 每页数量,默认 10
|
||||||
|
|
||||||
|
#### 获取单个邮箱
|
||||||
|
```http
|
||||||
|
GET /api/emails/{emailId}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除邮箱
|
||||||
|
```http
|
||||||
|
DELETE /api/emails/{emailId}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 获取邮件列表
|
||||||
|
```http
|
||||||
|
GET /api/emails/{emailId}/messages?cursor=xxx&limit=10
|
||||||
|
```
|
||||||
|
参数说明:
|
||||||
|
- `cursor`: 分页游标,可选
|
||||||
|
- `limit`: 每页数量,默认 10
|
||||||
|
|
||||||
|
#### 获取单封邮件
|
||||||
|
```http
|
||||||
|
GET /api/emails/{emailId}/messages/{messageId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
使用 curl 创建临时邮箱:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-domain.com/api/emails/generate \
|
||||||
|
-H "X-API-Key: YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "test",
|
||||||
|
"expiryTime": 3600000,
|
||||||
|
"domain": "moemail.app"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 JavaScript 获取邮件列表:
|
||||||
|
```javascript
|
||||||
|
const res = await fetch('https://your-domain.com/api/emails/your-email-id/messages', {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': 'YOUR_API_KEY'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
```
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
本项目使用以下环境变量:
|
本项目使用以下环境变量:
|
||||||
@@ -414,9 +498,9 @@ pnpx cloudflared tunnel --url http://localhost:3001
|
|||||||
本项目采用 [MIT](LICENSE) 许可证
|
本项目采用 [MIT](LICENSE) 许可证
|
||||||
|
|
||||||
## 交流群
|
## 交流群
|
||||||
<img src="https://pic.otaku.ren/20241224/AQADoMExG_K0WVd-.jpg" style="width: 400px;"/>
|
<img src="https://pic.otaku.ren/20250210/AQADOMUxG7BRUFV-.jpg" style="width: 400px;"/>
|
||||||
<br />
|
<br />
|
||||||
如二维码失效,请添加我的个人微信(hansenones),并备注 “MoeMail” 加入微信交流群
|
如二维码失效,请添加我的个人微信(hansenones),并备注 "MoeMail" 加入微信交流群
|
||||||
|
|
||||||
## 支持
|
## 支持
|
||||||
|
|
||||||
@@ -428,4 +512,4 @@ pnpx cloudflared tunnel --url http://localhost:3001
|
|||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://www.buymeacoffee.com/beilunyang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="width: 400px;" ></a>
|
<a href="https://www.buymeacoffee.com/beilunyang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="width: 400px;" ></a>
|
||||||
|
如二维码失效,请添加我的个人微信(hansenones),并备注 “MoeMail” 加入微信交流群
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const env = getRequestContext().env
|
||||||
|
const adminContact = await env.SITE_CONFIG.get("ADMIN_CONTACT")
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
adminContact: adminContact || ""
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { createDb } from "@/lib/db"
|
||||||
|
import { apiKeys } from "@/lib/schema"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { checkPermission } from "@/lib/auth"
|
||||||
|
import { PERMISSIONS } from "@/lib/permissions"
|
||||||
|
import { eq, and } from "drizzle-orm"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||||
|
if (!hasPermission) {
|
||||||
|
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const db = createDb()
|
||||||
|
const session = await auth()
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const result = await db.delete(apiKeys)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeys.id, id),
|
||||||
|
eq(apiKeys.userId, session!.user.id!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!result.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "API Key 不存在或无权删除" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete API key:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "删除 API Key 失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||||
|
if (!hasPermission) {
|
||||||
|
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const { enabled } = await request.json() as { enabled: boolean }
|
||||||
|
const db = createDb()
|
||||||
|
|
||||||
|
const result = await db.update(apiKeys)
|
||||||
|
.set({ enabled })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeys.id, id),
|
||||||
|
eq(apiKeys.userId, session!.user.id!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!result.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "API Key 不存在或无权更新" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update API key:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "更新 API Key 失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { createDb } from "@/lib/db"
|
||||||
|
import { apiKeys } from "@/lib/schema"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { checkPermission } from "@/lib/auth"
|
||||||
|
import { PERMISSIONS } from "@/lib/permissions"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||||
|
if (!hasPermission) {
|
||||||
|
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth()
|
||||||
|
try {
|
||||||
|
const db = createDb()
|
||||||
|
const keys = await db.query.apiKeys.findMany({
|
||||||
|
where: eq(apiKeys.userId, session!.user.id!),
|
||||||
|
orderBy: desc(apiKeys.createdAt),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
apiKeys: keys.map(key => ({
|
||||||
|
...key,
|
||||||
|
key: undefined
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch API keys:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "获取 API Keys 失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||||
|
if (!hasPermission) {
|
||||||
|
return NextResponse.json({ error: "权限不足" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth()
|
||||||
|
try {
|
||||||
|
const { name } = await request.json() as { name: string }
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "名称不能为空" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `mk_${nanoid(32)}`
|
||||||
|
const db = createDb()
|
||||||
|
|
||||||
|
await db.insert(apiKeys).values({
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
userId: session!.user.id!,
|
||||||
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ key })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create API key:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "创建 API Key 失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-7
@@ -5,31 +5,35 @@ export const runtime = "edge"
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const env = getRequestContext().env
|
const env = getRequestContext().env
|
||||||
const [defaultRole, emailDomains] = await Promise.all([
|
const [defaultRole, emailDomains, adminContact] = await Promise.all([
|
||||||
env.SITE_CONFIG.get("DEFAULT_ROLE"),
|
env.SITE_CONFIG.get("DEFAULT_ROLE"),
|
||||||
env.SITE_CONFIG.get("EMAIL_DOMAINS")
|
env.SITE_CONFIG.get("EMAIL_DOMAINS"),
|
||||||
|
env.SITE_CONFIG.get("ADMIN_CONTACT")
|
||||||
])
|
])
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
defaultRole: defaultRole || ROLES.CIVILIAN,
|
defaultRole: defaultRole || ROLES.CIVILIAN,
|
||||||
emailDomains: emailDomains || ""
|
emailDomains: emailDomains || "",
|
||||||
|
adminContact: adminContact || ""
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { defaultRole, emailDomains } = await request.json() as {
|
const { defaultRole, emailDomains, adminContact } = await request.json() as {
|
||||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||||
emailDomains: string
|
emailDomains: string,
|
||||||
|
adminContact: string
|
||||||
}
|
}
|
||||||
|
|
||||||
if (![ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
||||||
return Response.json({ error: "无效的角色" }, { status: 400 })
|
return Response.json({ error: "无效的角色" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = getRequestContext().env
|
const env = getRequestContext().env
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
|
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
|
||||||
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains)
|
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains),
|
||||||
|
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact)
|
||||||
])
|
])
|
||||||
|
|
||||||
return Response.json({ success: true })
|
return Response.json({ success: true })
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createDb } from "@/lib/db"
|
import { createDb } from "@/lib/db"
|
||||||
import { messages } from "@/lib/schema"
|
import { messages, emails } from "@/lib/schema"
|
||||||
import { and, eq } from "drizzle-orm"
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { getUserId } from "@/lib/apiKey"
|
||||||
export const runtime = "edge"
|
export const runtime = "edge"
|
||||||
|
|
||||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string; messageId: string }> }) {
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string; messageId: string }> }) {
|
||||||
try {
|
try {
|
||||||
const { id, messageId } = await params
|
const { id, messageId } = await params
|
||||||
const db = createDb()
|
const db = createDb()
|
||||||
|
const userId = await getUserId()
|
||||||
|
|
||||||
|
const email = await db.query.emails.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(emails.id, id),
|
||||||
|
eq(emails.userId, userId!)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "无权限查看" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const message = await db.query.messages.findFirst({
|
const message = await db.query.messages.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(messages.id, messageId),
|
eq(messages.id, messageId),
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
|
||||||
import { createDb } from "@/lib/db"
|
import { createDb } from "@/lib/db"
|
||||||
import { emails, messages } from "@/lib/schema"
|
import { emails, messages } from "@/lib/schema"
|
||||||
import { eq, and, lt, or, sql } from "drizzle-orm"
|
import { eq, and, lt, or, sql } from "drizzle-orm"
|
||||||
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||||
|
import { getUserId } from "@/lib/apiKey"
|
||||||
export const runtime = "edge"
|
export const runtime = "edge"
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await auth()
|
const userId = await getUserId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = createDb()
|
const db = createDb()
|
||||||
@@ -19,7 +18,7 @@ export async function DELETE(
|
|||||||
const email = await db.query.emails.findFirst({
|
const email = await db.query.emails.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(emails.id, id),
|
eq(emails.id, id),
|
||||||
eq(emails.userId, session!.user!.id!)
|
eq(emails.userId, userId!)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,6 +57,22 @@ export async function GET(
|
|||||||
const db = createDb()
|
const db = createDb()
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
|
const userId = await getUserId()
|
||||||
|
|
||||||
|
const email = await db.query.emails.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(emails.id, id),
|
||||||
|
eq(emails.userId, userId!)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "无权限查看" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const baseConditions = eq(messages.emailId, id)
|
const baseConditions = eq(messages.emailId, id)
|
||||||
|
|
||||||
const totalResult = await db.select({ count: sql<number>`count(*)` })
|
const totalResult = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { nanoid } from "nanoid"
|
import { nanoid } from "nanoid"
|
||||||
import { auth } from "@/lib/auth"
|
|
||||||
import { createDb } from "@/lib/db"
|
import { createDb } from "@/lib/db"
|
||||||
import { emails } from "@/lib/schema"
|
import { emails } from "@/lib/schema"
|
||||||
import { eq, and, gt, sql } from "drizzle-orm"
|
import { eq, and, gt, sql } from "drizzle-orm"
|
||||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||||
import { EMAIL_CONFIG } from "@/config"
|
import { EMAIL_CONFIG } from "@/config"
|
||||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||||
|
import { getUserId } from "@/lib/apiKey"
|
||||||
export const runtime = "edge"
|
export const runtime = "edge"
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const db = createDb()
|
const db = createDb()
|
||||||
const session = await auth()
|
|
||||||
|
const userId = await getUserId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activeEmailsCount = await db
|
const activeEmailsCount = await db
|
||||||
@@ -20,7 +20,7 @@ export async function POST(request: Request) {
|
|||||||
.from(emails)
|
.from(emails)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(emails.userId, session!.user!.id!),
|
eq(emails.userId, userId!),
|
||||||
gt(emails.expiresAt, new Date())
|
gt(emails.expiresAt, new Date())
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -76,7 +76,7 @@ export async function POST(request: Request) {
|
|||||||
address,
|
address,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: expires,
|
expiresAt: expires,
|
||||||
userId: session!.user!.id
|
userId: userId!
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await db.insert(emails)
|
const result = await db.insert(emails)
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { auth } from "@/lib/auth"
|
|
||||||
import { createDb } from "@/lib/db"
|
import { createDb } from "@/lib/db"
|
||||||
import { and, eq, gt, lt, or, sql } from "drizzle-orm"
|
import { and, eq, gt, lt, or, sql } from "drizzle-orm"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { emails } from "@/lib/schema"
|
import { emails } from "@/lib/schema"
|
||||||
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||||
|
import { getUserId } from "@/lib/apiKey"
|
||||||
|
|
||||||
export const runtime = "edge"
|
export const runtime = "edge"
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const session = await auth()
|
const userId = await getUserId()
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const cursor = searchParams.get('cursor')
|
const cursor = searchParams.get('cursor')
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const baseConditions = and(
|
const baseConditions = and(
|
||||||
eq(emails.userId, session!.user!.id!),
|
eq(emails.userId, userId!),
|
||||||
gt(emails.expiresAt, new Date())
|
gt(emails.expiresAt, new Date())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { userId, roleName } = await request.json() as {
|
const { userId, roleName } = await request.json() as {
|
||||||
userId: string,
|
userId: string,
|
||||||
roleName: typeof ROLES.KNIGHT | typeof ROLES.CIVILIAN
|
roleName: typeof ROLES.DUKE | typeof ROLES.KNIGHT | typeof ROLES.CIVILIAN
|
||||||
};
|
};
|
||||||
if (!userId || !roleName) {
|
if (!userId || !roleName) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -19,7 +19,7 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (![ROLES.KNIGHT, ROLES.CIVILIAN].includes(roleName)) {
|
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(roleName)) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "角色不合法" },
|
{ error: "角色不合法" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -47,9 +47,11 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!targetRole) {
|
if (!targetRole) {
|
||||||
const description = roleName === ROLES.KNIGHT
|
const description = {
|
||||||
? "高级用户"
|
[ROLES.DUKE]: "超级用户",
|
||||||
: "普通用户";
|
[ROLES.KNIGHT]: "高级用户",
|
||||||
|
[ROLES.CIVILIAN]: "普通用户",
|
||||||
|
}[roleName];
|
||||||
|
|
||||||
const [newRole] = await db.insert(roles)
|
const [newRole] = await db.insert(roles)
|
||||||
.values({
|
.values({
|
||||||
@@ -64,7 +66,6 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: roleName === ROLES.KNIGHT ? "册封成功" : "贬为平民"
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to change user role:", error);
|
console.error("Failed to change user role:", error);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useAdminContact } from "@/hooks/use-admin-contact"
|
||||||
export function NoPermissionDialog() {
|
export function NoPermissionDialog() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { adminContact } = useAdminContact()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
|
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
|
||||||
@@ -12,7 +13,12 @@ export function NoPermissionDialog() {
|
|||||||
<div className="bg-background border-2 border-primary/20 rounded-lg p-6 md:p-12 shadow-lg">
|
<div className="bg-background border-2 border-primary/20 rounded-lg p-6 md:p-12 shadow-lg">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h1 className="text-xl md:text-2xl font-bold">权限不足</h1>
|
<h1 className="text-xl md:text-2xl font-bold">权限不足</h1>
|
||||||
<p className="text-sm md:text-base text-muted-foreground">你没有权限访问此页面,请联系网站皇帝授权</p>
|
<p className="text-sm md:text-base text-muted-foreground">你没有权限访问此页面,请联系网站管理员</p>
|
||||||
|
{
|
||||||
|
adminContact && (
|
||||||
|
<p className="text-sm md:text-base text-muted-foreground">管理员联系方式:{adminContact}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="mt-4 w-full md:w-auto"
|
className="mt-4 w-full md:w-auto"
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Key, Plus, Loader2, Copy, Trash2, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
DialogClose,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { useCopy } from "@/hooks/use-copy"
|
||||||
|
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||||
|
import { PERMISSIONS } from "@/lib/permissions"
|
||||||
|
import { useAdminContact } from "@/hooks/use-admin-contact"
|
||||||
|
|
||||||
|
type ApiKey = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
key: string
|
||||||
|
createdAt: string
|
||||||
|
expiresAt: string | null
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyPanel() {
|
||||||
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [newKeyName, setNewKeyName] = useState("")
|
||||||
|
const [newKey, setNewKey] = useState<string | null>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { copyToClipboard } = useCopy()
|
||||||
|
const [showExamples, setShowExamples] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const { checkPermission } = useRolePermission()
|
||||||
|
const canManageApiKey = checkPermission(PERMISSIONS.MANAGE_API_KEY)
|
||||||
|
|
||||||
|
const fetchApiKeys = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/api-keys")
|
||||||
|
if (!res.ok) throw new Error("获取 API Keys 失败")
|
||||||
|
const data = await res.json() as { apiKeys: ApiKey[] }
|
||||||
|
setApiKeys(data.apiKeys)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast({
|
||||||
|
title: "获取失败",
|
||||||
|
description: "获取 API Keys 列表失败",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canManageApiKey) {
|
||||||
|
fetchApiKeys()
|
||||||
|
}
|
||||||
|
}, [canManageApiKey])
|
||||||
|
|
||||||
|
const { adminContact } = useAdminContact()
|
||||||
|
|
||||||
|
const createApiKey = async () => {
|
||||||
|
if (!newKeyName.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/api-keys", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newKeyName })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("创建 API Key 失败")
|
||||||
|
|
||||||
|
const data = await res.json() as { key: string }
|
||||||
|
setNewKey(data.key)
|
||||||
|
fetchApiKeys()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "创建失败",
|
||||||
|
description: error instanceof Error ? error.message : "请稍后重试",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setNewKeyName("")
|
||||||
|
setNewKey(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleApiKey = async (id: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/api-keys/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("更新失败")
|
||||||
|
|
||||||
|
setApiKeys(keys =>
|
||||||
|
keys.map(key =>
|
||||||
|
key.id === id ? { ...key, enabled } : key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "更新失败",
|
||||||
|
description: "更新 API Key 状态失败",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteApiKey = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/api-keys/${id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("删除失败")
|
||||||
|
|
||||||
|
setApiKeys(keys => keys.filter(key => key.id !== id))
|
||||||
|
toast({
|
||||||
|
title: "删除成功",
|
||||||
|
description: "API Key 已删除"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "删除失败",
|
||||||
|
description: "删除 API Key 失败",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background rounded-lg border-2 border-primary/20 p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Key className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
canManageApiKey && (
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2" onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
创建 API Key
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{newKey ? "API Key 创建成功" : "创建新的 API Key"}
|
||||||
|
</DialogTitle>
|
||||||
|
{newKey && (
|
||||||
|
<DialogDescription className="text-destructive">
|
||||||
|
请立即保存此密钥,它只会显示一次且无法恢复
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!newKey ? (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>名称</Label>
|
||||||
|
<Input
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="为你的 API Key 起个名字"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newKey}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(newKey)}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDialogClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{newKey ? "完成" : "取消"}
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
{!newKey && (
|
||||||
|
<Button
|
||||||
|
onClick={createApiKey}
|
||||||
|
disabled={loading || !newKeyName.trim()}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"创建"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
!canManageApiKey ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
<p>需要公爵或更高权限才能管理 API Key</p>
|
||||||
|
<p className="mt-2">请联系网站管理员升级您的角色</p>
|
||||||
|
{
|
||||||
|
adminContact && (
|
||||||
|
<p className="mt-2">管理员联系方式:{adminContact}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 space-y-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||||
|
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : apiKeys.length === 0 ? (
|
||||||
|
<div className="text-center py-8 space-y-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||||
|
<Key className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">没有 API Keys</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
点击上方的"创建 API Key"按钮来创建你的第一个 API Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{apiKeys.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key.id}
|
||||||
|
className="flex items-center justify-between p-4 rounded-lg border bg-card"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">{key.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
创建于 {new Date(key.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={key.enabled}
|
||||||
|
onCheckedChange={(checked) => toggleApiKey(key.id, checked)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteApiKey(key.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setShowExamples(!showExamples)}
|
||||||
|
>
|
||||||
|
{showExamples ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
查看使用文档
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showExamples && (
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">生成临时邮箱</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(
|
||||||
|
`curl -X POST https://${window.location.host}/api/emails/generate \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"name": "test",
|
||||||
|
"expiryTime": 3600000,
|
||||||
|
"domain": "moemail.app"
|
||||||
|
}'`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||||
|
{`curl -X POST https://${window.location.host}/api/emails/generate \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"name": "test",
|
||||||
|
"expiryTime": 3600000,
|
||||||
|
"domain": "moemail.app"
|
||||||
|
}'`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">获取邮箱列表</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(
|
||||||
|
`curl https://${window.location.host}/api/emails?cursor=CURSOR \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||||
|
{`curl https://${window.location.host}/api/emails?cursor=CURSOR \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">获取邮件列表</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(
|
||||||
|
`curl https://${window.location.host}/api/emails/{emailId}/messages?cursor=CURSOR \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||||
|
{`curl https://${window.location.host}/api/emails/{emailId}/messages?cursor=CURSOR \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">获取单封邮件</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(
|
||||||
|
`curl https://${window.location.host}/api/emails/{emailId}/{messageId} \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||||
|
{`curl https://${window.location.host}/api/emails/{emailId}/{messageId} \\
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground mt-4">
|
||||||
|
<p>注意:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||||
|
<li>请将 YOUR_API_KEY 替换为你的实际 API Key</li>
|
||||||
|
<li>emailId 是邮箱的唯一标识符</li>
|
||||||
|
<li>messageId 是邮件的唯一标识符</li>
|
||||||
|
<li>expiryTime 是邮箱的有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)</li>
|
||||||
|
<li>domain 是邮箱域名,可通过 /api/emails/domains 获取可用域名列表</li>
|
||||||
|
<li>cursor 用于分页,从上一次请求的响应中获取 nextCursor</li>
|
||||||
|
<li>所有请求都需要包含 X-API-Key 请求头</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
export function ConfigPanel() {
|
export function ConfigPanel() {
|
||||||
const [defaultRole, setDefaultRole] = useState<string>("")
|
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||||
|
const [adminContact, setAdminContact] = useState<string>("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -42,7 +44,11 @@ export function ConfigPanel() {
|
|||||||
const res = await fetch("/api/config", {
|
const res = await fetch("/api/config", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ defaultRole, emailDomains }),
|
body: JSON.stringify({
|
||||||
|
defaultRole,
|
||||||
|
emailDomains,
|
||||||
|
adminContact
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) throw new Error("保存失败")
|
if (!res.ok) throw new Error("保存失败")
|
||||||
@@ -77,6 +83,7 @@ export function ConfigPanel() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value={ROLES.DUKE}>公爵</SelectItem>
|
||||||
<SelectItem value={ROLES.KNIGHT}>骑士</SelectItem>
|
<SelectItem value={ROLES.KNIGHT}>骑士</SelectItem>
|
||||||
<SelectItem value={ROLES.CIVILIAN}>平民</SelectItem>
|
<SelectItem value={ROLES.CIVILIAN}>平民</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -94,6 +101,17 @@ export function ConfigPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm">管理员联系方式:</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={adminContact}
|
||||||
|
onChange={(e) => setAdminContact(e.target.value)}
|
||||||
|
placeholder="如: 微信号、邮箱等"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { User } from "next-auth"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { signOut } from "next-auth/react"
|
import { signOut } from "next-auth/react"
|
||||||
import { Github, Mail, Settings, Crown, Sword, User2 } from "lucide-react"
|
import { Github, Mail, Settings, Crown, Sword, User2, Gem } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { WebhookConfig } from "./webhook-config"
|
import { WebhookConfig } from "./webhook-config"
|
||||||
import { PromotePanel } from "./promote-panel"
|
import { PromotePanel } from "./promote-panel"
|
||||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||||
import { PERMISSIONS } from "@/lib/permissions"
|
import { PERMISSIONS } from "@/lib/permissions"
|
||||||
import { ConfigPanel } from "./config-panel"
|
import { ConfigPanel } from "./config-panel"
|
||||||
|
import { ApiKeyPanel } from "./api-key-panel"
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
user: User
|
user: User
|
||||||
@@ -18,6 +19,7 @@ interface ProfileCardProps {
|
|||||||
|
|
||||||
const roleConfigs = {
|
const roleConfigs = {
|
||||||
emperor: { name: '皇帝', icon: Crown },
|
emperor: { name: '皇帝', icon: Crown },
|
||||||
|
duke: { name: '公爵', icon: Gem },
|
||||||
knight: { name: '骑士', icon: Sword },
|
knight: { name: '骑士', icon: Sword },
|
||||||
civilian: { name: '平民', icon: User2 },
|
civilian: { name: '平民', icon: User2 },
|
||||||
} as const
|
} as const
|
||||||
@@ -96,6 +98,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
|||||||
|
|
||||||
{canManageConfig && <ConfigPanel />}
|
{canManageConfig && <ConfigPanel />}
|
||||||
{canPromote && <PromotePanel />}
|
{canPromote && <PromotePanel />}
|
||||||
|
{canManageWebhook && <ApiKeyPanel />}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Sword, Loader2, UserMinus } from "lucide-react"
|
import { Gem, Sword, User2, Loader2 } from "lucide-react"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { ROLES } from "@/lib/permissions"
|
import { ROLES, Role } from "@/lib/permissions"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
|
const roleIcons = {
|
||||||
|
[ROLES.DUKE]: Gem,
|
||||||
|
[ROLES.KNIGHT]: Sword,
|
||||||
|
[ROLES.CIVILIAN]: User2,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const roleNames = {
|
||||||
|
[ROLES.DUKE]: "公爵",
|
||||||
|
[ROLES.KNIGHT]: "骑士",
|
||||||
|
[ROLES.CIVILIAN]: "平民",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type RoleWithoutEmperor = Exclude<Role, typeof ROLES.EMPEROR>
|
||||||
|
|
||||||
export function PromotePanel() {
|
export function PromotePanel() {
|
||||||
const [searchText, setSearchText] = useState("")
|
const [searchText, setSearchText] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [action, setAction] = useState<"promote" | "demote">("promote")
|
const [targetRole, setTargetRole] = useState<RoleWithoutEmperor>(ROLES.KNIGHT)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const handleAction = async () => {
|
const handleAction = async () => {
|
||||||
@@ -39,49 +60,42 @@ export function PromotePanel() {
|
|||||||
if (!data.user) {
|
if (!data.user) {
|
||||||
toast({
|
toast({
|
||||||
title: "未找到用户",
|
title: "未找到用户",
|
||||||
description: "请确认邮箱地址是否正确",
|
description: "请确认用户名或邮箱地址是否正确",
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "promote" && data.user.role === ROLES.KNIGHT) {
|
if (data.user.role === targetRole) {
|
||||||
toast({
|
toast({
|
||||||
title: "用户已是骑士",
|
title: `用户已是${roleNames[targetRole]}`,
|
||||||
description: "无需重复册封",
|
description: "无需重复设置",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "demote" && data.user.role === ROLES.CIVILIAN) {
|
const promoteRes = await fetch("/api/roles/promote", {
|
||||||
toast({
|
method: "POST",
|
||||||
title: "用户已是平民",
|
headers: { "Content-Type": "application/json" },
|
||||||
description: "无需重复贬为平民",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const promoteRes = await fetch('/api/roles/promote', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userId: data.user.id,
|
userId: data.user.id,
|
||||||
roleName: action === "promote" ? ROLES.KNIGHT : ROLES.CIVILIAN
|
roleName: targetRole
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await promoteRes.json() as { error: string }
|
if (!promoteRes.ok) {
|
||||||
if (!promoteRes.ok) throw new Error(result.error)
|
const error = await promoteRes.json() as { error: string }
|
||||||
|
throw new Error(error.error || "设置失败")
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: action === "promote" ? "册封成功" : "贬为平民",
|
title: "设置成功",
|
||||||
description: `已将 ${data.user.email || data.user.username} ${action === "promote" ? "册封为骑士" : "贬为平民"}`
|
description: `已将用户 ${data.user.username || data.user.email} 设为${roleNames[targetRole]}`,
|
||||||
})
|
})
|
||||||
setSearchText("")
|
setSearchText("")
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "操作失败",
|
title: "设置失败",
|
||||||
description: error instanceof Error ? error.message : "请稍后重试",
|
description: error instanceof Error ? error.message : "请稍后重试",
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
})
|
})
|
||||||
@@ -90,55 +104,62 @@ export function PromotePanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Icon = roleIcons[targetRole]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<Sword className="w-5 h-5 text-primary" />
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">角色管理</h2>
|
<h2 className="text-lg font-semibold">角色管理</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="space-y-4">
|
||||||
<div className="flex-1">
|
<div className="flex gap-4">
|
||||||
<Input
|
<div className="flex-1">
|
||||||
placeholder="输入用户名或邮箱"
|
<Input
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
disabled={loading}
|
placeholder="输入用户名或邮箱"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 sm:flex-shrink-0">
|
<Select value={targetRole} onValueChange={(value) => setTargetRole(value as RoleWithoutEmperor)}>
|
||||||
<Button
|
<SelectTrigger className="w-32">
|
||||||
onClick={() => {
|
<SelectValue />
|
||||||
setAction("promote")
|
</SelectTrigger>
|
||||||
handleAction()
|
<SelectContent>
|
||||||
}}
|
<SelectItem value={ROLES.DUKE}>
|
||||||
disabled={!searchText || loading}
|
<div className="flex items-center gap-2">
|
||||||
className="flex-1 sm:flex-initial gap-2"
|
<Gem className="w-4 h-4" />
|
||||||
>
|
公爵
|
||||||
{loading && action === "promote" ? (
|
</div>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
</SelectItem>
|
||||||
) : (
|
<SelectItem value={ROLES.KNIGHT}>
|
||||||
<Sword className="w-4 h-4" />
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<Sword className="w-4 h-4" />
|
||||||
册封骑士
|
骑士
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
</SelectItem>
|
||||||
onClick={() => {
|
<SelectItem value={ROLES.CIVILIAN}>
|
||||||
setAction("demote")
|
<div className="flex items-center gap-2">
|
||||||
handleAction()
|
<User2 className="w-4 h-4" />
|
||||||
}}
|
平民
|
||||||
disabled={!searchText || loading}
|
</div>
|
||||||
variant="destructive"
|
</SelectItem>
|
||||||
className="flex-1 sm:flex-initial gap-2"
|
</SelectContent>
|
||||||
>
|
</Select>
|
||||||
{loading && action === "demote" ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<UserMinus className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
贬为平民
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleAction}
|
||||||
|
disabled={loading || !searchText.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
`设为${roleNames[targetRole]}`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,8 +37,13 @@ export function WebhookConfig() {
|
|||||||
|
|
||||||
if (initialLoading) {
|
if (initialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-8">
|
<div className="text-center">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto">
|
||||||
|
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,7 @@ export function WebhookConfig() {
|
|||||||
|
|
||||||
<p>请求体示例:</p>
|
<p>请求体示例:</p>
|
||||||
<pre className="bg-background p-2 rounded text-xs overflow-auto">
|
<pre className="bg-background p-2 rounded text-xs overflow-auto">
|
||||||
{`{
|
{`{
|
||||||
"emailId": "email-uuid",
|
"emailId": "email-uuid",
|
||||||
"messageId": "message-uuid",
|
"messageId": "message-uuid",
|
||||||
"fromAddress": "sender@example.com",
|
"fromAddress": "sender@example.com",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root
|
||||||
@@ -39,6 +40,10 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">关闭</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
))
|
||||||
@@ -58,6 +63,20 @@ const DialogHeader = ({
|
|||||||
)
|
)
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
@@ -73,11 +92,25 @@ const DialogTitle = React.forwardRef<
|
|||||||
))
|
))
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
|
DialogDescription,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
|
export function useAdminContact() {
|
||||||
|
const [adminContact, setAdminContact] = useState("")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const fetchAdminContact = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin-contact")
|
||||||
|
if (!res.ok) throw new Error("获取管理员联系方式失败")
|
||||||
|
const data = await res.json() as { adminContact: string }
|
||||||
|
setAdminContact(data.adminContact)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast({
|
||||||
|
title: "获取失败",
|
||||||
|
description: "获取管理员联系方式失败",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAdminContact()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
adminContact,
|
||||||
|
loading,
|
||||||
|
refreshAdminContact: fetchAdminContact
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { createDb } from "./db"
|
||||||
|
import { apiKeys } from "./schema"
|
||||||
|
import { eq, and, gt } from "drizzle-orm"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import type { User } from "next-auth"
|
||||||
|
import { auth } from "./auth"
|
||||||
|
import { headers } from "next/headers"
|
||||||
|
|
||||||
|
async function getUserByApiKey(key: string): Promise<User | null> {
|
||||||
|
const db = createDb()
|
||||||
|
const apiKey = await db.query.apiKeys.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(apiKeys.key, key),
|
||||||
|
eq(apiKeys.enabled, true),
|
||||||
|
gt(apiKeys.expiresAt, new Date())
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiKey) return null
|
||||||
|
|
||||||
|
return apiKey.user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleApiKeyAuth(apiKey: string, pathname: string) {
|
||||||
|
if (!pathname.startsWith('/api/emails')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "无权限查看" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByApiKey(apiKey)
|
||||||
|
if (!user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "无效的 API Key" },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next()
|
||||||
|
response.headers.set("X-User-Id", user.id)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserId = async () => {
|
||||||
|
const headersList = await headers()
|
||||||
|
const userId = headersList.get("X-User-Id")
|
||||||
|
|
||||||
|
if (userId) return userId
|
||||||
|
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
return session?.user.id
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { generateAvatarUrl } from "./avatar"
|
|||||||
|
|
||||||
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||||
[ROLES.EMPEROR]: "皇帝(网站所有者)",
|
[ROLES.EMPEROR]: "皇帝(网站所有者)",
|
||||||
|
[ROLES.DUKE]: "公爵(超级用户)",
|
||||||
[ROLES.KNIGHT]: "骑士(高级用户)",
|
[ROLES.KNIGHT]: "骑士(高级用户)",
|
||||||
[ROLES.CIVILIAN]: "平民(普通用户)",
|
[ROLES.CIVILIAN]: "平民(普通用户)",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
EMPEROR: 'emperor',
|
EMPEROR: 'emperor',
|
||||||
|
DUKE: 'duke',
|
||||||
KNIGHT: 'knight',
|
KNIGHT: 'knight',
|
||||||
CIVILIAN: 'civilian',
|
CIVILIAN: 'civilian',
|
||||||
} as const;
|
} as const;
|
||||||
@@ -11,12 +12,18 @@ export const PERMISSIONS = {
|
|||||||
MANAGE_WEBHOOK: 'manage_webhook',
|
MANAGE_WEBHOOK: 'manage_webhook',
|
||||||
PROMOTE_USER: 'promote_user',
|
PROMOTE_USER: 'promote_user',
|
||||||
MANAGE_CONFIG: 'manage_config',
|
MANAGE_CONFIG: 'manage_config',
|
||||||
|
MANAGE_API_KEY: 'manage_api_key',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
||||||
|
|
||||||
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
[ROLES.EMPEROR]: Object.values(PERMISSIONS),
|
[ROLES.EMPEROR]: Object.values(PERMISSIONS),
|
||||||
|
[ROLES.DUKE]: [
|
||||||
|
PERMISSIONS.MANAGE_EMAIL,
|
||||||
|
PERMISSIONS.MANAGE_WEBHOOK,
|
||||||
|
PERMISSIONS.MANAGE_API_KEY,
|
||||||
|
],
|
||||||
[ROLES.KNIGHT]: [
|
[ROLES.KNIGHT]: [
|
||||||
PERMISSIONS.MANAGE_EMAIL,
|
PERMISSIONS.MANAGE_EMAIL,
|
||||||
PERMISSIONS.MANAGE_WEBHOOK,
|
PERMISSIONS.MANAGE_WEBHOOK,
|
||||||
|
|||||||
@@ -93,6 +93,23 @@ export const userRoles = sqliteTable("user_role", {
|
|||||||
pk: primaryKey({ columns: [table.userId, table.roleId] }),
|
pk: primaryKey({ columns: [table.userId, table.roleId] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const apiKeys = sqliteTable('api_keys', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
key: text('key').notNull().unique(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
||||||
|
expiresAt: integer('expires_at', { mode: 'timestamp' }),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [apiKeys.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const userRolesRelations = relations(userRoles, ({ one }) => ({
|
export const userRolesRelations = relations(userRoles, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [userRoles.userId],
|
fields: [userRoles.userId],
|
||||||
@@ -106,6 +123,7 @@ export const userRolesRelations = relations(userRoles, ({ one }) => ({
|
|||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
userRoles: many(userRoles),
|
userRoles: many(userRoles),
|
||||||
|
apiKeys: many(apiKeys),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const rolesRelations = relations(roles, ({ many }) => ({
|
export const rolesRelations = relations(roles, ({ many }) => ({
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `api_keys` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`key` text NOT NULL,
|
||||||
|
`created_at` integer,
|
||||||
|
`expires_at` integer,
|
||||||
|
`last_used_at` integer,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `api_keys_key_unique` ON `api_keys` (`key`);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
CREATE UNIQUE INDEX `api_keys_name_unique` ON `api_keys` (`name`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `api_keys` DROP COLUMN `last_used_at`;
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "30be4c36-0174-4fc0-ae3a-c1fa786a638d",
|
||||||
|
"prevId": "144530f0-42a9-450d-ab24-a419eb7b4f65",
|
||||||
|
"tables": {
|
||||||
|
"account": {
|
||||||
|
"name": "account",
|
||||||
|
"columns": {
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"providerAccountId": {
|
||||||
|
"name": "providerAccountId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_userId_user_id_fk": {
|
||||||
|
"name": "account_userId_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"account_provider_providerAccountId_pk": {
|
||||||
|
"columns": [
|
||||||
|
"provider",
|
||||||
|
"providerAccountId"
|
||||||
|
],
|
||||||
|
"name": "account_provider_providerAccountId_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"api_keys": {
|
||||||
|
"name": "api_keys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"api_keys_key_unique": {
|
||||||
|
"name": "api_keys_key_unique",
|
||||||
|
"columns": [
|
||||||
|
"key"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"api_keys_user_id_user_id_fk": {
|
||||||
|
"name": "api_keys_user_id_user_id_fk",
|
||||||
|
"tableFrom": "api_keys",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"name": "address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"email_address_unique": {
|
||||||
|
"name": "email_address_unique",
|
||||||
|
"columns": [
|
||||||
|
"address"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"email_userId_user_id_fk": {
|
||||||
|
"name": "email_userId_user_id_fk",
|
||||||
|
"tableFrom": "email",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"emailId": {
|
||||||
|
"name": "emailId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"from_address": {
|
||||||
|
"name": "from_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"name": "html",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"received_at": {
|
||||||
|
"name": "received_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"message_emailId_email_id_fk": {
|
||||||
|
"name": "message_emailId_email_id_fk",
|
||||||
|
"tableFrom": "message",
|
||||||
|
"tableTo": "email",
|
||||||
|
"columnsFrom": [
|
||||||
|
"emailId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_role": {
|
||||||
|
"name": "user_role",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role_id": {
|
||||||
|
"name": "role_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_role_user_id_user_id_fk": {
|
||||||
|
"name": "user_role_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_role",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_role_role_id_role_id_fk": {
|
||||||
|
"name": "user_role_role_id_role_id_fk",
|
||||||
|
"tableFrom": "user_role",
|
||||||
|
"tableTo": "role",
|
||||||
|
"columnsFrom": [
|
||||||
|
"role_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_role_user_id_role_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"role_id"
|
||||||
|
],
|
||||||
|
"name": "user_role_user_id_role_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"name": "emailVerified",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"webhook": {
|
||||||
|
"name": "webhook",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"webhook_user_id_user_id_fk": {
|
||||||
|
"name": "webhook_user_id_user_id_fk",
|
||||||
|
"tableFrom": "webhook",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "75ed0edc-e2f7-4782-b317-e16de23405f8",
|
||||||
|
"prevId": "30be4c36-0174-4fc0-ae3a-c1fa786a638d",
|
||||||
|
"tables": {
|
||||||
|
"account": {
|
||||||
|
"name": "account",
|
||||||
|
"columns": {
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"providerAccountId": {
|
||||||
|
"name": "providerAccountId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_userId_user_id_fk": {
|
||||||
|
"name": "account_userId_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"account_provider_providerAccountId_pk": {
|
||||||
|
"columns": [
|
||||||
|
"provider",
|
||||||
|
"providerAccountId"
|
||||||
|
],
|
||||||
|
"name": "account_provider_providerAccountId_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"api_keys": {
|
||||||
|
"name": "api_keys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"api_keys_name_unique": {
|
||||||
|
"name": "api_keys_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"api_keys_key_unique": {
|
||||||
|
"name": "api_keys_key_unique",
|
||||||
|
"columns": [
|
||||||
|
"key"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"api_keys_user_id_user_id_fk": {
|
||||||
|
"name": "api_keys_user_id_user_id_fk",
|
||||||
|
"tableFrom": "api_keys",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"name": "address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"email_address_unique": {
|
||||||
|
"name": "email_address_unique",
|
||||||
|
"columns": [
|
||||||
|
"address"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"email_userId_user_id_fk": {
|
||||||
|
"name": "email_userId_user_id_fk",
|
||||||
|
"tableFrom": "email",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"emailId": {
|
||||||
|
"name": "emailId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"from_address": {
|
||||||
|
"name": "from_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"name": "html",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"received_at": {
|
||||||
|
"name": "received_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"message_emailId_email_id_fk": {
|
||||||
|
"name": "message_emailId_email_id_fk",
|
||||||
|
"tableFrom": "message",
|
||||||
|
"tableTo": "email",
|
||||||
|
"columnsFrom": [
|
||||||
|
"emailId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_role": {
|
||||||
|
"name": "user_role",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role_id": {
|
||||||
|
"name": "role_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_role_user_id_user_id_fk": {
|
||||||
|
"name": "user_role_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_role",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_role_role_id_role_id_fk": {
|
||||||
|
"name": "user_role_role_id_role_id_fk",
|
||||||
|
"tableFrom": "user_role",
|
||||||
|
"tableTo": "role",
|
||||||
|
"columnsFrom": [
|
||||||
|
"role_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_role_user_id_role_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"role_id"
|
||||||
|
],
|
||||||
|
"name": "user_role_user_id_role_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"name": "emailVerified",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"webhook": {
|
||||||
|
"name": "webhook",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"webhook_user_id_user_id_fk": {
|
||||||
|
"name": "webhook_user_id_user_id_fk",
|
||||||
|
"tableFrom": "webhook",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,20 @@
|
|||||||
"when": 1736922488093,
|
"when": 1736922488093,
|
||||||
"tag": "0008_cute_dormammu",
|
"tag": "0008_cute_dormammu",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1739079086122,
|
||||||
|
"tag": "0009_overconfident_gateway",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1739157879946,
|
||||||
|
"tag": "0010_brief_stellaris",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+13
-1
@@ -3,18 +3,28 @@ import { NextResponse } from "next/server"
|
|||||||
import { PERMISSIONS } from "@/lib/permissions"
|
import { PERMISSIONS } from "@/lib/permissions"
|
||||||
import { checkPermission } from "@/lib/auth"
|
import { checkPermission } from "@/lib/auth"
|
||||||
import { Permission } from "@/lib/permissions"
|
import { Permission } from "@/lib/permissions"
|
||||||
|
import { handleApiKeyAuth } from "@/lib/apiKey"
|
||||||
|
|
||||||
const API_PERMISSIONS: Record<string, Permission> = {
|
const API_PERMISSIONS: Record<string, Permission> = {
|
||||||
'/api/emails': PERMISSIONS.MANAGE_EMAIL,
|
'/api/emails': PERMISSIONS.MANAGE_EMAIL,
|
||||||
'/api/webhook': PERMISSIONS.MANAGE_WEBHOOK,
|
'/api/webhook': PERMISSIONS.MANAGE_WEBHOOK,
|
||||||
'/api/roles/promote': PERMISSIONS.PROMOTE_USER,
|
'/api/roles/promote': PERMISSIONS.PROMOTE_USER,
|
||||||
'/api/config': PERMISSIONS.MANAGE_CONFIG,
|
'/api/config': PERMISSIONS.MANAGE_CONFIG,
|
||||||
|
'/api/api-keys': PERMISSIONS.MANAGE_API_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(request: Request) {
|
export async function middleware(request: Request) {
|
||||||
const session = await auth()
|
|
||||||
const pathname = new URL(request.url).pathname
|
const pathname = new URL(request.url).pathname
|
||||||
|
|
||||||
|
// API Key 认证
|
||||||
|
request.headers.delete("X-User-Id")
|
||||||
|
const apiKey = request.headers.get("X-API-Key")
|
||||||
|
if (apiKey) {
|
||||||
|
return handleApiKeyAuth(apiKey, pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session 认证
|
||||||
|
const session = await auth()
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "未授权" },
|
{ error: "未授权" },
|
||||||
@@ -45,5 +55,7 @@ export const config = {
|
|||||||
'/api/webhook/:path*',
|
'/api/webhook/:path*',
|
||||||
'/api/roles/:path*',
|
'/api/roles/:path*',
|
||||||
'/api/config/:path*',
|
'/api/config/:path*',
|
||||||
|
'/api/api-keys/:path*',
|
||||||
|
'/api/admin-contact',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user