From b291c3f92c5764fa8d345a3babc11a5df7c27bbe Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sun, 14 Sep 2025 20:51:21 +0800 Subject: [PATCH 1/2] feat: enable PPR for instant response --- app/layout.tsx | 2 ++ next.config.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/app/layout.tsx b/app/layout.tsx index 544dac9..adae783 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -21,6 +21,8 @@ export const metadata: Metadata = { description: "Managing your Database Playground instance.", }; +export const experimental_ppr = true; + export default async function RootLayout({ children, }: Readonly<{ diff --git a/next.config.ts b/next.config.ts index 0085ebf..505454a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,7 @@ const nextConfig: NextConfig = { swcPlugins: [ ["@swc-contrib/plugin-graphql-codegen-client-preset", { artifactDirectory: "./gql", gqlTagName: "graphql" }], ], + ppr: "incremental", }, }; From 496252b5cffdee1d99e3eb1933ef8c00e12704e7 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 15 Sep 2025 01:18:27 +0800 Subject: [PATCH 2/2] feat: allow generating impersonation token --- .../(user-management)/users/[id]/page.tsx | 4 +- .../users/_actions/revoke-token.ts | 7 + .../users/_components/data-table-columns.tsx | 5 + .../users/_components/impersonate.tsx | 208 ++++++++++++++++++ .../users/_components/mutation.ts | 6 + gql/gql.ts | 6 + gql/graphql.ts | 8 + 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 app/(admin)/(user-management)/users/_actions/revoke-token.ts create mode 100644 app/(admin)/(user-management)/users/_components/impersonate.tsx diff --git a/app/(admin)/(user-management)/users/[id]/page.tsx b/app/(admin)/(user-management)/users/[id]/page.tsx index d228cb0..a5d3c0f 100644 --- a/app/(admin)/(user-management)/users/[id]/page.tsx +++ b/app/(admin)/(user-management)/users/[id]/page.tsx @@ -1,5 +1,6 @@ import { SiteHeader } from "@/components/site-header"; import { DeleteUserButtonTrigger } from "../_components/delete"; +import { ImpersonateUserButtonTrigger } from "../_components/impersonate"; import { LogoutUserDevicesButtonTrigger } from "../_components/logout-devices"; import { UpdateUserButtonTrigger } from "../_components/update"; import { AuditInfoCard } from "./_components/audit-info"; @@ -22,10 +23,11 @@ export default async function UserPage({ md:p-8 `} > -
+
+ diff --git a/app/(admin)/(user-management)/users/_actions/revoke-token.ts b/app/(admin)/(user-management)/users/_actions/revoke-token.ts new file mode 100644 index 0000000..2d5030a --- /dev/null +++ b/app/(admin)/(user-management)/users/_actions/revoke-token.ts @@ -0,0 +1,7 @@ +"use server"; + +import { revokeToken } from "@/lib/auth"; + +export default async function revokeSpecificToken(token: string) { + await revokeToken(token); +} diff --git a/app/(admin)/(user-management)/users/_components/data-table-columns.tsx b/app/(admin)/(user-management)/users/_components/data-table-columns.tsx index 7a603e0..8b08e8e 100644 --- a/app/(admin)/(user-management)/users/_components/data-table-columns.tsx +++ b/app/(admin)/(user-management)/users/_components/data-table-columns.tsx @@ -13,6 +13,7 @@ import type { ColumnDef } from "@tanstack/react-table"; import { MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { DeleteUserDropdownTrigger } from "./delete"; +import { ImpersonateUserDropdownTrigger } from "./impersonate"; import { LogoutUserDevicesDropdownTrigger } from "./logout-devices"; import { UpdateUserDropdownTrigger } from "./update"; @@ -106,6 +107,10 @@ export const columns: ColumnDef[] = [ + diff --git a/app/(admin)/(user-management)/users/_components/impersonate.tsx b/app/(admin)/(user-management)/users/_components/impersonate.tsx new file mode 100644 index 0000000..f75aff1 --- /dev/null +++ b/app/(admin)/(user-management)/users/_components/impersonate.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { useMutation } from "@apollo/client/react"; +import { Copy, Key, X } from "lucide-react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import revokeSpecificToken from "../_actions/revoke-token"; +import { USER_IMPERSONATE_MUTATION } from "./mutation"; + +export function ImpersonateUserDropdownTrigger({ + userId, + userName, +}: { + userId: string; + userName?: string; +}) { + const [open, setOpen] = useState(false); + + return ( + + + { + e.preventDefault(); + setOpen(true); + }} + > + 取得代理憑證 + + + + setOpen(false)} + /> + + ); +} + +export function ImpersonateUserButtonTrigger({ + userId, + userName, +}: { + userId: string; + userName?: string; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + 取得代理憑證 + + + setOpen(false)} + /> + + ); +} + +function ImpersonateUserDialogContent({ + userId, + userName, + onCompleted, +}: { + userId: string; + userName?: string; + onCompleted: () => void; +}) { + const [token, setToken] = useState(null); + const [isPending, startTransition] = useTransition(); + + const [impersonateUser, { loading }] = useMutation(USER_IMPERSONATE_MUTATION, { + onError(error) { + toast.error("無法取得代理操作憑證", { + description: error.message, + }); + }, + + onCompleted(data) { + setToken(data.impersonateUser); + toast.success("已產生代理操作憑證"); + }, + }); + + const handleImpersonate = () => { + impersonateUser({ + variables: { + userID: userId, + }, + }); + }; + + const handleCopy = async () => { + if (!token) return; + + try { + await navigator.clipboard.writeText(token); + toast.success("已將憑證複製到剪貼簿"); + } catch (error) { + toast.error("複製憑證失敗", { + description: error instanceof Error ? error.message : undefined, + }); + } + }; + + const handleRevoke = () => { + if (!token) return; + + startTransition(async () => { + try { + await revokeSpecificToken(token); + toast.success("已撤銷憑證"); + setToken(null); + onCompleted(); + } catch (error) { + toast.error("撤銷憑證失敗", { + description: error instanceof Error ? error.message : undefined, + }); + } + }); + }; + + return ( + + + 取得代理憑證 + + 產生代理憑證,以在 API 層面代理使用者執行動作。代理憑證有效期為 8 小時。 + + + +
+ {!token + ? ( +
+

+ 點選下方按鈕以產生代理憑證 +

+ +
+ ) + : ( +
+
+

+ 代理憑證 +

+ + {token} + +
+ +
+ + +
+
+ )} +
+ + + + + + +
+ ); +} diff --git a/app/(admin)/(user-management)/users/_components/mutation.ts b/app/(admin)/(user-management)/users/_components/mutation.ts index c7736d8..f7b8001 100644 --- a/app/(admin)/(user-management)/users/_components/mutation.ts +++ b/app/(admin)/(user-management)/users/_components/mutation.ts @@ -19,3 +19,9 @@ export const USER_LOGOUT_DEVICES_MUTATION = graphql(` logoutUser(userID: $userID) } `); + +export const USER_IMPERSONATE_MUTATION = graphql(` + mutation ImpersonateUser($userID: ID!) { + impersonateUser(userID: $userID) + } +`); diff --git a/gql/gql.ts b/gql/gql.ts index 5c52fa3..5a28ea3 100644 --- a/gql/gql.ts +++ b/gql/gql.ts @@ -52,6 +52,7 @@ type Documents = { "\n mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {\n updateUser(id: $id, input: $input) {\n id\n }\n }\n": typeof types.UpdateUserDocument, "\n mutation DeleteUser($id: ID!) {\n deleteUser(id: $id)\n }\n": typeof types.DeleteUserDocument, "\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n": typeof types.LogoutUserDevicesDocument, + "\n mutation ImpersonateUser($userID: ID!) {\n impersonateUser(userID: $userID)\n }\n": typeof types.ImpersonateUserDocument, "\n query UserById($id: ID!) {\n user(id: $id) {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n": typeof types.UserByIdDocument, "\n query GroupList {\n groups {\n id\n name\n }\n }\n": typeof types.GroupListDocument, "\n query UsersTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n users(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": typeof types.UsersTableDocument, @@ -98,6 +99,7 @@ const documents: Documents = { "\n mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {\n updateUser(id: $id, input: $input) {\n id\n }\n }\n": types.UpdateUserDocument, "\n mutation DeleteUser($id: ID!) {\n deleteUser(id: $id)\n }\n": types.DeleteUserDocument, "\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n": types.LogoutUserDevicesDocument, + "\n mutation ImpersonateUser($userID: ID!) {\n impersonateUser(userID: $userID)\n }\n": types.ImpersonateUserDocument, "\n query UserById($id: ID!) {\n user(id: $id) {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n": types.UserByIdDocument, "\n query GroupList {\n groups {\n id\n name\n }\n }\n": types.GroupListDocument, "\n query UsersTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n users(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": types.UsersTableDocument, @@ -272,6 +274,10 @@ export function graphql(source: "\n mutation DeleteUser($id: ID!) {\n delete * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n"): (typeof documents)["\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation ImpersonateUser($userID: ID!) {\n impersonateUser(userID: $userID)\n }\n"): (typeof documents)["\n mutation ImpersonateUser($userID: ID!) {\n impersonateUser(userID: $userID)\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/gql/graphql.ts b/gql/graphql.ts index f4d9a37..5a4313c 100644 --- a/gql/graphql.ts +++ b/gql/graphql.ts @@ -1215,6 +1215,13 @@ export type LogoutUserDevicesMutationVariables = Exact<{ export type LogoutUserDevicesMutation = { __typename?: 'Mutation', logoutUser: boolean }; +export type ImpersonateUserMutationVariables = Exact<{ + userID: Scalars['ID']['input']; +}>; + + +export type ImpersonateUserMutation = { __typename?: 'Mutation', impersonateUser: string }; + export type UserByIdQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -1293,6 +1300,7 @@ export const UserAuditInfoDocument = {"kind":"Document","definitions":[{"kind":" export const UpdateUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const DeleteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const LogoutUserDevicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"LogoutUserDevices"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userID"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logoutUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userID"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userID"}}}]}]}}]} as unknown as DocumentNode; +export const ImpersonateUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImpersonateUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userID"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"impersonateUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userID"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userID"}}}]}]}}]} as unknown as DocumentNode; export const UserByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const GroupListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GroupList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"groups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const UsersTableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UsersTable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Cursor"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"last"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Cursor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"Variable","name":{"kind":"Name","value":"last"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}}]}}]}}]}}]} as unknown as DocumentNode;