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
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
|
||||
scope String?
|
||||
scope String?
|
||||
enabled Boolean @default(true)
|
||||
lastUsedAt DateTime?
|
||||
|
||||
@@unique([userId, clientId])
|
||||
@@map("authorizations")
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
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 = () => {
|
||||
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 "";
|
||||
};
|
||||
|
||||
|
@ -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]"
|
||||
>
|
||||
登录
|
||||
|
@ -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) {
|
||||
|
@ -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