feat: Implement role-based access control and enhance permissions system

This commit is contained in:
beilunyang
2024-12-27 13:35:29 +08:00
parent e815d1bec5
commit 5a7c17752a
22 changed files with 1888 additions and 39 deletions

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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
}
})
}

View File

@@ -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(() => {

View File

@@ -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>
)
}

View File

@@ -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">
@@ -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")}

View File

@@ -0,0 +1,93 @@
"use client"
import { Button } from "@/components/ui/button"
import { Sword, Search, Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
import { useState } from "react"
import { useToast } from "@/components/ui/use-toast"
import { ROLES } from "@/lib/permissions"
export function PromotePanel() {
const [email, setEmail] = useState("")
const [loading, setLoading] = useState(false)
const { toast } = useToast()
const handlePromote = async () => {
if (!email) return
setLoading(true)
try {
const res = await fetch(`/api/roles/users?email=${encodeURIComponent(email)}`)
const data = await res.json() as { user?: { id: string; name?: string; email: string }; error?: string }
if (!res.ok) throw new Error(data.error || '未知错误')
if (!data.user) {
toast({
title: "未找到用户",
description: "请确认邮箱地址是否正确",
variant: "destructive"
})
return
}
const promoteRes = await fetch('/api/roles/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: data.user.id,
roleName: ROLES.KNIGHT
})
})
if (!promoteRes.ok) throw new Error('册封失败')
toast({
title: "册封成功",
description: `已将 ${data.user.email} 册封为骑士`
})
setEmail("")
} 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">
<Sword className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="输入用户邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
</div>
<Button
onClick={handlePromote}
disabled={!email || loading}
className="gap-2"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
{loading ? "册封中..." : "册封"}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
"use client"
import { useSession } from "next-auth/react"
import { Permission, Role, hasPermission } from "@/lib/permissions"
export function useRolePermission() {
const { data: session } = useSession()
const roles = session?.user?.roles
const checkPermission = (permission: Permission) => {
if (!roles) return false
return hasPermission(roles.map(r => r.name) as Role[], permission)
}
const hasRole = (role: Role) => {
if (!roles) return false
return roles.some(r => r.name === role)
}
return {
checkPermission,
hasRole,
roles,
}
}

View File

@@ -1,27 +1,127 @@
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { createDb } from "./db"
import { accounts, sessions, users } from "./schema"
import { createDb, Db } from "./db"
import { accounts, sessions, users, roles, userRoles } from "./schema"
import { eq } from "drizzle-orm"
import { Permission, hasPermission, ROLES, Role } from "./permissions"
const ROLE_DESCRIPTIONS: Record<Role, string> = {
[ROLES.EMPEROR]: "皇帝(网站所有者)",
[ROLES.KNIGHT]: "骑士(高级用户)",
[ROLES.CIVILIAN]: "平民(普通用户)",
}
const getDefaultRole = (): Role =>
process.env.OPEN_REGISTRATION === 'true' ? ROLES.KNIGHT : ROLES.CIVILIAN
async function findOrCreateRole(db: Db, roleName: Role) {
let role = await db.query.roles.findFirst({
where: eq(roles.name, roleName),
})
if (!role) {
const [newRole] = await db.insert(roles)
.values({
name: roleName,
description: ROLE_DESCRIPTIONS[roleName],
})
.returning()
role = newRole
}
return role
}
async function assignRoleToUser(db: Db, userId: string, roleId: string) {
await db.insert(userRoles)
.values({
userId,
roleId,
})
}
export async function checkPermission(permission: Permission) {
const session = await auth()
if (!session?.user?.id) return false
const db = createDb()
const userRoleRecords = await db.query.userRoles.findMany({
where: eq(userRoles.userId, session.user.id),
with: { role: true },
})
const userRoleNames = userRoleRecords.map(ur => ur.role.name)
return hasPermission(userRoleNames as Role[], permission)
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut
} = NextAuth(() => {
return {
secret: process.env.AUTH_SECRET,
adapter: DrizzleAdapter(createDb(), {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
}),
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
} = NextAuth(() => ({
secret: process.env.AUTH_SECRET,
adapter: DrizzleAdapter(createDb(), {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
}),
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
})
],
events: {
async signIn({ user }) {
if (!user.id) return
try {
const db = createDb()
const existingRole = await db.query.userRoles.findFirst({
where: eq(userRoles.userId, user.id),
})
if (existingRole) return
const defaultRole = await findOrCreateRole(db, getDefaultRole())
await assignRoleToUser(db, user.id, defaultRole.id)
} catch (error) {
console.error('Error assigning role:', error)
}
},
},
pages: {
signIn: "/",
error: "/",
},
callbacks: {
async session({ session, user }) {
if (!session?.user) return session
const db = createDb()
let userRoleRecords = await db.query.userRoles.findMany({
where: eq(userRoles.userId, user.id),
with: { role: true },
})
],
if (!userRoleRecords.length) {
const defaultRole = await findOrCreateRole(db, getDefaultRole())
await assignRoleToUser(db, user.id, defaultRole.id)
userRoleRecords = [{
userId: user.id,
roleId: defaultRole.id,
createdAt: new Date(),
role: defaultRole
}]
}
session.user.roles = userRoleRecords.map(ur => ({
name: ur.role.name,
}))
return session
},
}
})
}))

