mirror of
https://github.com/beilunyang/moemail.git
synced 2026-09-06 15:56:36 +08:00
feat: Add configuration management for default user roles and permissions
This commit is contained in:
@@ -74,6 +74,7 @@ jobs:
|
|||||||
cp wrangler.example.toml wrangler.toml
|
cp wrangler.example.toml wrangler.toml
|
||||||
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.toml
|
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.toml
|
||||||
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.toml
|
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.toml
|
||||||
|
sed -i "s/id = \".*\"/id = \"${{ secrets.KV_NAMESPACE_ID }}\"/" wrangler.toml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Process wrangler.email.example.toml
|
# Process wrangler.email.example.toml
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ pnpm deploy:cleanup
|
|||||||
- `DATABASE_NAME`: D1 数据库名称
|
- `DATABASE_NAME`: D1 数据库名称
|
||||||
- `DATABASE_ID`: D1 数据库 ID
|
- `DATABASE_ID`: D1 数据库 ID
|
||||||
- `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名 (例如: moemail.app)
|
- `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名 (例如: moemail.app)
|
||||||
|
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置
|
||||||
|
|
||||||
2. 选择触发方式:
|
2. 选择触发方式:
|
||||||
|
|
||||||
@@ -247,6 +248,12 @@ pnpm deploy:cleanup
|
|||||||
|
|
||||||
本项目采用基于角色的权限控制系统(RBAC)。
|
本项目采用基于角色的权限控制系统(RBAC)。
|
||||||
|
|
||||||
|
### 权限配置
|
||||||
|
|
||||||
|
新用户默认角色由皇帝在个人中心的网站设置中配置:
|
||||||
|
- 骑士:新用户将获得临时邮箱和 Webhook 配置权限
|
||||||
|
- 平民:新用户无任何权限,需要等待皇帝册封为骑士
|
||||||
|
|
||||||
### 角色等级
|
### 角色等级
|
||||||
|
|
||||||
系统包含三个角色等级:
|
系统包含三个角色等级:
|
||||||
@@ -254,33 +261,28 @@ pnpm deploy:cleanup
|
|||||||
1. **皇帝(Emperor)**
|
1. **皇帝(Emperor)**
|
||||||
- 网站所有者
|
- 网站所有者
|
||||||
- 拥有所有权限
|
- 拥有所有权限
|
||||||
|
- 可以配置新用户默认角色
|
||||||
|
- 可以册封骑士
|
||||||
- 每个站点仅允许一位皇帝
|
- 每个站点仅允许一位皇帝
|
||||||
|
|
||||||
2. **骑士(Knight)**
|
2. **骑士(Knight)**
|
||||||
- 高级用户
|
- 高级用户
|
||||||
- 可以使用临时邮箱功能
|
- 可以使用临时邮箱功能
|
||||||
- 可以配置 Webhook
|
- 可以配置 Webhook
|
||||||
- 开放注册时默认角色
|
|
||||||
|
|
||||||
3. **平民(Civilian)**
|
3. **平民(Civilian)**
|
||||||
- 普通用户
|
- 普通用户
|
||||||
- 无任何权限
|
- 无任何权限
|
||||||
- 非开放注册时默认角色
|
|
||||||
|
|
||||||
### 权限配置
|
|
||||||
|
|
||||||
通过环境变量 `OPEN_REGISTRATION` 控制注册策略:
|
|
||||||
- `true`: 新用户默认为骑士
|
|
||||||
- `false`: 新用户默认为平民
|
|
||||||
|
|
||||||
### 角色升级
|
### 角色升级
|
||||||
|
|
||||||
1. **成为皇帝**
|
1. **成为皇帝**
|
||||||
- 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝
|
- 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝,即网站所有者
|
||||||
- 站点已有皇帝后,无法再提升其他用户为皇帝
|
- 站点已有皇帝后,无法再提升其他用户为皇帝
|
||||||
|
|
||||||
2. **成为骑士**
|
2. **成为骑士**
|
||||||
- 皇帝在个人中心页面对平民进行册封
|
- 皇帝在个人中心页面对平民进行册封
|
||||||
|
- 或由皇帝设置新用户默认为骑士角色
|
||||||
|
|
||||||
|
|
||||||
## Webhook 集成
|
## Webhook 集成
|
||||||
@@ -342,9 +344,6 @@ pnpx cloudflared tunnel --url http://localhost:3001
|
|||||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||||
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
|
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
|
||||||
|
|
||||||
### 权限相关
|
|
||||||
- `OPEN_REGISTRATION`: 是否开放注册,`true` 表示开放注册,`false` 表示关闭注册
|
|
||||||
|
|
||||||
### 邮箱配置
|
### 邮箱配置
|
||||||
- `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名,支持多域名,用逗号分隔 (例如: moemail.app,bitibiti.com)
|
- `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名,支持多域名,用逗号分隔 (例如: moemail.app,bitibiti.com)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Role, ROLES } from "@/lib/permissions"
|
||||||
|
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const config = await getRequestContext().env.SITE_CONFIG.get("DEFAULT_ROLE")
|
||||||
|
|
||||||
|
return Response.json({ defaultRole: config || ROLES.CIVILIAN })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { defaultRole } = await request.json() as { defaultRole: Exclude<Role, typeof ROLES.EMPEROR> }
|
||||||
|
|
||||||
|
if (![ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
||||||
|
return Response.json({ error: "无效的角色" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await getRequestContext().env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole)
|
||||||
|
return Response.json({ success: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Settings } from "lucide-react"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Role, ROLES } from "@/lib/permissions"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
|
export function ConfigPanel() {
|
||||||
|
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
const res = await fetch("/api/config")
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { defaultRole: Exclude<Role, typeof ROLES.EMPEROR> }
|
||||||
|
setDefaultRole(data.defaultRole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ defaultRole }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("保存失败")
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "保存成功",
|
||||||
|
description: "默认角色设置已更新",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "保存失败",
|
||||||
|
description: error instanceof Error ? error.message : "请稍后重试",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Settings className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">网站设置</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm">新用户默认角色:</span>
|
||||||
|
<Select value={defaultRole} onValueChange={setDefaultRole}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ROLES.KNIGHT}>骑士</SelectItem>
|
||||||
|
<SelectItem value={ROLES.CIVILIAN}>平民</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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"
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
user: User
|
user: User
|
||||||
@@ -26,6 +27,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
|||||||
const { checkPermission } = useRolePermission()
|
const { checkPermission } = useRolePermission()
|
||||||
const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK)
|
const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK)
|
||||||
const canPromote = checkPermission(PERMISSIONS.PROMOTE_USER)
|
const canPromote = checkPermission(PERMISSIONS.PROMOTE_USER)
|
||||||
|
const canManageConfig = checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
@@ -85,6 +87,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManageConfig && <ConfigPanel />}
|
||||||
{canPromote && <PromotePanel />}
|
{canPromote && <PromotePanel />}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
||||||
|
|||||||
+13
-8
@@ -4,6 +4,7 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter"
|
|||||||
import { createDb, Db } from "./db"
|
import { createDb, Db } from "./db"
|
||||||
import { accounts, sessions, users, roles, userRoles } from "./schema"
|
import { accounts, sessions, users, roles, userRoles } from "./schema"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||||
import { Permission, hasPermission, ROLES, Role } from "./permissions"
|
import { Permission, hasPermission, ROLES, Role } from "./permissions"
|
||||||
|
|
||||||
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||||
@@ -12,8 +13,10 @@ const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
|||||||
[ROLES.CIVILIAN]: "平民(普通用户)",
|
[ROLES.CIVILIAN]: "平民(普通用户)",
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultRole = (): Role =>
|
const getDefaultRole = async (): Promise<Role> => {
|
||||||
process.env.OPEN_REGISTRATION === 'true' ? ROLES.KNIGHT : ROLES.CIVILIAN
|
const defaultRole = await getRequestContext().env.SITE_CONFIG.get("DEFAULT_ROLE")
|
||||||
|
return defaultRole === ROLES.KNIGHT ? ROLES.KNIGHT : ROLES.CIVILIAN
|
||||||
|
}
|
||||||
|
|
||||||
async function findOrCreateRole(db: Db, roleName: Role) {
|
async function findOrCreateRole(db: Db, roleName: Role) {
|
||||||
let role = await db.query.roles.findFirst({
|
let role = await db.query.roles.findFirst({
|
||||||
@@ -85,8 +88,9 @@ export const {
|
|||||||
|
|
||||||
if (existingRole) return
|
if (existingRole) return
|
||||||
|
|
||||||
const defaultRole = await findOrCreateRole(db, getDefaultRole())
|
const defaultRole = await getDefaultRole()
|
||||||
await assignRoleToUser(db, user.id, defaultRole.id)
|
const role = await findOrCreateRole(db, defaultRole)
|
||||||
|
await assignRoleToUser(db, user.id, role.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error assigning role:', error)
|
console.error('Error assigning role:', error)
|
||||||
}
|
}
|
||||||
@@ -107,13 +111,14 @@ export const {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!userRoleRecords.length) {
|
if (!userRoleRecords.length) {
|
||||||
const defaultRole = await findOrCreateRole(db, getDefaultRole())
|
const defaultRole = await getDefaultRole()
|
||||||
await assignRoleToUser(db, user.id, defaultRole.id)
|
const role = await findOrCreateRole(db, defaultRole)
|
||||||
|
await assignRoleToUser(db, user.id, role.id)
|
||||||
userRoleRecords = [{
|
userRoleRecords = [{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
roleId: defaultRole.id,
|
roleId: role.id,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
role: defaultRole
|
role: role
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const PERMISSIONS = {
|
|||||||
MANAGE_EMAIL: 'manage_email',
|
MANAGE_EMAIL: 'manage_email',
|
||||||
MANAGE_WEBHOOK: 'manage_webhook',
|
MANAGE_WEBHOOK: 'manage_webhook',
|
||||||
PROMOTE_USER: 'promote_user',
|
PROMOTE_USER: 'promote_user',
|
||||||
|
MANAGE_CONFIG: 'manage_config',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(request: Request) {
|
export async function middleware(request: Request) {
|
||||||
@@ -43,5 +44,6 @@ export const config = {
|
|||||||
'/api/emails/:path*',
|
'/api/emails/:path*',
|
||||||
'/api/webhook/:path*',
|
'/api/webhook/:path*',
|
||||||
'/api/roles/:path*',
|
'/api/roles/:path*',
|
||||||
|
'/api/config/:path*',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -4,6 +4,7 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface CloudflareEnv {
|
interface CloudflareEnv {
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
|
SITE_CONFIG: KVNamespace;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Env = CloudflareEnv
|
type Env = CloudflareEnv
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ binding = "DB"
|
|||||||
migrations_dir = "drizzle"
|
migrations_dir = "drizzle"
|
||||||
database_name = ""
|
database_name = ""
|
||||||
database_id = ""
|
database_id = ""
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "SITE_CONFIG"
|
||||||
|
id = ""
|
||||||
Reference in New Issue
Block a user