diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5d56e9b..7f7983a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -74,6 +74,7 @@ jobs: cp wrangler.example.toml 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/id = \".*\"/id = \"${{ secrets.KV_NAMESPACE_ID }}\"/" wrangler.toml fi # Process wrangler.email.example.toml diff --git a/README.md b/README.md index dd407ce..a354732 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ pnpm deploy:cleanup - `DATABASE_NAME`: D1 数据库名称 - `DATABASE_ID`: D1 数据库 ID - `NEXT_PUBLIC_EMAIL_DOMAIN`: 邮箱域名 (例如: moemail.app) + - `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置 2. 选择触发方式: @@ -247,6 +248,12 @@ pnpm deploy:cleanup 本项目采用基于角色的权限控制系统(RBAC)。 +### 权限配置 + +新用户默认角色由皇帝在个人中心的网站设置中配置: +- 骑士:新用户将获得临时邮箱和 Webhook 配置权限 +- 平民:新用户无任何权限,需要等待皇帝册封为骑士 + ### 角色等级 系统包含三个角色等级: @@ -254,33 +261,28 @@ pnpm deploy:cleanup 1. **皇帝(Emperor)** - 网站所有者 - 拥有所有权限 + - 可以配置新用户默认角色 + - 可以册封骑士 - 每个站点仅允许一位皇帝 2. **骑士(Knight)** - 高级用户 - 可以使用临时邮箱功能 - 可以配置 Webhook - - 开放注册时默认角色 3. **平民(Civilian)** - 普通用户 - 无任何权限 - - 非开放注册时默认角色 - -### 权限配置 - -通过环境变量 `OPEN_REGISTRATION` 控制注册策略: -- `true`: 新用户默认为骑士 -- `false`: 新用户默认为平民 ### 角色升级 1. **成为皇帝** - - 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝 + - 第一个访问 `/api/roles/init-emperor` 接口的用户将成为皇帝,即网站所有者 - 站点已有皇帝后,无法再提升其他用户为皇帝 2. **成为骑士** - 皇帝在个人中心页面对平民进行册封 + - 或由皇帝设置新用户默认为骑士角色 ## Webhook 集成 @@ -342,9 +344,6 @@ 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) diff --git a/app/api/config/route.ts b/app/api/config/route.ts new file mode 100644 index 0000000..0fb7d46 --- /dev/null +++ b/app/api/config/route.ts @@ -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 } + + 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 }) +} \ No newline at end of file diff --git a/app/components/profile/config-panel.tsx b/app/components/profile/config-panel.tsx new file mode 100644 index 0000000..bb94107 --- /dev/null +++ b/app/components/profile/config-panel.tsx @@ -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("") + 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 } + 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 ( +
+
+ +

网站设置

+
+ +
+
+ 新用户默认角色: + + +
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/profile/profile-card.tsx b/app/components/profile/profile-card.tsx index 24b27e5..8ea6fcc 100644 --- a/app/components/profile/profile-card.tsx +++ b/app/components/profile/profile-card.tsx @@ -10,6 +10,7 @@ import { WebhookConfig } from "./webhook-config" import { PromotePanel } from "./promote-panel" import { useRolePermission } from "@/hooks/use-role-permission" import { PERMISSIONS } from "@/lib/permissions" +import { ConfigPanel } from "./config-panel" interface ProfileCardProps { user: User @@ -26,6 +27,7 @@ export function ProfileCard({ user }: ProfileCardProps) { const { checkPermission } = useRolePermission() const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK) const canPromote = checkPermission(PERMISSIONS.PROMOTE_USER) + const canManageConfig = checkPermission(PERMISSIONS.MANAGE_CONFIG) return (
@@ -85,6 +87,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
)} + {canManageConfig && } {canPromote && }
diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 70a815c..2542524 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -4,6 +4,7 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter" import { createDb, Db } from "./db" import { accounts, sessions, users, roles, userRoles } from "./schema" import { eq } from "drizzle-orm" +import { getRequestContext } from "@cloudflare/next-on-pages" import { Permission, hasPermission, ROLES, Role } from "./permissions" const ROLE_DESCRIPTIONS: Record = { @@ -12,8 +13,10 @@ const ROLE_DESCRIPTIONS: Record = { [ROLES.CIVILIAN]: "平民(普通用户)", } -const getDefaultRole = (): Role => - process.env.OPEN_REGISTRATION === 'true' ? ROLES.KNIGHT : ROLES.CIVILIAN +const getDefaultRole = async (): Promise => { + 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) { let role = await db.query.roles.findFirst({ @@ -85,8 +88,9 @@ export const { if (existingRole) return - const defaultRole = await findOrCreateRole(db, getDefaultRole()) - await assignRoleToUser(db, user.id, defaultRole.id) + const defaultRole = await getDefaultRole() + const role = await findOrCreateRole(db, defaultRole) + await assignRoleToUser(db, user.id, role.id) } catch (error) { console.error('Error assigning role:', error) } @@ -107,13 +111,14 @@ export const { }) if (!userRoleRecords.length) { - const defaultRole = await findOrCreateRole(db, getDefaultRole()) - await assignRoleToUser(db, user.id, defaultRole.id) + const defaultRole = await getDefaultRole() + const role = await findOrCreateRole(db, defaultRole) + await assignRoleToUser(db, user.id, role.id) userRoleRecords = [{ userId: user.id, - roleId: defaultRole.id, + roleId: role.id, createdAt: new Date(), - role: defaultRole + role: role }] } diff --git a/app/lib/permissions.ts b/app/lib/permissions.ts index b9204a7..8f1d78b 100644 --- a/app/lib/permissions.ts +++ b/app/lib/permissions.ts @@ -10,6 +10,7 @@ export const PERMISSIONS = { MANAGE_EMAIL: 'manage_email', MANAGE_WEBHOOK: 'manage_webhook', PROMOTE_USER: 'promote_user', + MANAGE_CONFIG: 'manage_config', } as const; export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS]; diff --git a/middleware.ts b/middleware.ts index d79376e..579f547 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,6 +8,7 @@ const API_PERMISSIONS: Record = { '/api/emails': PERMISSIONS.MANAGE_EMAIL, '/api/webhook': PERMISSIONS.MANAGE_WEBHOOK, '/api/roles/promote': PERMISSIONS.PROMOTE_USER, + '/api/config': PERMISSIONS.MANAGE_CONFIG, } export async function middleware(request: Request) { @@ -43,5 +44,6 @@ export const config = { '/api/emails/:path*', '/api/webhook/:path*', '/api/roles/:path*', + '/api/config/:path*', ] } \ No newline at end of file diff --git a/types.d.ts b/types.d.ts index 2426752..c3d9fe4 100644 --- a/types.d.ts +++ b/types.d.ts @@ -4,6 +4,7 @@ declare global { interface CloudflareEnv { DB: D1Database; + SITE_CONFIG: KVNamespace; } type Env = CloudflareEnv diff --git a/wrangler.example.toml b/wrangler.example.toml index 53736e8..b65dd32 100644 --- a/wrangler.example.toml +++ b/wrangler.example.toml @@ -8,3 +8,7 @@ binding = "DB" migrations_dir = "drizzle" database_name = "" database_id = "" + +[[kv_namespaces]] +binding = "SITE_CONFIG" +id = "" \ No newline at end of file