View File

@@ -2,4 +2,6 @@ import { getRequestContext } from "@cloudflare/next-on-pages"
import { drizzle } from "drizzle-orm/d1"
import * as schema from "./schema"
export const createDb = () => drizzle(getRequestContext().env.DB, { schema })
export const createDb = () => drizzle(getRequestContext().env.DB, { schema })
export type Db = ReturnType<typeof createDb>

28
app/lib/permissions.ts Normal file
View File

@@ -0,0 +1,28 @@
export const ROLES = {
EMPEROR: 'emperor',
KNIGHT: 'knight',
CIVILIAN: 'civilian',
} as const;
export type Role = typeof ROLES[keyof typeof ROLES];
export const PERMISSIONS = {
MANAGE_EMAIL: 'manage_email',
MANAGE_WEBHOOK: 'manage_webhook',
PROMOTE_USER: 'promote_user',
} as const;
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
[ROLES.EMPEROR]: Object.values(PERMISSIONS),
[ROLES.KNIGHT]: [
PERMISSIONS.MANAGE_EMAIL,
PERMISSIONS.MANAGE_WEBHOOK,
],
[ROLES.CIVILIAN]: [],
} as const;
export function hasPermission(userRoles: Role[], permission: Permission): boolean {
return userRoles.some(role => ROLE_PERMISSIONS[role]?.includes(permission));
}

View File

@@ -1,6 +1,7 @@
import { integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core"
import type { AdapterAccountType } from "next-auth/adapters"
import { relations } from 'drizzle-orm';
// https://authjs.dev/getting-started/adapters/drizzle
export const users = sqliteTable("user", {
id: text("id")
@@ -81,4 +82,35 @@ export const webhooks = sqliteTable('webhook', {
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
})
})
export const roles = sqliteTable("role", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
description: text("description"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
export const rolesRelations = relations(roles, ({ many }) => ({
userRoles: many(userRoles),
}));
export const userRoles = sqliteTable("user_role", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
roleId: text("role_id").notNull().references(() => roles.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.roleId] }),
}));
export const userRolesRelations = relations(userRoles, ({ one }) => ({
user: one(users, {
fields: [userRoles.userId],
references: [users.id],
}),
role: one(roles, {
fields: [userRoles.roleId],
references: [roles.id],
}),
}));

View File

@@ -0,0 +1,24 @@
import { Header } from "@/components/layout/header"
import { Button } from "@/components/ui/button"
import { Crown } from "lucide-react"
import Link from "next/link"
export default function NoPermissionPage() {
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
<div className="container mx-auto h-full px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="h-full flex items-center justify-center">
<div className="text-center space-y-4">
<Crown className="w-12 h-12 text-primary mx-auto" />
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground">访</p>
<Link href="/">
<Button></Button>
</Link>
</div>
</main>
</div>
</div>
)
}

View File

@@ -1,23 +1,29 @@
import { Header } from "@/components/layout/header"
import { ThreeColumnLayout } from "@/components/emails/three-column-layout"
import { NoPermissionDialog } from "@/components/no-permission-dialog"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { checkPermission } from "@/lib/auth"
import { PERMISSIONS } from "@/lib/permissions"
export const runtime = "edge"
export default async function MoePage() {
const session = await auth()
if (!session) {
if (!session?.user) {
redirect("/")
}
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_EMAIL)
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
<div className="container mx-auto h-full px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="h-full">
<ThreeColumnLayout />
{!hasPermission && <NoPermissionDialog />}
</main>
</div>
</div>