diff --git a/README.md b/README.md
index c2b969a..2f04fd5 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
本地运行 •
部署 •
Cloudflare 邮件路由配置 •
+ 权限系统 •
Webhook 集成 •
环境变量 •
Github OAuth App 配置 •
@@ -31,7 +32,7 @@

-
+
## 特性
@@ -45,6 +46,7 @@
- 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
+- 🛡️ **权限系统**:支持基于角色的权限控制系统
## 技术栈
@@ -63,7 +65,7 @@
### 前置要求
- Node.js 18+
-- pnpm
+- Pnpm
- Wrangler CLI
- Cloudflare 账号
@@ -241,6 +243,45 @@ pnpm deploy:cleanup
- 确保域名的 DNS 托管在 Cloudflare
- Email Worker 必须已经部署成功
+## 权限系统
+
+本项目采用基于角色的权限控制系统(RBAC)。
+
+### 角色等级
+
+系统包含三个角色等级:
+
+1. **皇帝(Emperor)**
+ - 网站所有者
+ - 拥有所有权限
+ - 每个站点仅允许一位皇帝
+
+2. **骑士(Knight)**
+ - 高级用户
+ - 可以使用临时邮箱功能
+ - 可以配置 Webhook
+ - 开放注册时默认角色
+
+3. **平民(Civilian)**
+ - 普通用户
+ - 无任何权限
+ - 非开放注册时默认角色
+
+### 权限配置
+
+通过环境变量 `OPEN_REGISTRATION` 控制注册策略:
+- `true`: 新用户默认为骑士
+- `false`: 新用户默认为平民
+
+### 角色升级
+
+1. **成为皇帝**
+ - 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝
+ - 站点已有皇帝后,无法再提升其他用户为皇帝
+
+2. **成为骑士**
+ - 皇帝在个人中心页面对平民进行册封
+
## Webhook 集成
@@ -301,6 +342,9 @@ pnpx cloudflared tunnel --url http://localhost:3001
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
+### 权限相关
+- `OPEN_REGISTRATION`: 是否开放注册,`true` 表示开放注册,`false` 表示关闭注册
+
### 邮箱配置
- `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名,支持多域名,用逗号分隔 (例如: moemail.app,bitibiti.com)
@@ -335,7 +379,7 @@ pnpx cloudflared tunnel --url http://localhost:3001
## 支持
如果你喜欢这个项目,欢迎给它一个 Star ⭐️
-或者进行赞助
+或者进���赞助
diff --git a/app/api/roles/init-emperor/route.ts b/app/api/roles/init-emperor/route.ts
new file mode 100644
index 0000000..62eb3c0
--- /dev/null
+++ b/app/api/roles/init-emperor/route.ts
@@ -0,0 +1,54 @@
+import { auth } from "@/lib/auth";
+import { createDb } from "@/lib/db";
+import { roles, userRoles } from "@/lib/schema";
+import { ROLES } from "@/lib/permissions";
+import { eq } from "drizzle-orm";
+
+export const runtime = "edge";
+
+export async function GET() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return Response.json({ error: "未授权" }, { status: 401 });
+ }
+
+ const db = createDb();
+
+ const emperorRole = await db.query.roles.findFirst({
+ where: eq(roles.name, ROLES.EMPEROR),
+ with: {
+ userRoles: true,
+ },
+ });
+
+ if (emperorRole && emperorRole.userRoles.length > 0) {
+ return Response.json({ error: "已存在皇帝, 谋反将被处死" }, { status: 400 });
+ }
+
+ try {
+ let roleId = emperorRole?.id;
+ if (!roleId) {
+ const [newRole] = await db.insert(roles)
+ .values({
+ name: ROLES.EMPEROR,
+ description: "皇帝(网站所有者)",
+ })
+ .returning({ id: roles.id });
+ roleId = newRole.id;
+ }
+
+ await db.insert(userRoles)
+ .values({
+ userId: session.user.id,
+ roleId,
+ });
+
+ return Response.json({ message: "登基成功,你已成为皇帝" });
+ } catch (error) {
+ console.error("Failed to initialize emperor:", error);
+ return Response.json(
+ { error: "登基称帝失败" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/roles/promote/route.ts b/app/api/roles/promote/route.ts
new file mode 100644
index 0000000..11f6887
--- /dev/null
+++ b/app/api/roles/promote/route.ts
@@ -0,0 +1,58 @@
+import { createDb } from "@/lib/db";
+import { roles, userRoles } from "@/lib/schema";
+import { eq } from "drizzle-orm";
+import { ROLES } from "@/lib/permissions";
+
+export const runtime = "edge";
+
+export async function POST(request: Request) {
+ try {
+ const { userId, roleName } = await request.json() as { userId: string, roleName: string };
+ if (!userId || !roleName) {
+ return Response.json(
+ { error: "缺少必要参数" },
+ { status: 400 }
+ );
+ }
+
+ if (roleName !== ROLES.KNIGHT) {
+ return Response.json(
+ { error: "角色不合法" },
+ { status: 400 }
+ );
+ }
+
+ const db = createDb();
+
+ let targetRole = await db.query.roles.findFirst({
+ where: eq(roles.name, roleName),
+ });
+
+ if (!targetRole) {
+ const [newRole] = await db.insert(roles)
+ .values({
+ name: roleName,
+ description: "高级用户",
+ })
+ .returning();
+ targetRole = newRole;
+ }
+
+ await db.delete(userRoles)
+ .where(eq(userRoles.userId, userId));
+
+ await db.insert(userRoles)
+ .values({
+ userId,
+ roleId: targetRole.id,
+ });
+
+ return Response.json({ success: true });
+ } catch (error) {
+ console.error("Failed to promote user:", error);
+ return Response.json(
+ { error: "升级用户失败" },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/roles/users/route.ts b/app/api/roles/users/route.ts
new file mode 100644
index 0000000..ed78dda
--- /dev/null
+++ b/app/api/roles/users/route.ts
@@ -0,0 +1,43 @@
+import { createDb } from "@/lib/db"
+import { userRoles, users } from "@/lib/schema"
+import { eq } from "drizzle-orm"
+
+export const runtime = "edge"
+
+export async function GET(request: Request) {
+ const url = new URL(request.url)
+ const email = url.searchParams.get('email')
+
+ if (!email) {
+ return Response.json(
+ { error: "邮箱地址不能为空" },
+ { status: 400 }
+ )
+ }
+
+ const db = createDb()
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.email, email),
+ })
+
+ if (!user) {
+ return Response.json({ user: null })
+ }
+
+ const userRole = await db.query.userRoles.findFirst({
+ where: eq(userRoles.userId, user.id),
+ with: {
+ role: true
+ }
+ })
+
+ return Response.json({
+ user: {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ role: userRole?.role.name
+ }
+ })
+}
\ No newline at end of file
diff --git a/app/components/emails/create-dialog.tsx b/app/components/emails/create-dialog.tsx
index 2e87943..f715b21 100644
--- a/app/components/emails/create-dialog.tsx
+++ b/app/components/emails/create-dialog.tsx
@@ -90,8 +90,8 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
const fetchDomains = async () => {
const response = await fetch("/api/emails/domains");
const data = (await response.json()) as DomainResponse;
- setDomains(data.domains);
- setCurrentDomain(data.domains[0]);
+ setDomains(data.domains || []);
+ setCurrentDomain(data.domains[0] || "");
};
useEffect(() => {
diff --git a/app/components/no-permission-dialog.tsx b/app/components/no-permission-dialog.tsx
new file mode 100644
index 0000000..09349bc
--- /dev/null
+++ b/app/components/no-permission-dialog.tsx
@@ -0,0 +1,27 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { useRouter } from "next/navigation"
+
+export function NoPermissionDialog() {
+ const router = useRouter()
+
+ return (
+
+
+
+
+
权限不足
+
你没有权限访问此页面,请联系网站皇帝授权
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/profile/profile-card.tsx b/app/components/profile/profile-card.tsx
index 1e64e14..24b27e5 100644
--- a/app/components/profile/profile-card.tsx
+++ b/app/components/profile/profile-card.tsx
@@ -4,20 +4,31 @@ import { User } from "next-auth"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { signOut } from "next-auth/react"
-import { Github, Mail, Settings } from "lucide-react"
+import { Github, Mail, Settings, Crown, Sword, User2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { WebhookConfig } from "./webhook-config"
+import { PromotePanel } from "./promote-panel"
+import { useRolePermission } from "@/hooks/use-role-permission"
+import { PERMISSIONS } from "@/lib/permissions"
interface ProfileCardProps {
user: User
}
+const roleConfigs = {
+ emperor: { name: '皇帝', icon: Crown },
+ knight: { name: '骑士', icon: Sword },
+ civilian: { name: '平民', icon: User2 },
+} as const
+
export function ProfileCard({ user }: ProfileCardProps) {
const router = useRouter()
+ const { checkPermission } = useRolePermission()
+ const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK)
+ const canPromote = checkPermission(PERMISSIONS.PROMOTE_USER)
return (
- {/* 用户信息卡片 */}
@@ -42,20 +53,40 @@ export function ProfileCard({ user }: ProfileCardProps) {
{user.email}
+ {user.roles && (
+
+ {user.roles.map(({ name }) => {
+ const roleConfig = roleConfigs[name as keyof typeof roleConfigs]
+ const Icon = roleConfig.icon
+ return (
+
+
+ {roleConfig.name}
+
+ )
+ })}
+
+ )}
- {/* Webhook 配置卡片 */}
-
-
-
-
Webhook 配置
+ {canManageWebhook && (
+
-
-
+ )}
+
+ {canPromote &&
}
- {/* 操作按钮 */}