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:
wood chen 2025-02-20 01:13:22 +08:00
parent 402295d908
commit 781d2fc0c2
12 changed files with 800 additions and 117 deletions

View File

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

View File

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

View File

@ -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<ReturnType<typeof getAuthorizationsByClientId>>;
};
}
export default async function ClientDetailsPage({
@ -103,9 +107,7 @@ export default async function ClientDetailsPage({
</Button>
</Link>
<h1 className="text-2xl font-bold">{client.name}</h1>
<Badge variant={client.enabled ? "default" : "destructive"}>
{client.enabled ? "启用" : "禁用"}
</Badge>
<ClientStatusToggle client={client} />
</div>
</div>
@ -199,32 +201,44 @@ export default async function ClientDetailsPage({
<Card className="md:col-span-2">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>访</CardDescription>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>使</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(client.accessTokens || []).map(
(token: ExtendedAccessToken) => (
<TableRow key={token.id}>
<TableCell className="font-mono">{token.id}</TableCell>
<TableCell>
{new Date(token.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{new Date(token.expiresAt).toLocaleString()}
</TableCell>
</TableRow>
),
)}
{client.authorizations.map((auth) => (
<TableRow key={auth.id}>
<TableCell>{auth.user.username}</TableCell>
<TableCell>{auth.user.email}</TableCell>
<TableCell>
{new Date(auth.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{auth.lastUsedAt
? new Date(auth.lastUsedAt).toLocaleString()
: "从未使用"}
</TableCell>
<TableCell>
<Badge variant={auth.enabled ? "default" : "destructive"}>
{auth.enabled ? "已授权" : "已禁用"}
</Badge>
</TableCell>
<TableCell className="text-right">
<AuthorizationStatusToggle authorization={auth} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>

View File

@ -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 (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6">
<h2 className="text-base text-muted-foreground"></h2>
<div className="mb-8 flex items-center justify-between">
<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>
<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>
);
}

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

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

View 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>
</>
);
}

View 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>
</>
);
}

View File

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

View File

@ -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 (
<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="flex items-center justify-between py-3">
<Link href="/">
<div className="flex items-center space-x-3">
<div className="flex h-14 items-center justify-between">
<div className="flex items-center">
<Link href="/" className="flex items-center space-x-3">
<DynamicLogo />
<h1 className="text-2xl font-bold text-[#25263A] dark:text-white">
Q58 Connect
</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">
<ThemeToggle />
{status === "loading" ? (
<Button
variant="outline"
variant="ghost"
size="icon"
className="relative h-9 w-9 overflow-hidden rounded-full"
className="relative h-8 w-8"
disabled
>
<User className="h-5 w-5" />
<User className="h-4 w-4" />
</Button>
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative h-9 w-9 overflow-hidden rounded-full"
>
{user.avatarUrl ? (
<Image
src={user.avatarUrl}
width={36}
height={36}
alt={user.name || "用户头像"}
unoptimized
className="h-full w-full object-cover"
onError={(e) => {
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>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatarUrl || undefined} />
<AvatarFallback>
{user.name?.charAt(0) || user.username?.charAt(0)}
</AvatarFallback>
</Avatar>
<Button
variant="ghost"
size="sm"
onClick={handleSignOut}
className="text-muted-foreground hover:text-red-600"
>
退
</Button>
</div>
) : (
<Link href="/sign-in">
<Button
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]"
>

View File

@ -5,9 +5,24 @@ import { prisma } from "@/lib/prisma";
export async function createAccessToken(
data: Omit<AccessToken, "id" | "createdAt" | "updatedAt" | "error">,
): Promise<AccessToken> {
return prisma.accessToken.create({
data,
});
const [accessToken] = await prisma.$transaction([
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) {

View File

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