Skip to content

Commit 4e243d4

Browse files
authored
Merge pull request #13 from database-playground/pan93412/dbp-27-實作-impersonate-控制
DBP-27: 實作 impersonate 憑證產生 dialog
2 parents 8653747 + 496252b commit 4e243d4

File tree

9 files changed

+246
-1
lines changed

9 files changed

+246
-1
lines changed

app/(admin)/(user-management)/users/[id]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SiteHeader } from "@/components/site-header";
22
import { DeleteUserButtonTrigger } from "../_components/delete";
3+
import { ImpersonateUserButtonTrigger } from "../_components/impersonate";
34
import { LogoutUserDevicesButtonTrigger } from "../_components/logout-devices";
45
import { UpdateUserButtonTrigger } from "../_components/update";
56
import { AuditInfoCard } from "./_components/audit-info";
@@ -22,10 +23,11 @@ export default async function UserPage({
2223
md:p-8
2324
`}
2425
>
25-
<div className="flex items-center justify-between space-y-2">
26+
<div className="flex flex-col lg:flex-row items-center justify-between space-y-2">
2627
<Header id={id as string} />
2728

2829
<div className="flex items-center gap-2">
30+
<ImpersonateUserButtonTrigger userId={id as string} />
2931
<LogoutUserDevicesButtonTrigger id={id as string} />
3032
<UpdateUserButtonTrigger id={id as string} />
3133
<DeleteUserButtonTrigger id={id as string} />
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"use server";
2+
3+
import { revokeToken } from "@/lib/auth";
4+
5+
export default async function revokeSpecificToken(token: string) {
6+
await revokeToken(token);
7+
}

app/(admin)/(user-management)/users/_components/data-table-columns.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { ColumnDef } from "@tanstack/react-table";
1313
import { MoreHorizontal } from "lucide-react";
1414
import Link from "next/link";
1515
import { DeleteUserDropdownTrigger } from "./delete";
16+
import { ImpersonateUserDropdownTrigger } from "./impersonate";
1617
import { LogoutUserDevicesDropdownTrigger } from "./logout-devices";
1718
import { UpdateUserDropdownTrigger } from "./update";
1819

@@ -106,6 +107,10 @@ export const columns: ColumnDef<User>[] = [
106107
<UpdateUserDropdownTrigger id={row.original.id} />
107108
<DeleteUserDropdownTrigger id={row.original.id} />
108109
<DropdownMenuSeparator />
110+
<ImpersonateUserDropdownTrigger
111+
userId={row.original.id}
112+
userName={row.original.name}
113+
/>
109114
<LogoutUserDevicesDropdownTrigger id={row.original.id} />
110115
</DropdownMenuContent>
111116
</DropdownMenu>
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"use client";
2+
3+
import { Button, buttonVariants } from "@/components/ui/button";
4+
import {
5+
Dialog,
6+
DialogClose,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
DialogTrigger,
13+
} from "@/components/ui/dialog";
14+
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
15+
import { useMutation } from "@apollo/client/react";
16+
import { Copy, Key, X } from "lucide-react";
17+
import { useState, useTransition } from "react";
18+
import { toast } from "sonner";
19+
import revokeSpecificToken from "../_actions/revoke-token";
20+
import { USER_IMPERSONATE_MUTATION } from "./mutation";
21+
22+
export function ImpersonateUserDropdownTrigger({
23+
userId,
24+
userName,
25+
}: {
26+
userId: string;
27+
userName?: string;
28+
}) {
29+
const [open, setOpen] = useState(false);
30+
31+
return (
32+
<Dialog open={open} onOpenChange={setOpen}>
33+
<DialogTrigger asChild>
34+
<DropdownMenuItem
35+
onClick={(e) => {
36+
e.preventDefault();
37+
setOpen(true);
38+
}}
39+
>
40+
取得代理憑證
41+
</DropdownMenuItem>
42+
</DialogTrigger>
43+
44+
<ImpersonateUserDialogContent
45+
userId={userId}
46+
userName={userName}
47+
onCompleted={() => setOpen(false)}
48+
/>
49+
</Dialog>
50+
);
51+
}
52+
53+
export function ImpersonateUserButtonTrigger({
54+
userId,
55+
userName,
56+
}: {
57+
userId: string;
58+
userName?: string;
59+
}) {
60+
const [open, setOpen] = useState(false);
61+
62+
return (
63+
<Dialog open={open} onOpenChange={setOpen}>
64+
<DialogTrigger className={buttonVariants({ variant: "outline" })}>
65+
<Key className="h-4 w-4" />
66+
取得代理憑證
67+
</DialogTrigger>
68+
69+
<ImpersonateUserDialogContent
70+
userId={userId}
71+
userName={userName}
72+
onCompleted={() => setOpen(false)}
73+
/>
74+
</Dialog>
75+
);
76+
}
77+
78+
function ImpersonateUserDialogContent({
79+
userId,
80+
userName,
81+
onCompleted,
82+
}: {
83+
userId: string;
84+
userName?: string;
85+
onCompleted: () => void;
86+
}) {
87+
const [token, setToken] = useState<string | null>(null);
88+
const [isPending, startTransition] = useTransition();
89+
90+
const [impersonateUser, { loading }] = useMutation(USER_IMPERSONATE_MUTATION, {
91+
onError(error) {
92+
toast.error("無法取得代理操作憑證", {
93+
description: error.message,
94+
});
95+
},
96+
97+
onCompleted(data) {
98+
setToken(data.impersonateUser);
99+
toast.success("已產生代理操作憑證");
100+
},
101+
});
102+
103+
const handleImpersonate = () => {
104+
impersonateUser({
105+
variables: {
106+
userID: userId,
107+
},
108+
});
109+
};
110+
111+
const handleCopy = async () => {
112+
if (!token) return;
113+
114+
try {
115+
await navigator.clipboard.writeText(token);
116+
toast.success("已將憑證複製到剪貼簿");
117+
} catch (error) {
118+
toast.error("複製憑證失敗", {
119+
description: error instanceof Error ? error.message : undefined,
120+
});
121+
}
122+
};
123+
124+
const handleRevoke = () => {
125+
if (!token) return;
126+
127+
startTransition(async () => {
128+
try {
129+
await revokeSpecificToken(token);
130+
toast.success("已撤銷憑證");
131+
setToken(null);
132+
onCompleted();
133+
} catch (error) {
134+
toast.error("撤銷憑證失敗", {
135+
description: error instanceof Error ? error.message : undefined,
136+
});
137+
}
138+
});
139+
};
140+
141+
return (
142+
<DialogContent className="sm:max-w-md">
143+
<DialogHeader>
144+
<DialogTitle>取得代理憑證</DialogTitle>
145+
<DialogDescription>
146+
產生代理憑證,以在 API 層面代理使用者執行動作。代理憑證有效期為 8 小時。
147+
</DialogDescription>
148+
</DialogHeader>
149+
150+
<div className="space-y-4">
151+
{!token
152+
? (
153+
<div className="py-4 text-center">
154+
<p className="mb-4 text-sm text-muted-foreground">
155+
點選下方按鈕以產生代理憑證
156+
</p>
157+
<Button
158+
onClick={handleImpersonate}
159+
disabled={loading}
160+
className="w-full"
161+
>
162+
<Key className="mr-2 h-4 w-4" />
163+
{loading ? "產生中……" : "產生代理憑證"}
164+
</Button>
165+
</div>
166+
)
167+
: (
168+
<div className="space-y-3">
169+
<div className="rounded-md bg-muted p-3">
170+
<p className="mb-2 text-xs font-medium text-muted-foreground">
171+
代理憑證
172+
</p>
173+
<code className="font-mono text-sm break-all">
174+
{token}
175+
</code>
176+
</div>
177+
178+
<div className="flex gap-2">
179+
<Button
180+
onClick={handleCopy}
181+
variant="outline"
182+
className="flex-1"
183+
>
184+
<Copy className="mr-2 h-4 w-4" />
185+
複製
186+
</Button>
187+
<Button
188+
onClick={handleRevoke}
189+
variant="destructive"
190+
disabled={isPending}
191+
className="flex-1"
192+
>
193+
<X className="mr-2 h-4 w-4" />
194+
{isPending ? "撤銷中……" : "撤銷"}
195+
</Button>
196+
</div>
197+
</div>
198+
)}
199+
</div>
200+
201+
<DialogFooter>
202+
<DialogClose asChild>
203+
<Button variant="ghost">關閉</Button>
204+
</DialogClose>
205+
</DialogFooter>
206+
</DialogContent>
207+
);
208+
}

app/(admin)/(user-management)/users/_components/mutation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ export const USER_LOGOUT_DEVICES_MUTATION = graphql(`
1919
logoutUser(userID: $userID)
2020
}
2121
`);
22+
23+
export const USER_IMPERSONATE_MUTATION = graphql(`
24+
mutation ImpersonateUser($userID: ID!) {
25+
impersonateUser(userID: $userID)
26+
}
27+
`);

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const metadata: Metadata = {
2121
description: "Managing your Database Playground instance.",
2222
};
2323

24+
export const experimental_ppr = true;
25+
2426
export default async function RootLayout({
2527
children,
2628
}: Readonly<{

gql/gql.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type Documents = {
5252
"\n mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {\n updateUser(id: $id, input: $input) {\n id\n }\n }\n": typeof types.UpdateUserDocument,
5353
"\n mutation DeleteUser($id: ID!) {\n deleteUser(id: $id)\n }\n": typeof types.DeleteUserDocument,
5454
"\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n": typeof types.LogoutUserDevicesDocument,
55+
"\n mutation ImpersonateUser($userID: ID!) {\n impersonateUser(userID: $userID)\n }\n": typeof types.ImpersonateUserDocument,
5556
"\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,
5657
"\n query GroupList {\n groups {\n id\n name\n }\n }\n": typeof types.GroupListDocument,
5758
"\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 = {
9899
"\n mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {\n updateUser(id: $id, input: $input) {\n id\n }\n }\n": types.UpdateUserDocument,
99100
"\n mutation DeleteUser($id: ID!) {\n deleteUser(id: $id)\n }\n": types.DeleteUserDocument,
100101
"\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n": types.LogoutUserDevicesDocument,
102+
"\n mutation ImpersonateUser($userID: ID!) {\n impersonateUser(userID: $userID)\n }\n": types.ImpersonateUserDocument,
101103
"\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,
102104
"\n query GroupList {\n groups {\n id\n name\n }\n }\n": types.GroupListDocument,
103105
"\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
272274
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
273275
*/
274276
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"];
277+
/**
278+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
279+
*/
280+
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"];
275281
/**
276282
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
277283
*/

0 commit comments

Comments
 (0)