diff --git a/prisma/migrations/20250219170049_add_enabled_and_last_used_at/migration.sql b/prisma/migrations/20250219170049_add_enabled_and_last_used_at/migration.sql new file mode 100644 index 0000000..4c36183 --- /dev/null +++ b/prisma/migrations/20250219170049_add_enabled_and_last_used_at/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "access_tokens" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "authorizations" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "lastUsedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4456154..ef668b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,7 +106,9 @@ model Authorization { clientId String client Client @relation(fields: [clientId], references: [id]) - scope String? + scope String? + enabled Boolean @default(true) + lastUsedAt DateTime? @@unique([userId, clientId]) @@map("authorizations") diff --git a/src/app/(admin)/admin/clients/[id]/page.tsx b/src/app/(admin)/admin/clients/[id]/page.tsx index 540d1af..841b59a 100644 --- a/src/app/(admin)/admin/clients/[id]/page.tsx +++ b/src/app/(admin)/admin/clients/[id]/page.tsx @@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation"; import type { ExtendedAccessToken, ExtendedClient } from "@/types"; import { ArrowLeft } from "lucide-react"; +import { getAuthorizationsByClientId } from "@/lib/dto/authorization"; import { prisma } from "@/lib/prisma"; import { getCurrentUser } from "@/lib/session"; import { Badge } from "@/components/ui/badge"; @@ -22,6 +23,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { AuthorizationStatusToggle } from "@/components/admin/authorization-status-toggle"; +import { ClientStatusToggle } from "@/components/admin/client-status-toggle"; async function getClientDetails(id: string) { const client = await prisma.client.findUnique({ @@ -40,11 +43,6 @@ async function getClientDetails(id: string) { }, }, include: { - client: { - select: { - name: true, - }, - }, user: { select: { username: true, @@ -59,6 +57,9 @@ async function getClientDetails(id: string) { return null; } + // 获取授权用户列表 + const authorizations = await getAuthorizationsByClientId(id); + // 获取最近30天的授权统计 const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); @@ -75,7 +76,10 @@ async function getClientDetails(id: string) { return { ...client, authCount, - } as ExtendedClient; + authorizations, + } as ExtendedClient & { + authorizations: Awaited>; + }; } export default async function ClientDetailsPage({ @@ -103,9 +107,7 @@ export default async function ClientDetailsPage({

{client.name}

- - {client.enabled ? "启用" : "禁用"} - + @@ -199,32 +201,44 @@ export default async function ClientDetailsPage({ - 活跃令牌 - 当前有效的访问令牌 + 授权用户 + 已授权的用户列表 - 令牌ID - 创建时间 - 过期时间 + 用户名 + 邮箱 + 授权时间 + 最后使用 + 状态 + 操作 - {(client.accessTokens || []).map( - (token: ExtendedAccessToken) => ( - - {token.id} - - {new Date(token.createdAt).toLocaleString()} - - - {new Date(token.expiresAt).toLocaleString()} - - - ), - )} + {client.authorizations.map((auth) => ( + + {auth.user.username} + {auth.user.email} + + {new Date(auth.createdAt).toLocaleString()} + + + {auth.lastUsedAt + ? new Date(auth.lastUsedAt).toLocaleString() + : "从未使用"} + + + + {auth.enabled ? "已授权" : "已禁用"} + + + + + + + ))}
diff --git a/src/app/(dashboard)/dashboard/clients/[id]/page.tsx b/src/app/(dashboard)/dashboard/clients/[id]/page.tsx index a100cda..575dd38 100644 --- a/src/app/(dashboard)/dashboard/clients/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/clients/[id]/page.tsx @@ -1,38 +1,226 @@ +import Link from "next/link"; import { notFound, redirect } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; -import { getClientById } from "@/lib/dto/client"; +import { getClientAuthorizationStats } from "@/lib/dto/authorization"; +import { prisma } from "@/lib/prisma"; import { getCurrentUser } from "@/lib/session"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { EditClientForm } from "@/components/clients/edit-client"; -interface EditClientPageProps { - params: { - id: string; +async function getClientDetails(id: string, userId: string) { + const client = await prisma.client.findUnique({ + where: { + id, + userId, // 确保只能查看自己的应用 + }, + }); + + if (!client) { + return null; + } + + const stats = await getClientAuthorizationStats(id); + + return { + ...client, + stats, }; } -export default async function EditClientPage({ params }: EditClientPageProps) { +export default async function ClientDetailsPage({ + params, +}: { + params: { id: string }; +}) { const user = await getCurrentUser(); - if (!user) { + if (!user?.id) { redirect("/sign-in"); } - const client = await getClientById(params.id); + const client = await getClientDetails(params.id, user.id); if (!client) { notFound(); } - // 检查是否是应用的所有者 - if (client.userId !== user.id) { - redirect("/dashboard/clients"); - } - return (
-
-

编辑应用

+
+
+ + + +

{client.name}

+ + {client.enabled ? "启用" : "禁用"} + +
- +
+ + + 应用信息 + 应用的基本信息 + + +
+
+
+ Client ID +
+
{client.clientId}
+
+
+
+ Client Secret +
+
{client.clientSecret}
+
+
+
+ 回调地址 +
+
{client.redirectUri}
+
+
+
+ 应用主页 +
+
+ {client.home ? ( + + {client.home} + + ) : ( + "-" + )} +
+
+
+
+ 创建时间 +
+
+ {new Date(client.createdAt).toLocaleString()} +
+
+
+
+
+ + + + 使用统计 + 应用的使用情况统计 + + +
+
+
+ 总授权用户数 +
+
+
{client.stats.total}
+
+
+
+
+ 最近30天活跃用户 +
+
+
+ {client.stats.activeLastMonth} +
+

+ 占总用户的{" "} + {( + (client.stats.activeLastMonth / client.stats.total) * + 100 || 0 + ).toFixed(1)} + % +

+
+
+
+
+ 最近30天新增用户 +
+
+
+ {client.stats.newLastMonth} +
+

+ 环比增长{" "} + {( + (client.stats.newLastMonth / client.stats.total) * 100 || + 0 + ).toFixed(1)} + % +

+
+
+
+
+
+ + + + 最近授权 + 显示最近授权的用户 + + +
+ {client.stats.recentAuthorizations.map((auth) => ( +
+
+

+ 用户于 {new Date(auth.createdAt).toLocaleString()} 授权 +

+

+ 最后使用于{" "} + {auth.lastUsedAt + ? new Date(auth.lastUsedAt).toLocaleString() + : "从未使用"} +

+
+ + {auth.enabled ? "已授权" : "已禁用"} + +
+ ))} +
+
+
+ + + + 应用设置 + 编辑应用的基本信息 + + + + + +
); } diff --git a/src/app/api/admin/authorizations/[id]/status/route.ts b/src/app/api/admin/authorizations/[id]/status/route.ts new file mode 100644 index 0000000..72ab928 --- /dev/null +++ b/src/app/api/admin/authorizations/[id]/status/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { getCurrentUser } from "@/lib/session"; + +export async function POST( + req: Request, + { params }: { params: { id: string } }, +) { + try { + const user = await getCurrentUser(); + if (!user || user.role !== "ADMIN") { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { enabled } = await req.json(); + + const authorization = await prisma.authorization.update({ + where: { id: params.id }, + data: { enabled }, + }); + + return NextResponse.json(authorization); + } catch (error) { + console.error("[AUTHORIZATION_STATUS_UPDATE]", error); + return new NextResponse("Internal Error", { status: 500 }); + } +} diff --git a/src/app/api/admin/clients/[id]/status/route.ts b/src/app/api/admin/clients/[id]/status/route.ts new file mode 100644 index 0000000..c980c33 --- /dev/null +++ b/src/app/api/admin/clients/[id]/status/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { getCurrentUser } from "@/lib/session"; + +export async function POST( + req: Request, + { params }: { params: { id: string } }, +) { + try { + const user = await getCurrentUser(); + if (!user || user.role !== "ADMIN") { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { enabled } = await req.json(); + + const client = await prisma.client.update({ + where: { id: params.id }, + data: { enabled }, + }); + + // 如果禁用应用,同时禁用所有授权 + if (!enabled) { + await prisma.authorization.updateMany({ + where: { clientId: params.id }, + data: { enabled: false }, + }); + } + + return NextResponse.json(client); + } catch (error) { + console.error("[CLIENT_STATUS_UPDATE]", error); + return new NextResponse("Internal Error", { status: 500 }); + } +} diff --git a/src/components/admin/authorization-status-toggle.tsx b/src/components/admin/authorization-status-toggle.tsx new file mode 100644 index 0000000..fdc1ad2 --- /dev/null +++ b/src/components/admin/authorization-status-toggle.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Authorization } from "@prisma/client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; + +interface AuthorizationStatusToggleProps { + authorization: Authorization & { + user: { + username: string; + email: string; + name: string | null; + }; + }; +} + +export function AuthorizationStatusToggle({ + authorization, +}: AuthorizationStatusToggleProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [showDialog, setShowDialog] = useState(false); + + const handleToggle = async () => { + try { + setIsLoading(true); + const response = await fetch( + `/api/admin/authorizations/${authorization.id}/status`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + enabled: !authorization.enabled, + }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to update authorization status"); + } + + router.refresh(); + } catch (error) { + console.error("Error updating authorization status:", error); + } finally { + setIsLoading(false); + setShowDialog(false); + } + }; + + return ( + <> + + + + + + + 确定要 + {authorization.enabled ? "禁用" : "启用"} + 用户 {authorization.user.name || authorization.user.username}{" "} + 的授权吗? + + + {authorization.enabled + ? "禁用后,该用户将无法使用此应用的服务。" + : "启用后,该用户将恢复使用此应用的权限。"} + + + + 取消 + + 确定 + + + + + + ); +} diff --git a/src/components/admin/client-status-toggle.tsx b/src/components/admin/client-status-toggle.tsx new file mode 100644 index 0000000..96f70b7 --- /dev/null +++ b/src/components/admin/client-status-toggle.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { ExtendedClient } from "@/types"; +import type { Client } from "@prisma/client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface ClientStatusToggleProps { + client: Client | ExtendedClient; +} + +export function ClientStatusToggle({ client }: ClientStatusToggleProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [showDialog, setShowDialog] = useState(false); + + const handleToggle = async () => { + try { + setIsLoading(true); + const response = await fetch(`/api/admin/clients/${client.id}/status`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + enabled: !client.enabled, + }), + }); + + if (!response.ok) { + throw new Error("Failed to update client status"); + } + + router.refresh(); + } catch (error) { + console.error("Error updating client status:", error); + } finally { + setIsLoading(false); + setShowDialog(false); + } + }; + + return ( + <> + setShowDialog(true)} + > + {client.enabled ? "启用" : "禁用"} + + + + + + + 确定要{client.enabled ? "禁用" : "启用"}该应用吗? + + + {client.enabled + ? "禁用后,该应用将无法使用 OAuth 服务,所有已授权的用户将无法访问。" + : "启用后,该应用将恢复使用 OAuth 服务的权限。"} + + + + 取消 + + 确定 + + + + + + ); +} diff --git a/src/components/layout/dashboard-header.tsx b/src/components/layout/dashboard-header.tsx index 4f30976..c1f8c02 100644 --- a/src/components/layout/dashboard-header.tsx +++ b/src/components/layout/dashboard-header.tsx @@ -10,8 +10,10 @@ export function DashboardHeader() { const getTitle = () => { if (pathname === "/dashboard") return "控制台"; if (pathname === "/dashboard/clients") return "应用管理"; - if (pathname.includes("/dashboard/clients/")) return "应用编辑"; - if (pathname === "/admin") return "管理后台"; + if (pathname.includes("/dashboard/clients/")) return "应用详情"; + if (pathname === "/dashboard/settings") return "账号设置"; + if (pathname === "/admin/users") return "用户管理"; + if (pathname === "/admin/logs") return "系统日志"; return ""; }; diff --git a/src/components/layout/nav-bar.tsx b/src/components/layout/nav-bar.tsx index f941702..1d242f4 100644 --- a/src/components/layout/nav-bar.tsx +++ b/src/components/layout/nav-bar.tsx @@ -3,25 +3,27 @@ import { useEffect } from "react"; import Image from "next/image"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { User } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { ChevronDown, User } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; -import DynamicLogo from "../dynamic-logo"; -import { ThemeToggle } from "../theme-toggle"; -import { Button } from "../ui/button"; +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, -} from "../ui/dropdown-menu"; +} from "@/components/ui/dropdown-menu"; + +import DynamicLogo from "../dynamic-logo"; +import { ThemeToggle } from "../theme-toggle"; +import { Button } from "../ui/button"; export function NavBar() { const { data: session, status } = useSession(); const router = useRouter(); + const pathname = usePathname(); const user = session?.user; const handleSignOut = async () => { @@ -32,85 +34,168 @@ export function NavBar() { return (