Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/(admin)/(user-management)/users/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,10 +23,11 @@ export default async function UserPage({
md:p-8
`}
>
<div className="flex items-center justify-between space-y-2">
<div className="flex flex-col lg:flex-row items-center justify-between space-y-2">
<Header id={id as string} />

<div className="flex items-center gap-2">
<ImpersonateUserButtonTrigger userId={id as string} />
<LogoutUserDevicesButtonTrigger id={id as string} />
<UpdateUserButtonTrigger id={id as string} />
<DeleteUserButtonTrigger id={id as string} />
Expand Down
7 changes: 7 additions & 0 deletions app/(admin)/(user-management)/users/_actions/revoke-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use server";

import { revokeToken } from "@/lib/auth";

export default async function revokeSpecificToken(token: string) {
await revokeToken(token);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -106,6 +107,10 @@ export const columns: ColumnDef<User>[] = [
<UpdateUserDropdownTrigger id={row.original.id} />
<DeleteUserDropdownTrigger id={row.original.id} />
<DropdownMenuSeparator />
<ImpersonateUserDropdownTrigger
userId={row.original.id}
userName={row.original.name}
/>
<LogoutUserDevicesDropdownTrigger id={row.original.id} />
</DropdownMenuContent>
</DropdownMenu>
Expand Down
208 changes: 208 additions & 0 deletions app/(admin)/(user-management)/users/_components/impersonate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setOpen(true);
}}
>
取得代理憑證
</DropdownMenuItem>
</DialogTrigger>

<ImpersonateUserDialogContent
userId={userId}
userName={userName}
onCompleted={() => setOpen(false)}
/>
</Dialog>
);
}

export function ImpersonateUserButtonTrigger({
userId,
userName,
}: {
userId: string;
userName?: string;
}) {
const [open, setOpen] = useState(false);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className={buttonVariants({ variant: "outline" })}>
<Key className="h-4 w-4" />
取得代理憑證
</DialogTrigger>

<ImpersonateUserDialogContent
userId={userId}
userName={userName}
onCompleted={() => setOpen(false)}
/>
</Dialog>
);
}

function ImpersonateUserDialogContent({
userId,
userName,

Check warning on line 80 in app/(admin)/(user-management)/users/_components/impersonate.tsx

View workflow job for this annotation

GitHub Actions / Run Linters

'userName' is defined but never used
onCompleted,
}: {
userId: string;
userName?: string;
onCompleted: () => void;
}) {
const [token, setToken] = useState<string | null>(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 (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>取得代理憑證</DialogTitle>
<DialogDescription>
產生代理憑證,以在 API 層面代理使用者執行動作。代理憑證有效期為 8 小時。
</DialogDescription>
</DialogHeader>

<div className="space-y-4">
{!token
? (
<div className="py-4 text-center">
<p className="mb-4 text-sm text-muted-foreground">
點選下方按鈕以產生代理憑證
</p>
<Button
onClick={handleImpersonate}
disabled={loading}
className="w-full"
>
<Key className="mr-2 h-4 w-4" />
{loading ? "產生中……" : "產生代理憑證"}
</Button>
</div>
)
: (
<div className="space-y-3">
<div className="rounded-md bg-muted p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
代理憑證
</p>
<code className="font-mono text-sm break-all">
{token}
</code>
</div>

<div className="flex gap-2">
<Button
onClick={handleCopy}
variant="outline"
className="flex-1"
>
<Copy className="mr-2 h-4 w-4" />
複製
</Button>
<Button
onClick={handleRevoke}
variant="destructive"
disabled={isPending}
className="flex-1"
>
<X className="mr-2 h-4 w-4" />
{isPending ? "撤銷中……" : "撤銷"}
</Button>
</div>
</div>
)}
</div>

<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">關閉</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
);
}
6 changes: 6 additions & 0 deletions app/(admin)/(user-management)/users/_components/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
`);
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down
6 changes: 6 additions & 0 deletions gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand Down
Loading