Skip to content

Commit

Permalink
feat: Implement role-based access control and enhance permissions system
Browse files Browse the repository at this point in the history
  • Loading branch information
beilunyang committed Dec 27, 2024
1 parent e815d1b commit 5a7c177
Show file tree
Hide file tree
Showing 22 changed files with 1,888 additions and 39 deletions.
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<a href="#本地运行">本地运行</a> •
<a href="#部署">部署</a> •
<a href="#Cloudflare 邮件路由配置">Cloudflare 邮件路由配置</a> •
<a href="#权限系统">权限系统</a> •
<a href="#Webhook 集成">Webhook 集成</a> •
<a href="#环境变量">环境变量</a> •
<a href="#Github OAuth App 配置">Github OAuth App 配置</a> •
Expand All @@ -31,7 +32,7 @@

![邮箱](https://pic.otaku.ren/20241209/AQADw8UxG9k1uVZ-.jpg "邮箱")

![个人中心](https://pic.otaku.ren/20241217/AQAD9sQxG0g1EVd-.jpg "个人中心")
![个人中心](https://pic.otaku.ren/20241227/AQADVsIxG7OzcFd-.jpg "个人中心")

## 特性

Expand All @@ -45,6 +46,7 @@
- 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
- 🛡️ **权限系统**:支持基于角色的权限控制系统

## 技术栈

Expand All @@ -63,7 +65,7 @@
### 前置要求

- Node.js 18+
- pnpm
- Pnpm
- Wrangler CLI
- Cloudflare 账号

Expand Down Expand Up @@ -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 集成

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -335,7 +379,7 @@ pnpx cloudflared tunnel --url http://localhost:3001
## 支持

如果你喜欢这个项目,欢迎给它一个 Star ⭐️
或者进行赞助
或者进���赞助
<br />
<br />
<img src="https://pic.otaku.ren/20240212/AQADPrgxGwoIWFZ-.jpg" style="width: 400px;"/>
Expand Down
54 changes: 54 additions & 0 deletions app/api/roles/init-emperor/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
58 changes: 58 additions & 0 deletions app/api/roles/promote/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
43 changes: 43 additions & 0 deletions app/api/roles/users/route.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
}
4 changes: 2 additions & 2 deletions app/components/emails/create-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
27 changes: 27 additions & 0 deletions app/components/no-permission-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client"

import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"

export function NoPermissionDialog() {
const router = useRouter()

return (
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
<div className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[90%] max-w-md">
<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">
<h1 className="text-xl md:text-2xl font-bold">权限不足</h1>
<p className="text-sm md:text-base text-muted-foreground">你没有权限访问此页面,请联系网站皇帝授权</p>
<Button
onClick={() => router.push("/")}
className="mt-4 w-full md:w-auto"
>
返回首页
</Button>
</div>
</div>
</div>
</div>
)
}
51 changes: 41 additions & 10 deletions app/components/profile/profile-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="max-w-2xl mx-auto space-y-6">
{/* 用户信息卡片 */}
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-6">
<div className="relative">
Expand All @@ -42,20 +53,40 @@ export function ProfileCard({ user }: ProfileCardProps) {
<p className="text-sm text-muted-foreground truncate mt-1">
{user.email}
</p>
{user.roles && (
<div className="flex gap-2 mt-2">
{user.roles.map(({ name }) => {
const roleConfig = roleConfigs[name as keyof typeof roleConfigs]
const Icon = roleConfig.icon
return (
<div
key={name}
className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"
title={roleConfig.name}
>
<Icon className="w-3 h-3" />
{roleConfig.name}
</div>
)
})}
</div>
)}
</div>
</div>
</div>

{/* Webhook 配置卡片 */}
<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">Webhook 配置</h2>
{canManageWebhook && (
<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">Webhook 配置</h2>
</div>
<WebhookConfig />
</div>
<WebhookConfig />
</div>
)}

{canPromote && <PromotePanel />}

{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-4 px-1">
<Button
onClick={() => router.push("/moe")}
Expand Down
Loading

0 comments on commit 5a7c177

Please sign in to comment.