From 70e66294e3e400b886b9ae26624bc53608779091 Mon Sep 17 00:00:00 2001 From: wood chen Date: Thu, 20 Feb 2025 01:49:52 +0800 Subject: [PATCH] feat: Add user access control and client management enhancements - Introduced `allowedUsers` field to Client model for granular access control - Implemented user filtering in authorization process - Updated client edit form with allowed users configuration - Enhanced dashboard and admin pages with improved user and client management - Refactored client update and delete API routes - Added form validation using Zod and react-hook-form --- package.json | 9 +- pnpm-lock.yaml | 35 ++- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/actions/authorizing.ts | 23 ++ src/app/(admin)/admin/clients/page.tsx | 22 +- src/app/(admin)/admin/users/page.tsx | 45 +--- src/app/(dashboard)/dashboard/page.tsx | 154 +++++++++--- src/app/api/clients/[id]/route.ts | 65 +++-- src/components/clients/edit-client.tsx | 238 +++++++++++------- src/components/layout/dashboard-header.tsx | 2 +- src/components/layout/nav-bar.tsx | 2 +- src/components/ui/button.tsx | 2 +- src/components/ui/form.tsx | 179 +++++++++++++ src/components/ui/textarea.tsx | 22 ++ 15 files changed, 587 insertions(+), 214 deletions(-) create mode 100644 prisma/migrations/20250219174349_add_allowed_users/migration.sql create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/textarea.tsx diff --git a/package.json b/package.json index 6259b57..e1dcab0 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,15 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.4.2", + "@hookform/resolvers": "^4.1.0", "@prisma/client": "^5.19.0", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "class-variance-authority": "^0.7.0", @@ -37,8 +38,10 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.54.2", "tailwind-merge": "^2.5.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" }, "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bca38f6..dba85e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@auth/prisma-adapter': specifier: ^2.4.2 version: 2.7.4(@prisma/client@5.22.0) + '@hookform/resolvers': + specifier: ^4.1.0 + version: 4.1.0(react-hook-form@7.54.2) '@prisma/client': specifier: ^5.19.0 version: 5.22.0(prisma@5.22.0) @@ -27,10 +30,10 @@ dependencies: specifier: ^2.1.1 version: 2.1.6(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-label': - specifier: ^2.1.0 + specifier: ^2.1.2 version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.1.0 + specifier: ^1.1.2 version: 1.1.2(@types/react@18.3.18)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.0 @@ -68,12 +71,18 @@ dependencies: react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@18.3.1) tailwind-merge: specifier: ^2.5.2 version: 2.6.0 tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17) + zod: + specifier: ^3.24.2 + version: 3.24.2 devDependencies: '@ianvs/prettier-plugin-sort-imports': @@ -320,6 +329,15 @@ packages: resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} dev: false + /@hookform/resolvers@4.1.0(react-hook-form@7.54.2): + resolution: {integrity: sha512-fX/uHKb+OOCpACLc6enuTQsf0ZpRrKbeBBPETg5PCPLCIYV6osP2Bw6ezuclM61lH+wBF9eXcuC0+BFh9XOEnQ==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + caniuse-lite: 1.0.30001700 + react-hook-form: 7.54.2(react@18.3.1) + dev: false + /@humanwhocodes/config-array@0.13.0: resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -3645,6 +3663,15 @@ packages: scheduler: 0.23.2 dev: false + /react-hook-form@7.54.2(react@18.3.1): + resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + dependencies: + react: 18.3.1 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true @@ -4449,3 +4476,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zod@3.24.2: + resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + dev: false diff --git a/prisma/migrations/20250219174349_add_allowed_users/migration.sql b/prisma/migrations/20250219174349_add_allowed_users/migration.sql new file mode 100644 index 0000000..3cd9f78 --- /dev/null +++ b/prisma/migrations/20250219174349_add_allowed_users/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "clients" ADD COLUMN "allowedUsers" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef668b5..8755108 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model Client { logo String description String? enabled Boolean @default(true) + allowedUsers String[] @default([]) clientId String @unique clientSecret String diff --git a/src/actions/authorizing.ts b/src/actions/authorizing.ts index f17979b..50ce151 100644 --- a/src/actions/authorizing.ts +++ b/src/actions/authorizing.ts @@ -2,6 +2,7 @@ import { createAuthorization } from "@/lib/dto/authorization"; import { getAuthorizeUrl } from "@/lib/oauth/authorize-url"; +import { prisma } from "@/lib/prisma"; export async function handleAuthorizeAction( oauth: string, @@ -9,6 +10,28 @@ export async function handleAuthorizeAction( clientId: string, scope: string, ) { + // 检查客户端是否限制了允许的用户 + const client = await prisma.client.findUnique({ + where: { id: clientId }, + select: { allowedUsers: true }, + }); + + if (!client) { + throw new Error("应用不存在"); + } + + // 如果设置了允许的用户列表,检查当前用户是否在列表中 + if (client.allowedUsers.length > 0) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); + + if (!user || !client.allowedUsers.includes(user.username)) { + throw new Error("您没有权限使用此应用"); + } + } + const oauthParams = new URLSearchParams(atob(oauth)); const redirectUrl = getAuthorizeUrl(oauthParams); diff --git a/src/app/(admin)/admin/clients/page.tsx b/src/app/(admin)/admin/clients/page.tsx index 11a8d1f..70912a1 100644 --- a/src/app/(admin)/admin/clients/page.tsx +++ b/src/app/(admin)/admin/clients/page.tsx @@ -1,3 +1,4 @@ +import Image from "next/image"; import Link from "next/link"; import { redirect } from "next/navigation"; import { Search } from "lucide-react"; @@ -22,6 +23,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { ClientStatusToggle } from "@/components/admin/client-status-toggle"; async function getClients(search?: string) { const where = search @@ -107,10 +109,13 @@ export default async function ClientsPage({
{client.logo && ( - {client.name} )} {client.name} @@ -130,11 +135,14 @@ export default async function ClientsPage({ - - - +
+ + + + +
))} diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx index 78c3b26..875ed19 100644 --- a/src/app/(admin)/admin/users/page.tsx +++ b/src/app/(admin)/admin/users/page.tsx @@ -20,27 +20,6 @@ import { TableRow, } from "@/components/ui/table"; -async function getUsers(search?: string) { - const where = search - ? { - OR: [ - { username: { contains: search } }, - { email: { contains: search } }, - { name: { contains: search } }, - ], - } - : {}; - - const users = await prisma.user.findMany({ - where, - orderBy: { - createdAt: "desc", - }, - }); - - return users; -} - export default async function UsersPage({ searchParams, }: { @@ -51,7 +30,13 @@ export default async function UsersPage({ redirect("/dashboard"); } - const users = await getUsers(searchParams.search); + const search = searchParams.search || ""; + const users = await prisma.user.findMany({ + where: { + OR: [{ name: { contains: search } }, { email: { contains: search } }], + }, + orderBy: { createdAt: "desc" }, + }); return (
@@ -59,8 +44,8 @@ export default async function UsersPage({
- 用户管理 - 查看和管理系统中的所有用户 + 用户列表 + 查看系统中的所有用户
@@ -79,9 +64,9 @@ export default async function UsersPage({ + ID 用户名 邮箱 - 昵称 角色 创建时间 用户组 @@ -90,15 +75,11 @@ export default async function UsersPage({ {users.map((user) => ( - {user.username} + {user.id} + {user.name} {user.email} - {user.name || "-"} - {user.role === "ADMIN" - ? "管理员" - : user.moderator - ? "版主" - : "用户"} + {user.role === "ADMIN" ? "管理员" : "用户"} {new Date(user.createdAt).toLocaleString()} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index fef08f8..14e0fd5 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,6 +1,11 @@ import Link from "next/link"; -import { AppWindow, Settings } from "lucide-react"; +import { redirect } from "next/navigation"; +import type { Client } from "@prisma/client"; +import { AppWindow, Users } from "lucide-react"; +import { getAuthorizationStats } from "@/lib/dto/authorization"; +import { prisma } from "@/lib/prisma"; +import { getCurrentUser } from "@/lib/session"; import { Button } from "@/components/ui/button"; import { Card, @@ -10,45 +15,120 @@ import { CardTitle, } from "@/components/ui/card"; -export default function DashboardPage() { +interface ClientWithStats extends Client { + stats: { + total: number; + activeLastMonth: number; + newLastMonth: number; + }; +} + +async function getUserClients(userId: string): Promise { + try { + const clients = await prisma.client.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + + // 获取每个应用的授权统计 + const clientsWithStats = await Promise.all( + clients.map(async (client) => { + try { + const stats = await getAuthorizationStats(client.id); + return { + ...client, + stats, + }; + } catch (error) { + console.error(`获取应用 ${client.name} 的统计信息失败:`, error); + return { + ...client, + stats: { + total: 0, + activeLastMonth: 0, + newLastMonth: 0, + }, + }; + } + }), + ); + + return clientsWithStats; + } catch (error) { + console.error("获取用户应用列表失败:", error); + return []; + } +} + +export default async function DashboardPage() { + const user = await getCurrentUser(); + if (!user?.id) { + redirect("/sign-in"); + } + + const clients = await getUserClients(user.id); + return (
-
- - - - - 应用管理 - - - 管理您的 OAuth 应用,查看应用详情和统计信息 - - - - - - - - +
+

我的应用

+ + + +
- - - - - 账号设置 - - - 管理您的账号信息,包括个人资料和安全设置 - - - - - - - - +
+ {clients.map((client) => ( + + + + + {client.name} + + + {client.description || "暂无描述"} + + + +
+
+
+ +

授权用户数

+
+

+ {client.stats.total} +

+
+ 30天活跃: {client.stats.activeLastMonth} + 30天新增: {client.stats.newLastMonth} +
+
+ + + +
+
+
+ ))} + + {clients.length === 0 && ( + + + 还没有应用 + + 创建一个新应用来开始使用 OAuth 服务 + + + + + + + + + )}
); diff --git a/src/app/api/clients/[id]/route.ts b/src/app/api/clients/[id]/route.ts index 4b94409..ffb3614 100644 --- a/src/app/api/clients/[id]/route.ts +++ b/src/app/api/clients/[id]/route.ts @@ -1,68 +1,61 @@ -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import type { Client, Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { getCurrentUser } from "@/lib/session"; -export async function PUT( - request: NextRequest, +export async function PATCH( + req: Request, { params }: { params: { id: string } }, ) { try { const user = await getCurrentUser(); if (!user) { - return new Response("Unauthorized", { status: 401 }); + return new NextResponse("Unauthorized", { status: 401 }); } + const data = await req.json(); const client = await prisma.client.findUnique({ where: { id: params.id }, }); - if (!client) { - return new Response("Not Found", { status: 404 }); + if (!client || client.userId !== user.id) { + return new NextResponse("Forbidden", { status: 403 }); } - if (client.userId !== user.id) { - return new Response("Forbidden", { status: 403 }); - } + const updateData = { + name: data.name, + description: data.description, + home: data.home, + logo: data.logo, + redirectUri: data.redirectUri, + } satisfies Partial; - const formData = await request.formData(); - const name = formData.get("name") as string; - const home = formData.get("home") as string; - const logo = formData.get("logo") as string; - const redirectUri = formData.get("redirectUri") as string; - const description = formData.get("description") as string; - - // 验证必填字段 - if (!name || !home || !logo || !redirectUri) { - return new Response("Missing required fields", { status: 400 }); + // 单独处理 allowedUsers 字段 + if (Array.isArray(data.allowedUsers)) { + await prisma.$executeRaw`UPDATE clients SET "allowedUsers" = ${data.allowedUsers}::text[] WHERE id = ${params.id}`; } const updatedClient = await prisma.client.update({ where: { id: params.id }, - data: { - name, - home, - logo, - redirectUri, - description, - }, + data: updateData, }); - return Response.json(updatedClient); + return NextResponse.json(updatedClient); } catch (error) { - console.error("Error updating client:", error); - return new Response("Internal Server Error", { status: 500 }); + console.error("[CLIENT_UPDATE]", error); + return new NextResponse("Internal Error", { status: 500 }); } } export async function DELETE( - _request: NextRequest, + _request: Request, { params }: { params: { id: string } }, ) { try { const user = await getCurrentUser(); if (!user) { - return new Response("Unauthorized", { status: 401 }); + return new NextResponse("Unauthorized", { status: 401 }); } const client = await prisma.client.findUnique({ @@ -70,11 +63,11 @@ export async function DELETE( }); if (!client) { - return new Response("Not Found", { status: 404 }); + return new NextResponse("Not Found", { status: 404 }); } if (client.userId !== user.id) { - return new Response("Forbidden", { status: 403 }); + return new NextResponse("Forbidden", { status: 403 }); } // 删除相关的授权记录 @@ -97,9 +90,9 @@ export async function DELETE( where: { id: params.id }, }); - return new Response(null, { status: 204 }); + return new NextResponse(null, { status: 204 }); } catch (error) { - console.error("Error deleting client:", error); - return new Response("Internal Server Error", { status: 500 }); + console.error("[CLIENT_DELETE]", error); + return new NextResponse("Internal Error", { status: 500 }); } } diff --git a/src/components/clients/edit-client.tsx b/src/components/clients/edit-client.tsx index d5c573c..2200f53 100644 --- a/src/components/clients/edit-client.tsx +++ b/src/components/clients/edit-client.tsx @@ -2,38 +2,70 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; import type { Client } from "@prisma/client"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; -import { useToast } from "@/hooks/use-toast"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +const formSchema = z.object({ + name: z.string().min(1, "应用名称不能为空"), + description: z.string().optional(), + home: z.string().url("请输入有效的URL"), + logo: z.string().url("请输入有效的URL"), + redirectUri: z.string().url("请输入有效的URL"), + allowedUsers: z.string().optional(), +}); interface EditClientFormProps { client: Client; } export function EditClientForm({ client }: EditClientFormProps) { - const [isLoading, setIsLoading] = useState(false); - const { toast } = useToast(); const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); - async function onSubmit(event: React.FormEvent) { - event.preventDefault(); - setIsLoading(true); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: client.name, + description: client.description || "", + home: client.home, + logo: client.logo, + redirectUri: client.redirectUri, + allowedUsers: client.allowedUsers?.join(", ") || "", + }, + }); + async function onSubmit(values: z.infer) { try { - const formData = new FormData(event.currentTarget); + setIsLoading(true); const response = await fetch(`/api/clients/${client.id}`, { - method: "PUT", - body: formData, + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...values, + allowedUsers: values.allowedUsers + ? values.allowedUsers + .split(",") + .map((u) => u.trim()) + .filter(Boolean) + : [], + }), }); if (!response.ok) { @@ -41,90 +73,108 @@ export function EditClientForm({ client }: EditClientFormProps) { } router.refresh(); - toast({ - title: "更新成功", - description: "应用信息已更新", - }); - router.push("/dashboard/clients"); } catch (error) { - toast({ - variant: "destructive", - title: "更新失败", - description: error instanceof Error ? error.message : "未知错误", - }); + console.error("Error updating client:", error); } finally { setIsLoading(false); } } return ( - - - 编辑应用 - 修改应用的基本信息 - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
+
+ + ( + + 应用名称 + + + + + + )} + /> + + ( + + 应用描述 + +