mirror of
https://github.com/woodchen-ink/Q58Connect.git
synced 2025-07-18 14:01:55 +08:00
feat: Add authorization tracking and management features
- Enhanced Authorization model with `enabled` and `lastUsedAt` fields - Implemented client-side and admin authorization status toggles - Added comprehensive authorization statistics and recent authorization tracking - Updated client details pages to display authorization information - Improved navigation and user experience for managing client authorizations
This commit is contained in:
parent
402295d908
commit
781d2fc0c2
@ -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);
|
@ -106,7 +106,9 @@ model Authorization {
|
|||||||
clientId String
|
clientId String
|
||||||
client Client @relation(fields: [clientId], references: [id])
|
client Client @relation(fields: [clientId], references: [id])
|
||||||
|
|
||||||
scope String?
|
scope String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
|
||||||
@@unique([userId, clientId])
|
@@unique([userId, clientId])
|
||||||
@@map("authorizations")
|
@@map("authorizations")
|
||||||
|
@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import type { ExtendedAccessToken, ExtendedClient } from "@/types";
|
import type { ExtendedAccessToken, ExtendedClient } from "@/types";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
import { getAuthorizationsByClientId } from "@/lib/dto/authorization";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -22,6 +23,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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) {
|
async function getClientDetails(id: string) {
|
||||||
const client = await prisma.client.findUnique({
|
const client = await prisma.client.findUnique({
|
||||||
@ -40,11 +43,6 @@ async function getClientDetails(id: string) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
client: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
username: true,
|
username: true,
|
||||||
@ -59,6 +57,9 @@ async function getClientDetails(id: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取授权用户列表
|
||||||
|
const authorizations = await getAuthorizationsByClientId(id);
|
||||||
|
|
||||||
// 获取最近30天的授权统计
|
// 获取最近30天的授权统计
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
@ -75,7 +76,10 @@ async function getClientDetails(id: string) {
|
|||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
authCount,
|
authCount,
|
||||||
} as ExtendedClient;
|
authorizations,
|
||||||
|
} as ExtendedClient & {
|
||||||
|
authorizations: Awaited<ReturnType<typeof getAuthorizationsByClientId>>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ClientDetailsPage({
|
export default async function ClientDetailsPage({
|
||||||
@ -103,9 +107,7 @@ export default async function ClientDetailsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold">{client.name}</h1>
|
<h1 className="text-2xl font-bold">{client.name}</h1>
|
||||||
<Badge variant={client.enabled ? "default" : "destructive"}>
|
<ClientStatusToggle client={client} />
|
||||||
{client.enabled ? "启用" : "禁用"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -199,32 +201,44 @@ export default async function ClientDetailsPage({
|
|||||||
|
|
||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>活跃令牌</CardTitle>
|
<CardTitle>授权用户</CardTitle>
|
||||||
<CardDescription>当前有效的访问令牌</CardDescription>
|
<CardDescription>已授权的用户列表</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>令牌ID</TableHead>
|
<TableHead>用户名</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
<TableHead>邮箱</TableHead>
|
||||||
<TableHead>过期时间</TableHead>
|
<TableHead>授权时间</TableHead>
|
||||||
|
<TableHead>最后使用</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(client.accessTokens || []).map(
|
{client.authorizations.map((auth) => (
|
||||||
(token: ExtendedAccessToken) => (
|
<TableRow key={auth.id}>
|
||||||
<TableRow key={token.id}>
|
<TableCell>{auth.user.username}</TableCell>
|
||||||
<TableCell className="font-mono">{token.id}</TableCell>
|
<TableCell>{auth.user.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(token.createdAt).toLocaleString()}
|
{new Date(auth.createdAt).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(token.expiresAt).toLocaleString()}
|
{auth.lastUsedAt
|
||||||
</TableCell>
|
? new Date(auth.lastUsedAt).toLocaleString()
|
||||||
</TableRow>
|
: "从未使用"}
|
||||||
),
|
</TableCell>
|
||||||
)}
|
<TableCell>
|
||||||
|
<Badge variant={auth.enabled ? "default" : "destructive"}>
|
||||||
|
{auth.enabled ? "已授权" : "已禁用"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<AuthorizationStatusToggle authorization={auth} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@ -1,38 +1,226 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
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 { 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";
|
import { EditClientForm } from "@/components/clients/edit-client";
|
||||||
|
|
||||||
interface EditClientPageProps {
|
async function getClientDetails(id: string, userId: string) {
|
||||||
params: {
|
const client = await prisma.client.findUnique({
|
||||||
id: string;
|
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();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user?.id) {
|
||||||
redirect("/sign-in");
|
redirect("/sign-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await getClientById(params.id);
|
const client = await getClientDetails(params.id, user.id);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是应用的所有者
|
|
||||||
if (client.userId !== user.id) {
|
|
||||||
redirect("/dashboard/clients");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<div className="mb-6">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<h2 className="text-base text-muted-foreground">编辑应用</h2>
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/dashboard/clients">
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold">{client.name}</h1>
|
||||||
|
<Badge variant={client.enabled ? "default" : "destructive"}>
|
||||||
|
{client.enabled ? "启用" : "禁用"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditClientForm client={client} />
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>应用信息</CardTitle>
|
||||||
|
<CardDescription>应用的基本信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Client ID
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 font-mono">{client.clientId}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
Client Secret
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 font-mono">{client.clientSecret}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
回调地址
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1">{client.redirectUri}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
应用主页
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{client.home ? (
|
||||||
|
<a
|
||||||
|
href={client.home}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{client.home}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
创建时间
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{new Date(client.createdAt).toLocaleString()}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>使用统计</CardTitle>
|
||||||
|
<CardDescription>应用的使用情况统计</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
总授权用户数
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
<div className="text-3xl font-bold">{client.stats.total}</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
最近30天活跃用户
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{client.stats.activeLastMonth}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
占总用户的{" "}
|
||||||
|
{(
|
||||||
|
(client.stats.activeLastMonth / client.stats.total) *
|
||||||
|
100 || 0
|
||||||
|
).toFixed(1)}
|
||||||
|
%
|
||||||
|
</p>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
|
最近30天新增用户
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{client.stats.newLastMonth}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
环比增长{" "}
|
||||||
|
{(
|
||||||
|
(client.stats.newLastMonth / client.stats.total) * 100 ||
|
||||||
|
0
|
||||||
|
).toFixed(1)}
|
||||||
|
%
|
||||||
|
</p>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近授权</CardTitle>
|
||||||
|
<CardDescription>显示最近授权的用户</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{client.stats.recentAuthorizations.map((auth) => (
|
||||||
|
<div
|
||||||
|
key={auth.id}
|
||||||
|
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
用户于 {new Date(auth.createdAt).toLocaleString()} 授权
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
最后使用于{" "}
|
||||||
|
{auth.lastUsedAt
|
||||||
|
? new Date(auth.lastUsedAt).toLocaleString()
|
||||||
|
: "从未使用"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={auth.enabled ? "default" : "destructive"}>
|
||||||
|
{auth.enabled ? "已授权" : "已禁用"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>应用设置</CardTitle>
|
||||||
|
<CardDescription>编辑应用的基本信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EditClientForm client={client} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
28
src/app/api/admin/authorizations/[id]/status/route.ts
Normal file
28
src/app/api/admin/authorizations/[id]/status/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
36
src/app/api/admin/clients/[id]/status/route.ts
Normal file
36
src/app/api/admin/clients/[id]/status/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
105
src/components/admin/authorization-status-toggle.tsx
Normal file
105
src/components/admin/authorization-status-toggle.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={authorization.enabled ? "destructive" : "default"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{authorization.enabled ? "禁用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AlertDialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
确定要
|
||||||
|
{authorization.enabled ? "禁用" : "启用"}
|
||||||
|
用户 {authorization.user.name || authorization.user.username}{" "}
|
||||||
|
的授权吗?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{authorization.enabled
|
||||||
|
? "禁用后,该用户将无法使用此应用的服务。"
|
||||||
|
: "启用后,该用户将恢复使用此应用的权限。"}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isLoading}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={authorization.enabled ? "bg-destructive" : undefined}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
92
src/components/admin/client-status-toggle.tsx
Normal file
92
src/components/admin/client-status-toggle.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant={client.enabled ? "default" : "destructive"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
>
|
||||||
|
{client.enabled ? "启用" : "禁用"}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<AlertDialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
确定要{client.enabled ? "禁用" : "启用"}该应用吗?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{client.enabled
|
||||||
|
? "禁用后,该应用将无法使用 OAuth 服务,所有已授权的用户将无法访问。"
|
||||||
|
: "启用后,该应用将恢复使用 OAuth 服务的权限。"}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isLoading}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={client.enabled ? "bg-destructive" : undefined}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -10,8 +10,10 @@ export function DashboardHeader() {
|
|||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (pathname === "/dashboard") return "控制台";
|
if (pathname === "/dashboard") return "控制台";
|
||||||
if (pathname === "/dashboard/clients") return "应用管理";
|
if (pathname === "/dashboard/clients") return "应用管理";
|
||||||
if (pathname.includes("/dashboard/clients/")) return "应用编辑";
|
if (pathname.includes("/dashboard/clients/")) return "应用详情";
|
||||||
if (pathname === "/admin") return "管理后台";
|
if (pathname === "/dashboard/settings") return "账号设置";
|
||||||
|
if (pathname === "/admin/users") return "用户管理";
|
||||||
|
if (pathname === "/admin/logs") return "系统日志";
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,25 +3,27 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { User } from "lucide-react";
|
import { ChevronDown, User } from "lucide-react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
import DynamicLogo from "../dynamic-logo";
|
import { cn } from "@/lib/utils";
|
||||||
import { ThemeToggle } from "../theme-toggle";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
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() {
|
export function NavBar() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
@ -32,85 +34,168 @@ export function NavBar() {
|
|||||||
return (
|
return (
|
||||||
<nav className="sticky top-0 z-10 bg-white shadow-md dark:bg-gray-800">
|
<nav className="sticky top-0 z-10 bg-white shadow-md dark:bg-gray-800">
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex h-14 items-center justify-between">
|
||||||
<Link href="/">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center space-x-3">
|
<Link href="/" className="flex items-center space-x-3">
|
||||||
<DynamicLogo />
|
<DynamicLogo />
|
||||||
<h1 className="text-2xl font-bold text-[#25263A] dark:text-white">
|
<h1 className="text-2xl font-bold text-[#25263A] dark:text-white">
|
||||||
Q58 Connect
|
Q58 Connect
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
|
||||||
|
{user && (
|
||||||
|
<div className="ml-8 flex items-center space-x-1">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
|
pathname === "/dashboard"
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
控制台
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/clients"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
|
pathname.startsWith("/dashboard/clients")
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
应用管理
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
|
pathname === "/dashboard/settings"
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
设置
|
||||||
|
</Link>
|
||||||
|
{user.role === "ADMIN" && (
|
||||||
|
<>
|
||||||
|
<div className="mx-2 h-4 w-px bg-border" />
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
|
pathname === "/admin"
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
管理后台
|
||||||
|
</Link>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
|
pathname.startsWith("/admin/") &&
|
||||||
|
pathname !== "/admin"
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
管理功能
|
||||||
|
<ChevronDown className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
pathname === "/admin/users" && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
用户管理
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/admin/clients"
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
pathname === "/admin/clients" && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
应用管理
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/admin/authorizations"
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
pathname === "/admin/authorizations" &&
|
||||||
|
"bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
授权管理
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/admin/logs"
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
pathname === "/admin/logs" && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
系统日志
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{status === "loading" ? (
|
{status === "loading" ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="relative h-9 w-9 overflow-hidden rounded-full"
|
className="relative h-8 w-8"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<User className="h-5 w-5" />
|
<User className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : user ? (
|
) : user ? (
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-3">
|
||||||
<DropdownMenuTrigger asChild>
|
<Avatar className="h-8 w-8">
|
||||||
<Button
|
<AvatarImage src={user.avatarUrl || undefined} />
|
||||||
variant="outline"
|
<AvatarFallback>
|
||||||
size="icon"
|
{user.name?.charAt(0) || user.username?.charAt(0)}
|
||||||
className="relative h-9 w-9 overflow-hidden rounded-full"
|
</AvatarFallback>
|
||||||
>
|
</Avatar>
|
||||||
{user.avatarUrl ? (
|
<Button
|
||||||
<Image
|
variant="ghost"
|
||||||
src={user.avatarUrl}
|
size="sm"
|
||||||
width={36}
|
onClick={handleSignOut}
|
||||||
height={36}
|
className="text-muted-foreground hover:text-red-600"
|
||||||
alt={user.name || "用户头像"}
|
>
|
||||||
unoptimized
|
退出
|
||||||
className="h-full w-full object-cover"
|
</Button>
|
||||||
onError={(e) => {
|
</div>
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.style.display = "none";
|
|
||||||
target.nextElementSibling?.classList.remove("hidden");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
{user.name || user.username || "用户"}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/dashboard">控制台</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/dashboard/clients">应用管理</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{user.role === "ADMIN" && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/admin">管理后台</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-red-600 dark:text-red-400"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
>
|
|
||||||
退出登录
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
) : (
|
||||||
<Link href="/sign-in">
|
<Link href="/sign-in">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
className="border-[#25263A] text-[#25263A] hover:bg-[#25263A] hover:text-white dark:border-[#A0A1B2] dark:text-[#A0A1B2] dark:hover:bg-[#A0A1B2] dark:hover:text-[#25263A]"
|
className="border-[#25263A] text-[#25263A] hover:bg-[#25263A] hover:text-white dark:border-[#A0A1B2] dark:text-[#A0A1B2] dark:hover:bg-[#A0A1B2] dark:hover:text-[#25263A]"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
|
@ -5,9 +5,24 @@ import { prisma } from "@/lib/prisma";
|
|||||||
export async function createAccessToken(
|
export async function createAccessToken(
|
||||||
data: Omit<AccessToken, "id" | "createdAt" | "updatedAt" | "error">,
|
data: Omit<AccessToken, "id" | "createdAt" | "updatedAt" | "error">,
|
||||||
): Promise<AccessToken> {
|
): Promise<AccessToken> {
|
||||||
return prisma.accessToken.create({
|
const [accessToken] = await prisma.$transaction([
|
||||||
data,
|
prisma.accessToken.create({
|
||||||
});
|
data,
|
||||||
|
}),
|
||||||
|
prisma.authorization.update({
|
||||||
|
where: {
|
||||||
|
userId_clientId: {
|
||||||
|
userId: data.userId,
|
||||||
|
clientId: data.clientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAccessTokenByToken(token: string) {
|
export async function getAccessTokenByToken(token: string) {
|
||||||
|
@ -20,3 +20,113 @@ export async function findAuthorization(userId: string, clientId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAuthorizationsByClientId(clientId: string) {
|
||||||
|
return prisma.authorization.findMany({
|
||||||
|
where: {
|
||||||
|
clientId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAuthorizationStatus(
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return prisma.authorization.update({
|
||||||
|
where: {
|
||||||
|
userId_clientId: {
|
||||||
|
userId,
|
||||||
|
clientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
enabled,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthorizationStats(clientId: string) {
|
||||||
|
const now = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [total, activeLastMonth, newLastMonth] = await Promise.all([
|
||||||
|
// 总授权用户数
|
||||||
|
prisma.authorization.count({
|
||||||
|
where: {
|
||||||
|
clientId,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// 最近30天活跃用户数
|
||||||
|
prisma.authorization.count({
|
||||||
|
where: {
|
||||||
|
clientId,
|
||||||
|
enabled: true,
|
||||||
|
lastUsedAt: {
|
||||||
|
gte: thirtyDaysAgo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// 最近30天新增用户数
|
||||||
|
prisma.authorization.count({
|
||||||
|
where: {
|
||||||
|
clientId,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: {
|
||||||
|
gte: thirtyDaysAgo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
activeLastMonth,
|
||||||
|
newLastMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClientAuthorizationStats(clientId: string) {
|
||||||
|
const stats = await getAuthorizationStats(clientId);
|
||||||
|
|
||||||
|
// 获取最近的授权记录
|
||||||
|
const recentAuthorizations = await prisma.authorization.findMany({
|
||||||
|
where: {
|
||||||
|
clientId,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
lastUsedAt: "desc",
|
||||||
|
},
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...stats,
|
||||||
|
recentAuthorizations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user