+
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 (
+
+ );
+}
+
+export function ImpersonateUserButtonTrigger({
+ userId,
+ userName,
+}: {
+ userId: string;
+ userName?: string;
+}) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ );
+}
+
+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/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/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;
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",
},
};